Module kit / Identity and auth
Desk domain sign-in
identity-auth/desk-domain-sign-in
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | prod-flag-off |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-sync, postgres, dnspython-2.8, smtp |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/error-envelope, _shared/alembic-migrations, _shared/scheduler-and-background-work, _shared/test-layout |
| Frontends | identity-auth/ui/auth-screens |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Also measured on |
|
| Version | 1 |
Purpose
Anyone with a mailbox at a domain a workspace has proven (one TXT record) can ask for a magic link and land on the account the workspace's rule names, either the practice's one shared account or their own auto-created one, with no user administration by anyone; and everything that session then does carries the address that asked, so a shared account never hides who ordered what. The invariant that makes it trustworthy is that a declaration grants nothing until the zone's owner has published the challenge, that the link is always mailed to the address that asked and never to the account's own address, that a sign-in request never creates an account, that a revoked address gets no new link however many rules would re-mint it (its session rows are deactivated at once; its still-valid access token is refused only with the denylist on, Gotchas), and that a desk-only account cannot reach any Labs route outside /api/desk/ and /api/auth/.
When to use
Include it when a product must let a whole organisation in through its mail domain without provisioning people one by one, and when a shared login must still say who acted. Trigger phrases: "everyone at this domain", "one account for the practice", "who actually ordered this", "add my colleague's other address", "revoke the departed employee", "the DNS record", "verify the domain", "the session says acting email", "desk-only account".
Do not use it for the magic link itself (identity-auth/magic-link-login, B5, whose create_magic_link and verify_magic_link this recipe calls), for the session claims aem and dsk (_shared/auth-dependency-pattern, S5, which mints and re-derives them), for the workspace configuration document that declares a rule (service-desk and ADR-0035: desk.login_domains is a section of workspace_configs.config), for the desk's roles and agent gating (B138, DeskAgent and PlatformAdmin), or for the ticket surface that renders acting_email (B132 onward). Prima surfaces are out: resolve_login_email is entered only when the requested surface is labs (auth_router.py 1039 to 1041).
Stack assumptions
Pinned at fd8558f (2026-09-12): fastapi==0.118.2, dnspython==2.8.0 (requirements.txt 28; imported lazily on the verify path, access.py 475), SQLAlchemy sync, Postgres (15-alpine in docker-compose.prod.yml 9; 16 on labs-staging per alchemycloud README at b3d34da line 28, and its scripts/sync-prod-to-staging.sh 77 to 79 pulls a postgres:18-alpine client because prod answers as 18; the tests below ran on the :5434 server, postgres:14 in docker-compose.test.yml 6, which reports 14.19) with every timestamp in this module timestamptz (user_login_email_model.py 31 to 33 and _ts, 54 to 58), the desk's mail transport (notifications.send_requester_mail, notifications.py 58 to 80: the desk's own intake mailbox when one is configured, inbox_config.send_via_mailbox, the same connection the IMAP poller reads; otherwise the platform default transport, email_service._send_email; never the workspace SMTP relay, as the function's own comment says). That transport carries the alias confirmation only (_send_alias_confirmation, desk_login_router.py 479 to 507); the magic link itself rides B5's email_service.send_magic_link_email_task with the template_override argument (auth_router.py 1109 to 1117). Foundations assumed: S4, S5 (create_session with acting_email, _enforce_desk_only_containment), S6 (WorkspaceID on the agent routes), S8 (one additive revision), S12 (the daily re-check under the leader lock), S9.
Environment names and their fail behaviour (core/config.py 982 to 988):
| Name | Default | Behaviour |
|---|---|---|
DESK_DOMAIN_LOGIN_ENABLED | false | off: resolve_login_email returns None (signin.py 110 to 111) and the daily re-check returns without checking (access.py 769 to 770); the agent routes still work, so a workspace can declare and prove a domain before the flag is turned on. Set nowhere in alchemycloud at b3d34da (grep DESK_DOMAIN_LOGIN_ENABLED scripts/distribute-env.sh env/.env.example prints nothing), which is why the maturity is prod-flag-off |
DESK_SIGNIN_LINKS_PER_DOMAIN_PER_HOUR | 60 | the per-domain budget for addresses without a binding; 0 disables it |
DESK_SIGNIN_INTENT_TTL_MINUTES | 15 | an individual intent's lifetime; matches the magic link's 15 minutes (auth_service.py 1141) |
DESK_ALIAS_CONFIRM_TTL_HOURS | 72 | the agent-added alias confirmation's lifetime |
DESK_DOMAIN_DNS_TIMEOUT_SECONDS | 5 | per query; the resolution lifetime is twice it (access.py 480 to 483) |
DESK_DOMAIN_UNVERIFY_AFTER_FAILURES | 14 | consecutive daily failures before the proof lapses; agents warned at 3 |
DESK_DOMAIN_REMOVAL_GRACE_DAYS | 30 | how long a disabled verification row keeps its token |
FRONTEND_URL | http://localhost:3000 (core/config.py 43) | the base of the alias confirmation link (desk_login_router.py 65 to 67) |
Data model
The migration desk_domain_login_001 (20260905_0001_desk_domain_login.py, revises service_desk_core_001, every column add guarded so a re-run is a no-op, 32 to 50):
-- one row per address that may sign a person in, beyond users.email. models/user_login_email_model.py 65 to 123
user_login_emails (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email varchar(320) NOT NULL, -- lowercased, plus tag stripped
source varchar(16) NOT NULL CHECK (source IN ('explicit','domain_rule')), -- ck_user_login_emails_source
workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE SET NULL, -- the rule's or the granting workspace; every write path sets it
domain varchar(253) NULL, -- the rule's domain for domain_rule rows; the provenance re-check reads it
verified_at timestamptz NULL, -- inert while NULL
revoked_at timestamptz NULL, -- set, never deleted: the deny marker
revoked_by_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL,
created_by_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL,
confirm_token_hash varchar(64) NULL, -- sha256 hex of an explicit alias's confirmation token
confirm_expires_at timestamptz NULL,
last_login_at timestamptz NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_user_login_emails_email UNIQUE (email) -- one address, one account
);
CREATE INDEX ix_user_login_emails_user ON user_login_emails(user_id);
CREATE INDEX ix_user_login_emails_ws ON user_login_emails(workspace_id);
CREATE UNIQUE INDEX uq_user_login_emails_confirm_token ON user_login_emails(confirm_token_hash) WHERE confirm_token_hash IS NOT NULL;
-- the proof that a workspace controls a domain. 126 to 182
desk_domain_verifications (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
domain varchar(253) NOT NULL, -- lowercased, no @, no trailing dot
verification_token varchar(64) NOT NULL, -- secrets.token_urlsafe(24); a challenge, returned to the agent in full
verified_at timestamptz NULL, verified_by_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL,
verification_method varchar(16) NULL, -- dns | manual
last_checked_at timestamptz NULL, check_failures integer NOT NULL DEFAULT 0,
disabled_at timestamptz NULL, -- soft removal from the document; token kept for the grace window
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_desk_domain_verifications_ws_domain UNIQUE (workspace_id, domain)
);
CREATE UNIQUE INDEX uq_desk_domain_verifications_verified ON desk_domain_verifications(domain)
WHERE verified_at IS NOT NULL AND disabled_at IS NULL; -- at most one live proof per domain, platform-wide
CREATE INDEX ix_desk_domain_verifications_domain ON desk_domain_verifications(domain);
-- individual mode: a mailbox proof that exists before the account does. 185 to 209
desk_signin_intents (
id uuid PRIMARY KEY, email varchar(320) NOT NULL,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
domain varchar(253) NOT NULL, role varchar(20) NOT NULL, -- desk_requester | viewer
token_hash varchar(64) NOT NULL, next_path varchar(512) NULL,
expires_at timestamptz NOT NULL, consumed_at timestamptz NULL, created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_desk_signin_intents_token_hash UNIQUE (token_hash)
);
CREATE INDEX ix_desk_signin_intents_email ON desk_signin_intents(email);
CREATE INDEX ix_desk_signin_intents_domain_created ON desk_signin_intents(domain, created_at); -- the per-domain budget reads this
-- additive columns on existing tables (migration 55 to 81)
ALTER TABLE magic_links ADD COLUMN login_email varchar(320) NULL, ADD COLUMN login_workspace_id uuid NULL REFERENCES workspaces(id), ADD COLUMN next_path varchar(512) NULL;
ALTER TABLE sessions ADD COLUMN acting_email varchar(320) NULL;
ALTER TABLE desk_tickets ADD COLUMN acting_email varchar(320) NULL; -- backfilled from requester_email (231 to 232)
ALTER TABLE desk_messages ADD COLUMN acting_email varchar(320) NULL;
ALTER TABLE desk_status_events ADD COLUMN acting_email varchar(320) NULL;
The declaration lives elsewhere: workspace_configs.config -> 'desk' -> 'login_domains' is a JSONB array of {domain, mode: shared_account | individual, account_email?, role?} (config_schema.py 29 to 33; LOGIN_DOMAINS_SCHEMA defined at 195 to 203 and wired into the desk section at 290; ADR-0035), read by _declarations with a containment probe (access.py 134 to 152). config_service._write mirrors every declaration into desk_domain_verifications after the flush (config_service.py 167 to 169; sync_domain_verifications, access.py 507 to 565: a new domain gets a row and a token, a removed one gets disabled_at, a re-add inside the grace clears it and keeps the token).
What is not stored: the raw confirmation token and the raw intent token (SHA-256 only; magic_links.token itself is stored raw by B5, which this module inherits); a user row for an individual request before the click; a deleted binding (rows are revoked); the DNS answer (only check_failures and last_checked_at); the address in any log line (email_fingerprint, signin.py 53 to 55, twelve hex characters of its SHA-256).
API surface
Agent routes, included into the desk router (desk_login_router.py 599) and therefore under /api/desk on LABS_SURFACES (app_fastapi.py 824), each with DeskAgent (VerifiedUser plus WorkspaceID plus the agent desk role, service_desk_router.py 80 to 90, 110) unless noted:
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
GET /api/desk/config/domains | DeskAgent | X-Workspace-ID | 200 list of {domain, mode, account_email, role, declared, verified_at, verification_method, was_verified, disabled_at, dns_name, dns_value, last_checked_at, check_failures} | runs _sync first (self-heal for documents that predate the mirror); rows without a rule are listed as declared: false (136 to 188) |
POST /api/desk/config/domains/{domain}/verify | DeskAgent | header | 200 {verified_at, method: "dns"}; 422 {"error": "domain_public"}; 404 {"error": "domain_not_declared"}; 409 {"error": "domain_verified_elsewhere"}; 409 {"error": "txt_record_not_found", expected_name, expected_value, found: [...]}; 409 {"error": "shared_account_unusable"}; 422 {"error": "shared_account_missing_email"} | the resolver's answer is the gate (250 to 273); idempotent on an already-verified row |
POST /api/desk/config/domains/{domain}/verify-manual | PlatformAdmin and DeskAgent | header | 200 {verified_at, method: "manual", verified_by_user_id}; 403 {"error": "admin_required"}; the _prepare refusals | us, never the workspace's agent (276 to 296) |
DELETE /api/desk/config/domains/{domain}/verification | DeskAgent | header | 200 {verified_at: null, revoked_bindings, killed_sessions}; 404 {"error": "not_found"} | body {revoke_sessions: true} by default; the declaration stays (299 to 326) |
GET /api/desk/config/login-emails | DeskAgent | header | 200 list of {email, source, domain, verified_at, revoked_at, last_login_at, created_by, user: {id, email, name}} | scoped by user_login_emails.workspace_id (334 to 368) |
POST /api/desk/config/login-emails | DeskAgent | header | 201 {email, user_id, verified_at: null, confirm_sent_to}; 403 {"error": "target_not_member"}, {"error": "target_is_admin"}; 409 {"error": "domain_owned_elsewhere"}, {"error": "email_taken"} | body {email, user_id}; the target must already be an active member of this workspace; mails a confirmation to the address, mints no session (376 to 476) |
POST /api/desk/login-emails/confirm | none | none | 200 {email, workspace, account}; 400 {"error": "invalid_or_expired"} | body {token}; sets verified_at, clears the token, mints nothing (515 to 552); no rate limit of its own (Gotchas) |
DELETE /api/desk/config/login-emails/{email} | DeskAgent | header | 200 {revoked_at, killed_sessions}; 404 {"error": "not_found"} (also for another workspace's binding); 409 {"error": "is_account_email"}, {"error": "target_is_agent"} | revokes, deactivates that address's session rows only and denylists their tokens (560 to 592; the access token outlives this while the denylist is off, Gotchas) |
The sign-in routes are B5's, with this module's branches: POST /api/auth/request-magic-link (auth_router.py 1007 to 1120; auth_rate_limit and the per-mailbox throttle, B18) answers 202 whatever the resolution, and POST /api/auth/verify-magic-link (1127 onward) returns the session pair plus acting_email, next and desk_only (the body from 1243 to 1258; MagicLinkCallback.tsx 68 reads desk_only to choose the landing route), or 503 "Access control is temporarily unavailable." when the containment question cannot be answered (1211 to 1216), or 403 "This account is not permitted on this surface." from create_session.
Error shape: the agent routes raise HTTPException with a dict detail ({"error": "<code>"}), rendered by S7 (app_fastapi.py 772 to 787): the dict's error string becomes the envelope's error and the whole dict travels in details (schemas/error_schemas.py 112 to 136); the auth routes use string details.
Application logic
Resolution, at request-magic-link on labs (signin.resolve_login_email, 107 to 171), in order:
- Flag off:
None, and the pre-slice-2 path runs unchanged. Normalise the address (normalise_login_email, 58 to 72: strip, lowercase, drop the plus tag), take its domain. - The deny marker first: a row with
revoked_atfor this address isNoneand adesk.signin_denied reason=revokedlog line (118 to 123). effective_rule_for_domain(access.py197 to 227): the live proof row for the domain (verified_at IS NOT NULL AND disabled_at IS NULL, at most one by the partial index), then that workspace's declaration; a proof without a declaration grants nothing.user_for_email(252 to 301):users.emailfirst, then a live binding whose provenance is re-checked (adomain_rulerow whose domain is no longer proven, or now proven by a workspace whose shared account is someone else, is inert).- An owner exists:
kind="user", and if a rule exists and the account is notapi_only,heal_membershipadds the rule's role (never overwriting, never re-activating an inactive row, 327 to 350). The person's own account always wins over the rule. - No owner and no rule:
None. No owner andmode="shared_account": the account named byaccount_emailmust exist, be active and be a member, elseNonewith adesk.shared_account_missingerror (fail closed; verification provisioned it, a login request never will);heal_shared_accountre-grantslabsand the membership;kind="shared_account". No owner andmode="individual":kind="individual"withuser_id=None. domain_link_budget_ok(179 to 227): exempt forkind="user"and for any address with a live binding; else count the last hour'smagic_linksrows with alogin_emailat this domain (a naive-datetime table, compared withutcnow()) plusdesk_signin_intentsrows (aware,now(timezone.utc)), and refuse at the limit with adomain_link_budgetnotification to the agents. A refusal turns the resolution back intoNone, so the answer is still 202 and the ordinary path mints nothing for an unknown address (auth_router.py1042 to 1048).- Mint:
individualwrites an intent (create_intent, 267 to 292:secrets.token_urlsafe(32), SHA-256 stored, TTL,next_path) and mails the raw token as the magic link;userandshared_accountcallcreate_magic_link_for_user(user_id, surface=LABS, login_email=addr, next_path, login_workspace_id)(auth_service.py1088 onward:login_emailstored only when it differs fromusers.email). The mail goes torequest_data.email, the address that asked (auth_router.py1109 to 1117); the template ismagic_link__deskfor a shared account and for a desk-only person, the ordinary one for an ordinary account that happens to be at a proven domain (1082 to 1087;email_service.py464 to 480 tries the override first).
Redemption, at verify-magic-link (auth_router.py 1148 to 1262):
auth_service.verify_magic_link(token)first; onNone,desk_signin.redeem_intent(db, token)(signin.py307 to 397): find by hash, refuse consumed or expired; if a user with that email now exists (created between request and click), refuse an inactive orapi_onlyone and an inactive membership, else heal the membership; else create the user (password_hash=None, verified, 2FA off,role="user",token_quota=0, 343 to 360),grant_surfaces({labs}), heal the membership with the intent's role; upsert theuser_login_emailsrow asdomain_rulewithverified_atandlast_login_at; mark the intent consumed; return aRedemption(user_id, surface=LABS, login_email, login_workspace_id, next_path).- The user must exist and be active; the verification gate (
REQUIRE_EMAIL_VERIFICATION) applies as for any magic link. record_login(405 to 467) whenlogin_emaildiffers fromusers.email: refuse an address that is another account'susers.email, or bound to another account, or revoked (log and return; the login proceeds without a binding); else insert or update the row withverified_at,last_login_atand the workspace carried from the link row. Wrapped so a ledger failure never costs the login (1192 to 1202).is_desk_only_user(access.py367 to 391): at least one active membership, every one inDESK_ONLY_ROLES = ("desk_requester",), and the user owns no workspace; any query error raisesDeskAccessUnavailableand the route answers 503 rather than guessing. A desk-only account skips_ensure_workspace(1211 to 1218): it must never own a workspace, because ownership is checked instead of membership in three places and an owner is inside every fence.create_session(..., surface=LABS, acting_email=<the address, when it is not the account's own>)mints the pair withaemanddsk(S5); the response carriesacting_email,nextanddesk_only.
Verification, at the agent routes:
_prepare(191 to 212): normalise; a public mail domain is 422 (PUBLIC_MAIL_DOMAINS,access.py43 to 55, 23 entries);_sync; the rule must be declared and its row live; a domain proven by another workspace is 409 before anything is written (the partial index would otherwise surface as a 500 with a constraint name, 115 to 122).check_domain_txt(access.py458 to 499): resolve_alchemy-desk.<domain>TXT with the configured timeout; every resolver exception is(False, []); each record's strings are joined (a value split at 255 bytes is one value, 442 to 455); match is case-insensitive on thealchemy-desk-verifyprefix and case-sensitive on the token. A miss answers 409 withexpected_name,expected_valueand everything actually found at that name (262 to 272)._finish(215 to 247): forshared_account,ensure_shared_accountruns before the proof is written (598 to 669: create the account with no password or reuse it, refuse an inactive orapi_onlyone,grant_surfaces({labs}), refuse an inactive membership, heal the membership); thenverified_at = verified_at or now,verification_method,verified_by_user_id,check_failures = 0, commit. Verifying twice keeps the first date and re-runs the healing.DELETE .../verificationcallsrevoke_domain_access(kill_sessions=True)(677 to 745): clearverified_atandverification_method(keepverified_by_user_id, which is whatwas_verifiedreads), revoke everydomain_rulebinding of that workspace and domain, deactivate those users' session rows for those addresses only and denylist their tokens (revoke_sessions_for,signin.py470 to 499, always scoped byuser_id; what a deactivated row stops is in Gotchas), keep memberships. The document is untouched.- Daily (
scheduled_jobs.run_daily_jobs1766,_run_desk_domain_recheck1792 to 1813,recheck_domain_verifications748 to 811): with the flag on, re-resolve every live DNS proof (manual ones are skipped); success resetscheck_failures; a failure increments it, warns the agents at exactly 3, and at 14 callsrevoke_domain_access(kill_sessions=False): bindings go inert, sessions survive, because the record proves the declaration, not the person at the keyboard. Thenpurge_disabled_verificationshard-deletes rows disabled longer than the grace.
Aliases, at the login-emails routes:
POST(376 to 476): the target must be an active member of this workspace, not a platform admin; the address's domain must not be proven by another workspace; the address must not be another account'susers.emailor a live binding of another account; then a row (or the re-armed revoked row) withsource="explicit",verified_at=None, a 32-byte token hashed intoconfirm_token_hashand a 72-hour expiry; commit before the mail (458 to 464); the German confirmation names the workspace, the agent and the account it binds to (479 to 507).confirm(515 to 552) flipsverified_aton the matching hash and clears the token.DELETE(560 to 592): 404 for a row of another workspace, 409 for the account's own address or an agent's, elserevoked_at,revoked_by_user_id, and that address's session rows deactivated.
Invariants
- A declaration proves nothing:
effective_rule_for_domainstarts from the proof row, andtest_a_declared_but_unproven_domain_signs_nobody_in(test_desk_domain_signin.py146) andtest_declaring_creates_the_row_and_the_token_but_proves_nothing(test_desk_domain_verification.py217) pin it. - At most one live proof per domain, platform-wide, by the partial unique index;
_verified_elsewhereanswers 409 before the index would answer 500. - The mail goes to the address that asked, never to
account_email(auth_router.py1109 to 1117;test_request_magic_link_mints_for_the_account_and_mails_the_asker, 720). - A sign-in request creates no user row (
test_no_user_row_is_created_by_resolving, 278;test_a_request_writes_an_intent_and_no_user, 385); anindividualaccount exists only after the click. - The deny marker is read before the rule branch (
signin.py118 to 123); a revoked address is refused under a live rule (test_a_revoked_address_is_refused_even_under_a_live_rule, 210). - Rows are revoked, never deleted;
grep -n "db.delete" services/service_desk/signin.py api/routers/desk_login_router.pyprints nothing (the only delete in the module ispurge_disabled_verifications, on proof rows past the grace). - An existing account always wins over a rule (
test_an_existing_account_always_wins_and_gains_the_membership, 165);record_loginrefuses to bind an address that is another account'susers.email(450). - An inactive membership is a removal and stays one:
heal_membershipnever re-activates (181, 432). - A shared account and an
individualaccount have no password (ensure_shared_account627 to 646;redeem_intent350 to 352), sologin()can never authenticate a shared mailbox and bypass the per-address trail. - An agent nominates an address, never an account: the confirmation token sets
verified_atand mints nothing (515 to 522;test_an_alias_mails_a_confirmation_and_mints_no_session, 588). - The containment question is answered or the login is refused:
DeskAccessUnavailableis 503 at redemption (1211 to 1216) and in the dependency (S5), neverFalse(test_is_desk_only_user_refuses_rather_than_guessing, 528;test_redemption_refuses_when_containment_cannot_be_answered, 841). - A desk-only account never owns a workspace:
_ensure_workspaceis skipped for it at redemption (1218) and at login (603 to 604). - The per-domain budget can never lock out someone who already signed in (
test_the_cap_never_locks_out_someone_who_already_signed_in, 315). - Session revocation is scoped by
user_idfirst and addresses second (revoke_sessions_for), so an address list from an endpoint cannot reach another account (test_revocation_can_never_reach_another_account, 506). What it does is setsessions.is_active = Falseand denylist both jtis (493 to 497); refresh refuses the row at once (auth_service.py809 to 821), the access token only when the denylist answers (Gotchas). - Every timestamp in the three tables is aware; the one naive comparison is against
magic_links.created_atand usesutcnow()deliberately (signin.py202 to 217).
Frontend
identity-auth/ui/auth-screens interim: the magic-link request screen (the address is typed here; the
desk branch is invisible to it, the answer is 202 either way) and
MagicLinkCallback, which posts verify-magic-link and honours `next`
service-desk/ui/desk-client-portal /desk/login-email/confirm ConfirmLoginEmail (pages/desk/ConfirmLoginEmail.tsx,
App.tsx 255): posts the token, shows the workspace and account it bound;
the ticket views render acting_email beside the requester
service-desk/ui/desk-settings-and-notifications /admin/desk/login AdminDeskLogin (pages/admin/desk/AdminDeskLogin.tsx, App.tsx
381): the domains table with the proof states removed | declared | failing |
proven from lib/deskLogin.ts (unit-tested in lib/deskLogin.test.ts), the
dns_name and dns_value to copy, verify and un-verify, the login-email
aliases list with add and revoke; serviceDeskService.ts 1038 to 1105
Minimum admin and debug surface: GET /api/desk/config/domains for one workspace (every state, the token, check_failures, last_checked_at); GET /api/desk/config/login-emails; the log lines desk.domain_verified, desk.domain_verification_deleted, desk.login_email_added, desk.login_email_confirmed, desk.login_email_revoked, desk.signin_denied, desk.binding_inert, desk.shared_account_missing, desk.domain_link_budget_exhausted, desk.domain_unverified, desk.sessions_revoked, each with a workspace id or a user id, and a fingerprint wherever an address is involved (desk.domain_unverified carries workspace and domain, desk.sessions_revoked a user id and a count), never an address; SCHEDULER: desk domain re-check {stats} once a day when anything was checked; dig TXT _alchemy-desk.<domain> from the host, which is what the verifier sees.
Security & tenancy
Auth dependency: DeskAgent on the agent routes (a VerifiedUser with the agent role in the named workspace), PlatformAdmin (is_admin) additionally on manual verification, none on confirm and on the two auth routes. Workspace derivation: X-Workspace-ID through WorkspaceID (S6) on every agent route; the listing and revoke routes filter on user_login_emails.workspace_id and answer 404, not 403, for another tenant's row (567 to 570). Fail direction: closed everywhere a decision is made (an unresolvable domain is not proven; a missing shared account mints nothing; an unanswerable containment question is 503; a ledger failure at record_login is logged and the login proceeds without the binding, which is the one open seam, chosen so that the audit trail can never cost a login, 1192 to 1202).
Credentials at rest: the confirmation and intent tokens as SHA-256 (confirm_token_hash, token_hash); the DNS challenge token in clear, because it is a challenge that proves control of a zone only to whoever can already write that zone (_domain_payload 164 to 167); magic_links.token in clear, inherited from B5. Rate limits: auth_rate_limit and the per-mailbox throttle on request-magic-link (B18), the per-domain hourly budget here (which counts links, not requests, and exempts known addresses), nothing on confirm (Gotchas), nothing on the agent routes beyond the global tier.
Attacker-controlled inputs: the requested address (normalised; its domain looked up; never logged in clear; its plus tag stripped so one mailbox cannot mint unlimited distinct identities against the per-domain cap, signin.py 61 to 65); next (a relative path by validator, schemas/auth_schemas.py 150 to 156, stored on the link row and returned at redemption; test_an_absolute_next_is_refused, 785); the domain path parameter on the agent routes (normalised, refused when public, matched against the workspace's own declarations); the TXT record's contents (compared, then returned to the agent in the 409 as found, which is the zone's own data); the confirmation and intent tokens (hashed before lookup, single use, expiring); the user_id an agent nominates (must be an active member of the agent's own workspace, never an admin, 383 to 394: desk.agents is readable through the workspace config, so an unchecked id would let any agent target any account). The X-Alchemy-Surface and Host decide the surface (B5), and the desk branch runs only for labs.
Tenancy of the binding: a domain_rule row belongs to the workspace whose rule minted it and carries that domain; the provenance re-check (user_for_email 283 to 300) makes it inert when that domain is no longer proven or is now proven by a workspace whose shared account is a different user, so a domain that changes hands does not carry its old bindings to the new owner. A desk-only account is fenced to /api/desk/ and /api/auth/ plus GET /api/workspaces and one bare workspace object (desk_only_path_allowed, access.py 405 to 423); the fence is on the user, not the workspace, because POST /api/meta/disconnect names a workspace in its body and reaches neither workspace fence (the docstring at 406 to 417). GET /api/workspaces/{id}/settings is outside the fence on purpose: it returns the whole settings JSON (identity-auth/credential-encryption-at-rest, B19).
Tests
Backend (alchemy_labs/backend/tests/, run 2026-09-12 at fd8558f against the :5434 Postgres): python -m pytest tests/test_desk_domain_signin.py tests/test_desk_domain_verification.py tests/test_desk_acting_email.py -q --no-cov -o addopts="" -p no:randomly prints 1 failed, 121 passed in 75.76s. The -p no:randomly is load-bearing: the three files together are not order-independent (Gotchas), and without it the run is under pytest-randomly like every other suite in this codebase, so the same command can surface a different failure count on a different seed.
test_desk_domain_signin.py(998 lines, 45 test functions): the flag (test_the_flag_is_off_by_default, 135), the three resolution shapes (142 to 278), the budget (308 to 353), intents (385 to 432),record_login(450, 462), revocation scope (492, 506), containment (522, 528), the alias routes (553 to 696), the two auth routes end to end (720 to 841), the claims on the token and the session row and the per-address session cap (914 to 983: six people on one account keep six sessions, one person still cannot hoard devices).test_desk_domain_verification.py(628 lines, 34 test functions): the TXT check against a fake resolver (zonefixture, 63 to 81, monkeypatchesdns.resolver.Resolver; 167 to 208, including the split-record join and the resolver-failure case), the mirror (217 to 261), the verify routes (281 to 435), the daily re-check (495 to 579: nothing lost at a few failures, warned at three, lost at the fourteenth, sessions spared, manual proofs skipped, nothing while the flag is off), deletion (591 to 625).test_desk_acting_email.py(614 lines, 27 test functions; the three files collect 122 cases with parametrisation):aemanddskend to end withOptionalUser(S5 Tests);test_requester_owns_is_decided_by_the_acting_address(276 to 305) is the red one (Gotchas).- Known-answer vectors:
normalise_login_email(" Fritz+Desk@Practice.EXAMPLE ")isfritz@practice.example(adapted from the docstring's example atsignin.py59, which uses a client domain, with a fictional one;test_a_plus_tag_is_the_same_person, 262);dns_challenge("Practice.Example.", "tok")is("_alchemy-desk.practice.example", "alchemy-desk-verify=tok"); a TXT answer ofalchemy-desk-verify=tokmatches,ALCHEMY-DESK-VERIFY=tokmatches (prefix case-folded),alchemy-desk-verify=TOKdoes not (test_the_token_is_compared_case_sensitively, 172;test_the_prefix_is_not, 178);email_fingerprintis the first twelve hex characters ofsha256(lower(strip(email))), soemail_fingerprint("a@example.com")is08168cd80dfd(computed 2026-09-12 from the definition at 53 to 55). - Hermetic throughout: DNS is the fake resolver, mail is the
mail_logfixture, the flag is monkeypatched (login_on, 58 to 61). Playwright: none at fd8558f for the confirm page or the admin login page; thedeskLogin.test.tsvitest (proof-state model) is F59's.
Deploy & ops
Config keys per app heredoc (S13): DESK_DOMAIN_LOGIN_ENABLED=true when a workspace is ready to sign people in by domain (absent at b3d34da: the code ships, the switch is off); the six tuning values only when a default is wrong; FRONTEND_URL for the confirmation link. Migrations order: desk_domain_login_001 after service_desk_core_001 and before any code from c591c90 (2026-09-05, the router's first commit, which already reads the three tables); it is chained ahead of the mail half so the login half can be reverted alone (its docstring 9 to 13); downgrade drops the three tables and the columns (236 to 248). Scheduler jobs: _run_desk_domain_recheck inside run_daily_jobs under the S12 leader lock; it commits its own stats and rolls back on error. Argus checks: dig +short TXT _alchemy-desk.<domain> for every proven domain returning the published value (the daily job checks the same thing from inside; an external check catches a resolver difference between the host and the world); the desk.domain_unverified log line as an alert. Rollback: DESK_DOMAIN_LOGIN_ENABLED=false stops resolution and re-checks at once without touching a row; bindings and proofs stay and mean nothing until the flag returns; a code rollback past c923b8a needs the migration's downgrade only if the columns must go, and the acting_email columns are additive and nullable, so they can stay.
Gotchas
- 2026-09-12 (measured at fd8558f, D8 gotcha, red test):
tests/test_desk_acting_email.py::test_requester_owns_is_decided_by_the_acting_address(276 to 305) constructsDeskSettings(...)withoutclient_brief, a field dd38691 (2026-09-06) added after the test was written on 2026-09-05; it fails withTypeError: DeskSettings.__init__() missing 1 required positional argument: 'client_brief'. The other 121 pass. The fix isclient_brief=Nonein the constructor call, or a fixture that buildsDeskSettingsfromconfig_service.resolved_desk. - 2026-09-12 (measured at fd8558f):
POST /api/desk/login-emails/confirmis unauthenticated and carries noauth_rate_limit(515 to 516), unlike every other token-redeeming route (B18). The token is 32 bytes oftoken_urlsafelooked up by hash, so guessing is not the risk; the risk is a flood that costs a database read per attempt inside the global 1000 per minute. Adddependencies=[Depends(auth_rate_limit)]. - 2026-09-12 (measured at fd8558f, D8 gotcha): revocation does not end a live access token.
revoke_sessions_forsetssessions.is_active = Falseand callsauth_service._denylist_tokenfor both tokens (signin.py493 to 497), butget_current_userreads the JWT and theusersrow and never thesessionstable (api/dependencies.py186 to 241), anddecode_tokenasks the denylist only throughis_jti_revoked, which answersFalsewheneverJWT_DENYLIST_ENABLEDis off or Redis is unreachable (auth_service.py57 to 70, 146 to 163, 516 to 520).core/config.pydeclares no such field, so the flag is the environment variable, defaultfalse;alchemycloudat b3d34da sets it nowhere (git grep JWT_DENYLIST b3d34daprints nothing; B13's Gotchas record the same), and the harness runs withREDIS_ENABLED=false(tooling/verify/labs-local.sh103). So a revoked address keeps its access token for up toJWT_ACCESS_TOKEN_EXPIRE_MINUTES(60,core/config.py117) and loses only refresh (refresh_sessionfiltersSession.is_active == True,auth_service.py809 to 821, and the route turns theValueErrorinto 401). The test pins onlyfritz.is_active is False(test_desk_domain_signin.py676 to 693), never a 401. The fix the reference needs:JWT_DENYLIST_ENABLED=truewith Redis reachable on every app that mints Labs sessions, at which point the access lifetime also drops to 15 minutes (S5); a new build readssessions.is_activein its dependency instead, so revocation needs no second store. - 2026-09-12 (measured at fd8558f): the per-domain budget counts
magic_linksrows with alogin_emailat the domain (202 to 213), andcreate_magic_link_for_userstoreslogin_emailonly when it differs fromusers.email(auth_service.pydocstring, "stored ONLY when it differs"). A shared account whose ownaccount_emailasks for a link is therefore not counted, and akind="user"resolution is exempt anyway; the budget bounds strangers, which is what the docstring says it does. - 2026-09-12 (measured at fd8558f):
_notify_budgetand_warn_dnsemit throughDeskNotification.emitinside the caller's transaction (signin.py230 to 259) and swallow every exception; the notification's commit is the caller's. Atrequest-magic-linkthe ordinary refusal path setsresolution = Noneand commits atauth_router.py1050, so the notification is committed with it; it would be lost only if something raised between the emit and that commit, which reaches the rollback at 1053, and nothing on that path raises today. Harmless for the requester (202 either way); a new build that adds work between the budget check and the commit should keep the notification's fate in mind. - 2026-09-12 (measured at fd8558f): the daily re-check runs
check_domain_txtserially for every proven domain with a lifetime of twice the timeout each (10 s at defaults), inside the onerun_daily_jobssession; a hundred proven domains with a dead resolver hold the daily job for a quarter of an hour. Bounded today by the number of desk workspaces; a new build resolves in a thread pool with a total deadline. - 2026-09-12 (measured at fd8558f): the migration backfills
desk_tickets.acting_emailfromrequester_email(231 to 232), so tickets older than 2026-09-05 read the requester as the actor, which is right for tickets a person filed as themselves and a guess for tickets filed by the shared account before the column existed. The docstring says so; nothing else can be known. - 2026-09-12 (measured at fd8558f):
PUBLIC_MAIL_DOMAINSis a 23-entry literal (access.py43 to 55), so a freemail provider not on it can be declared, and a workspace that happens to control a TXT record there (it cannot, for any real provider) would sign in every stranger with an address there. The list is the fence; extend it before a new market. - 2026-09-12 (measured at fd8558f):
sessions.ip_addressat redemption isrequest.client.host(auth_router.py1228), the forgeable value (B18 Gotchas); theacting_emailon the same row is trustworthy, the IP is not. - 2026-09-12 (measured at fd8558f, public-safety): the schema examples for the desk login-domain fields (
services/service_desk/config_schema.pylines 170, 183 and 201, the field-leveldomainandaccount_emailexamples and the whole-entry example that repeats them) carry a real client domain and a real client mailbox as theirexamplesvalues, not a fictional one, at all three sites, not the one this recipe first measured. All three are reachable through the same JSON schema endpoint (GET /api/config/schema, mounted atapp_fastapi.py826,VerifiedUseronly) and returned to every Labs session, not only desk agents. Replace all three withpractice.example/desk@practice.example, matching the fictional domain this recipe uses throughout; the real values are not repeated here (AUTHORING.md, no client names in a recipe). - 2026-09-12 (measured at fd8558f, D8 gotcha): the three desk test files are not order-independent.
test_desk_domain_signin.py::test_refresh_rederives_the_claim_from_the_session_rowuses acommitted_accountfixture that openscore.database.get_db_context()directly and inserts aUserrow without depending on the session-scoped schema-building fixture (conftest.py, the same pattern B4's Gotchas records fortest_surface_scoped_access.py); run alone, or first in a shuffled order, it fails at setup withpsycopg2.errors.UndefinedTable: relation "users" does not exist(measured:pytest tests/test_desk_domain_signin.py -k test_refresh_rederives_the_claim_from_the_session_rowerrors on its own; the full three-file run without-p no:randomlyprinted1 failed, 120 passed, 1 erroragainst the same1 failed, 121 passedthe pinned order gives). Fix: havecommitted_accountrequest thedbfixture (or any fixture that forces schema creation) instead of opening its own context, the same fix B4 names for its own flaky test. - 2026-09-05 (c591c90, the router's first commit, whose docstring 18 to 24 already records it): an earlier draft let an agent name a
user_idand mailed that account a session-minting link; becausedesk.agentsis a list of user ids readable through the workspace config, every target was public. The confirmation token that setsverified_atand mints nothing is the replacement. A new build never lets an authenticated caller cause a session to be minted for an account they name. - 2026-09-05 (e895e7d, found while building this module): the shared per-IP counter was 429ing a client's own login because their site's analytics beacon shared the office IP;
/api/authleft the volumetric tier and the per-mailbox throttle arrived (B18). Any product that puts a tracking beacon and a login behind one IP needs the same split.
Reference implementation
alchemy_labs, branch d1a-token-source at fd8558f (2026-09-12; backend identical to feat/service-desk b1089b6; the module landed across 034e23b (the three models and the migration), fbd26b1 (the declaration schema), c591c90 (the router and signin.py), cfd833b (the DNS proof and the re-check), c923b8a (containment in access.py and test_desk_acting_email.py) and 44955d1 (was_verified, desk_only, the frontend), all on 2026-09-05). Paths relative to backend/:
api/routers/desk_login_router.py(599 lines):_confirm_url(65 to 67),_sync(90 to 101),_verified_elsewhere(115 to 133),_domain_payload(136 to 172),list_domains(175 to 188),_prepare(191 to 212),_finish(215 to 247),verify_domain(250 to 273),verify_domain_manually(276 to 296),delete_domain_verification(299 to 326),list_login_emails(334 to 368),add_login_email(376 to 476),_send_alias_confirmation(479 to 507),confirm_login_email(515 to 552),revoke_login_email(560 to 592), the include (599).services/service_desk/signin.py(499 lines):email_fingerprint(53 to 55),normalise_login_email(58 to 72),Resolution(79 to 85),resolve_login_email(107 to 171),domain_link_budget_ok(179 to 227),_notify_budget(230 to 259),create_intent(267 to 292),_unique_username(295 to 304),redeem_intent(307 to 397),record_login(405 to 467),revoke_sessions_for(470 to 499).services/service_desk/access.py(839 lines):PUBLIC_MAIL_DOMAINS(43 to 55),DESK_ONLY_ROLES(58),DeskAccessUnavailable(75 to 85),DomainRule(88 to 109),_declarations(134 to 152),_verification(155 to 165),declared_rule_for_domain(168 to 194),effective_rule_for_domain(197 to 227),rules_for_workspace(230 to 245),user_for_email(252 to 301),is_workspace_member(304 to 314),heal_membership(327 to 350),heal_shared_account(353 to 364),is_desk_only_user(367 to 391),desk_only_path_allowed(405 to 423),dns_challenge(437 to 439),_txt_strings(442 to 455),check_domain_txt(458 to 499),sync_domain_verifications(507 to 565),purge_disabled_verifications(567 to 590),ensure_shared_account(598 to 669),revoke_domain_access(677 to 745),recheck_domain_verifications(748 to 811),_warn_dns(814 to 839).models/user_login_email_model.py(209 lines): the three tables.api/routers/auth_router.py: the desk branch ofrequest_magic_link(1032 to 1120),verify_magic_link(1127 to 1262: intent redemption 1152 to 1159,record_login1190 to 1202, containment 1204 to 1218, the session 1222 to 1236), the same containment inlogin(603 to 604).api/dependencies.py:_enforce_desk_only_containment(160 to 184),ActingEmail(782 to 794) (S5).services/auth_service.py:Redemption(308 to 325),create_magic_link_for_user(1088 to 1146),create_sessionwithacting_email(683 to 769).services/service_desk/config_service.py167 to 169 (the mirror call),services/service_desk/config_schema.py29 to 33,LOGIN_DOMAINS_SCHEMA195 to 203 (definition) and 290 (wired into the desk section),services/service_desk/access.pyas above.services/scheduled_jobs.py:run_daily_jobs(1753 to 1770),_run_desk_domain_recheck(1792 to 1813).migrations/versions/20260905_0001_desk_domain_login.py(248 lines).core/config.py982 to 988.- Tests as listed under Tests.
- Frontend (
frontend/):src/services/serviceDeskService.ts1038 to 1105,src/pages/admin/desk/AdminDeskLogin.tsx,src/pages/desk/ConfirmLoginEmail.tsx,src/lib/deskLogin.tsanddeskLogin.test.ts,src/pages/MagicLinkCallback.tsx48,src/App.tsx255 and 381. - Wiki (
philosophers_stone6322fa3):platform/service-desk.mdline 105 (the founder's model, 2026-09-04) and 126 (the slice).
Deviations of the reference from this recipe, all dated 2026-09-12: the flag off on the estate; the red DeskSettings test; no rate limit on confirm; serial DNS re-checks; the backfilled acting_email on old tickets; the three schema examples naming a client; one test not order-independent; the forgeable session IP; the access token outliving a revocation while the denylist is off.
Acceptance checks
Run from alchemy_labs/backend at fd8558f with the :5434 Postgres up, or from a new service built to this recipe. Expected observations are exact.
python -m pytest tests/test_desk_domain_signin.py tests/test_desk_domain_verification.py tests/test_desk_acting_email.py -q --no-cov -o addopts="" -p no:randomlyprints1 failed, 121 passedat fd8558f and122 passedonce theDeskSettingscall carriesclient_brief.python -c "from core.config import settings; print(settings.DESK_DOMAIN_LOGIN_ENABLED)"printsFalsewith no environment set; inalchemycloudat b3d34da,grep -c DESK_DOMAIN_LOGIN_ENABLED scripts/distribute-env.shprints0.python -c " from services.service_desk.signin import normalise_login_email as n, email_fingerprint as f from services.service_desk.access import dns_challenge as d, is_public_mail_domain as p print(n(' Fritz+Desk@Practice.EXAMPLE '), d('Practice.Example.', 'tok'), p('gmail.com'), p('practice.example'), f('a@example.com'))"printsfritz@practice.example ('_alchemy-desk.practice.example', 'alchemy-desk-verify=tok') True False 08168cd80dfd.grep -n "db.delete" services/service_desk/signin.py api/routers/desk_login_router.py | wc -lprints0;grep -c "db.delete(row)" services/service_desk/access.pyprints1(the purge past the grace window, and nothing else).- On a database at alembic head (the harness's scratch database
labs_renderfromtooling/verify/labs-local.sh up, which runsalembic upgrade head, oralembic upgrade headagainst a scratch database of your own; nottest_dbafter a pytest run, becausetests/conftest.py102 to 108 builds the schema withBase.metadata.create_alland drops every table at session teardown, so noalembic_versionrow and no migrated schema survive it):psql postgresql://test_user:test_password@localhost:5434/labs_render -c "\d desk_domain_verifications" | grep -c "uq_desk_domain_verifications_verified"prints1and the index line reads"uq_desk_domain_verifications_verified" UNIQUE, btree (domain) WHERE verified_at IS NOT NULL AND disabled_at IS NULL. Keep-c, not-Atc: tuples-only mode prints the column rows and suppresses the Indexes footer, so the same grep prints0on a fully migrated database (measured 2026-09-12). - Setup for 6 to 11, with the harness up and
DESK_DOMAIN_LOGIN_ENABLED=trueexported beforetooling/verify/labs-local.sh up(the script starts the backend throughenvwith its own fixed list,labs-local.sh100 to 107 and 160, andenvinherits the caller's exports, so the variable reaches it). The harness seeds one owner,render-owner@example.com, with no desk role and no admin bit; keep it that way (AUTHORING.md).$Wis that owner's workspace:psql postgresql://test_user:test_password@localhost:5434/labs_render -Atc "SELECT id FROM workspaces WHERE owner_id = (SELECT id FROM users WHERE email = 'render-owner@example.com')";$Ois the owner's id from the inner select.$P:curl -s -X POST -H "Content-Type: application/json" -d '{"email":"platform-admin@example.com","password":"<a fresh one>","first_name":"Platform","last_name":"Admin"}' http://127.0.0.1:8020/api/auth/registerreturns a session (REQUIRE_EMAIL_VERIFICATION=false); thenpsql ... -c "UPDATE users SET is_admin = true, admin_appointed_by = id, admin_appointed_at = now() WHERE email = 'platform-admin@example.com'"(the shape fromidentity-auth/admin-user-management-and-impersonation, Deploy & ops;is_adminis read from the row on every request, so the session just minted is now an admin's, and an admin is an agent in every workspace,roles.py64 to 65); thenpsql ... -c "INSERT INTO workspace_users (id, workspace_id, user_id, role, is_active, joined_at, updated_at) VALUES (gen_random_uuid(), '$W', (SELECT id FROM users WHERE email = 'platform-admin@example.com'), 'member', true, now(), now())", because the workspace middleware admitsX-Workspace-IDonly for an owner or an activeworkspace_usersrow and has no admin bypass (middleware/workspace_middleware.py286 to 360; the tests never see this,desk_support.py119 to 125 overrides the workspace dependency). The harness's/loginpage auto-signs-in the first admin from now on (dev-login, AUTHORING.md);tooling/verify/labs-local.sh resetundoes all of this. The declaration and the agent list, written by$Pbecause the owner is not yet an agent andConfigAgentrefuses anyone else (service_desk_router.py1012 to 1020):curl -s -i -H "Authorization: Bearer $P" http://127.0.0.1:8020/api/workspaces/$W/config | grep -i etagprintsETag: "1"(the document is created at v1 on first read,config_service.py173 to 187), thencurl -s -X PATCH -H "Authorization: Bearer $P" -H "If-Match: \"1\"" -H "Content-Type: application/json" -d '{"patch": {"agents": ["'$O'"], "login_domains": [{"domain": "practice.example", "mode": "shared_account", "account_email": "desk@practice.example"}]}}' http://127.0.0.1:8020/api/workspaces/$W/config/desk -w "\n%{http_code}"prints200(the owner counts as an active member fordesk.agents,roles.py105 to 121; the shared account need not exist yet,config_validate.py101 to 107).$A:POST /api/auth/loginwith{"email": "render-owner@example.com", "password": "<tooling/verify/.labs-local/owner.pass>"}returns the pair; the owner is now an agent (roles.py72 to 73). With$A,$Wand$Pin hand:curl -s -H "Authorization: Bearer $A" -H "X-Workspace-ID: $W" http://127.0.0.1:8020/api/desk/config/domains | python -c "import json,sys; d=json.load(sys.stdin)[0]; print(d['declared'], d['verified_at'], d['dns_name'], d['dns_value'][:20])"printsTrue None _alchemy-desk.practice.example alchemy-desk-verify=; thencurl -s -X POST -H "Authorization: Bearer $A" -H "X-Workspace-ID: $W" http://127.0.0.1:8020/api/desk/config/domains/practice.example/verify -w "\n%{http_code}"prints a body with"txt_record_not_found","expected_name","expected_value"and"found":[]and the code409(the harness has no such zone). - Same setup, with the platform admin's session in
$P:curl -s -X POST -H "Authorization: Bearer $P" -H "X-Workspace-ID: $W" http://127.0.0.1:8020/api/desk/config/domains/practice.example/verify-manual | python -c "import json,sys; d=json.load(sys.stdin); print(d['method'], bool(d['verified_at']))"printsmanual True;psql ... -Atc "SELECT password_hash IS NULL, is_verified FROM users WHERE email = 'desk@practice.example'"printst|t(the shared account was provisioned at verification with no password); the same call with$Ainstead of$Pis 403 with"admin_required". curl -s -X POST -H "Content-Type: application/json" -d '{"email":"someone@practice.example"}' http://127.0.0.1:8020/api/auth/request-magic-link -w "%{http_code}"prints202; the harness log carries a[DEV] Magic link (labs) for someone@practice.example:line whose token, posted toPOST /api/auth/verify-magic-link, returns a body with"acting_email":"someone@practice.example"and"user":{"email":"desk@practice.example", ...}; decoding the access token's payload (S5 check 10) showsaemequal to the acting address anddskequal to1;psql ... -Atc "SELECT source, verified_at IS NOT NULL, workspace_id = '$W' FROM user_login_emails WHERE email = 'someone@practice.example'"printsdomain_rule|t|t.- With that session in
$S:curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $S" http://127.0.0.1:8020/api/forms/templatesprints403and the body'serrorisdesk_only_rolewithdetails.errorequal to it (the S7 envelope of a dict detail);curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $S" http://127.0.0.1:8020/api/desk/ticketsprints200;psql ... -Atc "SELECT count(*) FROM workspaces WHERE owner_id = (SELECT id FROM users WHERE email = 'desk@practice.example')"prints0. curl -s -X DELETE -H "Authorization: Bearer $A" -H "X-Workspace-ID: $W" http://127.0.0.1:8020/api/desk/config/login-emails/someone@practice.example | python -c "import json,sys; d=json.load(sys.stdin); print(bool(d['revoked_at']), d['killed_sessions'])"printsTrue 1;psql ... -Atc "SELECT is_active FROM sessions WHERE acting_email = 'someone@practice.example'"printsf;curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -d '{"refresh_token": "'$R'"}' http://127.0.0.1:8020/api/auth/refresh(with$Rthe refresh token minted in check 8) prints401;curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $S" http://127.0.0.1:8020/api/desk/ticketsstill prints200on the harness and on the estate at b3d34da, and prints401only whereJWT_DENYLIST_ENABLED=trueand Redis are set (Gotchas); a secondrequest-magic-linkfor the same address is202and the log carries no[DEV] Magic linkline for it, onlydesk.signin_denied reason=revoked.curl -s -X DELETE -H "Authorization: Bearer $A" -H "X-Workspace-ID: $W" -H "Content-Type: application/json" -d '{"revoke_sessions": true}' http://127.0.0.1:8020/api/desk/config/domains/practice.example/verification | python -c "import json,sys; d=json.load(sys.stdin); print(d['verified_at'], d['revoked_bindings'] >= 0)"printsNone True;GET /api/desk/config/domainsnow shows"verified_at": null, "was_verified": true, "declared": truefor the domain, and a freshrequest-magic-linkforanother@practice.exampleis202with no link minted.grep -n "acting_email" migrations/versions/20260905_0001_desk_domain_login.py | wc -lprints10(four adds, the two-line backfill, four drops);python -m alembic headsfrombackend/prints one head.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.