a06e88d58b
auto_slug_from_email maps an email's local-part to a schema-valid base
slug (lowercase, [+_.]→'-', alphanumeric-and-hyphen, clamp 32 chars,
fallback to user-{token_hex(4)} for pathological inputs).
next_available_slug walks the {base, base-2, base-3, ...} sequence
against a caller-supplied async exists_check until it lands on a free
slot, truncating base when the suffix would push past 32 chars.
Pulled forward of T4.8/T4.9 because both /auth/initialize and
/auth/register need it. Lives in app.gateway.auth (not persistence)
since "email → slug" is a registration-time concern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
84 lines
2.9 KiB
Python
84 lines
2.9 KiB
Python
"""Workspace slug helpers for the registration / initialize flow.
|
|
|
|
Stage 0 PR4 T4.10. Two responsibilities:
|
|
|
|
1. ``auto_slug_from_email(email)`` — pure transform from an email's
|
|
local part to a base slug that matches the schema's
|
|
``^[a-z0-9](-?[a-z0-9])*$`` pattern.
|
|
2. ``next_available_slug(base, exists_check=...)`` — collision walker
|
|
that appends ``-2``, ``-3``, … until ``exists_check`` reports the
|
|
candidate is free. Kept separate from ``auto_slug_from_email`` so
|
|
the pure function can be tested without a database.
|
|
|
|
Lives in the auth package (not in ``persistence``) because the input
|
|
is the user's email — a registration-time concept that doesn't belong
|
|
in a generic ``WorkspaceRepository``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import secrets
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
# Mirror the schema's slug rules from
|
|
# ``deerflow.persistence.workspace.sql`` so callers of this module
|
|
# never need to import private constants from persistence.
|
|
_SLUG_MIN_LEN = 3
|
|
_SLUG_MAX_LEN = 32
|
|
|
|
|
|
def auto_slug_from_email(email: str) -> str:
|
|
"""Map an email to a deterministic, schema-valid base slug.
|
|
|
|
Algorithm (from workspace-schema-design §3.1):
|
|
|
|
1. Take the local part (before ``@``).
|
|
2. Replace ``+``, ``_``, ``.`` with ``-`` and lowercase.
|
|
3. Strip everything that isn't ``[a-z0-9-]``.
|
|
4. Collapse repeated ``-``; strip leading/trailing ``-``.
|
|
5. Clamp to 32 chars.
|
|
6. If the result is shorter than the schema minimum (3 chars) or
|
|
empty, fall back to ``user-{token_hex(4)}`` so we always emit
|
|
a valid slug.
|
|
|
|
The returned slug is the *base* — callers must run it through
|
|
:func:`next_available_slug` before persisting to handle collisions.
|
|
"""
|
|
local = email.split("@", 1)[0]
|
|
local = re.sub(r"[+_.]", "-", local).lower()
|
|
local = re.sub(r"[^a-z0-9-]", "", local)
|
|
local = re.sub(r"-+", "-", local).strip("-")
|
|
slug = local[:_SLUG_MAX_LEN]
|
|
if len(slug) < _SLUG_MIN_LEN:
|
|
return f"user-{secrets.token_hex(4)}"
|
|
return slug
|
|
|
|
|
|
async def next_available_slug(
|
|
base: str,
|
|
*,
|
|
exists_check: Callable[[str], Awaitable[bool]],
|
|
) -> str:
|
|
"""Return the first of ``base``, ``base-2``, ``base-3``, … that ``exists_check`` reports free.
|
|
|
|
Caller-supplied ``exists_check`` is awaited once per candidate so
|
|
we can swap in a repository's ``get_by_slug`` without coupling
|
|
this module to persistence imports.
|
|
|
|
When ``base + '-N'`` would exceed the 32-char schema limit, the
|
|
base is truncated before the suffix is appended. The walker never
|
|
returns an over-long slug.
|
|
"""
|
|
if not await exists_check(base):
|
|
return base
|
|
|
|
n = 2
|
|
while True:
|
|
suffix = f"-{n}"
|
|
max_base_len = _SLUG_MAX_LEN - len(suffix)
|
|
candidate = f"{base[:max_base_len]}{suffix}"
|
|
if not await exists_check(candidate):
|
|
return candidate
|
|
n += 1
|