Skip to content

feat(memory-platform): per-key MCP scoping, key rotation, plan entitlement, and the web surface - #11417

Draft
undivisible wants to merge 44 commits into
BasedHardware:mainfrom
undivisible:feat/memory-platform-product
Draft

feat(memory-platform): per-key MCP scoping, key rotation, plan entitlement, and the web surface#11417
undivisible wants to merge 44 commits into
BasedHardware:mainfrom
undivisible:feat/memory-platform-product

Conversation

@undivisible

@undivisible undivisible commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Memories as a platform: per-key scoping, key rotation, plan entitlement, and the web surface

Combines the backend and web slices of the memory-platform product into one reviewable change.

Stacks on PR #11033. This branches from pr-10783-memory-api-mcp-zkr, not main — the /v1/memory/platform endpoints do not exist on main. Review the delta against that base.


Review round 2 — what the bots found, and what was actually wrong

Codex and cubic filed 30 inline comments. Several were correct and the product did not work. Corrections in this round, most severe first.

1. The embeddable widget could not be framed at all

Not a sandbox or storage problem, as an earlier revision of this description guessed. next.config.mjs sent X-Frame-Options: DENY on every route, so /memory-platform/widget — a widget whose entire purpose is to be embedded in a host page — rendered a broken-document placeholder inside any iframe, in dev and in production alike.

Only the widget route is opened up, with Content-Security-Policy: frame-ancestors *; every other route keeps DENY. X-Frame-Options cannot be relaxed per-route by a later rule, so the catch-all excludes the widget path with a negative lookahead. Framing it from any origin is safe because the widget carries no ambient authority: the published embed sandboxes it without allow-same-origin, so it has an opaque origin, no cookies, and no durable storage, and holds no session a framing page could exercise. It is inert until the host explicitly hands it a token.

2. The widget had no way to get a session when framed

The sandboxed frame's opaque origin denies it every durable storage API, so Firebase cannot establish a session inside it and live search always fell through to "Sign in". Relaxing the sandbox is not the fix — a document granted both allow-scripts and allow-same-origin can rewrite its own sandbox attribute and escape entirely, which test_memory_platform_docs_guards.py correctly fails on.

The host page owns the credential instead. The frame asks with omi.memory.embed.session-request; the host mints a short-lived token server-side for the visitor it has already authenticated and replies with omi.memory.embed.session. The token lives only in hook memory for the frame's lifetime. Top-level, the Firebase session remains the source of truth.

Messages are validated by event.source, never by origin. An opaque-origin sender always reports origin === "null", so the previous event.origin !== 'https://h.omi.me' check dropped every event.

3. Rotation: the previous "strictly uncached before the swap" claim was wrong

The earlier description claimed the retired hash was uncached strictly before the swap and that this closed the window. That claim was wrong as written. Deleting the cache first loses the race it was meant to win: an authentication that already read the pre-swap Firestore document writes the retired hash back into the cache after the delete, keeping a rotated-away secret valid for the remainder of the one-hour TTL.

Rotation now tombstones the retired hash durably before touching the cache, and authentication refuses to honor or write a cache entry whose fence is not provably absent. The tombstone outlives every auth-cache TTL, so the racing write is harmless — no later request can authorize from it. An unreadable fence is treated as retired, which costs a Firestore lookup and never authorizes a retired secret. MCP and Developer keys share one helper, retired_hash_fences_cache, so the two rotations cannot drift.

4. Scope semantics were not rollout-safe

Per-key scopes became authoritative without bumping MCP_API_KEY_AUTH_CONTEXT_VERSION. During a rolling deploy an old revision — whose normalize_mcp_scopes always widened to full access — could cache full scopes for a key a new revision recorded as read-only, and the new revision would honor that entry for up to an hour. Version bumped to 4.

5. Search rendered empty

The client declared memories; ProductMemorySearchResponse returns items. The field never existed on the wire, so every successful search decoded to nothing.

6. Browser requests never reached the backend

The client modules resolved their base URL from envConfig.API_URL, which reads process.env.API_URLnot a NEXT_PUBLIC_* variable, so Next.js never inlines it into the client bundle. In the browser it is undefined, the base collapses to '', and every key, billing, and platform request went to the web origin as a relative path. All three browser clients now share one browserApiBase() helper reading NEXT_PUBLIC_API_BASE_URL. Audited every module in src/lib/api/: apps.ts is server-only and correctly keeps API_URL.

Also fixed

  • getToken identity was recreated every render; the keys and billing pages use it as a useCallback dependency and those callbacks as useEffect dependencies, so every successful load immediately triggered another. Those pages issued requests continuously. Memoized.
  • Router bound the legacy global db proxy; now resolves get_firestore_client() at the call boundary.
  • The documented 400 for invalid search bounds was unreachableQuery constraints made FastAPI answer 422 before the handler ran. The handler owns validation now.
  • X-App-Platform: web on /v1/payments/available-plans. Without it should_show_new_plans treats the storefront as a legacy client: the catalog drops Operator and exposes deprecated Neo.
  • Billing panel used Promise.all, so one failure discarded the successful sibling and rendered the fallback Free plan next to the error. Settled independently.
  • Snippets: MCP config pointed at https://api.omi.me/mcp, which is registered nowhere (the transport is /v1/mcp/sse); the proxy example implied a durable OMI_SERVER_KEY works against a route that accepts only Firebase ID tokens.
  • Docs: the quota section pointed at /v1/payments/overage-info, which meters chat questions, not platform requests.
  • Guard scope: the quota seam tripwire scanned for /v1/payments/ rather than the quota path it claims to protect.

Reviewed and rejected

  • Masthead offset (cubic, P1, confidence 10): claimed the masthead starts beneath the fixed AppHeader and asked for pt-[6.5rem]/md:pt-28. Verified visually against a production build at 1280px and 390px — the masthead clears the header exactly. AppHeader is 4rem, which is also what main's own mobile-menu top-16 assumes. Applying the suggestion would open a 2.5rem gap.
  • Duplication findings (rotate_dev_key vs rotate_mcp_key, memory_platform_usage.py vs phone_call_usage.py, DB-layer orchestration): fair observations, but extracting shared credential-rotation and monthly-counter primitives is a refactor with blast radius beyond this PR. The rotation fence is the shared surface that matters for correctness and it is factored. Not doing a speculative extraction inside a security fix.

Backend

Per-key scoping. McpApiKeyCreate accepts scopes; the route 400s on an unknown or empty scope list rather than silently widening. Absent or unreadable scope state predates the per-key contract and still resolves to full access, so already-issued keys keep working — proven by test_unmigrated_key_without_recorded_scopes_still_authorizes_with_full_access.

Rotation issues a new secret for an existing key id, preserving name, scopes, app identity, creation time, and grants, returning the raw key exactly once. Hard cutover by design, now actually enforced by the retirement fence above. An unconfirmed fence or delete returns 503 rather than issuing a new secret — the caller is told rotation is unavailable instead of being handed a second live credential.

Quota: free tier 1000/month, every paid plan uncapped. platform_api_requests_per_month on PlanLimits, metered with a Redis monthly counter. None means uncapped, so shipping this gate cannot regress a paying subscriber, and Free keeps a usable allowance on an API that was previously ungated. Over-quota is a bounded 429 naming plan, limit, usage, and reset instant. Counter outages fail open through record_fallback.

Metering paid usage is out of scope and needs human sign-off. Charging for platform-API usage is a pricing decision, deliberately not made here.

Web

/memory-platform plus /docs, /embed, /keys, /billing, and /widget, on Tailwind + shadcn in a dark theme.

Guards added

Cross-boundary guards compare the declared TypeScript interfaces against real introspection of the Pydantic models, so renaming a model field fails until the client follows: test_web_quota_client_type_matches_the_response_model (MemoryPlatformQuota) and test_web_search_client_type_matches_the_response_model (ProductMemorySearchResponse). Siblings forbid browser clients from reading the server-only API_URL, and pin the widget-route frame policy. Labelled as static checkers — they read source rather than executing it.


Verification

Mutation-verified (broke it, confirmed the failure, reverted):

Guard Mutation Result
Rotation fence force retired_hash_fences_cache to False both interleaving tests fail — retired secret still authorizes
Cache version restore MCP_API_KEY_AUTH_CONTEXT_VERSION = 3 read-only key widened to all nine scopes
Search decode rename items back to memories assert not {'memories'}
Browser origin restore envConfig.API_URL in mcp-keys.ts origin guard fails
400 bounds restore the Query constraints assert 422 == 400 on all five cases
Frame policy restore the '/(.*)?' catch-all frame guard fails

Backend — 55 tests pass across test_api_key_rotation.py, test_mcp_api_key_scoping.py, test_mcp_api_key_full_access.py, test_memory_platform_quota.py, test_memory_platform_router.py, test_memory_platform_docs_guards.py, test_api_key_route_contract.py, test_memory_platform_api_service.py, run through backend/test.sh (not bare pytest).

Web (web/frontend) — npm test 105/105 pass; npm run lint clean; npx tsc --noEmit reports 33 errors, all pre-existing and unchanged (same 33 with the branch stashed), in src/components/trends/, tendencies/, apps/, chat/, tasks/, and memories/memory-list/ — files this PR does not touch. npm run build succeeds.

