feat(memory-platform): per-key MCP scoping, key rotation, plan entitlement, and the web surface - #11417
feat(memory-platform): per-key MCP scoping, key rotation, plan entitlement, and the web surface#11417undivisible wants to merge 44 commits into
Conversation
Failure-Class: none
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.
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
| const getToken = async () => { | ||
| const current = auth.currentUser; | ||
| if (!current) return null; | ||
| return current.getIdToken(); | ||
| }; |
There was a problem hiding this comment.
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[]; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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}\` } }, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Fixed in c34b135 — snippets and docs/memory/api-service.md now use https://api.omi.me/v1/mcp/sse.
|
|
||
| from fastapi import APIRouter, Depends, HTTPException, Query, Request | ||
|
|
||
| from database._client import db |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| const [info, available] = await Promise.all([ | ||
| getOverageInfo(token), | ||
| getAvailablePlans(token), | ||
| ]); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Fixed in be74f02 — both requests settle independently with Promise.allSettled, so a successful sibling is retained and the error is reported alongside real data.
| 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), |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
|
|
||
| ## 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. |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
15 issues found across 67 files
Confidence score: 2/5
- In
web/frontend/src/lib/api/memory-platform.ts, the search response is modeled asmemorieswhile the endpoint returnsitems, so successful queries can look empty to users. Align the client type and widget consumption toitemsso 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 optionalscopesflow inbackend/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
| */ | ||
| export default function PlatformShell({ active, children }: PlatformShellProps) { | ||
| return ( | ||
| <div className="dark min-h-screen bg-[#0a0a0a] pt-16 text-neutral-100 antialiased"> |
There was a problem hiding this comment.
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>
| <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"> |
There was a problem hiding this comment.
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[]; |
There was a problem hiding this comment.
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>
| saved = snapshot_sys_modules(_MODULE_NAMES) | ||
| auth_module = _auth_stub() | ||
| sys.modules.pop('routers.memory_platform', None) | ||
| sys.modules['utils.other.endpoints'] = auth_module |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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]: |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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. | |||
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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.jsonupdates the app-client schema, but the public developer OpenAPI artifact is still stale. The failing Public Developer API contract reports thatdocs/api-reference/openapi.jsonneeds 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.dartandapp/lib/backend/schema/gen/subscription_usage_wire.g.dartaddplatformApiRequestsPerMonth, but the Dart analyzer ratchet is failing with one newavoid_printoccurrence. 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.pyandtests/unit/test_mcp_api_key_auth_context.py: several existing auth-context fake Redis adapters now lack the newapi_key_hash_is_retiredmethod used bybackend/database/dev_api_key.pyandbackend/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, andbackend/database/mcp_api_key.pyadd a useful rotation tombstone and avoid trusting caches for retired hashes. The production seam is directionally sound, but the newredis_db.api_key_hash_is_retired()dependency needs the test doubles updated everywhere auth lookup is exercised.backend/utils/mcp_scopes.pychanges 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.pycorrectly routes discovery, search, quota, and ingest through authenticated user sessions and the canonicalMemoryService; 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.yamlrecords 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, andweb/frontend/src/app/memory-platform/hooks/use-embed-session.tsdocument and implement the sandboxed embed approach. The no-allow-same-originguidance 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.mdaffects 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.
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>
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, notmain— the/v1/memory/platformendpoints do not exist onmain. 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.mjssentX-Frame-Options: DENYon 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 keepsDENY.X-Frame-Optionscannot 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 withoutallow-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-scriptsandallow-same-origincan rewrite its ownsandboxattribute and escape entirely, whichtest_memory_platform_docs_guards.pycorrectly 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 withomi.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 reportsorigin === "null", so the previousevent.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 — whosenormalize_mcp_scopesalways 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;ProductMemorySearchResponsereturnsitems. 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 readsprocess.env.API_URL— not aNEXT_PUBLIC_*variable, so Next.js never inlines it into the client bundle. In the browser it isundefined, 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 onebrowserApiBase()helper readingNEXT_PUBLIC_API_BASE_URL. Audited every module insrc/lib/api/:apps.tsis server-only and correctly keepsAPI_URL.Also fixed
getTokenidentity was recreated every render; the keys and billing pages use it as auseCallbackdependency and those callbacks asuseEffectdependencies, so every successful load immediately triggered another. Those pages issued requests continuously. Memoized.dbproxy; now resolvesget_firestore_client()at the call boundary.Queryconstraints made FastAPI answer 422 before the handler ran. The handler owns validation now.X-App-Platform: webon/v1/payments/available-plans. Without itshould_show_new_planstreats the storefront as a legacy client: the catalog drops Operator and exposes deprecated Neo.Promise.all, so one failure discarded the successful sibling and rendered the fallback Free plan next to the error. Settled independently.https://api.omi.me/mcp, which is registered nowhere (the transport is/v1/mcp/sse); the proxy example implied a durableOMI_SERVER_KEYworks against a route that accepts only Firebase ID tokens./v1/payments/overage-info, which meters chat questions, not platform requests./v1/payments/rather than the quota path it claims to protect.Reviewed and rejected
AppHeaderand asked forpt-[6.5rem]/md:pt-28. Verified visually against a production build at 1280px and 390px — the masthead clears the header exactly.AppHeaderis 4rem, which is also what main's own mobile-menutop-16assumes. Applying the suggestion would open a 2.5rem gap.rotate_dev_keyvsrotate_mcp_key,memory_platform_usage.pyvsphone_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.
McpApiKeyCreateacceptsscopes; 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 bytest_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_monthonPlanLimits, metered with a Redis monthly counter.Nonemeans 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 throughrecord_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-platformplus/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) andtest_web_search_client_type_matches_the_response_model(ProductMemorySearchResponse). Siblings forbid browser clients from reading the server-onlyAPI_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):
retired_hash_fences_cachetoFalseMCP_API_KEY_AUTH_CONTEXT_VERSION = 3itemsback tomemoriesassert not {'memories'}envConfig.API_URLinmcp-keys.tsQueryconstraintsassert 422 == 400on all five cases'/(.*)?'catch-allBackend — 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 throughbackend/test.sh(not bare pytest).Web (
web/frontend) —npm test105/105 pass;npm run lintclean;npx tsc --noEmitreports 33 errors, all pre-existing and unchanged (same 33 with the branch stashed), insrc/components/trends/,tendencies/,apps/,chat/,tasks/, andmemories/memory-list/— files this PR does not touch.npm run buildsucceeds.Browser — the embed now actually works
Against a production build, with a host page framing the widget at
sandbox="allow-scripts"(noallow-same-origin) and a stub backend returning a realitemspayload: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 andsourceMatches=trueproving source validation is the right control, the request reaches the configured backend origin rather than a relative path, and the response decodes fromitems.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/frontendcan 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 on127.0.0.1:8000with theFirestore/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:POST /v1/mcp/keys 200. Dialog showedomi_mcp_802481c4...once with "Copy it now. You will not see this key again."omi_mcp_8024...ecf9…/conversations.read goals.read memories.read/ created / last-used only. No raw key anywhere.400and renderedInvalid 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.POST /v1/mcp/keys/{id}/rotate 200, new secret shown once. Old secret:GET /v1/mcp/memories200 before, 401 after. New secret: 200. No grace window.DELETE /v1/mcp/keys/{id} 204, row disappears, the secret then returns 401.GET /v1/payments/available-plans 500— no Stripe credentials locally) and the panel still rendered the plan fromoverage-info 200and0 / 1000 requestsfromGET /v1/memory/platform/quota 200, with the error shown alongside. This is exactly thePromise.allSettledbehaviour the code comments claim.http://127.0.0.1:4321),sandbox="allow-scripts"with noallow-same-origin:session-request origin="null" srcMatch=true→ host-minted token →ready→resize.The dev-harness CORS gap this exposed (backend default-deny allowlist was empty, so the
first browser
POSTdied on preflight with 400) is fixed in a separate commit.Still not exercised, and why
items— anempty-result search returns
{"uid":...,"query":...,"items":[],...}, so this PR'sitemsdecode is right. But any non-empty result 500s on a pre-existing backend bugthat is not in this PR's diff:
fetch_default_product_memory_searchreturnsList[Dict[str, Any]]projections (memory_id,memory_layer,lifecycle_status, ...)while
ProductMemorySearchResponse.itemsis declaredList[MemoryItem], so FastAPI'sresponse 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.fetchis preflighted withOrigin: null(that is what the opaque sandbox origin means)and the backend's explicit allowlist rejects it — observed as
OPTIONS /v1/memory/platform/search ... 400from the framed instance. Allowingnullwould 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.
Needs human sign-off
/memory-platform/widgettoframe-ancestors *is a deliberate relaxation of a repository-wideX-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.Product invariants affected
Failure-Class: none