DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path investigations/loomworks-authenticated-marketing-access-investigation-v0_1.md

Authenticated access to the marketing site — what exists today — investigation — v0.1

Version. 0.1 Date. 2026-07-17 Author. Claude Code (CC) on DUNIN7-M4, read-only inspection; for Marvin Percival (DUNIN7 Operator). Status. Findings document. Grounding investigation across four repos; no CR. Markdown primary (technical consumer). Every claim is from this session's own read of source — ~/loomworks (Operator Layer frontend, d6f7639), ~/loomworks-engine (4fe7541), ~/stele (f581840), ~/loomworks-marketing (37a0287) — cited file:line, produced by four parallel read-only source-reads and spot-checkable; no prior session's findings relied upon; nothing changed. Reports and characterises; designs, recommends, and resolves nothing.


Plain-language summary

What this reports. The present state of every building block for one idea: a menu in the Operator Layer that authenticates a human with Stele, checks authorization, and issues something a Cloudflare Pages Function on the marketing site can verify — so the sales generator and future tools are reachable only by authorised people. It characterises what exists today; it does not design the mechanism. Agent identity is out of scope — a human surface only; Stele Agentic ID returns a verdict, not a credential, and has no edge story, appearing below only to confirm its crypto is not on the engine's path.

What was found. Every server-side building block for the human side exists in some form; the edge-verification and cross-domain pieces do not exist at all, and one of them (a public-key artifact Stele can produce) is structurally absent.

What decision this grounds. This is grounding, not a build order — no CR is attached. It informs whether and how to put authenticated, per-person-gated tools (starting with the sales generator) on the marketing origin, by making plain which pieces exist, which are absent, and which are structurally blocked — notably that no cookie can cross the two registrable domains, the engine mints no public-key artifact, and Stele's session is symmetric-only.

Out of scope. Designs, recommends, and resolves nothing; chooses no architecture. Agent identity is excluded. External/ops facts the repos do not settle — production reachability and the live production hostnames — are reported as unsettled, not decided here. (Two incidental security findings — plaintext API keys on disk — are noted at the end.) Read-only; nothing changed.


THE MENU SURFACE

Route structure and post-auth landing. The Operator Layer is a Next.js 16 App-Router app; routes live under src/app/: /, /auth (dev session minter), /signin (production sign-in), /chat, /dashboard, /inbox, /library, /spend, /settings, /settings/security, /claim, /admin/grants, /operator/engagement-navigation, /operator/create-engagement, /operator/engagement/[engagement_address], /operator/filters.

A signed-in Operator lands on /operator/engagement-navigation: / is a server component that unconditionally redirects there (src/app/page.tsx:16-20, POST_AUTH_LANDING = "/operator/engagement-navigation"); sign-in success routes to / which bounces on (src/app/signin/page.tsx:102,137). (Caveat: first-login routes to /welcome, but no /welcome route exists — that path would 404. signin/page.tsx:102.)

Is there a surface where a tool list could live, or would that be a new page? The signed-in home (/operator/engagement-navigation, Home.tsx) is a cross-engagement list — three lens tabs (My engagements / Needs you / Recent) and visibility zones, plus a "+ Create engagement" button (Home.tsx:81-93). /dashboard (DashboardView.tsx:3-14) is likewise Active / Needs You / Recently Finished work items. Both list engagements, not tools. A tools menu would be a new surface; the only existing places to hang one are the persistent top NavBar or the engagement-navigation home.