Browser — the embed now actually works

Against a production build, with a host page framing the widget at sandbox="allow-scripts" (no allow-same-origin) and a stub backend returning a real items payload:

ANY msg type=omi.memory.embed.session-request origin="null" sourceMatches=true
sent session token
ANY msg type=omi.memory.embed.ready         origin="null" sourceMatches=true
ANY msg type=omi.memory.embed.resize        origin="null" sourceMatches=true

GET /v1/memory/platform/search?query=launch&limit=20&offset=0 auth= Bearer host-minted-token

The results rendered in the frame. That single run exercises four of the fixes end-to-end: the frame loads (frame policy), the handshake completes with origin="null" proving the opaque origin and sourceMatches=true proving source validation is the right control, the request reaches the configured backend origin rather than a relative path, and the response decodes from items.

All six routes return 200 on the production server. The masthead offset was inspected at 1280px and 390px.

Clicked through, signed in, against a local stack

An earlier revision of this PR said the signed-in flows were "verified by contract, not
clicked, because there is no Firebase project available locally". That gap is now closed.
web/frontend can point at the Firebase Auth emulator (NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_HOST,
gated to non-production builds and loopback hosts only), and the repo's own harness
(PROVIDER_MODE=offline make dev-up) supplies the backend on 127.0.0.1:8000 with the
Firestore/Auth emulators. Setup is documented in web/frontend/README.md.

Signed in through the real header sign-in button as an emulator Google account
(uid LHKnvoooJ9Xbi7NmpFCghHJ16ioN), then exercised, in a browser:

