Repository navigation
Promote hosted accounts to canonical identity and fix spoofable owner authorization #23
Description
Context
Hosted identity is effectively a self-asserted ownerId query param / body field defaulting to "anonymous" (apps/api/src/worker.ts:273/1704/1742), so GET /api/schedules?ownerId=<someone-else> and GET /api/alerts?ownerId=... (worker.ts:1704/1742) return another user's data, and namespace ownership is checked against the same untrusted value (worker.ts:1125). The contextmem_accounts + contextmem_sessions tables already exist (migrations/0001_account_gate.sql) but the hosted Worker never uses them for identity; authed users are even hardcoded unlimited: true (worker.ts:1503).
Goal / user story
As a hosted user, I want my owner-scoped data (namespaces, schedules, alerts, usage) keyed to a verified session/account so no one can read or mutate my resources by guessing my ownerId.
Acceptance criteria
- A
resolveOwner(request, env)helper derivesowner_idfrom a verified session (contextmem_sessions.token_hashvia httpOnly cookie orAuthorization: Bearer ctx_…) — never from a query/body param — and is the single source for all owner-scoped routes. - Owner-scoped routes (
/api/schedules,/api/alerts,/api/namespaces,/api/extractions,/api/usage) reject requests whose resolved owner != the row owner with 401/403; the?ownerId=param is ignored for authorization. - Delegate import (
/api/memwal/import-delegate) associates the delegate with an account row (reusedelegate_key_ciphertext, accounts table) rather than standing alone. -
quota.unlimited(worker.ts:1503) is replaced by a per-account/plan lookup (default plan = limited) so quotas become enforceable. - Anonymous/demo callers keep working via the existing
demo:<ip>owner (worker.ts:4317) but are confined to demo-scoped resources only. - Tests: cross-owner read is denied; a valid session reads its own rows; an expired/absent session falls back to anonymous with no access to owned rows.
Implementation notes
Use the local Fastify model (LocalAccountStore, hashed ctx_ sessions) as the reference for the hosted version; hash session tokens with the same scheme as namespace tokens. Keep changes backend-only and additive: add session middleware in the router (worker.ts:577+) ahead of owner-scoped branches. Out of scope (handled by the onboarding theme): signup/login UX, managed-delegate first-write flow, email/passkey/wallet — this issue only makes authorization trustworthy. Org/tenant_id can be a nullable forward-compat column; multi-tenant RBAC is out of scope. Add a migration only if new columns (e.g. plan, tenant_id) are needed.
Sui Overflow angle
Trustworthy owner identity is the off-chain anchor that later binds to a Sui-owned Namespace object / Seal policy (roadmap §2 registry) — without it, an on-chain ownership claim would attest a spoofable value. It also closes a real cross-owner data-leak bug before the repo goes public for the hackathon.
Dependencies
Unblocks per-account quotas (rate-limit issue) and owner-scoped /api/usage (ledger issue). Deliberately does not overlap the onboarding theme's signup-UX work.
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Introduce a single
resolveOwner(request, env)helper that derivesowner_idonly from a verifiedctx_session (contextmem_sessions.token_hashlooked up via httpOnly cookie orAuthorization: Bearer ctx_…), joined tocontextmem_accounts, falling back to the existingdemo:<ip>owner for anonymous callers. Every owner-scoped branch inrouteWorkerRequest(worker.ts:575+) switches from the spoofable?ownerId=/x-memwal-account-idvalue to this resolved owner, and the sharedCONTEXTMEM_NAMESPACE_IMPORT_TOKENis demoted from "the auth" to a server-to-server service credential (Fastify proxy / CLI / tests) that may name an explicit owner because its holder is trusted. The key decision: bootstrap sessions by porting the local Fastify/api/memwal/import-delegateflow into the Worker so it mints a hashedctx_session bound to acontextmem_accountsrow (canonicalowner_id = hostedOwnerId(memwalAccountId), preserving today'shosted:<id>row ownership), and replace the hardcodedunlimited:true(worker.ts:1503) with a per-accountplanlookup defaulting to a limited free plan.Prerequisites & dependencies
/api/usage(ledger) issues — but it can ship standalone.CONTEXTMEM_ACCOUNT_SECRET(AES-GCM key fordelegate_key_ciphertext; mirror localaccountSecretatindex.ts:77). Add toWorkerEnv(worker.ts:32-55),.env.example, andwrangler secret put.wrangler d1 migrations apply) — per project memory, CI does not deploy the Worker or run migrations.Access-Control-Allow-Origin: *(worker.ts:5303) for an echoed Origin +Access-Control-Allow-Credentials: true. Recommend Bearerctx_as the primary hosted transport (no CORS-credential rework; same XSS profile as today's delegate) and reserve the httpOnly cookie for same-origin / local dev.Step-by-step
apps/api/migrations/0007_account_identity.sql(new).ALTER TABLE contextmem_accounts ADD COLUMN plan TEXT NOT NULL DEFAULT 'free';andADD COLUMN tenant_id TEXT;(nullable forward-compat). Thecontextmem_accounts+contextmem_sessions+contextmem_quota_consumptionstables already exist (0001_account_gate.sql) and need no shape change. Optionally addCREATE INDEX … ON contextmem_sessions(expires_at).CONTEXTMEM_ACCOUNT_SECRET?: string(and optionalCONTEXTMEM_SESSION_TTL_DAYS) toWorkerEnv(worker.ts:32-55); document in.env.example.worker.ts, nearhashReadTokenat:4503). Add WebCrypto AES-256-GCMencryptSecret/decryptSecret(functionally equal toaccount-store.ts:217-233but viacrypto.subtleundernodejs_compat), and reuse the existinghashReadToken(sha256) as the session-hash scheme (issue note: "same scheme as namespace tokens"). Session token formatctx_<base64url(32 random bytes)>.resolveOwner(request, env)(new, besiderequireImportAuthorizationatworker.ts:4436):ctx_token fromCookie: contextmem.session=orAuthorization: Bearer ctx_…(distinguish from read tokens/import token by thectx_prefix).SELECT s.account_id, s.expires_at, a.id, a.owner_address, a.plan FROM contextmem_sessions s JOIN contextmem_accounts a ON a.id = s.account_id WHERE s.token_hash = ?(bindhashReadToken(token)); reject if absent orexpires_at <= now.{ ownerId: hostedOwnerId(a.owner_address), accountId: a.id, plan: a.plan, kind: "session" }(reusehostedOwnerIdat:1512so existinghosted:<accountId>rows stay owned).{ ownerId: demoOwnerId(request), plan: "demo", kind: "demo" }(demoOwnerIdat:4317). Never read?ownerId/x-memwal-account-id.resolveServiceAuth(request, env)thin wrapper: ifrequireImportAuthorizationpasses, allow an explicit?ownerId(trusted infra path) — keeps the Fastify proxy/CLI/tests working without re-introducing the browser spoof (browsers must stop shipping the shared token; see step 11).POST /api/memwal/import-delegateinrouteWorkerRequestnear the other/api/memwal/*branches (worker.ts:631-636). Mirrorindex.ts:218-226: zod{ memwalAccountId: string, delegateKey: min(12) };UPSERT contextmem_accountsbyowner_address = memwalAccountId; storedelegate_key_ciphertext = encryptSecret(normalizeDelegateSecret(delegateKey), env.CONTEXTMEM_ACCOUNT_SECRET); INSERT actx_row intocontextmem_sessions(expires +7d); respond{ token, me }with aSet-Cookie: contextmem.session=…; HttpOnly; Secure; SameSite=None; Path=/. This satisfies "delegate import associates the delegate with an account row."resolveOwner:listSchedules(worker.ts:1703): drop?ownerId || "anonymous"; bind(await resolveOwner(...)).ownerId.listAlerts(worker.ts:1741): same.listManagedNamespaces(worker.ts:921): always filterWHERE owner_id = ?with the resolved owner; remove the "no ownerId ⇒ list ALL namespaces" branch (current cross-owner leak). Service auth may still pass explicitownerId.getExtraction(/api/extractions/,worker.ts:723): assert the row'sowner_idequals resolved owner else 404/403.createSchedule/insertSchedule(:1697/:3866): stampowner_idfromresolveOwner, ignorescheduleCreateSchema.ownerId(:384).updateSchedule(:1716):getScheduleRow(env, id, resolvedOwner)(:3881already supports owner filter) → 404 on mismatch.importNamespace/createExtraction/createNamespaceBuild(:677/:713/:718): setinput.ownerIdfromresolveOwnerbefore insert (currently defaults to"anonymous"at:273/:337/:374); ignore the body field for authz.updateNamespace/delete/token routes (:951/:977/:994/:1014/:700-702): passresolvedOwnerintoassertManagedNamespace(:3629) and gate token list/create/revoke on namespace ownership.updateNamespaceArtifact(:1104-1129): replace theprovidedOwner = x-memwal-account-id ?? ?ownerIdblock withresolveOwner; keep therow.owner_id.startsWith("demo:") && === demoOwnerallowance for demo rows; service token still bypasses.unlimitedquota (hostedMeatworker.ts:1490, field at:1503). AddplanQuota(plan, used)with aPLAN_LIMITSmap ({ free: <N>, pro: <M>, demo: 1 }); computeusedfromcontextmem_quota_consumptions(already migrated) or extraction-job count over the rolling window, mirroringquotaFromState(account-store.ts:239).getHostedMe(:1192) resolves the account viaresolveOwnerand returns{ limit, used, remaining, unlimited: false }.GET /api/usage(new owner-scoped branch): aggregate counts (extractions/schedules/alerts) forresolveOwner().ownerId— the minimal owner-scoped ledger surface the AC names; demo callers see only theirdemo:scope.resolveOwnerreturnskind:"demo"may only touch rows whoseowner_id === demo:<ip>(already the pattern inlistHostedRunsat:1216-1224andupdateNamespaceArtifact); ensure list/read filters apply the demo owner, never"anonymous".apps/web/src/main.tsx: after a successfulimportDelegate, persist the returnedctx_session and send it as the Bearer inauthHeaders(:7910) for/api/hosted/*owner-scoped calls (NamespacesAppPageat:5943, fetches at:5972-6166), instead of the shared import token; stop emitting the shared import token from the browser bundle. Delegatex-memwal-*headers remain only for the MemWal relayer recall path, not for authz.New / changed files
apps/api/migrations/0007_account_identity.sql(new)apps/api/src/worker.ts(changed:WorkerEnv,resolveOwner/resolveServiceAuth,encryptSecret/decryptSecret,import-delegateroute,/api/usageroute, all owner-scoped handlers in steps 6-8, router:575-746)apps/api/src/worker.test.ts(changed: extendMemoryD1Databasewithaccounts/sessionsmaps + query handlers; new auth tests)apps/web/src/main.tsx(changed: session-token wiring, drop shared import token from browser).env.example/wrangler.jsoncsecret note (changed:CONTEXTMEM_ACCOUNT_SECRET)Test plan
worker.test.ts, vitest, in-memory D1): First extendMemoryD1Database(:845) withaccounts/sessionsmaps andfirst()/run()handlers for the newcontextmem_accounts/contextmem_sessionsSELECT/INSERT/UPSERT shapes (the mock is query-shape-matched, so new SQL must be taught). Then:import-delegatecreates an account + returns actx_token; a second import for the samememwalAccountIdupserts (no dup).ctx_token,GET /api/schedulesandGET /api/namespacesreturn only B's rows (A's absent) andPATCH/DELETEon A's row → 403/404.?ownerIdignored: session A passing?ownerId=<B>still only sees A's rows.demo:<ip>and cannot read A's owned rows (the AC's third test)./api/mefor afreeaccount returnsunlimited:false,limit>0.0007,wrangler secret put CONTEXTMEM_ACCOUNT_SECRET, deploy.curl POST /api/memwal/import-delegate→ capturectx_;GET /api/hosted/scheduleswith it = own rows; repeat with a second account and confirm isolation; confirmGET /api/schedules?ownerId=<victim>with attacker session returns nothing. Proof-of-fix = the pre-change spoof (?ownerId=<someone-else>returning their rows) now returns only the caller's scope.Risks & gotchas
Access-Control-Allow-Origin: *(worker.ts:5305) is incompatible with credentialed cross-site cookies — use Bearerctx_for the pages.dev→workers.dev hosted split; only use the httpOnly cookie same-origin/local (else rework CORS to echo Origin +Allow-Credentials).wrangler d1 migrations applyon staging+prod breaksplan/session lookups."anonymous"map to no session and become unreadable; decide to leave them inert or reassign on first import.hosted:<accountId>anddemo:<ip>rows continue to resolve correctly becauseresolveOwnerreuseshostedOwnerId/demoOwnerId.hostedWorkerJson), CLI, and current tests authenticate with the shared import token + explicitownerId; keepresolveServiceAuthhonoring that, but the browser must stop shipping the shared token (step 11) or the spoof remains open.nodejs_compat/bundle: prefercrypto.subtleAES-GCM overnode:cryptoto stay within Worker limits; sha256 viahashReadTokenis already Worker-safe.MemoryD1Databasewill silently returnnullfor un-taught queries → tests pass for the wrong reason; assert on seeded data, not just status codes.Acceptance-criteria mapping
resolveOwnerfrom verified session, single source → Steps 3-4 (+ used by 6-10).?ownerId→ Steps 6-7, 9 (/api/schedules,/api/alerts,/api/namespaces,/api/extractions,/api/usage).import-delegateupserts account + storesdelegate_key_ciphertext).quota.unlimitedwith per-account/plan lookup → Steps 1 (plancolumn) + 8.demo:<ip>→ Steps 4 (demo fallback) + 10.Effort
M (~3-4 person-days): ~2 days Worker auth refactor + import-delegate + quota + migration, ~1 day extending the D1 test mock and writing auth tests, ~0.5 day frontend session wiring + manual deploy verification.
Implementation plan generated for execution (planning phase). Part of the roadmap (#4).
Merge pull request #30 from harrymove-ctrl/fix/owner-auth-idor