Existing tool list / app launcher / anything similar? None. The only navigation chrome is NavBar (src/components/nav/NavBar.tsx:34-90) — a fixed, hardcoded set of <Link>s (wordmark, Dashboard, Inbox, Library, Spend, Engagement navigation, a Companion button, BalanceChip, TierBadge, UserMenu), collapsing to a 4-item bottom bar on mobile (:96-111). AppShell hides nav entirely on /, /auth, /signin, /operator/engagement-navigation, and /operator/engagement/* (AppShell.tsx:25-46). There is no data-driven or extensible "tools/apps" menu anywhere in the code.


HOW THE OPERATOR LAYER AUTHENTICATES TODAY

The sign-in flow, end to end (frontend src/lib/auth/signin-flow.ts + src/app/signin/page.tsx; engine src/loomworks/api/routers/login.py + src/loomworks/persons/login.py). It is WebAuthn passkey (1st factor) + TOTP/recovery code (2nd factor); no passwords, and every call rides the shared api() wrapper with credentials:"include" (src/lib/api.ts:56, native fetch, no axios):

  1. Browser → POST /auth/login/begin with empty body {}. Engine → {authentication_id, options} — a discoverable/resident-key ceremony, allow_credentials=None; identity is resolved by the chosen credential, email is never sent (login.py:92, persons/login.py:197). Challenge parked in an in-process store (5-min TTL).
  2. Browser runs navigator.credentials.get() (webauthn-client.ts:129), then POST /auth/login/complete with {authentication_id, credential}. Engine verifies the assertion via stele.webauthn.verify_authentication, bumps sign_count, clone-checks, and {person_id, requires_totp:true, partial_session} — an opaque psk_… token (in-process PartialSessionStore, 10-min TTL). No cookie set yet; the partial session grants no access (login.py:130).
  3. Browser → POST /auth/login/totp-verify (or /auth/login/recovery) with {partial_session, code}. Engine verifies TOTP (pyotp) / single-use recovery code, gates account status (deleted→403; suspended→no cookie), and on success sets the session cookie and returns {session, expires_at, session_established, first_login, reason} (login.py:245/332, cookie at login.py:321/404).
  4. POST /auth/logout clears the cookie; there is no server state to invalidate (login.py:415).

What the browser holds afterward: a cookie, and only a cookie. The session/partial_session string fields in the JSON are not persistedpartial_session is carried between steps in React state (in-memory); the final session/expires_at are read but never stored (signin/page.tsx:83-87). Session establishment is the engine-set loomworks_session cookie (signout.ts:5-6; src/loomworks/api/constants.py:7). There is no Authorization/Bearer header and no auth in localStorage/sessionStorage anywhere in the frontend (grep for Authorization/Bearer/document.cookie in src/ → nothing; storage holds only workspace scope and composer drafts). On a 401 the api() wrapper redirects to /auth (api.ts:69-78); AuthProvider probes GET /me on mount (AuthProvider.tsx:68).

What it presents on subsequent calls: the loomworks_session cookie, auto-sent because every api() call sets credentials:"include".

The cookie's actual attributes (engine _set_session_cookie, login.py:194-225):

Cross-origin in dev (frontend :3001 ↔ engine :8000). Handled by a same-origin Next.js rewrite proxy, not a direct cross-origin fetch. The dev script sets NEXT_PUBLIC_API_URL=/api; next.config.ts:20-24 rewrites /api/:path → the engine origin (DEV_ENGINE_ORIGIN, default http://127.0.0.1:8000) only when NODE_ENV==="development" (production build emits no rewrites). So in dev the browser calls http://localhost:3001/api/first-party — keeping the session cookie first-party (next.config.ts:15-19). Independently, the engine's CORS also allows http://localhost:3000 and http://localhost:3001 when LOOMWORKS_ENV ∈ (development, test), with allow_credentials=True (app.py:395-428) — but the frontend proxy means the browser doesn't actually make a cross-origin credentialed call in dev. Both mechanisms are present; the proxy is the one in the path.

Production hostnames — not pinned in either repo; same-site is not settled by code.


THE CROSS-DOMAIN PROBLEM

Different registrable domains — confirmed; no cookie can be shared. The marketing site's own config declares site: https://loomworks.doneinseven.com (~/loomworks-marketing/astro.config.mjs), and the engine is intended under …dunin7.com. doneinseven.com and dunin7.com are two separate eTLD+1s (registrable domains). A cookie set by one cannot be read or sent by the browser to the other: the Domain attribute can only be the setter's own registrable domain or a subdomain of it, never a sibling domain, and browsers reject a Domain outside the current registrable domain. SameSite does not change this — it only loosens/tightens sending of a cookie that already belongs to that domain. So cookie sharing doneinseven.comdunin7.com is impossible. (The loomworks_session cookie is additionally host-only — no Domain set — so it doesn't even span subdomains of its own setter.)

Redirect-based flow in the engine today — none exists. Grep across src/ for RedirectResponse, 302/303/307, redirect_uri, callback returns no HTTP redirect (the only callback hits are in-process async callbacks). There is no OAuth flow, SSO callback, or magic-link redirect. Every auth endpoint is a JSON POST that returns a body and/or sets a cookie; the browser is never sent a Location. Navigation is driven client-side off JSON responses.

Engine public reachability in production — designed for a Cloudflare tunnel; not proven in-repo. The engine repo commits no deployment or tunnel infrastructure — no Dockerfile, docker-compose, cloudflared/tunnel config, systemd unit, Procfile, or deploy script. It is a plain uvicorn loomworks.api.app:app (app.py:8). The docs describe the intended posture as behind a Cloudflare tunnel ("M4 tunnel"), not a direct public bind, repeatedly flagged as a parallel non-CR operational track whose live status was left conditional (docs/phase-crs/phase-52-…). The repo does not establish that the engine is currently publicly reachable — a Pages Function or a marketing-origin browser would need a reachable public engine URL, and that is an external/ops fact the code does not settle.

Engine CORS — what origins it currently allows. app.py:395-428. CORSMiddleware is registered only if the computed origin list is non-empty:


SIGNING

Crypto the engine already has. From ~/loomworks-engine/pyproject.toml (uv.lock resolved): cryptography>=43 (→ 46.0.7), webauthn>=2.7.1, bcrypt>=5.0.0, pyotp>=2.9. No pynacl, no ed25519, no standalone Ed25519 lib.

Is Stele Agentic ID's Ed25519 reusable, or confined to that product? Confined. The engine depends on stele (the identity substrate at ~/stele, an editable local path in pyproject.toml), and imports only session/WebAuthn/principal/credential machinery from it (stele.session.resolve_session, stele.webauthn, stele.registry.Principal, stele.models.PrincipalRow, ~20 sites). It does not depend on or import stele-agentic-id, stele-core, or any agentic-id package (grep of src/, pyproject.toml, uv.lock → nothing). The stele package itself contains no Ed25519 (its asymmetric handling is WebAuthn COSE-key verify; sessions are Fernet). The domain-separated Ed25519 scheme (stele-aid/…/v1 prefixes) lives only in ~/stele-agentic-id, which the engine neither depends on nor imports. That crypto is not on the engine's import path — reusing it would require adding a dependency that is not present.

Can a Cloudflare Pages Function verify an Ed25519 signature? Yes — natively, no library. Cloudflare Workers/Pages Functions run a runtime whose WebCrypto/SubtleCrypto implements Ed25519 as a supported algorithm; the shape is crypto.subtle.importKey('raw', <32-byte pubkey>, {name:'Ed25519'}, false, ['verify']) (or 'spki') then crypto.subtle.verify('Ed25519', publicKey, signature, message) → Promise<boolean>. No jose/tweetnacl needed for plain Ed25519 verify. Caveats for this project: the marketing project imports no crypto today (grep of functions/+src/ for crypto/subtle/importKey/verify/sign/Ed25519/jose/tweetnacl → zero real hits; the one match is the word "verify" in a comment), and there is no wrangler.toml/wrangler.jsonc anywhere, so no compatibility_date is pinned in the repo — Ed25519's availability would rely on the Pages project's default compatibility date (Ed25519 stabilized in Workers WebCrypto with recent compatibility dates). The runtime supports it; the repo has neither exercised nor configured it.

Where a private key would live / how a public key would reach the Pages Function — mechanisms available (not a recommendation).


AUTHORIZATION

The actual functions and tables that make authorization decisions today — coarse role/designation checks, on two axes.

| Function (deps.py unless noted) | What it checks | Backing table / source | |---|---|---| | require_operator_designation (:443) [confirmed] | OPERATOR ∈ membership.designations, else 403 | membership_designations (PK (membership_id, designation), soft-delete active) joined from memberships (persons.id×engagements.id). Allowed set is code-side: ALLOWED_DESIGNATIONS = {"operator","contributor","domain_expert"} (persons/memberships.py:36). Migrations 0029/0031. | | require_founder (:598) [confirmed] | current principal's UUID == stored platform-founder UUID, else 403 (fail-closed if unset) | system_config singleton, key platform_founder_person_id (Fernet-encrypted); set only by scripts/bootstrap_platform_founder.py (no HTTP write). Gates the three /admin/* credit-grant routes (admin_grants.py:214,456,641). | | get_current_principal (:550) | session cookie → Principal; enforces totp_verified (else 401) | stele.session.resolve_session. The authentication gate all authorization builds on. | | get_current_membership_for (:416) | 403 if the person has no membership on the engagement | memberships (belongs-to gate, precedes designation checks) | | assert_engagement_creator (:497) | caller's session == engagements.created_by_person_id | engagements.created_by_person_id (candidate-engagement ownership) | | get_current_contributor (:625) | dual-path (cookie/human vs. Authorization: Bearer/agent); for humans synthesizes commit_authority from designations (operator/contributor → writes) | membership_designations | | _require_contributor_designation (contributions.py:60) | contributor-designation variant of the operator check | membership_designations | | is_spend_authorized (credit/authorization.py:107), check_credit_balance (credit/balance.py:69) | gate on balance/eligibility, not role | credit tables |

**Could an endpoint consult these and get a yes/no for a named person against a named tool? For humans, no — there is no per-tool authorization concept. Human access is decided solely by: (a) authenticated session, (b) membership on an engagement, (c) coarse designation from a fixed 3-value set (operator/contributor/domain_expert, extended only by code change), and (d) the single platform founder flag. There is no table or function that answers "may person X invoke tool/capability Y"** — no per-capability grant rows keyed to a person, no capability column on memberships.

A per-capability model does exist, but only for the Companion (agent), not for humans: verify_companion_authorization (delegation/authorization.py:97) answers "is the Companion authorized to perform this capability for this person on this engagement?" over a fixed Tier-1 frozenset (produce_specification, produce_artifact, initiate_render, commit_notes; delegation/schema.py:31-56), gated by delegation assertions the person stored in personal-engagement memory (DelegationContent, schema.py:74) — an agent-acting-for-a-person model, not a which-human-may-use-which-tool model. So: named-person × named-tool authorization for humans does not exist in the code; the nearest existing machinery authorizes an agent, and is out of scope here.

Does Stele have any opinion downstream of a resolved session? No — confirmed verbatim ("Stele resolves who; the host gates what").


Consolidated — what does not exist yet, or is not settled by the code

Incidental findings (not requested; flagged for awareness)


DUNIN7 · inspection only · 2026-07-17 · nothing designed, recommended, or built; every claim traced to source this session.