Flow What I saw
Create key with selected scopes POST /v1/mcp/keys 200. Dialog showed omi_mcp_802481c4... once with "Copy it now. You will not see this key again."
After dismissing the reveal Table renders name / omi_mcp_8024...ecf9… / conversations.read goals.read memories.read / created / last-used only. No raw key anywhere.
400 on a bad scope list Submitting with every scope unchecked returned 400 and rendered Invalid scopes. Available: [...]. This surfaced a real defect — the message was painted behind the still-open dialog's overlay. Fixed in this PR; the message now renders inside the dialog.
Rotation POST /v1/mcp/keys/{id}/rotate 200, new secret shown once. Old secret: GET /v1/mcp/memories 200 before, 401 after. New secret: 200. No grace window.
Revocation DELETE /v1/mcp/keys/{id} 204, row disappears, the secret then returns 401.
Billing panel Real plan and quota. A sibling request genuinely failed (GET /v1/payments/available-plans 500 — no Stripe credentials locally) and the panel still rendered the plan from overage-info 200 and 0 / 1000 requests from GET /v1/memory/platform/quota 200, with the error shown alongside. This is exactly the Promise.allSettled behaviour the code comments claim.
Framed widget Against a production build, from a separate origin (http://127.0.0.1:4321), sandbox="allow-scripts" with no allow-same-origin: session-request origin="null" srcMatch=true → host-minted token → readyresize.

The dev-harness CORS gap this exposed (backend default-deny allowlist was empty, so the
first browser POST died on preflight with 400) is fixed in a separate commit.

Still not exercised, and why

  • Search returning actual result rows. The wire field is confirmed to be items — an
    empty-result search returns {"uid":...,"query":...,"items":[],...}, so this PR's
    items decode is right. But any non-empty result 500s on a pre-existing backend bug
    that is not in this PR's diff: fetch_default_product_memory_search returns
    List[Dict[str, Any]] projections (memory_id, memory_layer, lifecycle_status, ...)
    while ProductMemorySearchResponse.items is declared List[MemoryItem], so FastAPI's
    response validation rejects every row. Reproduced with a real canonical user:
    GET /v1/memory/platform/search?query=zzzznomatch → 200, ?query=software → 500. Filed as GET /v1/memory/platform/search 500s on any non-empty result page #11438; no router test covers a non-empty page, which is why it has gone unnoticed.
  • A framed widget calling the API directly. The handshake works, but the frame's own
    fetch is preflighted with Origin: null (that is what the opaque sandbox origin means)
    and the backend's explicit allowlist rejects it — observed as
    OPTIONS /v1/memory/platform/search ... 400 from the framed instance. Allowing null
    would be unsafe, so in practice the framed widget must go through the server-side proxy
    the docs already recommend; the direct-call branch is unreachable when framed as published.
  • Stripe checkout and the customer portal, which need real Stripe credentials.

Needs human sign-off

  • Opening /memory-platform/widget to frame-ancestors * is a deliberate relaxation of a repository-wide X-Frame-Options: DENY. The reasoning is above and I believe it is sound, but it is a security-policy decision on a shared config file and should be confirmed rather than assumed.
  • Charging for platform-API usage remains an unmade pricing decision.

Product invariants affected

  • INV-MEM-1

Failure-Class: none

undivisible and others added 30 commits August 2, 2026 17:46
The memory-platform surface stays at /memory-platform; the homepage routing decision is a separate product call, so the historical root redirect to /apps is preserved.
Resolves two conflicts:
- backend/routers/mcp_sse.py: keep both sides' imports (main's mcp_analytics
  helpers and this branch's memory-platform payload builder).
- product line-count ratchet baseline: take main's counts/justifications and
  add this branch's +12 mcp_sse delta (2000 lines after merge).
The embed guide's proxy sample forwarded the caller's Authorization header
straight upstream, which is the pattern the surrounding security checklist
tells integrators to avoid. Replace it with a proxy that authenticates the
visitor's own session, resolves the tenant's Omi token from a server-side
store, clamps query/limit to the documented bounds, and never forwards the
inbound credential.

Adds a static tripwire asserting the published sample keeps the server-owned
token and does not reintroduce the header pass-through.
Both the web docs page and docs/memory/api-service.md claimed limit was
capped at 100, but /v1/memory/platform/search bounds it by
MAX_PRODUCT_MEMORY_READ_LIMIT (500) from the shared product read service;
100 is only the default. Document the real contract rather than lowering
the router, so the platform surface keeps the same bound as the product
memory reader it delegates to.

Adds a test that parses MAX_PRODUCT_MEMORY_READ_LIMIT out of the backend
source and asserts the docs page quotes the same number, so the two cannot
drift apart silently again.
Whitespace-only `content` passed the `Memory` request model and only failed
deep in the canonical adapter with `ValueError("canonical write requires
non-empty content")`. The ingest handler's broad `except Exception` turned
that deterministic client error into a 503, so callers saw a transient
outage and retried invalid input indefinitely.

Verification: BACKEND_UNIT_TEST_FILE_LIST with
tests/unit/test_memory_platform_api_service.py — 7 passed. Mutation: with
the guard removed the three new blank-content cases fail (3 failed,
4 passed).

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The platform ingest handler never resolved the request's device context, so
canonical items written through it carried no client_device_id evidence.
Device-scoped reads (device_scope=current or an explicit device) then
excluded memories that did originate on that device. /v3/memories already
resolves the device from the request headers; this makes the platform
surface match.

Verification: BACKEND_UNIT_TEST_FILE_LIST with
tests/unit/test_memory_platform_api_service.py — 8 passed. Mutation:
dropping the client_device_id argument fails the new test
(assert [None] == ['macos_abcd1234']).

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every published embed example paired `allow-scripts` with `allow-same-origin`.
Together those tokens let the framed document reach its embedder and remove
its own `sandbox` attribute, so the "strict sandbox" the surrounding copy
promises provided no isolation at all. Dropping `allow-same-origin` gives the
frame an opaque origin it cannot escape, which is also what the credentialless
server-proxy model in the same guide assumes.

Verification: `node --test src/__tests__/memory-platform-page.test.mjs` —
5 pass. Mutation: restoring `allow-same-origin` in the embed page fails the
new guard ("lets the framed document remove its own sandbox").

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`AppHeader` is `fixed top-0` in the root layout, so it overlays page content.
The memory platform shell only reserved 2rem (1.5rem on narrow viewports) of
top padding, which put its entire masthead — wordmark plus the Authority /
Surfaces / Docs nav — underneath the header and out of reach. The sibling
/memory-platform/embed page already clears it with pt-32.

Verification: `bun run dev` on web/frontend and loaded
http://localhost:3001/memory-platform. Before: masthead fully hidden behind
the global header (/tmp/mp-before.png). After: masthead visible below it
(/tmp/mp-after2.png).

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing called `memory_platform_capability()`; the router, the MCP payload
builder, and the tests all use `build_memory_platform_capability()`. Also
removes two duplicated surface assertions in the capability test.

Verification: BACKEND_UNIT_TEST_FILE_LIST with the four memory-platform test
files — 4, 3, 8 and 2 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The web surface is not shipping; only the backend-authoritative REST and MCP
work is. Deleting it rather than gating it keeps the repo free of dead
scaffolding.

Removed: /memory-platform and its /docs and /embed routes, the
components/memory-platform landing page and stylesheet, and
memory-platform-page.test.mjs. web/frontend/src/app/page.tsx returns to its
main-branch content, so the root redirect to /apps is untouched by this PR.

The deleted frontend test carried two static checkers that guarded shipped
developer documentation, so they move to
backend/tests/unit/test_memory_platform_docs_guards.py and now assert against
docs/memory/api-service.md and docs/memory/embedding.md — the docs that still
publish the limit bound and the iframe example. They are static tripwires over
documentation, not behavioral coverage.

Verification: `bun run dev` on web/frontend — `/` still 307s to `/apps`,
`/apps` 200s, `/memory-platform` now 404s. Backend docs guards: 2 passed;
mutation: lowering the documented limit to 400 and restoring
`allow-same-origin` in embedding.md fails both (2 failed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	.github/scripts/product_file_line_count_ratchet_baseline/backend-routers.json
One client function per backend endpoint. Live: platform capability/search/ingest,
MCP key list/create/revoke, Stripe plans/checkout/portal/overage. Pending backend
work is isolated behind exactly two seams — rotateMcpKey() and
getPlatformApiQuota() — so wiring each is a one-line change.

MEMORY_PLATFORM_LIMITS mirrors the bounds the backend enforces
(MAX_PRODUCT_MEMORY_READ_LIMIT = 500, query 500 chars, offset 100,000).

Verified: npm test (100 pass), npm run lint clean, tsc --noEmit reports no new
errors for these files.
Restores /memory-platform, /docs and /embed, and adds /keys, /billing, and the
embeddable /widget route. Replaces the previous split styling (a 401-line CSS
module for the landing page plus inline arbitrary-value Tailwind on the subpages)
with one Tailwind + shadcn/ui system: dark, hairline card borders, mono code,
restrained motion. PlatformShell owns the chrome and applies the .dark class the
primitives key off, since the app's root layout is light.

- Keys: list, create with scopes, rotate, revoke. The raw key is held in
  component state for the lifetime of the reveal dialog only, behind an explicit
  "you will not see this key again" state, and is never written to durable
  browser storage.
- Embed: live preview of the widget plus copy-paste snippets. Published iframes
  use allow-scripts WITHOUT allow-same-origin.
- Docs: bounds render from MEMORY_PLATFORM_LIMITS, so the page cannot drift from
  the enforced limit the way the old 1-100 text did.
- INV-UI-1: no purple; accents are white/neutral plus lime and coral.

Verified in a browser against next dev on :3111 — /memory-platform,
/memory-platform/docs, /embed, /keys, /billing and /widget?demo=1 all render 200,
/ still 307s to /apps, the widget renders live on /embed and its typed
postMessage event is received by the origin-validating listener, and the only
console errors are the pre-existing Elfsight WIDGET_NOT_FOUND ones.
Labelled static tripwires, not behavioral coverage: they read source text and
assert on it. They cover the contracts that have already drifted or that other
gates enforce elsewhere — route + metadata presence, INV-UI-1 purple-freedom,
the iframe sandbox pairing (in both the package and docs/memory/*.md), no raw
key reaching durable browser storage, the client limit matching
MAX_PRODUCT_MEMORY_READ_LIMIT, and the two pending backend seams staying inside
a single client function each.

Discovered by the component's documented runner: cd web/frontend && npm test
(node --test src/__tests__/*.test.mjs). 100 tests pass. There is currently no
web/frontend lane in .github/checks-manifest.yaml, so these do not yet run in CI.
api-service.md: MCP client config, the key lifecycle endpoints with the
one-time raw-key rule, the dot-form scope list, and where plan and platform-API
quota are reported. The enforced-limit sentence the backend guard test asserts
is unchanged.

embedding.md: link the live preview, state explicitly why allow-same-origin must
never accompany allow-scripts, give the origin- and source-validating parent
listener with the real omi.memory.embed.* event names, and add a credentials
section.

Verified: the assertions in backend/tests/unit/test_memory_platform_docs_guards.py
re-run standalone against these files and pass (backend deps are not installed in
this worktree, so pytest itself could not run).
normalize_mcp_scopes() ignored its argument and returned MCP_FULL_ACCESS_SCOPES
unconditionally, so every MCP key authorized every tool regardless of the scopes
it was created with — the per-key scope contract that mcp_sse's TOOL_REQUIRED_SCOPE
and the scope-readiness inventory both assume was never enforced at the source.

A recorded list of known scopes is now authoritative. Absent or unreadable scope
state predates the contract and still resolves to full access, so already-issued
keys keep working without being regenerated. A key's memory grant is derived from
its own scopes instead of always granting memories.read + memories.write, so the
grant gate can never be broader than the credential it was seeded for.

Rotation (POST /v1/{mcp,dev}/keys/{key_id}/rotate) issues a new secret for an
existing key id, preserving name, scopes, app identity, creation time, and grants,
and returns the raw key exactly once. It is a hard cutover with no grace window:
the cached auth context is deleted strictly before the swap, because a surviving
cache entry would keep the retired secret valid for the cache TTL.

Verification (backend/test.sh via BACKEND_UNIT_TEST_FILE_LIST):
- tests/unit/test_mcp_api_key_scoping.py: 3 passed
- tests/unit/test_api_key_rotation.py: 5 passed
- tests/unit/test_mcp_api_key_full_access.py: 7 passed
- tests/unit/test_api_key_route_contract.py, test_api_key_family_errors.py,
  test_api_key_observability.py, test_mcp_api_key_auth_context.py,
  test_mcp_api_key_scope_readiness.py, test_api_key_listability_contract.py: all passed
- Mutation: forcing normalize_mcp_scopes back to full access failed 3 tests;
  dropping the rotation cache invalidation failed 2 tests. Both reverted.

Legacy principal: test_unmigrated_key_without_recorded_scopes_still_authorizes_with_full_access
asserts a key document with no `scopes` field authenticates with full access and
keeps a read+write memory grant.

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GET /v1/memory/platform/search and POST /v1/memory/platform/ingest were
ungated: no plan check, no usage accounting, nothing to sell. They now consume a
monthly request allowance expressed as a PlanLimits field
(`platform_api_requests_per_month`) read through get_plan_limits, alongside the
existing chat caps.

Quota shape:
- Free (basic): 1000 requests/month, env-overridable via
  FREE_PLATFORM_API_REQUESTS_PER_MONTH. Deliberately generous — the free
  allowance exists to make the API evaluable end to end, and no already-signed-up
  Free user may lose access to a surface that was previously ungated.
- Every paid plan: None (uncapped), so shipping the gate cannot regress a paying
  subscriber. Metering paid usage is a separate pricing decision.

Counters are a Redis monthly bucket mirroring database/phone_call_usage.py; the
cap bounds free-tier usage rather than producing a billing ledger, so no
historical retention is needed. A counter outage fails open and reports through
the shared record_fallback helper (new `memory_platform` component) — quota
infrastructure must never take the product API offline.

Over-quota is a bounded 429 naming the plan, limit, usage, and reset instant —
never a 500 and never a silently truncated result. Metering happens after
validation and authorization, so a rejected request never burns an allowance.
GET /v1/memory/platform/quota exposes the remaining allowance so a client can
render usage before it 429s; the capability contract model is frozen and
extra-forbidding, so the read is a sibling route rather than a new field on it.

Verification (backend/test.sh via BACKEND_UNIT_TEST_FILE_LIST):
- tests/unit/test_memory_platform_quota.py: 5 passed
- tests/unit/test_memory_platform_router.py: 4 passed
- tests/unit/test_memory_platform_api_service.py: 8 passed
- Full backend/test.sh: failure set matches the pre-change baseline on this
  machine (16 env-dependent files failing before and after); each file unique to
  a run passes standalone.
- Mutation: removing the Free platform limit failed 4 quota tests; removing the
  search enforcement call failed 2 tests. Both reverted.
- scripts/scan_async_blockers.py: 0 selected blocking findings.

Legacy principal: test_existing_basic_subscriber_is_still_served asserts a user
already on a `basic` subscription is served and reports remaining allowance.

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… for rotation

`make preflight` gates two contracts the new routes touch:

- `backend-route-policy-baseline` requires every new route to declare reviewed
  policy rather than grow the legacy unreviewed baseline. The three new routes
  (`GET /v1/memory/platform/quota`, `POST /v1/{mcp,dev}/keys/{key_id}/rotate`)
  are declared as reviewed, first-party, Firebase-authenticated entries.
- `firestore-model-read-boundary` caps direct Firestore model construction.
  Rotation parses its repaired projection through `parse_snapshot_strict`;
  credential rotation is correctness-critical, so a structurally invalid document
  must raise rather than parse fail-open.

Verification: `OMI_PR_BODY_FILE=... make preflight` -> passed: 25 checks in 56.40s.
Rotation and scoping suites still pass (3 + 5).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Persisted scope lists are untrusted Firestore payloads, not a validated
`list[str]`; the declared parameter type made pyright reject the element-level
`isinstance(scope, str)` guard as unnecessary while that guard is exactly what
keeps a malformed document from widening a key's authority.

Verification: backend pre-push typecheck phase; scoping (3) and rotation (5)
suites pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Additive only: the rotation routes, their once-returned key response models, and
the memory platform quota read. Generated with
`backend/scripts/export_openapi.py --surface app-client --write`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mechanical consequence of the new `platform_api_requests_per_month` plan limit,
which appears in both the subscription-usage and payments generated groups.
Generated with `backend/scripts/generate_dart_models.py`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mechanical consequence of the new rotation routes, the memory platform quota
read, and the new plan-limit field. Generated with
`backend/scripts/generate_ts_openapi_types.py`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mechanical consequence of the new rotation routes, the memory platform quota
read, and the new plan-limit field. Generated with
`backend/scripts/generate_swift_openapi_types.py`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… backend

The three calls the web surface isolated behind single client functions now
have real routes on the merged tree:

- Per-key scopes on POST /v1/mcp/keys. The client's MCP_SCOPES list matches
  backend MCP_FULL_ACCESS_SCOPES exactly; the route 400s on an unknown or
  empty list and the detail string reaches the user through the keys manager.
- POST /v1/mcp/keys/{key_id}/rotate returns the raw key exactly once. Dropped
  the stale "rotation is not available yet" fallback message.
- Platform quota moved from the placeholder /v1/payments/platform-api-quota to
  the route that actually exists, GET /v1/memory/platform/quota, and the
  response type now mirrors backend MemoryPlatformQuota. The billing panel
  reads limit/used/remaining instead of the invented *_requests fields, and
  renders uncapped plans as uncapped rather than as a zeroed bar.

The panel no longer degrades to "pending backend rollout": the endpoint is
live, so a failure is reported as a real read failure while leaving the plan
and usage the user can already see intact.

Verification: web/frontend npm test (100 pass), npm run lint clean,
npx tsc --noEmit adds no error in memory-platform or lib/api. Backend
tests/unit for keys, rotation, scoping, quota, router, and docs guards:
43 passed via backend/test.sh.
The web surface shipped a PlatformApiQuota interface naming
included_requests/used_requests/remaining_requests against a route that was
still pending. Those fields exist nowhere on MemoryPlatformQuota, so the
billing panel would have rendered a zeroed quota bar against the real
response without any test failing.

Two static checkers, labelled as such: one compares the declared TypeScript
interface against real introspection of MemoryPlatformQuota.model_fields, so
renaming or adding a model field fails until the client follows; the other
pins the seam to /v1/memory/platform/quota and forbids the placeholder
/v1/payments/platform-api-quota path from returning.

Verification: mutating `remaining` to `remaining_requests` in billing.ts makes
the first test fail with the field diff; restoring it passes. 7 passed via
BACKEND_UNIT_TEST_FILE_LIST=... backend/test.sh.
@cursor

cursor Bot commented Aug 11, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_ac56f57e-1c58-410a-8fbd-f54e36f1815c)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 32f2068be9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if not is_valid_api_key_hash(previous_hashed_key):
raise ApiKeyRevocationUnavailableError("MCP API key credential metadata is invalid")
try:
cache_deleted = redis_db.delete_cached_mcp_api_key_strict(previous_hashed_key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fence authentication while rotating cached credentials

When an old-key authentication overlaps rotation, it can miss the just-deleted cache, read the still-old Firestore hash before key_ref.update(), and repopulate the retired hash after the rotation completes; subsequent requests then authorize from that cache for up to its one-hour TTL. The same delete-before-swap ordering exists in rotate_dev_key, so both supposedly hard-cutover rotations need serialization or generation/version fencing that prevents an in-flight lookup from recaching the retired credential.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and the PR body's claim that the retired hash was "uncached strictly before the swap" was wrong as written. Fixed in cd2be95.

Rotation now tombstones the retired hash durably before deleting the cache, and authentication refuses to honor or write a cache entry whose fence is not provably absent. The tombstone outlives the auth-cache TTL, so the racing write is harmless. Unreadable fence is treated as retired. MCP and dev keys share one helper (retired_hash_fences_cache) so they can't drift.

Regression tests reproduce the interleaving by committing the rotation inside the authentication window. Mutation-verified — forcing the fence to False:

test_rotation_fences_an_authentication_that_races_the_secret_swap FAILED
E  assert {'app_id': 'mcp-api', 'key_id': 'key-1', ...} is None
test_dev_rotation_fences_an_authentication_that_races_the_secret_swap FAILED

Comment on lines +29 to +33
const getToken = async () => {
const current = auth.currentUser;
if (!current) return null;
return current.getIdToken();
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stabilize the token getter before using it in effects

For a signed-in user, this creates a new getToken function on every render. KeysManager and BillingPanel use it as a useCallback dependency and then use those callbacks as useEffect dependencies, so every successful load updates state, recreates the callback, and immediately starts another load; these pages continuously issue key, payment, and quota requests. Memoize getToken or otherwise remove the unstable function identity from the effect chain.

Useful? React with 👍 / 👎.

}

export interface PlatformSearchResponse {
memories?: PlatformMemoryItem[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Read search results from the response's items field

Every successful platform search is decoded with the wrong property name: the backend's ProductMemorySearchResponse returns items, while this interface declares memories and MemoryWidget reads response.memories. Consequently, a signed-in search discards all returned memories and always renders an empty result list; align the client type and consumer with the actual items payload.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 56dfd0a — decoded from items, widget updated. Added test_web_search_client_type_matches_the_response_model, which diffs the TS interface against real ProductMemorySearchResponse.model_fields introspection. Mutation-verified: renaming back to memories fails with assert not {'memories'}. Confirmed end-to-end in a framed production build — the results render.

const upstream = await fetch(
'${API_HOST}/v1/memory/platform/search?' +
new URLSearchParams({ query: q.slice(0, ${MEMORY_PLATFORM_LIMITS.maxQueryLength}), limit: '20' }),
{ headers: { Authorization: \`Bearer \${process.env.OMI_SERVER_KEY}\` } },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Authenticate the proxy with a supported credential

The published server-proxy example implies that a durable OMI_SERVER_KEY can call /v1/memory/platform/search, but that route depends on get_current_user_uid, which explicitly accepts only Firebase ID tokens and rejects MCP/developer key families; a static Firebase ID token would also expire. Users following this example therefore receive 401s, so the proxy must forward or mint a refreshable user session, or the route must explicitly support an appropriate scoped server key.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c34b135 — the proxy example forwards the visitor's own refreshable Omi ID token instead of a durable key, and the credentials section now states that this route accepts only Firebase ID tokens and rejects the MCP/dev key families.

export const postMessageSnippet = `const OMI_ORIGIN = 'https://h.omi.me';

window.addEventListener('message', (event) => {
if (event.origin !== OMI_ORIGIN) return; // validate the origin

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the sandboxed frame by source instead of its URL origin

With the documented sandbox="allow-scripts" and no allow-same-origin, messages from the frame have the opaque origin "null", not https://h.omi.me, so this listener drops every ready/resize event. The iframe example also points at your-app.example, making the hard-coded Omi origin inconsistent even without sandboxing; validate event.source against the expected iframe window and handle the deliberately opaque origin safely.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c34b135 — snippet and doc both validate event.source === frame.contentWindow and explain why origin can't work here. Confirmed live: every message from the sandboxed frame arrived as origin="null" with sourceMatches=true.

export const mcpClientSnippet = `{
"mcpServers": {
"omi": {
"url": "https://api.omi.me/mcp",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Point MCP clients at the registered SSE endpoint

The published MCP client configuration uses https://api.omi.me/mcp, but a repo-wide route search finds the hosted transport only at /v1/mcp/sse, which is also the endpoint reported by the MCP info and OAuth resource configuration; there is no /mcp route or rewrite. Anyone copying this configuration receives a 404 and cannot connect, so use https://api.omi.me/v1/mcp/sse throughout the snippets and docs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c34b135 — snippets and docs/memory/api-service.md now use https://api.omi.me/v1/mcp/sse.

Comment thread backend/routers/memory_platform.py Outdated

from fastapi import APIRouter, Depends, HTTPException, Query, Request

from database._client import db

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the injectable Firestore client in the new router

This new router binds the legacy global db compatibility proxy and passes it through every authorization, search, and ingest path, coupling the handlers to process-global state and preventing tests or alternate runtimes from injecting the owned client boundary. The backend guide explicitly reserves db for legacy code and requires new paths to resolve get_firestore_client() at the call boundary with injectable helpers.

AGENTS.md reference: backend/AGENTS.md:L191-L193

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 29e1246 — every authorization, search, and ingest path resolves get_firestore_client() at the call boundary. Tests inject the client through an autouse fixture instead of asserting on the global.

Comment on lines +39 to +42
const [info, available] = await Promise.all([
getOverageInfo(token),
getAvailablePlans(token),
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve successful billing data when a sibling request fails

If either /v1/payments/overage-info or /v1/payments/available-plans fails, Promise.all discards the successful sibling response and neither state value is updated. The page then renders the fallback Free, zero questions, and zero overage alongside the error even when the backend successfully returned the user's paid plan and usage; settle these requests independently and retain whichever result succeeded.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in be74f02 — both requests settle independently with Promise.allSettled, so a successful sibling is retained and the error is reported alongside real data.

Comment thread backend/routers/memory_platform.py Outdated
Comment on lines +111 to +113
query: str = Query('', max_length=MAX_PLATFORM_SEARCH_QUERY_LENGTH),
limit: int = Query(100, ge=1, le=MAX_PRODUCT_MEMORY_READ_LIMIT),
offset: int = Query(0, ge=0, le=MAX_PLATFORM_SEARCH_OFFSET),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return the documented 400 for invalid search bounds

For real HTTP requests with a query over 500 characters or an out-of-range limit/offset, FastAPI enforces these Query constraints before entering search_memory_platform, so _validate_search_bounds never produces its intended 400 and clients receive the framework's 422 response instead. The direct-call unit test and published API documentation promise 400; either let the handler own validation or update the public contract and clients to expect 422.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 29e1246 — the handler owns validation now; the bounds stay documented as OpenAPI descriptions. Mutation-verified: restoring the Query constraints fails all five cases with assert 422 == 400.

Comment thread docs/memory/api-service.md Outdated

## Plan and quota

Platform API access follows the Omi subscription. `GET /v1/payments/overage-info` reports the current plan, included usage, consumed usage, and any overage. Plan and usage are also shown at [`/memory-platform/billing`](https://h.omi.me/memory-platform/billing), alongside the upgrade path through Stripe checkout.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Point quota consumers at the platform quota endpoint

This quota section directs API consumers to /v1/payments/overage-info, but that endpoint reports chat-question usage and chat overage rather than Memory Platform requests. The newly added platform allowance is exposed by /v1/memory/platform/quota; following the current documentation yields unrelated counts and no platform limit or remaining, so document the actual quota endpoint here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in be74f02 — the quota section now documents GET /v1/memory/platform/quota and its fields, and states that /v1/payments/overage-info is a different meter (chat questions).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

15 issues found across 67 files

Confidence score: 2/5

  • In web/frontend/src/lib/api/memory-platform.ts, the search response is modeled as memories while the endpoint returns items, so successful queries can look empty to users. Align the client type and widget consumption to items so results actually render.
  • In backend/database/mcp_api_key.py, concurrent rotate requests can each return success with different secrets even though only the last secret remains valid, which creates immediate auth failures for one caller. Make rotation a serialized/conditional transition (or fail/retry the second request) so responses stay truthful.
  • In backend/utils/mcp_scopes.py (and the optional scopes flow in backend/models/mcp_api_key.py), empty/invalid scope input can normalize to full access, creating an unintended privilege escalation path. Treat empty or invalid scopes as an error path and require explicit scopes at creation.
  • In web/frontend/src/lib/api/billing.ts, scheduled-cancel reactivation can succeed server-side but appear to do nothing because the UI only handles redirect URLs and not status/message responses. Model the non-redirect success shape and surface a clear success state in the panel.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/frontend/src/app/memory-platform/components/copy-button.tsx">

<violation number="1" location="web/frontend/src/app/memory-platform/components/copy-button.tsx:31">
P3: When clipboard write fails (e.g. navigator.clipboard unavailable in non-secure contexts or permission denied), the catch block silently does nothing, so the user gets no feedback that the copy failed and the button stays looking idle. Consider a transient error state or at least a console/diagnostic so failure isn't silent.</violation>
</file>

<file name="web/frontend/src/app/memory-platform/components/platform-shell.tsx">

<violation number="1" location="web/frontend/src/app/memory-platform/components/platform-shell.tsx:25">
P1: Memory-platform masthead starts beneath the fixed AppHeader because this shell reserves only 4rem; reserve 6.5rem on narrow screens and 7rem at `md` so page content clears the header.</violation>
</file>

<file name="web/frontend/src/lib/api/memory-platform.ts">

<violation number="1" location="web/frontend/src/lib/api/memory-platform.ts:27">
P1: Successful platform searches render no results because the client models `memories` while the endpoint returns `items`. Model the response as `items` and have the widget consume that field.</violation>
</file>

<file name="web/frontend/src/app/memory-platform/components/embed-preview.tsx">

<violation number="1" location="web/frontend/src/app/memory-platform/components/embed-preview.tsx:32">
P3: The `ready` event is emitted before this parent effect subscribes, so the live preview cannot observe or demonstrate readiness. Register the listener before mounting `MemoryWidget`, or route readiness through a component callback.</violation>
</file>

<file name="backend/database/mcp_api_key.py">

<violation number="1" location="backend/database/mcp_api_key.py:260">
P1: Two overlapping rotate requests can each return 200 with a different secret, but only the last writer's secret authenticates. Make rotation a serialized/conditional state transition so a second request retries or fails rather than returning a credential immediately superseded by the other request.</violation>
</file>

<file name="web/frontend/src/lib/api/billing.ts">

<violation number="1" location="web/frontend/src/lib/api/billing.ts:98">
P2: Reactivating a scheduled-to-cancel subscription succeeds without a redirect but appears to do nothing because this response type omits the backend’s `status`/`message` path and the panel only handles `url`. Model `session_id`, `status`, and `message`, then show the reactivation result or reload billing state.</violation>
</file>

<file name="backend/tests/unit/test_api_key_rotation.py">

<violation number="1" location="backend/tests/unit/test_api_key_rotation.py:127">
P3: The dev-key rotation test is weaker than its MCP counterpart: it never asserts `rotated_at` was set, never verifies the retired secret was evicted from the auth cache, and the ownership test omits the missing-key case (the MCP tests cover all three). A regression in dev rotation's cache-clear ordering or `rotated_at` write would pass unnoticed.</violation>
</file>

<file name="backend/models/mcp_api_key.py">

<violation number="1" location="backend/models/mcp_api_key.py:24">
P2: McpApiKeyCreate.scopes is optional, so a new key can be created with scopes omitted entirely. The router's validation is guarded by `if key_data.scopes is not None`, and a None value flows into create_mcp_key → normalize_mcp_scopes(None), which resolves to MCP_FULL_ACCESS_SCOPES. This contradicts the PR's per-key scoping intent (and its claim that create-path scope problems no longer widen to full access): a client that omits scopes silently gets a full-access key. Consider making scopes required for new key creation (or treating None on create as an invalid-scope 400) so every new key carries explicit scopes.</violation>
</file>

<file name="backend/tests/unit/test_memory_platform_router.py">

<violation number="1" location="backend/tests/unit/test_memory_platform_router.py:24">
P3: The module-scoped fixture mutates `sys.modules`/`utils.other.endpoints` with a hand-built stub instead of using the sanctioned `backend/testing/import_isolation.py` helper or a conftest-based seam, which the repo guidance (AGENTS.md 'Never mutate sys.modules at module scope in tests' and prior feedback to avoid module-scope sys.modules stubs for routers) steers away from. The partial stub (only `get_current_user_uid`, no `with_rate_limit`) plus module `__all__`-driven imports can silently mask behavior of `_rate_limited_uid`/`within_rate_limit`; prefer the sanctioned isolation helper so the pattern stays consistent and auditable.</violation>
</file>

<file name="backend/database/dev_api_key.py">

<violation number="1" location="backend/database/dev_api_key.py:118">
P2: This new function places an entire rotation orchestration in the database layer: it orders the cache invalidation strictly before the Firestore secret swap, generates the new credential, updates the document, and then builds the response model through the read boundary. Per the repo convention, DB modules should stay focused on persistence, with this decision/orchestration logic living in a util/service layer and the DB write carrying only the reference object id. As it stands, correctness-critical ordering ("delete retired secret from cache before persisting the new one") is buried inside the low-level persistence module, which makes the safety invariant harder to audit, and it duplicates the same orchestration already wedged into `mcp_api_key.rotate_mcp_key`. Consider extracting the rotation flow (read → ownership check → cache retire → swap → projection) into a shared service/util used by both key kinds, keeping the DB layer as thin persistence.</violation>

<violation number="2" location="backend/database/dev_api_key.py:147">
P3: New `rotate_dev_key` is a near-verbatim duplicate of the existing `rotate_mcp_key` (~60 lines). The two differ only in the cache-delete function, key generator, Pydantic model, app-id handling, and scope normalizer; the structure (read doc → ownership check → hash validity → retire cache strictly → generate → update → project → strict-parse) is identical. Since these two credential types share the exact same hard-cutover rotation contract, the shared flow can be parameterized by a small kind descriptor (cache fn, generator, model, normalize helpers) and reused, so rotation semantics can't drift between Dev and MCP keys. If a shared helper is intentionally avoided, a comment explaining why the duplication is worthwhile would help.</violation>
</file>

<file name="backend/models/memory_platform.py">

<violation number="1" location="backend/models/memory_platform.py:71">
P3: MemoryPlatformContract is a dead alias that simply equals MemoryPlatformCapability and is not referenced anywhere except its own definition and the __all__ export (the router and utils import MemoryPlatformCapability directly). Since nothing consumes the alias, this adds a redundant public name that can drift from the real model. Consider dropping it (and its __all__ entry) or, if a stable contract name is intended, documenting what it guarantees beyond the concrete model.</violation>
</file>

<file name="backend/database/memory_platform_usage.py">

<violation number="1" location="backend/database/memory_platform_usage.py:64">
P3: This new module substantially duplicates `database/phone_call_usage.py`: `_period_id`, `_period_reset_epoch`, `_key`, the 40-day `_TTL_SECONDS`, and the entire `reserve_current_month_slot` INCR/EXPIRE/over-limit-DECR logic are near-verbatim copies (the only addition is `record_fallback` on the failure path). That is a maintenance hazard — a bug or policy change in the shared monthly-counter semantics (e.g., rollover handling, TTL, over-limit rollback) now has to be fixed in two places that can drift apart. Consider extracting the common monthly Redis-counter helper and having both modules reuse it, keeping only the component-specific bits (key prefix, fallback reporting) local.</violation>
</file>

<file name="backend/utils/mcp_scopes.py">

<violation number="1" location="backend/utils/mcp_scopes.py:32">
P2: The per-key scope normalizer opens back up to full access any time the resolved scope set is empty. normalize_mcp_scopes([]) and normalize_mcp_scopes(["bogus"]) both return the full 9-scope set, and this is the exact helper the auth path, rotate path, and DB-layer create path all funnel through. That directly contradicts the PR's stated invariant that a key "authorizes exactly the scopes recorded on its document and nothing else" and that unknown/empty scopes fail closed. The HTTP create router shields the create flow, but the DB-layer create_mcp_key and the authentication path trust this helper, so a key whose stored scopes field is empty/corrupt (or a future direct caller of create_mcp_key that forgets router validation) silently escalates to full access instead of failing closed. I'd suggest distinguishing the legacy fallback (missing/unreadable state → full access, which is intentional) from an explicitly present empty/unknown scope set (→ no access), and adding a test for the all-unknown/empty case.</violation>
</file>

<file name="backend/utils/mcp_memory_platform.py">

<violation number="1" location="backend/utils/mcp_memory_platform.py:10">
P3: build_memory_platform_payload() is a duplicate adapter that only delegates to build_mcp_memory_platform_payload() and is referenced exclusively by the test file — no production code path calls it (the REST router returns MemoryPlatformCapability directly, and mcp_sse.py calls build_mcp_memory_platform_payload()). This is an alias that only exists to back tests, consistent with the repo's 'no duplicate adapters' guidance. Consider removing it and updating the test to assert against build_mcp_memory_platform_payload(), or just inline its only test use.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread web/frontend/src/app/memory-platform/hooks/use-session-token.ts Outdated
*/
export default function PlatformShell({ active, children }: PlatformShellProps) {
return (
<div className="dark min-h-screen bg-[#0a0a0a] pt-16 text-neutral-100 antialiased">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Memory-platform masthead starts beneath the fixed AppHeader because this shell reserves only 4rem; reserve 6.5rem on narrow screens and 7rem at md so page content clears the header.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/frontend/src/app/memory-platform/components/platform-shell.tsx, line 25:

<comment>Memory-platform masthead starts beneath the fixed AppHeader because this shell reserves only 4rem; reserve 6.5rem on narrow screens and 7rem at `md` so page content clears the header.</comment>

<file context>
@@ -0,0 +1,70 @@
+ */
+export default function PlatformShell({ active, children }: PlatformShellProps) {
+  return (
+    <div className="dark min-h-screen bg-[#0a0a0a] pt-16 text-neutral-100 antialiased">
+      <div className="sticky top-16 z-30 border-b border-white/10 bg-[#0a0a0a]/80 backdrop-blur">
+        <div className="mx-auto flex max-w-6xl items-center gap-6 px-5 py-4 md:px-8">
</file context>
Suggested change
<div className="dark min-h-screen bg-[#0a0a0a] pt-16 text-neutral-100 antialiased">
<div className="dark min-h-screen bg-[#0a0a0a] pt-[6.5rem] text-neutral-100 antialiased md:pt-28">

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reproducible — declining this one. Verified visually against a production build at 1280px and 390px: the masthead clears the fixed AppHeader exactly, no overlap at either width. AppHeader is 4rem, which is what main's own mobile-menu top-16 also assumes. The suggested pt-[6.5rem]/md:pt-28 would open a ~2.5rem gap.

}

export interface PlatformSearchResponse {
memories?: PlatformMemoryItem[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Successful platform searches render no results because the client models memories while the endpoint returns items. Model the response as items and have the widget consume that field.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/frontend/src/lib/api/memory-platform.ts, line 27:

<comment>Successful platform searches render no results because the client models `memories` while the endpoint returns `items`. Model the response as `items` and have the widget consume that field.</comment>

<file context>
@@ -0,0 +1,100 @@
+}
+
+export interface PlatformSearchResponse {
+  memories?: PlatformMemoryItem[];
+  [key: string]: unknown;
+}
</file context>

Comment thread web/frontend/src/lib/api/mcp-keys.ts Outdated
Comment thread backend/database/dev_api_key.py
saved = snapshot_sys_modules(_MODULE_NAMES)
auth_module = _auth_stub()
sys.modules.pop('routers.memory_platform', None)
sys.modules['utils.other.endpoints'] = auth_module

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The module-scoped fixture mutates sys.modules/utils.other.endpoints with a hand-built stub instead of using the sanctioned backend/testing/import_isolation.py helper or a conftest-based seam, which the repo guidance (AGENTS.md 'Never mutate sys.modules at module scope in tests' and prior feedback to avoid module-scope sys.modules stubs for routers) steers away from. The partial stub (only get_current_user_uid, no with_rate_limit) plus module __all__-driven imports can silently mask behavior of _rate_limited_uid/within_rate_limit; prefer the sanctioned isolation helper so the pattern stays consistent and auditable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/tests/unit/test_memory_platform_router.py, line 24:

<comment>The module-scoped fixture mutates `sys.modules`/`utils.other.endpoints` with a hand-built stub instead of using the sanctioned `backend/testing/import_isolation.py` helper or a conftest-based seam, which the repo guidance (AGENTS.md 'Never mutate sys.modules at module scope in tests' and prior feedback to avoid module-scope sys.modules stubs for routers) steers away from. The partial stub (only `get_current_user_uid`, no `with_rate_limit`) plus module `__all__`-driven imports can silently mask behavior of `_rate_limited_uid`/`within_rate_limit`; prefer the sanctioned isolation helper so the pattern stays consistent and auditable.</comment>

<file context>
@@ -0,0 +1,133 @@
+    saved = snapshot_sys_modules(_MODULE_NAMES)
+    auth_module = _auth_stub()
+    sys.modules.pop('routers.memory_platform', None)
+    sys.modules['utils.other.endpoints'] = auth_module
+    other_package = sys.modules.get('utils.other')
+    if isinstance(other_package, types.ModuleType):
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixture uses the repo's memory_import_isolation helper (snapshot_sys_modules/restore_sys_modules) and restores in a finally, so it is the sanctioned seam rather than an ad-hoc mutation. Kept as-is; backend-module-isolation passes.

if cache_deleted is not True:
raise ApiKeyRevocationUnavailableError("Developer API key cache invalidation was not confirmed")

raw_key, hashed_key, key_prefix = generate_dev_api_key()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: New rotate_dev_key is a near-verbatim duplicate of the existing rotate_mcp_key (~60 lines). The two differ only in the cache-delete function, key generator, Pydantic model, app-id handling, and scope normalizer; the structure (read doc → ownership check → hash validity → retire cache strictly → generate → update → project → strict-parse) is identical. Since these two credential types share the exact same hard-cutover rotation contract, the shared flow can be parameterized by a small kind descriptor (cache fn, generator, model, normalize helpers) and reused, so rotation semantics can't drift between Dev and MCP keys. If a shared helper is intentionally avoided, a comment explaining why the duplication is worthwhile would help.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/database/dev_api_key.py, line 147:

<comment>New `rotate_dev_key` is a near-verbatim duplicate of the existing `rotate_mcp_key` (~60 lines). The two differ only in the cache-delete function, key generator, Pydantic model, app-id handling, and scope normalizer; the structure (read doc → ownership check → hash validity → retire cache strictly → generate → update → project → strict-parse) is identical. Since these two credential types share the exact same hard-cutover rotation contract, the shared flow can be parameterized by a small kind descriptor (cache fn, generator, model, normalize helpers) and reused, so rotation semantics can't drift between Dev and MCP keys. If a shared helper is intentionally avoided, a comment explaining why the duplication is worthwhile would help.</comment>

<file context>
@@ -113,6 +115,64 @@ def create_dev_key(user_id: str, name: str, scopes: Optional[List[str]] = None)
+    if cache_deleted is not True:
+        raise ApiKeyRevocationUnavailableError("Developer API key cache invalidation was not confirmed")
+
+    raw_key, hashed_key, key_prefix = generate_dev_api_key()
+    now = datetime.now(timezone.utc)
+    scopes = _normalize_dev_scopes(key_data.get("scopes"))
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, not doing it here. The correctness-critical part of the rotation contract — the retirement fence — is now shared (retired_hash_fences_cache), so the safety invariant can't drift between the two key kinds. Extracting the full read/own/retire/swap/project flow is a wider refactor than belongs in a security fix; leaving the remaining structural duplication for a follow-up.

status: Literal["created"] = "created"


MemoryPlatformContract = MemoryPlatformCapability

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: MemoryPlatformContract is a dead alias that simply equals MemoryPlatformCapability and is not referenced anywhere except its own definition and the all export (the router and utils import MemoryPlatformCapability directly). Since nothing consumes the alias, this adds a redundant public name that can drift from the real model. Consider dropping it (and its all entry) or, if a stable contract name is intended, documenting what it guarantees beyond the concrete model.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/models/memory_platform.py, line 71:

<comment>MemoryPlatformContract is a dead alias that simply equals MemoryPlatformCapability and is not referenced anywhere except its own definition and the __all__ export (the router and utils import MemoryPlatformCapability directly). Since nothing consumes the alias, this adds a redundant public name that can drift from the real model. Consider dropping it (and its __all__ entry) or, if a stable contract name is intended, documenting what it guarantees beyond the concrete model.</comment>

<file context>
@@ -0,0 +1,91 @@
+    status: Literal["created"] = "created"
+
+
+MemoryPlatformContract = MemoryPlatformCapability
+
+
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — dead alias. Leaving the removal for the follow-up that also handles the duplicate build_memory_platform_payload adapter, to keep this round scoped to the correctness fixes.

return build_memory_platform_capability().model_dump(mode="json")


def build_memory_platform_payload() -> dict[str, Any]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: build_memory_platform_payload() is a duplicate adapter that only delegates to build_mcp_memory_platform_payload() and is referenced exclusively by the test file — no production code path calls it (the REST router returns MemoryPlatformCapability directly, and mcp_sse.py calls build_mcp_memory_platform_payload()). This is an alias that only exists to back tests, consistent with the repo's 'no duplicate adapters' guidance. Consider removing it and updating the test to assert against build_mcp_memory_platform_payload(), or just inline its only test use.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/utils/mcp_memory_platform.py, line 10:

<comment>build_memory_platform_payload() is a duplicate adapter that only delegates to build_mcp_memory_platform_payload() and is referenced exclusively by the test file — no production code path calls it (the REST router returns MemoryPlatformCapability directly, and mcp_sse.py calls build_mcp_memory_platform_payload()). This is an alias that only exists to back tests, consistent with the repo's 'no duplicate adapters' guidance. Consider removing it and updating the test to assert against build_mcp_memory_platform_payload(), or just inline its only test use.</comment>

<file context>
@@ -0,0 +1,11 @@
+    return build_memory_platform_capability().model_dump(mode="json")
+
+
+def build_memory_platform_payload() -> dict[str, Any]:
+    return build_mcp_memory_platform_payload()
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, same follow-up as the MemoryPlatformContract alias — a test-only adapter that should be inlined.

@@ -0,0 +1,93 @@
"""Memory Platform API request counters for plan-quota enforcement.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This new module substantially duplicates database/phone_call_usage.py: _period_id, _period_reset_epoch, _key, the 40-day _TTL_SECONDS, and the entire reserve_current_month_slot INCR/EXPIRE/over-limit-DECR logic are near-verbatim copies (the only addition is record_fallback on the failure path). That is a maintenance hazard — a bug or policy change in the shared monthly-counter semantics (e.g., rollover handling, TTL, over-limit rollback) now has to be fixed in two places that can drift apart. Consider extracting the common monthly Redis-counter helper and having both modules reuse it, keeping only the component-specific bits (key prefix, fallback reporting) local.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/database/memory_platform_usage.py, line 64:

<comment>This new module substantially duplicates `database/phone_call_usage.py`: `_period_id`, `_period_reset_epoch`, `_key`, the 40-day `_TTL_SECONDS`, and the entire `reserve_current_month_slot` INCR/EXPIRE/over-limit-DECR logic are near-verbatim copies (the only addition is `record_fallback` on the failure path). That is a maintenance hazard — a bug or policy change in the shared monthly-counter semantics (e.g., rollover handling, TTL, over-limit rollback) now has to be fixed in two places that can drift apart. Consider extracting the common monthly Redis-counter helper and having both modules reuse it, keeping only the component-specific bits (key prefix, fallback reporting) local.</comment>

<file context>
@@ -0,0 +1,93 @@
+    return used, _period_reset_epoch(now)
+
+
+def reserve_current_month_request(uid: str, monthly_limit: int) -> Tuple[bool, int, int]:
+    """Atomically reserve one request against the month's allowance.
+
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed the monthly-counter logic is duplicated from phone_call_usage.py. Extracting a shared Redis monthly-counter primitive touches an existing metered path, so it's out of scope for this PR — tracking as follow-up rather than doing it alongside the auth changes.

@undivisible
undivisible marked this pull request as draft August 11, 2026 13:16
Deleting the auth cache before the credential swap loses the race it was
meant to win: an authentication that already read the pre-swap Firestore
document writes the retired hash back into the cache afterwards, keeping a
rotated-away secret valid for the remainder of the one-hour TTL. The PR body
claimed the retired hash was strictly uncached before the swap; that claim
was wrong as written.

Rotation now tombstones the retired hash durably before touching the cache,
and authentication refuses to honor or write a cache entry whose fence is not
provably absent. The tombstone outlives every auth-cache TTL, so the racing
write is harmless: no later request can authorize from it. An unreadable
fence is treated as retired, which costs a Firestore lookup and never
authorizes a retired secret.

Also bumps MCP_API_KEY_AUTH_CONTEXT_VERSION to 4. Per-key scopes became
authoritative without a version bump, so during a rolling deploy an old
revision could cache full access for a key a new revision recorded as
read-only, and the new revision would honor it for up to an hour.

Both fixes share one helper, retired_hash_fences_cache, so MCP and Developer
rotation cannot drift apart.

Mutation-verified: forcing the fence to False fails both interleaving tests
with the retired secret still authorizing; restoring the cache version to 3
fails the scope test with the read-only key widened to all nine scopes.

Failure-Class: none
…e platform router

Two defects in the new router:

The documented 400 for invalid search bounds was unreachable. Declaring the
bounds as Query constraints let FastAPI reject the request with its framework
422 before the handler ran, so `_validate_search_bounds` only ever fired for
direct unit calls. Real clients got a 422 the published contract never
mentioned. The handler now owns validation and the bounds stay documented in
the OpenAPI descriptions.

The router also bound the legacy global `db` compatibility proxy, which
backend/AGENTS.md reserves for legacy code. Every authorization, search, and
ingest path now resolves `get_firestore_client()` at the call boundary, so
the owned client is injectable.

Mutation-verified: restoring the Query constraints fails all five bounds
cases with `assert 422 == 400`.

Failure-Class: none
The memory-platform client modules resolved their base URL from
`envConfig.API_URL`, which reads `process.env.API_URL` — not a
`NEXT_PUBLIC_*` variable, so Next.js never inlines it into the client bundle.
In the browser it is undefined, the base collapses to the empty string, and
every key, billing, and platform request goes to the web origin as a relative
path instead of the backend.

All three browser clients now share one `browserApiBase()` helper reading
`NEXT_PUBLIC_API_BASE_URL`, so they cannot drift apart, with the server-only
value kept as a fallback for server-rendered callers. Audited every module in
src/lib/api/: apps.ts is server-only and correctly keeps API_URL.

Failure-Class: none
Every successful search rendered an empty list. The client interface declared
`memories` and the widget read `response.memories`, but the backend's
`ProductMemorySearchResponse` returns the page as `items` — the field never
existed on the wire, so the decode always yielded undefined.

Adds a cross-boundary guard modelled on the existing quota guard: it compares
the declared TypeScript interface against real introspection of
`ProductMemorySearchResponse.model_fields`, rejects any field the model does
not return, and pins the widget to the same field the interface declares.
Labelled a static checker — it reads TypeScript source rather than executing
it. A sibling guard forbids the browser clients from reading the server-only
API_URL.

Mutation-verified: renaming `items` back to `memories` fails with
`assert not {'memories'}`; restoring `envConfig.API_URL` in mcp-keys.ts fails
the origin guard.

Failure-Class: none
The core embeddable product could not work when framed. The published embed
sandboxes the frame with allow-scripts and deliberately without
allow-same-origin, so the framed document has an opaque origin and is denied
every durable storage API. Firebase cannot establish a session there, so the
widget's live search always fell through to "Sign in" and rendered nothing.

Relaxing the sandbox is not the fix: a document granted both allow-scripts and
allow-same-origin can rewrite its own sandbox attribute and escape entirely,
which backend/tests/unit/test_memory_platform_docs_guards.py correctly fails on.

The host page owns the credential instead. The frame asks with
omi.memory.embed.session-request; the host mints a short-lived token
server-side for the visitor it already authenticated and replies with
omi.memory.embed.session. The token lives only in hook memory for the frame's
lifetime. Top-level, the Firebase session remains the source of truth.

Messages are validated by event.source, never by origin: an opaque-origin
sender always reports origin 'null', so an origin allowlist cannot identify it
and comparing against an https literal drops every event. The embed preview
now exercises that same source check rather than an origin check.

Also stabilizes getToken with useCallback. It was recreated every render, and
the keys and billing pages use it as a useCallback dependency and those
callbacks as useEffect dependencies, so every successful load immediately
triggered another one — those pages issued key, payment, and quota requests
continuously. Enter-key search is now gated on pending for the same reason.

Failure-Class: none
…age guidance

Four defects in what this surface tells developers to do:

The MCP client config pointed at https://api.omi.me/mcp. The hosted transport
is registered only at /v1/mcp/sse, which is also what the MCP info endpoint and
the OAuth resource metadata report. There is no /mcp route or rewrite, so
anyone copying the config got a 404.

The server-proxy example implied a durable OMI_SERVER_KEY authenticates
/v1/memory/platform/search. That route depends on get_current_user_uid, which
accepts only Firebase ID tokens and rejects the MCP and Developer key families.
The example now forwards the visitor's own refreshable session, and the
credentials section says plainly that no durable server key exists for it.

The postMessage listener validated event.origin against an https literal. The
sandbox the same page mandates gives the frame an opaque origin, so every
message arrives as origin 'null' and the check dropped all of them. Both the
snippet and the doc now validate event.source, and explain why origin cannot
work here.

The docs also now cover the session handshake a framed widget needs, and the
security checklist names the source check as the real control.

Failure-Class: none
getAvailablePlans sent no X-App-Platform header. should_show_new_plans returns
false for an absent platform, so the backend classified this always-current
storefront as a legacy client: the catalog dropped Operator and exposed the
deprecated Neo entry, offering users the wrong products.

The panel also used Promise.all for overage and plans, so one failure discarded
the successful sibling and rendered the fallback Free plan with zero questions
and zero overage next to the error, even when the backend had returned the
user's real paid plan. Both requests now settle independently.

Docs and guards follow the code: the quota section pointed consumers at
/v1/payments/overage-info, which meters chat questions rather than platform
requests, and now documents GET /v1/memory/platform/quota with its real fields.
The quota seam tripwire scanned for /v1/payments/ rather than the quota path it
claims to protect, so a direct call to the quota route from a component passed
undetected. ARCHITECTURE.md no longer describes rotation, per-key scopes, and
quota as pending seams — all three are live.

Failure-Class: none
The app sent X-Frame-Options: DENY on every route, so /memory-platform/widget
— an embeddable widget whose entire purpose is to be put in a host product's
page — could not be framed at all, in dev or in production. Framing it showed
a broken-document placeholder.

This was the real cause of the failure the PR body attributed to sandboxed
frames needing same-origin storage. It is not storage: with the header fixed,
the sandboxed opaque-origin frame loads, hydrates, and runs fine.

Only the widget route is opened up, and it is safe to frame from any origin
because it carries no ambient authority: the published embed sandboxes it
without allow-same-origin, so it has an opaque origin, no cookies, and no
durable storage, and holds no session a framing page could exercise. It is
inert until the host explicitly hands it a short-lived token. Every other
route keeps DENY. X-Frame-Options cannot be relaxed per-route by a later rule,
so the catch-all excludes the widget path with a negative lookahead.

Verified against a production build with a host page framing the widget at
sandbox="allow-scripts": the handshake completes (origin "null",
event.source match true), the search reaches the backend with the host-minted
bearer token, and the results render.

Mutation-verified: restoring the '/(.*)?' catch-all fails the new guard.

Failure-Class: none
The four /v1/memory/platform entries authenticate with
auth.get_current_user_uid, which always calls validate_byok_request(uid), so
byok: not_applicable misrecorded their real behavior and diverged from the
sibling /v1/chat/* routes on the same dependency. The rotate routes keep
not_applicable — they use get_current_user_id, which does not validate BYOK.

Failure-Class: none
@Git-on-my-level Git-on-my-level added needs-tests PR introduces logic that should be covered by tests security-review Touches auth, provider routing, secrets, or security-sensitive surfaces needs-maintainer-review Needs a human maintainer to sign off before merge backend Backend Task (python) labels Aug 11, 2026

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pushing this forward. The shape is cohesive around a backend-authoritative memory platform, but I don't think this head is ready to merge yet.

Blocking items I see:

  • docs/api-reference/app-client-openapi.json updates the app-client schema, but the public developer OpenAPI artifact is still stale. The failing Public Developer API contract reports that docs/api-reference/openapi.json needs to be regenerated after adding the new /v1/memory/platform/* and key-rotation routes. Because this PR publishes developer-facing API docs, both public contract artifacts need to match the backend routes before merge.
  • app/lib/backend/schema/gen/payments_wire.g.dart and app/lib/backend/schema/gen/subscription_usage_wire.g.dart add platformApiRequestsPerMonth, but the Dart analyzer ratchet is failing with one new avoid_print occurrence. Please either remove the new print or update the analyzer baseline if the new occurrence is intentional.
  • The backend unit suite is failing in tests/unit/test_api_key_listability_contract.py and tests/unit/test_mcp_api_key_auth_context.py: several existing auth-context fake Redis adapters now lack the new api_key_hash_is_retired method used by backend/database/dev_api_key.py and backend/database/mcp_api_key.py. This looks like a test-fixture drift from the new retirement-fence seam rather than evidence that the production Redis path is broken, but the contract tests still need to be updated so the key-rotation/auth-cache behavior is covered consistently.

Specific code notes:

  • backend/database/api_key_metadata.py, backend/database/redis_db.py, backend/database/dev_api_key.py, and backend/database/mcp_api_key.py add a useful rotation tombstone and avoid trusting caches for retired hashes. The production seam is directionally sound, but the new redis_db.api_key_hash_is_retired() dependency needs the test doubles updated everywhere auth lookup is exercised.
  • backend/utils/mcp_scopes.py changes MCP scopes from always-full-access to per-key scoping. Please add/keep explicit coverage for malformed recorded scope lists: the docstring says a recorded list is authoritative, so make sure an empty or unknown-only stored list cannot accidentally widen to full access unless that is an intentional legacy-compatibility rule.
  • backend/routers/memory_platform.py correctly routes discovery, search, quota, and ingest through authenticated user sessions and the canonical MemoryService; the metering-after-validation placement is good. Because this is a new memory read/write surface, maintainer sign-off is still needed for the product/API boundary.
  • backend/route_policy_manifest.yaml records the new memory platform and rotation routes. Please keep that in sync with the regenerated OpenAPI artifacts so route policy, backend code, and public developer docs agree.
  • web/frontend/next.config.mjs, docs/memory/embedding.md, and web/frontend/src/app/memory-platform/hooks/use-embed-session.ts document and implement the sandboxed embed approach. The no-allow-same-origin guidance is security-positive, but the widget framing/session-token model is a sensitive web/API boundary and should get human maintainer sign-off before it ships.
  • docs/memory/backend-authority.md affects AI coding/review agents and MCP users by telling them Omi backend memory is authoritative and local/zkr stores are only replicas or pending buffers. The guidance appears safe and aligned with the new backend contract, but because it changes agent-facing instructions for how integrations should write memory, it should be reviewed deliberately by a maintainer.

Leaving this as changes requested because required CI/contract checks are failing. Separately, the new backend-authoritative memory platform, scoped MCP keys, rotation, and embeddable web surface are product/security-sensitive enough to need maintainer sign-off before merge.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

undivisible and others added 3 commits August 12, 2026 10:40
Signed-in web flows could not be exercised locally: `src/lib/firebase.ts`
always initialised against whatever project the env named, so any
`/memory-platform` verification stopped at "no local Firebase project".

`NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_HOST` now opts a build in to
`connectAuthEmulator`. The gate is deliberately double-locked in
`firebase-auth-emulator.mjs`: it refuses production builds and refuses any
non-loopback host, so a stray environment variable in a deploy cannot redirect
real sign-in.

Verification: `cd web/frontend && npm test` (111 pass); the two guard branches
were mutation-checked by deleting each one in turn, each failing exactly one
test. Signed in end to end through the real UI against `make dev-up`
(project demo-omi-local, auth emulator 127.0.0.1:9099) and confirmed the
emulator banner plus `GET /v1/mcp/keys 200` from the browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`make dev-up` started a backend the local `web/frontend` could not call. The
backend's CORS policy is default-deny and the harness set no allowlist, so every
browser request from `npm run dev` failed its preflight with 400 — the first
`POST /v1/mcp/keys` from the memory-platform keys page died there.

The harness now opts the two loopback dev origins in (3000, plus the 3001 Next
falls back to when 3000 is taken). Production keeps its own allowlist; these
origins only ever resolve to the developer's own machine.

Verification: before, `curl -X OPTIONS -H 'Origin: http://localhost:3000' ...
/v1/mcp/keys` returned 400; after `make dev-down && PROVIDER_MODE=offline make
dev-up` it returns 200, and key create/rotate/revoke all complete from the
browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Creating a key with no scope selected returns the documented 400, but the
message went to the page-level error state while the create dialog stayed open.
The dialog's own overlay dims the page behind it, so the user saw a "Create key"
button that appeared to do nothing and no reason why.

The create path now reports into a dialog-scoped `createError` rendered as a
`role="alert"` above the footer, cleared when the dialog closes. The page-level
error still owns list, rotate and revoke failures, which have no dialog.

Verification: reproduced by hand against a local backend — unchecking every
scope and submitting returned `POST /v1/mcp/keys 400`, and a screenshot showed
the message painted behind the open dialog. After the fix the same submission
renders "Invalid scopes. Available: [...]" inside the dialog. The added test is
a STATIC TRIPWIRE (labelled as such; the lane cannot render components); it was
mutation-checked by routing the failure back to `setError`, which fails it.

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Backend Task (python) needs-maintainer-review Needs a human maintainer to sign off before merge needs-tests PR introduces logic that should be covered by tests security-review Touches auth, provider routing, secrets, or security-sensitive surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants