Skip to content

Promote hosted accounts to canonical identity and fix spoofable owner authorization #23

Open

Description

@harrymove-ctrl

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 derives owner_id from a verified session (contextmem_sessions.token_hash via httpOnly cookie or Authorization: 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 (reuse delegate_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

  1. added this to the Sui Overflow milestone on Jun 16, 2026
  2. added
    P1Important: hardens the demo and core product
    featureUser- or agent-facing capability
    platformBackend platform plumbing: Worker, D1, queues, secrets, metering
    on Jun 16, 2026
  3. harrymove-ctrl commented on Jun 19, 2026

    @harrymove-ctrl
    OwnerAuthor

    Implementation Plan

    Approach
    Introduce a single resolveOwner(request, env) helper that derives owner_id only from a verified ctx_ session (contextmem_sessions.token_hash looked up via httpOnly cookie or Authorization: Bearer ctx_…), joined to contextmem_accounts, falling back to the existing demo:<ip> owner for anonymous callers. Every owner-scoped branch in routeWorkerRequest (worker.ts:575+) switches from the spoofable ?ownerId=/x-memwal-account-id value to this resolved owner, and the shared CONTEXTMEM_NAMESPACE_IMPORT_TOKEN is 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-delegate flow into the Worker so it mints a hashed ctx_ session bound to a contextmem_accounts row (canonical owner_id = hostedOwnerId(memwalAccountId), preserving today's hosted:<id> row ownership), and replace the hardcoded unlimited:true (worker.ts:1503) with a per-account plan lookup defaulting to a limited free plan.

    Prerequisites & dependencies

    • No blocking issues. This is the identity anchor the roadmap §2 registry / Seal-policy work later binds to, and it unblocks the per-account quota (rate-limit) and /api/usage (ledger) issues — but it can ship standalone.
    • New Worker secret: CONTEXTMEM_ACCOUNT_SECRET (AES-GCM key for delegate_key_ciphertext; mirror local accountSecret at index.ts:77). Add to WorkerEnv (worker.ts:32-55), .env.example, and wrangler secret put.
    • D1 migration must be applied manually to prod/staging (wrangler d1 migrations apply) — per project memory, CI does not deploy the Worker or run migrations.
    • Open roadmap decision (Namespace→Space/Bucket/Seal mapping + Harbor service-key management in Worker secrets #7): keep MemWal-delegate-as-identity (this issue) vs real email/passkey accounts — out of scope here; signup/login UX belongs to the onboarding theme. This issue only makes authorization trustworthy.
    • CORS decision: httpOnly cross-site cookies require dropping Access-Control-Allow-Origin: * (worker.ts:5303) for an echoed Origin + Access-Control-Allow-Credentials: true. Recommend Bearer ctx_ 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

    1. Migration apps/api/migrations/0007_account_identity.sql (new). ALTER TABLE contextmem_accounts ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'; and ADD COLUMN tenant_id TEXT; (nullable forward-compat). The contextmem_accounts + contextmem_sessions + contextmem_quota_consumptions tables already exist (0001_account_gate.sql) and need no shape change. Optionally add CREATE INDEX … ON contextmem_sessions(expires_at).
    2. Env wiring. Add CONTEXTMEM_ACCOUNT_SECRET?: string (and optional CONTEXTMEM_SESSION_TTL_DAYS) to WorkerEnv (worker.ts:32-55); document in .env.example.
    3. Port crypto + account store helpers into the Worker (top of worker.ts, near hashReadToken at :4503). Add WebCrypto AES-256-GCM encryptSecret/decryptSecret (functionally equal to account-store.ts:217-233 but via crypto.subtle under nodejs_compat), and reuse the existing hashReadToken (sha256) as the session-hash scheme (issue note: "same scheme as namespace tokens"). Session token format ctx_<base64url(32 random bytes)>.
    4. Add resolveOwner(request, env) (new, beside requireImportAuthorization at worker.ts:4436):
      • Extract a ctx_ token from Cookie: contextmem.session= or Authorization: Bearer ctx_… (distinguish from read tokens/import token by the ctx_ 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 = ? (bind hashReadToken(token)); reject if absent or expires_at <= now.
      • On hit return { ownerId: hostedOwnerId(a.owner_address), accountId: a.id, plan: a.plan, kind: "session" } (reuse hostedOwnerId at :1512 so existing hosted:<accountId> rows stay owned).
      • On miss return { ownerId: demoOwnerId(request), plan: "demo", kind: "demo" } (demoOwnerId at :4317). Never read ?ownerId/x-memwal-account-id.
      • Add a resolveServiceAuth(request, env) thin wrapper: if requireImportAuthorization passes, 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).
    5. Add Worker route POST /api/memwal/import-delegate in routeWorkerRequest near the other /api/memwal/* branches (worker.ts:631-636). Mirror index.ts:218-226: zod { memwalAccountId: string, delegateKey: min(12) }; UPSERT contextmem_accounts by owner_address = memwalAccountId; store delegate_key_ciphertext = encryptSecret(normalizeDelegateSecret(delegateKey), env.CONTEXTMEM_ACCOUNT_SECRET); INSERT a ctx_ row into contextmem_sessions (expires +7d); respond { token, me } with a Set-Cookie: contextmem.session=…; HttpOnly; Secure; SameSite=None; Path=/. This satisfies "delegate import associates the delegate with an account row."
    6. Rewrite owner-scoped reads to use resolveOwner:
      • listSchedules (worker.ts:1703): drop ?ownerId || "anonymous"; bind (await resolveOwner(...)).ownerId.
      • listAlerts (worker.ts:1741): same.
      • listManagedNamespaces (worker.ts:921): always filter WHERE owner_id = ? with the resolved owner; remove the "no ownerId ⇒ list ALL namespaces" branch (current cross-owner leak). Service auth may still pass explicit ownerId.
      • getExtraction (/api/extractions/, worker.ts:723): assert the row's owner_id equals resolved owner else 404/403.
    7. Rewrite owner-scoped writes/mutations:
      • createSchedule/insertSchedule (:1697/:3866): stamp owner_id from resolveOwner, ignore scheduleCreateSchema.ownerId (:384).
      • updateSchedule (:1716): getScheduleRow(env, id, resolvedOwner) (:3881 already supports owner filter) → 404 on mismatch.
      • importNamespace/createExtraction/createNamespaceBuild (:677/:713/:718): set input.ownerId from resolveOwner before 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): pass resolvedOwner into assertManagedNamespace (:3629) and gate token list/create/revoke on namespace ownership.
      • updateNamespaceArtifact (:1104-1129): replace the providedOwner = x-memwal-account-id ?? ?ownerId block with resolveOwner; keep the row.owner_id.startsWith("demo:") && === demoOwner allowance for demo rows; service token still bypasses.
    8. Replace the unlimited quota (hostedMe at worker.ts:1490, field at :1503). Add planQuota(plan, used) with a PLAN_LIMITS map ({ free: <N>, pro: <M>, demo: 1 }); compute used from contextmem_quota_consumptions (already migrated) or extraction-job count over the rolling window, mirroring quotaFromState (account-store.ts:239). getHostedMe (:1192) resolves the account via resolveOwner and returns { limit, used, remaining, unlimited: false }.
    9. Add GET /api/usage (new owner-scoped branch): aggregate counts (extractions/schedules/alerts) for resolveOwner().ownerId — the minimal owner-scoped ledger surface the AC names; demo callers see only their demo: scope.
    10. Confine demo callers: any route where resolveOwner returns kind:"demo" may only touch rows whose owner_id === demo:<ip> (already the pattern in listHostedRuns at :1216-1224 and updateNamespaceArtifact); ensure list/read filters apply the demo owner, never "anonymous".
    11. Thin frontend wiring (required to actually close the bug) in apps/web/src/main.tsx: after a successful importDelegate, persist the returned ctx_ session and send it as the Bearer in authHeaders (:7910) for /api/hosted/* owner-scoped calls (NamespacesAppPage at :5943, fetches at :5972-6166), instead of the shared import token; stop emitting the shared import token from the browser bundle. Delegate x-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-delegate route, /api/usage route, all owner-scoped handlers in steps 6-8, router :575-746)
    • apps/api/src/worker.test.ts (changed: extend MemoryD1Database with accounts/sessions maps + query handlers; new auth tests)
    • apps/web/src/main.tsx (changed: session-token wiring, drop shared import token from browser)
    • .env.example / wrangler.jsonc secret note (changed: CONTEXTMEM_ACCOUNT_SECRET)

    Test plan

    • Unit (worker.test.ts, vitest, in-memory D1): First extend MemoryD1Database (:845) with accounts/sessions maps and first()/run() handlers for the new contextmem_accounts/contextmem_sessions SELECT/INSERT/UPSERT shapes (the mock is query-shape-matched, so new SQL must be taught). Then:
      • import-delegate creates an account + returns a ctx_ token; a second import for the same memwalAccountId upserts (no dup).
      • Cross-owner read denied: seed a schedule/namespace owned by session A; with session B's ctx_ token, GET /api/schedules and GET /api/namespaces return only B's rows (A's absent) and PATCH/DELETE on A's row → 403/404.
      • ?ownerId ignored: session A passing ?ownerId=<B> still only sees A's rows.
      • Own rows visible: session A reads its own schedule/namespace = 200.
      • Expired/absent session ⇒ demo: no token and an expired session both resolve to demo:<ip> and cannot read A's owned rows (the AC's third test).
      • Quota not unlimited: /api/me for a free account returns unlimited:false, limit>0.
    • Integration / manual (deployed Worker + D1): apply 0007, wrangler secret put CONTEXTMEM_ACCOUNT_SECRET, deploy. curl POST /api/memwal/import-delegate → capture ctx_; GET /api/hosted/schedules with it = own rows; repeat with a second account and confirm isolation; confirm GET /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

    • CORS + cookies: Access-Control-Allow-Origin: * (worker.ts:5305) is incompatible with credentialed cross-site cookies — use Bearer ctx_ for the pages.dev→workers.dev hosted split; only use the httpOnly cookie same-origin/local (else rework CORS to echo Origin + Allow-Credentials).
    • Migration is manual (CI ships neither Worker nor migrations) — forgetting wrangler d1 migrations apply on staging+prod breaks plan/session lookups.
    • Backfill / orphaned rows: existing rows owned by literal "anonymous" map to no session and become unreadable; decide to leave them inert or reassign on first import. hosted:<accountId> and demo:<ip> rows continue to resolve correctly because resolveOwner reuses hostedOwnerId/demoOwnerId.
    • Don't break the service path: the Fastify proxy (hostedWorkerJson), CLI, and current tests authenticate with the shared import token + explicit ownerId; keep resolveServiceAuth honoring that, but the browser must stop shipping the shared token (step 11) or the spoof remains open.
    • nodejs_compat/bundle: prefer crypto.subtle AES-GCM over node:crypto to stay within Worker limits; sha256 via hashReadToken is already Worker-safe.
    • Test mock drift: the hand-rolled MemoryD1Database will silently return null for un-taught queries → tests pass for the wrong reason; assert on seeded data, not just status codes.

    Acceptance-criteria mapping

    • resolveOwner from verified session, single source → Steps 3-4 (+ used by 6-10).
    • Owner-scoped routes reject cross-owner, ignore ?ownerId → Steps 6-7, 9 (/api/schedules, /api/alerts, /api/namespaces, /api/extractions, /api/usage).
    • Delegate import associates with an account row → Step 5 (import-delegate upserts account + stores delegate_key_ciphertext).
    • Replace quota.unlimited with per-account/plan lookup → Steps 1 (plan column) + 8.
    • Anonymous/demo confined to demo:<ip> → Steps 4 (demo fallback) + 10.
    • Tests: cross-owner denied / valid session own rows / expired-absent ⇒ no access → Test plan (unit cases).

    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).

  4. added a commit that references this issue on Jun 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Important: hardens the demo and core productfeatureUser- or agent-facing capabilityplatformBackend platform plumbing: Worker, D1, queues, secrets, metering

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions