feat(auth): workspace slug helpers — auto_slug_from_email + next_available_slug
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>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user