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.
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.
loomworks_session cookie — a stateless Fernet (symmetric) token. Dev cross-origin is solved by a same-origin Next.js proxy, so the cookie stays first-party. No bearer, no redirect flow, no OAuth.loomworks.doneinseven.com and dunin7.com are different registrable domains; no cookie can cross. The engine has no redirect flow, is designed to sit behind a Cloudflare tunnel (not proven publicly reachable in-repo), and its production CORS allowlist is empty by default (no marketing origin allowed today).cryptography but unused; only Fernet/HMAC and WebAuthn-verify are used). Stele has no public-key story — its session is symmetric-only, verifiable at the edge only by sharing its secret key. A Cloudflare Pages Function can verify Ed25519 natively via WebCrypto, but the marketing project imports no crypto today and pins no compatibility_date.require_operator_designation and require_founder confirmed, list completed below. There is no "may person X use tool Y" concept for humans. The only per-capability model is the Companion/agent delegation path.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.
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.
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):
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).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).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).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 persisted — partial_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):
laxenv LOOMWORKS_SESSION_COOKIE_SECURE — off by default (localhost http dev), on when set (HTTPS/prod/tunnel)/; expires = 24h{person_id, totp_verified, created_at, expires_at} under the KEK MultiFernet — integrity/signature are Fernet's AEAD, no server-side session store, default TTL 24h (~/stele/src/stele/session.py:32,47,68). (A stale comment in login.py:432 says "1-hour expiry"; the code default is 24h.)
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.
.env* is committed in either repo (both .gitignore them); the Operator Layer has no vercel.json/wrangler.toml, and unset NEXT_PUBLIC_API_URL falls back to http://localhost:8000 (api.ts:16) — a dev fallback, not a real endpoint. The only hostnames in committed frontend config are dev-scoped: loomworks-dev.dunin7.com, 192.168.4.25 (next.config.ts:13).api.loomworks.dunin7.com via a Cloudflare "M4 tunnel" (docs/phase-crs/phase-52-…:96,180,355, bound at runtime via LOOMWORKS_AUTHORITY_INSTANCE_URL, config.py:90); product identity corrected from loomworks.com (not owned) to loomworks.doneinseven.com (docs/phase-impl-notes/phase-51-…-v0_2.md:173-181); dev tunnel loomworks-dev.dunin7.com, WEBAUTHN_RP_ID=dunin7.com (engine .env).api.loomworks.dunin7.com is not determined by the repos. It would be injected at deploy time via NEXT_PUBLIC_API_URL/DEV_ENGINE_ORIGIN. What is certain: the marketing site (loomworks.doneinseven.com) is a different registrable domain from the engine (…dunin7.com) — see next section.
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.com ↔ dunin7.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:
LOOMWORKS_ENV ∈ development, test): http://localhost:3000, http://localhost:3001, plus anything in LOOMWORKS_CORS_ORIGINS.LOOMWORKS_CORS_ORIGINS — whose config default is "" (config.py:76). With the env var unset in production, the CORS middleware is not even added — there is no cross-origin allowlist at all.allow_credentials=True; allow_methods=[GET,POST,PUT,PATCH,DELETE,OPTIONS]; allow_headers=["Content-Type"].loomworks.doneinseven.com) is allowed today; a credentialed cross-origin call from the marketing site would be blocked unless that origin is explicitly added to LOOMWORKS_CORS_ORIGINS — and even then, the cross-domain cookie fact above means the cookie wouldn't ride along regardless.
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.
cryptography ships cryptography.hazmat.primitives.asymmetric.ed25519. But nothing in the engine imports or uses it. Grep of src/ for ed25519|nacl|\.sign(|private_key|hazmat returns no asymmetric-signing code.public_key + sign_count through the webauthn library (~/stele/src/stele/webauthn.py:31-32; engine call sites commit_attestation.py:186-187, persons/login.py:305); the private key lives on the user's authenticator, never server-side. Everything else the engine does is symmetric: Fernet/MultiFernet for API-key store, system_config, TOTP-at-rest, credential envelope/KEK, and session tokens; HMAC (hmac.compare_digest) for bearer tokens. The engine mints no asymmetric signature of any kind today.
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).
STELE_SECRET_KEY for the KEK); there is no asymmetric key material or key-loading path in the code.context.env.X — exactly how ANTHROPIC_API_KEY is supplied (functions/api/_lib.js:238; documented as a Dashboard/wrangler pages secret put value in .env.example), and (b) a KV namespace binding read via env.X — exactly how RATE_LIMIT_KV is bound (functions/api/_lib.js:202-203). A public key is not secret, so a plain Pages env var (or a build-time constant) would carry it; KV is available if rotation/lookup is wanted. Because there is no committed wrangler config, these bindings are configured on the Cloudflare Pages dashboard, not in-repo. Local dev supplies the same via a gitignored .dev.vars.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").
docs/stele-mount-contract-v0_1.md:12: "Stele proves who a caller is; your app decides what they may reach." And :14-16: "It does not authorize … the seam is one slot: resolve_current_principal." README.md:19 and AGENTS.md:3,50-52 repeat it ("Do not expect a permissions or roles system from Stele").docs/…:77, "how it reaches the browser is yours") or mint a CSRF token (:93). Everything past resolving the principal is the host's business.decode_session (~/stele/src/stele/session.py:66-92) validates MAC/integrity/expiry with only the secret key, no DB call. But it is sealed with the symmetric KEK MultiFernet (kek.py:5-6,108-116) — there is no Ed25519, no asymmetric key, no public-key verify path anywhere in Stele. A JS Pages Function could verify a Stele session only if it held Stele's symmetric secret (the sealing key itself) — not a distributable public key. (Note: the default resolve_session also does one DB read to confirm the principal still exists (session.py:159); the crypto check is DB-free, the shipped resolver is not.) So there is no "hand the edge a public key" story for Stele sessions today — any edge-verifiable, public-key-signed artifact would be something the engine mints on top of Stele, which (per SIGNING) it does not do today./welcome, referenced on first login, also does not exist.)cryptography) but unused; the engine mints no signatures; only Fernet/HMAC and WebAuthn-verify are exercised.stele, not the agentic product.wrangler.toml / pinned compatibility_date — Ed25519-verify is available in the Workers runtime but neither imported nor configured here.api.loomworks.dunin7.com behind a Cloudflare tunnel (per docs, with a mid-project loomworks.com→loomworks.doneinseven.com correction); the Operator Layer frontend's production host is stated nowhere, so frontend↔engine same-site cannot be confirmed from code.loomworks.doneinseven.com and dunin7.com are different registrable domains (a fact, not a gap, but it constrains everything above).~/loomworks/.dev.vars contains a live-looking ANTHROPIC_API_KEY=sk-ant-api03-… in plaintext. Gitignored (not committed), but a real secret on disk.~/loomworks-marketing/.dev.vars likewise holds a live-looking ANTHROPIC_API_KEY=sk-ant-api03-…. Gitignored; same note.DUNIN7 · inspection only · 2026-07-17 · nothing designed, recommended, or built; every claim traced to source this session.