Author SHA1 Message Date
shadowdaoandClaude Opus 5 3be9135aee docs: correct the segment-decoding comment on the catch-all route
Review caught the stated invariant being false. Next splits the matched
suffix on literal `/` before decoding each piece, so a segment can be empty
(`api//mcp`) and a single segment can carry a decoded slash (`api%2Fmcp`).
Neither reaches the allowlist — both fail closed — but the comment claimed
an invariant the router does not provide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:32:46 -07:00
shadowdaoandClaude Opus 5 f46f54d50b docs: quote the loopback regex actually deployed on the provider
The pattern in the walkthrough was written from memory and differs from the
one on the `shared-memory-mcp` provider in a way that matters: `…:[0-9]+/.*`
requires a path component, so it fails to match a bare
`http://localhost:5693`, which the deployed `…:\d+(/.*)?$` accepts. Copy the
live value verbatim instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:11:08 -07:00
shadowdaoandClaude Opus 5 29f5673eac fix: serve RFC 9728 path-suffixed metadata, document connector redirect URIs
Two separate discovery footguns, both found while debugging an Authentik
"Redirect URI Error" on a claude.ai custom connector.

RFC 9728 §3.1 puts the metadata for a resource identified by
`https://host/api/mcp` at `/.well-known/oauth-protected-resource/api/mcp`.
Only the root form was served, so clients that derive the metadata URL from
the MCP endpoint URL — rather than reading `resource_metadata` off our 401 —
got Next.js's HTML 404 and failed discovery with a JSON parse error.

Add a `[...path]` route serving the same document with `resource` naming the
suffixed identifier (§3.3 has the client compare it as an exact string, so
echoing the bare origin would be rejected). The document body moves to
`lib/auth/resource-metadata.ts` so the two routes cannot drift apart on
`scopes_supported` — a divergence there costs you the `aud` claim or the
refresh token. Paths are allowlisted rather than wildcarded so this cannot
advertise resources the app does not serve. `buildWwwAuthenticate()` still
points at the root URL; this change is purely additive.

Separately, the redirect URIs an MCP provider needs depend on how clients
reach it: a loopback URI for the CLI, `https://claude.ai/api/mcp/auth_callback`
for a claude.ai custom connector. Registering only the former is what produces
the "Redirect URI Error" page, and a portless `http://localhost/callback`
entry matches nothing the CLI sends. Document both, keyed on the literal
error text, and note that DCR is enterprise-gated on Authentik so these are
hand-registered on a FOSS instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:08:06 -07:00
jknapp c68d72857f Merge pull request 'docs: note that Entra keys identity on oid, not sub' (#25) from docs/identity-keying-note into main 2026-08-13 02:40:44 +00:00
shadowdaoandClaude Opus 5 391d8e0360 docs: note that Entra keys identity on oid, not sub
The Architecture section still described identity as keyed on `sub` + `iss`,
which stopped being universally true when 0005_user_oid.sql landed. It is
still correct for Authentik, Keycloak and Okta — but on Entra, `sub` is
pairwise per app registration and `oid` is the key. Someone reading only
this section would draw exactly the wrong conclusion about why a second
account appeared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:40:31 -07:00
jknapp bd7a1ca59a Merge pull request 'feat: adopt the rebalanced mark and add a wordmark lockup' (#24) from feat/rebalanced-mark-and-lockup into main 2026-08-13 01:46:31 +00:00
jknapp a3f28c52a5 Merge pull request 'feat: support Microsoft Entra ID as an OIDC provider' (#22) from feat/entra-id-support into main 2026-08-13 01:46:29 +00:00
jknapp 423a3fa7fe Merge pull request 'docs: draft ten logo concepts' (#23) from docs/logo-concepts into main 2026-08-13 01:46:26 +00:00
shadowdaoandClaude Opus 5 5d06095883 feat: adopt the rebalanced mark and add a wordmark lockup
The shipped mark failed the one size it most needed to survive. At 16px
in a browser tab its outer strokes rasterised away entirely and the
2.5px gap between stroke and node filled in, so the favicon rendered as
an indeterminate horizontal smear rather than as anything. Three changes
fix it, all driven by that case and verified by rendering rather than by
reasoning: strokes stop at x=30 so the gap survives, outer opacity goes
.45/.55 -> .7 so the neighbours stay visible, and the node grows to
r=8.5 so it still reads as a disc.

This is not a rebrand. The mark is the same three-signals-converging
figure and keeps whatever recognition it has earned; only its
proportions changed. icon.svg and logo.svg had drifted slightly apart
(different node radii and stroke ends) and are now geometrically
identical, differing only in palette.

Adds the lockup, which had no asset at all, in two variants because one
cannot serve both jobs: public/lockup.svg uses currentColor for in-app
use, and docs/assets/lockup.svg fixes the colour to #0092fd for the
README. An <img src> reference resolves currentColor to black, so the
currentColor variant would be invisible against a dark README. The
wordmark carries textLength, without which it overruns the viewBox on
wider monospace fonts.

Concepts and the reasoning behind the ones not chosen are on the
docs/logo-concepts branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:09:56 -07:00
shadowdaoandClaude Opus 5 2c127886ff docs: draft ten logo concepts
Eight fresh directions plus two refinements of the shipped mark, each
authored as a real currentColor SVG in the app's house style rather than
as a description, so any of them can be dropped straight in and the
favicon tile is a mechanical derivation.

Every mark was rendered at 120/32/24/16px and looked at, which changed
the set three times: the braid came out as a plain multiplication sign
and was rebuilt with actual over/under crossings, the anchor was
rebalanced to three-fold symmetry, and the first return-arc read even
more like a copyright glyph than the current one.

The finding that matters is not one of the eight. The mark shipping
today fails at 16px — its outer strokes drop out to rasterisation and
the 2.5px gap to the node fills in, so the favicon renders as a smear.
Concept 09 fixes that in five lines and is worth taking regardless of
whether anything here replaces the mark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 10:51:47 -07:00
shadowdaoandClaude Opus 5 4d6694620a docs: add the Entra ID provider walkthrough
The README's OIDC section is written against Authentik and stays that
way; Entra differs enough that inlining it would have doubled a file
that is already 32k. The new doc parallels the README's A/B structure so
the two are diffable, and leads with the traps, since every one of them
surfaces as an opaque 401 rather than as anything resembling its cause:
the access token version, the tenant-specific authority, `aud` being the
client-ID GUID while the requested scope is an `api://` URI, redirect-URI
platform types, and group GUIDs.

Sections 7 and 10b document the identity and overage behaviour shipped
in the previous commit, including the one upgrade-ordering caveat: an
existing Entra deployment should sign a user into the Web UI once before
reconnecting their MCP client, or the pre-migration row is stranded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 10:51:39 -07:00
shadowdaoandClaude Opus 5 bbea0f74f3 feat: support Microsoft Entra ID as an OIDC provider
Three defects stood between this codebase and a working Entra deployment.
All three fail silently, which is why they are grouped: each one masks the
next, and fixing any two still leaves a broken or dangerous install.

1. JWKS discovery. The key set URL was hardcoded to `${issuer}/jwks/`,
   which is Authentik's convention, not a standard. Entra serves keys at
   `/{tenant}/discovery/v2.0/keys`, so every Entra-issued MCP token failed
   verification on a 404 — authentication was impossible, not merely
   misconfigured. We now read `jwks_uri` from the issuer's discovery
   document and fall back to the old path, so Authentik is untouched.
   Discovery failure arms a 60s retry rather than pinning the wrong URL
   for the life of the container.

2. Identity. Entra's `sub` is pairwise — derived from the token
   recipient — so the Web UI and MCP app registrations emit different
   `sub` values for the same human. Keyed on (iss, sub), that person got
   two rows: sign into the Web UI, connect Claude Code, land in an empty
   account. Both paths upsert, so nothing errored. Identity now keys on
   `oid`, which Microsoft documents as constant across applications in a
   tenant, via one resolver both surfaces share. Rows created before the
   0005 migration adopt their `oid` on next sign-in.

3. Groups overage. Past 200 groups Entra omits `groups` entirely and
   substitutes a `_claim_names` pointer. `normalizeGroupsClaim` read that
   as "zero groups" and the sync deleted every membership the user had,
   revoking access to every shared project on both surfaces with no error
   raised. Both surfaces now refuse such a token instead — the Web UI
   fails the sign-in, MCP returns 401 — leaving memberships intact and
   naming the operator fix. An absent claim with no overage marker still
   clears memberships, which is unchanged and deliberate.

Verified against a real pgvector instance: 66 tests pass, and reverting
either new behaviour fails exactly the tests that cover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 10:51:29 -07:00
jknapp 80341c558d Merge pull request 'fix: pass OIDC_OFFLINE_ACCESS through to the app container' (#21) from fix/compose-offline-access-passthrough into main 2026-08-11 22:29:19 +00:00
shadowdaoandClaude Opus 5 1a44362202 fix: pass OIDC_OFFLINE_ACCESS through to the app container
The app service's environment block is an explicit allow-list, not
env_file — as the comment two lines above this change says, a var added
to .env but not listed here never reaches the container.

PR #20 added OIDC_OFFLINE_ACCESS to .env.example and documented it in the
README but missed this, so setting it in .env would have been silently
ignored and the refresh-token fix would not have taken effect. Caught
while deploying, before it could look like the IdP change had failed.

The external-db override merges its environment block rather than
replacing it, so it inherits this and needs no equivalent change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:29:05 -07:00
jknapp e0d0d3b2b3 Merge pull request 'fix: let deployments advertise offline_access so MCP sessions can refresh' (#20) from fix/oauth-offline-access into main 2026-08-11 22:21:51 +00:00
shadowdaoandClaude Opus 5 db17e0890e fix: let deployments advertise offline_access so MCP sessions can refresh
MCP clients were being kicked back to an interactive login on a short
cycle, reporting "requires re-authorization (token expired)".

Cause: /.well-known/oauth-protected-resource advertised only
openid/profile/email plus the audience scope. A client requests exactly
the scopes it finds there, and Authentik issues a refresh token only when
offline_access is among them — so the client received an access token
with nothing to renew it with. Once that token aged out, re-authenticating
by hand was the only path forward.

This is the same trap the audience scope already documents one comment
further up: a scope missing from this document is a scope the client will
never ask for, however the IdP is configured.

Adds OIDC_OFFLINE_ACCESS (default false). Enabling it appends
offline_access to the advertised scopes.

Left opt-in rather than always-on because it is only half the fix — the
IdP also needs an offline_access scope mapping on the provider, and
advertising a scope the IdP doesn't offer risks an invalid_scope
rejection that would break authentication outright. A deployment turns
this on after configuring its IdP; README documents both halves and how
to verify each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:19:14 -07:00
jknapp d4d478d2f3 Merge pull request 'feat: add memory.patch, trim memory.get, unify the memory write path' (#19) from feat/memory-patch-and-lean-get into main 2026-08-11 22:16:55 +00:00
shadowdaoandClaude Opus 5 6e9628b073 docs: record the memory API decisions, including the one declined
Keeps the reasoning behind PR #19 next to the code, since none of it is
recoverable from the diff: why memory_get stopped returning the embedding
and tsvector, why memory_patch refuses ambiguous matches rather than
picking one, and why memory_append was dropped as redundant with patch.

Also records P3 (mechanising file->memory mirroring) as DECLINED with its
reasoning and, more usefully, the condition that would reopen it — the
mirror going stale again now that patching is cheap. The evidence we had
pointed at edit cost, which P2 fixed; if drift recurs the cause was
attention instead, and the answer is probably to remove the duplication
rather than build a drift detector for it.

Notes two traps for anyone extending this: content_tsv is a generated
column so full-text search cannot rot after a patch (only the embedding
needs recomputing), and the obvious "does search find the patched text"
acceptance check therefore passes on an implementation that skips
re-embedding entirely.

The brief previously lived outside the repo. Moved rather than copied —
two hand-maintained copies is the exact drift problem described in the
document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:10:36 -07:00
shadowdaoandClaude Opus 5 c3bbea5134 feat: add memory.patch, trim memory.get, unify the memory write path
memory.get no longer returns the embedding and content_tsv
----------------------------------------------------------
It used a bare select() and returned the raw DB row, while memory.list
and memory.search already projected an explicit 9-field shape. On a
~13k-char memory those two internal columns were 55% of the response
and pushed it past the MCP tool-output cap, so large memories could not
be fetched inline at all. memory.get now returns the same 9 fields as
its siblings; user_id is still selected for the authorization check and
stripped before responding.

memory.patch
------------
memory.update only accepts full replacement, so adding one line to a
large document meant resending the whole document — expensive enough
that edits were being skipped rather than risk silently truncating
shared team documents.

memory.patch replaces one exact occurrence of old_string. An absent or
ambiguous match is an error, never a silent no-op and never an
arbitrary pick; that refusal is what makes the operation safe to hand
to an agent. The semantics live in lib/memory-patch.ts as a pure
function, free of DB and auth, so both surfaces share them.

Shared mutation layer
---------------------
The MCP tools and the Web UI Server Actions each reimplemented
authorize -> mutate -> re-embed -> CAS -> audit, and had drifted. Both
now route through lib/memory-mutations.ts.

BEHAVIOUR CHANGE: memory.delete over MCP skipped the project ACL
whenever the caller authored the row, so a memory written while a share
was rw stayed deletable by its author after an owner downgraded that
share to ro. memory.update and the whole Web UI always checked.
Authoring a row now grants no standing write privilege on any path.

The one deliberate difference between the surfaces is injected as a
ProjectResolver: MCP refuses an unknown project key so an agent cannot
spawn near-miss projects off a typo, while the Web UI creates one
because a person typing a name into a form means to.

Tests and lint
--------------
Adds vitest. The integration tests run against a real Postgres rather
than a mocked DB. The embedder sidecar is the only stub and it is
deterministic per-text, so re-embedding is verified by asserting the
stored vector actually changed rather than that a mock was called. One
test pins that content_tsv is a generated column and therefore cannot
rot after a patch — only the embedding needs an explicit recompute.

pnpm lint previously dropped into an interactive `next lint` setup
prompt and exited 1; ESLint had never been configured here. Replaced
with the ESLint CLI and a flat config bridging eslint-config-next
through FlatCompat. Clean at --max-warnings=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:58:11 -07:00
56 changed files with 5121 additions and 648 deletions
+9
View File
@@ -53,6 +53,15 @@ OIDC_AUDIENCE=shared-memory
# named the scope mapping something else.
#OIDC_AUDIENCE_SCOPE=aud-shared-memory
# Advertise `offline_access` so MCP clients receive a REFRESH token and can
# renew silently. Without it the client only gets a short-lived access token
# and kicks the user back to an interactive login every time it expires.
#
# Enable this ONLY after adding an `offline_access` scope mapping to the MCP
# provider in your IdP — advertising a scope the IdP doesn't offer can fail
# the whole authorization request. See README -> "Keeping sessions alive".
#OIDC_OFFLINE_ACCESS=true
# -----------------------------------------------------------------------------
# Database (Postgres 16 + pgvector — pgvector/pgvector:pg16 image)
# -----------------------------------------------------------------------------
+209 -8
View File
@@ -1,4 +1,4 @@
# shared-memory
# <img src="docs/assets/lockup.svg" alt="shared-memory" width="283">
A self-hosted MCP server that gives Claude Code sessions a **shared, persistent
memory** plus a **reusable snippet library**, behind your own OIDC login.
@@ -37,8 +37,10 @@ Workspace. Anything that publishes a `/.well-known/openid-configuration`.
The same container serves both the MCP endpoint (under `/api/mcp`) and the
Web UI. Users authenticate via your OIDC provider with pre-registered
confidential clients. Identity is keyed on the OIDC `sub` + `iss` so
memories are scoped per user.
confidential clients. Identity is keyed on the OIDC `iss` + `sub` so memories
are scoped per user — except on Microsoft Entra ID, where `sub` is pairwise
(a different value per app registration for the same person) and `oid` is
used instead. See [`docs/oidc-entra-id.md`](docs/oidc-entra-id.md) §7.
---
@@ -249,6 +251,13 @@ shape is the same on any OIDC provider; the UI labels differ:
| Redirect URI list | Provider's "Redirect URIs / Origins" | App's "Redirect URIs" | Client's "Valid Redirect URIs" |
| Audience claim | Scope mapping or property mapping | "Expose an API" + scope | Client scope with audience mapper |
> **Using Microsoft Entra ID?** The differences are large enough that Entra
> gets its own walkthrough: **[docs/oidc-entra-id.md](docs/oidc-entra-id.md)**.
> It follows the same A/B structure as the steps below, and covers the
> Entra-specific traps — access token version, tenant-specific authority,
> `aud` vs. scope URI, redirect-URI platform type, group GUIDs and overage —
> which otherwise surface only as opaque 401s.
### A. Web UI provider
**Admin → Applications → Providers → Create → OAuth2/OpenID Provider**
@@ -289,11 +298,114 @@ tokens carry `aud: shared-memory` (or whatever value you chose).
or `Confidential` if you prefer to issue a secret to each Claude Code
install — both work. Phase 1 expects Public.
- **Client ID:** auto-generated → copy to `.env` as `OIDC_CLIENT_ID_MCP`
- **Redirect URIs:** Claude Code prints the exact value when it first
connects to the MCP endpoint. Paste it into Authentik then.
- **Scopes:** `openid`, `profile`, `email`
- **Redirect URIs:** more than one, and which ones depends on how people
reach the server — see **Which redirect URIs to register** below.
- **Scopes:** `openid`, `profile`, `email` (plus `offline_access` — see
**Keeping sessions alive** below)
- **Signing Key:** same cert as the Web provider
#### Which redirect URIs to register
The MCP provider is reached by clients of two different shapes — one running on
your machine, one running inside claude.ai — and they come back from the IdP at
**different** redirect URIs. Register every one you intend to use, before the
first connection attempt:
- **Claude Code CLI, including this repo's plugin** — the CLI catches the
callback on a loopback listener, so the URI is
`http://localhost:<port>/callback`, where `<port>` is whatever
`--callback-port` (or the plugin's `callbackPort`) is set to. Set the entry's
matching mode to **Regex** so any port works without re-registering:
```
^http://(127\.0\.0\.1|localhost):\d+(/.*)?$
```
**The port is not optional.** Authentik rejects a portless
`http://localhost/callback`, so an entry copied from the Entra ID walkthrough
— where the port component is ignored on purpose, see
[docs/oidc-entra-id.md](docs/oidc-entra-id.md) — matches nothing the CLI ever
sends.
- **A claude.ai custom connector** — the server added through claude.ai's web
UI rather than installed locally. claude.ai brokers the OAuth flow, so the
browser never returns to your machine and the loopback entries above are
irrelevant. Register the exact string:
```
https://claude.ai/api/mcp/auth_callback
```
- **The manual-paste fallback** (*C. Manual-paste fallback* below) — that
callback is hosted by *this* server, not by the client:
```
https://memory.example.com/auth/cli-callback
```
> **A missing entry shows up as Authentik's "Redirect URI Error" page** — *"The
> request fails due to a missing, invalid, or mismatching redirection URI
> (redirect_uri)"* — served **after** the client sends you to Authentik but
> **before** any login or consent screen. Nothing in the client says which URI
> was rejected, so it reads as "the connector is broken" when the provider
> simply has no entry matching what was sent. Adding the server as a claude.ai
> connector without the `https://claude.ai/api/mcp/auth_callback` entry is the
> common way to land here.
>
> **Nothing registers these for you — on a FOSS instance.** Authentik *does*
> implement RFC 7591 Dynamic Client Registration
> ([goauthentik/authentik#8751](https://github.com/goauthentik/authentik/issues/8751),
> closed July 2026), but gated behind an **enterprise** licence; a maintainer
> has since said it will move to the open-source build. Until it does, a FOSS
> instance advertises no `registration_endpoint` at all. Check yours:
>
> ```
> curl -s "${OIDC_ISSUER_MCP}.well-known/openid-configuration" | jq .registration_endpoint
> ```
>
> `null` means no client — CLI or claude.ai — can add its own redirect URI, so
> every URI above is typed into the provider by hand. (Same gap as **Why no
> zero-config plugin yet** below.)
#### Keeping sessions alive (`offline_access`)
Without this, a connected MCP client gets an access token and **no refresh
token**. It cannot renew silently, so the moment the access token expires the
client reports `requires re-authorization (token expired)` and the user has to
log in again — repeatedly, on a short cycle.
Two changes are required, and **neither works alone**:
1. **In Authentik**, edit the `shared-memory-mcp` provider and add the built-in
`authentik default OAuth Mapping: offline_access` to its **Scopes**. You can
confirm it took by checking that `offline_access` appears in:
```bash
curl -s https://auth.example.com/application/o/shared-memory-mcp/.well-known/openid-configuration \
| jq .scopes_supported
```
2. **In `.env`**, set `OIDC_OFFLINE_ACCESS=true` and redeploy.
Step 2 is needed because a client only requests the scopes advertised in our
`/.well-known/oauth-protected-resource` document — the same mechanism that
makes the `aud` scope mapping necessary below. Step 1 is needed because
Authentik only issues a refresh token when a configured mapping is requested.
It is left opt-in rather than always-on because advertising a scope the IdP
doesn't offer risks an `invalid_scope` rejection that breaks authentication
outright. Configure the IdP first, then flip the flag.
Verify afterwards with:
```bash
curl -s https://memory.example.com/.well-known/oauth-protected-resource | jq .scopes_supported
```
If interactive login isn't practical at all — a headless container, CI — skip
OAuth and use a static bearer token instead (**D. Static bearer token**, below);
those default to a 90-day lifetime.
#### Setting the `aud` claim
The MCP endpoint requires the access token's `aud` claim to equal
@@ -364,6 +476,13 @@ prompt, never reaching the app.
Three paths, in order of preference:
> **Connecting from claude.ai instead?** A server added there as a *custom
> connector* needs nothing on your machine, but its OAuth callback is
> `https://claude.ai/api/mcp/auth_callback`, not a loopback URI. Register it on
> the MCP provider first (**Which redirect URIs to register** above) or the
> connector stops at Authentik's *Redirect URI Error* page before you ever see
> a login prompt.
### A. Plugin (recommended — one command, no flags to remember)
This repo doubles as a Claude Code plugin marketplace. `plugin/.mcp.json` ships a
@@ -452,8 +571,8 @@ What happens:
`--callback-port` is required because your IdP only accepts pre-registered
redirect URIs. Pick any free port; just make sure the matching URI is in
your MCP client's **Redirect URIs** list. Authentik users with the regex
pattern from the setup step (`^http://(127\.0\.0\.1|localhost):\d+/.*$`)
can use any port without re-registering.
entry from the setup step (`^http://(127\.0\.0\.1|localhost):\d+(/.*)?$`) can
use any port without re-registering.
### C. Manual-paste fallback (when loopback isn't reachable)
@@ -595,6 +714,80 @@ The OIDC client you use locally must accept
---
## Running the tests
```bash
pnpm test # all packages
pnpm --filter @shared-memory/web test:watch
```
Unit tests (e.g. `lib/memory-patch.test.ts`) need nothing but `pnpm install`.
The integration tests in `lib/mcp/tools.integration.test.ts` exercise the
real tool handlers against a **real Postgres with pgvector** — they assert on
stored rows, so there is no mock DB to drift from production behaviour. Spin
one up:
```bash
docker run -d --name sm-test-db \
-e POSTGRES_USER=test -e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=shared_memory_test \
-p 55432:5432 pgvector/pgvector:pg16
for f in apps/web/drizzle/*.sql; do
docker exec -i sm-test-db psql -U test -d shared_memory_test -v ON_ERROR_STOP=1 -q < "$f"
done
pnpm test
```
The default `DATABASE_URL` assumes the published port is reachable on
localhost. If your test runner is itself inside a container, point it at the
database container's address instead:
```bash
DATABASE_URL="postgres://test:test@$(docker inspect -f \
'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' sm-test-db):5432/shared_memory_test" \
pnpm test
```
The embedder sidecar is stubbed in tests (it's an external ML service); the
stub is deterministic per-text, so re-embedding is verified by asserting the
stored vector actually changed — not by asserting a mock was called.
Teardown: `docker rm -f sm-test-db`.
### Linting
```bash
pnpm lint
```
Runs the ESLint CLI directly against `eslint.config.mjs`. Note that `next
lint` is deprecated (it goes away in Next 16) and had never been configured
here, so this replaces it. `eslint-config-next` is still published in the
legacy `.eslintrc` format, so the config bridges it through `FlatCompat`;
that bridge can be dropped once the package ships a native flat export.
The tree is currently clean at `--max-warnings=0`, so adding that flag to
the `lint` script is a cheap way to keep it that way.
---
## Design notes
`docs/` holds decision records for changes whose reasoning isn't recoverable
from the diff — what was built, what was deliberately rejected, and what would
reopen a closed question.
- [`docs/memory-api-improvements.md`](docs/memory-api-improvements.md) — why
`memory_get` stopped returning the embedding and tsvector, why `memory_patch`
refuses ambiguous matches instead of guessing, why `memory_append` was
dropped, and why file-mirroring was left as a convention rather than
mechanised.
---
## Troubleshooting
- **`401 claim invalid: aud`** from `/api/mcp` — your MCP client isn't
@@ -602,6 +795,14 @@ The OIDC client you use locally must accept
scope mapping; on EntraID it's the API "Application ID URI"; on Keycloak
it's a client-scope audience mapper. See **Setting the `aud` claim** above
for the Authentik recipe; other IdPs need the equivalent in their UI.
- **Authentik shows "Redirect URI Error — The request fails due to a missing,
invalid, or mismatching redirection URI (redirect_uri)"** — the redirect URI
the client sent is not registered on the `shared-memory-mcp` provider. From a
claude.ai custom connector the missing entry is
`https://claude.ai/api/mcp/auth_callback`; from the CLI it's the loopback URI
for your `--callback-port`, and a portless `http://localhost/callback` entry
will not match it. Authentik has no Dynamic Client Registration, so no client
can add the URI itself — see **Which redirect URIs to register** above.
- **Auth.js callback fails with `OAUTH_CALLBACK_ERROR`** — your `PUBLIC_URL`
doesn't match the redirect URI your IdP is configured with. They must be
exactly equal, scheme and trailing slash included.
+1 -1
View File
@@ -5,7 +5,7 @@ import { db } from "@/lib/db/client";
import { memories, projects, projectShares } from "@/lib/db/schema";
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
import { Card, CardBody } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
import { Button } from "@/app/_components/ui/button";
import { EmptyState } from "@/app/_components/ui/empty-state";
@@ -6,7 +6,7 @@ import { db, pg } from "@/lib/db/client";
import { projects, users } from "@/lib/db/schema";
import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody } from "@/app/_components/ui/card";
import { Card } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
import { Button } from "@/app/_components/ui/button";
import { EmptyState } from "@/app/_components/ui/empty-state";
@@ -1,5 +1,5 @@
import { revalidatePath } from "next/cache";
import { and, asc, desc, eq, isNull } from "drizzle-orm";
import { and, asc, desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { env } from "@/lib/env";
import { db } from "@/lib/db/client";
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, test, vi } from "vitest";
/**
* RFC 9728 §3.1 puts the metadata for `https://host/api/mcp` at
* `https://host/.well-known/oauth-protected-resource/api/mcp`. MCP clients
* that derive that URL from the endpoint URL — instead of following the
* `resource_metadata` parameter on our 401 — used to receive the Next.js 404
* HTML page here, so discovery died on a JSON parse error.
*
* Two things therefore have to hold, and both are easy to break silently:
* the document must name the SUFFIXED resource (§3.3 has the client reject a
* document whose `resource` isn't the identifier it asked about), and it must
* stay byte-for-byte in step with the root document's scope list, because a
* scope missing from whichever document a given client reads is a scope that
* client will never request.
*/
type Metadata = { resource: string; scopes_supported: string[] };
async function fetchSuffixed(
segments: string[],
): Promise<{ status: number; body: Metadata }> {
vi.resetModules();
const { GET } = await import(
"@/app/.well-known/oauth-protected-resource/[...path]/route"
);
const res = await GET(new Request("http://localhost/ignored"), {
params: Promise.resolve({ path: segments }),
});
return { status: res.status, body: (await res.json()) as Metadata };
}
async function fetchRoot(): Promise<Metadata> {
vi.resetModules();
const { GET } = await import("@/app/.well-known/oauth-protected-resource/route");
return (await GET().json()) as Metadata;
}
afterEach(() => {
delete process.env.OIDC_OFFLINE_ACCESS;
delete process.env.OIDC_AUDIENCE_SCOPE;
});
describe("path-suffixed oauth-protected-resource metadata", () => {
test("serves the MCP endpoint's document with the suffixed resource identifier", async () => {
const { status, body } = await fetchSuffixed(["api", "mcp"]);
expect(status).toBe(200);
// §3.3: a strict client compares this against the identifier it asked
// about, so the bare origin would get the whole document rejected.
expect(body.resource).toBe("http://localhost:3000/api/mcp");
});
test("404s for a path this app does not serve, rather than advertising it", async () => {
// The allowlist exists so we never claim that arbitrary paths are
// OAuth-protected resources of this deployment.
const { status } = await fetchSuffixed(["api", "not-mcp"]);
expect(status).toBe(404);
});
test("advertises exactly the scopes the root document does", async () => {
// Regression guard against the two documents drifting apart: the audience
// scope is what makes `aud` appear on the token at all, and a client that
// discovered us through the suffixed URL would never request a scope that
// only the root document lists.
process.env.OIDC_OFFLINE_ACCESS = "true";
const root = await fetchRoot();
const { body: suffixed } = await fetchSuffixed(["api", "mcp"]);
expect(suffixed.scopes_supported).toEqual(root.scopes_supported);
expect(suffixed.scopes_supported).toContain("aud-test-audience");
expect(suffixed.scopes_supported).toContain("offline_access");
});
test("honours an explicit audience scope name, like the root document", async () => {
process.env.OIDC_AUDIENCE_SCOPE = "custom-aud-scope";
const root = await fetchRoot();
const { body: suffixed } = await fetchSuffixed(["api", "mcp"]);
expect(suffixed.scopes_supported).toContain("custom-aud-scope");
expect(suffixed.scopes_supported).toEqual(root.scopes_supported);
});
});
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { buildResourceMetadata, publicOrigin } from "@/lib/auth/resource-metadata";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Resource paths this deployment will publish metadata for.
*
* An allowlist rather than a wildcard, for two reasons. RFC 9728 §3.1 maps a
* metadata URL to one specific protected resource, so answering for arbitrary
* paths would advertise resources this app does not serve — a client could
* "discover" `https://host/anything` as an OAuth-protected resource and be
* told, wrongly, that tokens for it are obtainable from our IdP. And every
* path that answers is surface: a wildcard turns this into an open reflector
* that echoes attacker-chosen path segments back inside a JSON document.
*
* `api/mcp` is the only MCP endpoint here (app/api/mcp/route.ts). Add an
* entry when a second one ships — not before.
*/
const METADATA_RESOURCE_PATHS: ReadonlySet<string> = new Set(["api/mcp"]);
/**
* RFC 9728 §3.1 — path-suffixed protected resource metadata.
*
* For a resource identified by `https://host/api/mcp`, the spec puts its
* metadata at `https://host/.well-known/oauth-protected-resource/api/mcp`:
* the resource's path is appended to the well-known path. Clients that derive
* the metadata URL from the MCP endpoint URL — rather than reading
* `resource_metadata` off our 401's `WWW-Authenticate` header — probe that URL
* first, and before this route existed they got Next.js's 404 HTML page, which
* fails discovery with a JSON parse error rather than anything diagnosable.
*
* The document is identical to the root one except for `resource`, which must
* name the suffixed identifier: §3.3 requires the client to check that the
* returned `resource` equals the identifier it asked about, so echoing the
* bare origin here would make a strict client reject the document outright.
*/
export async function GET(
_req: Request,
ctx: { params: Promise<{ path: string[] }> },
): Promise<NextResponse> {
const { path } = await ctx.params;
// Next splits the matched suffix on literal `/` and only then decodes each
// piece, so a segment can be empty (`api//mcp` -> ["api","","mcp"]) and a
// single segment can itself contain a decoded slash (`api%2Fmcp` -> one
// element, "api/mcp"). Join and compare on the same normalized form the
// allowlist is written in, and let anything else fail closed.
const resourcePath = path.join("/");
if (!METADATA_RESOURCE_PATHS.has(resourcePath)) {
// JSON, not the HTML 404 page, so a client that probes a wrong path gets
// a parseable answer instead of the failure mode this route exists to fix.
return NextResponse.json(
{ error: "not_found", error_description: "no such protected resource" },
{ status: 404 },
);
}
return NextResponse.json(
buildResourceMetadata(`${publicOrigin()}/${resourcePath}`),
);
}
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, test, vi } from "vitest";
/**
* The resource metadata document is the ONLY way an MCP client learns which
* scopes to request. Authentik evaluates a scope mapping only when the client
* asks for that scope by name — so a scope missing from this document is a
* scope the client will never request, no matter how the IdP is configured.
*
* That is exactly how `offline_access` came to be missing: without it the IdP
* issues an access token and no refresh token, so the client cannot renew
* silently and the user is forced to re-authenticate every time the access
* token expires.
*/
async function fetchMetadata(): Promise<{ scopes_supported: string[] }> {
vi.resetModules();
const { GET } = await import("@/app/.well-known/oauth-protected-resource/route");
return (await GET().json()) as { scopes_supported: string[] };
}
afterEach(() => {
delete process.env.OIDC_OFFLINE_ACCESS;
delete process.env.OIDC_AUDIENCE_SCOPE;
});
describe("oauth-protected-resource metadata", () => {
test("omits offline_access by default, so deployments without the IdP mapping are unaffected", async () => {
const body = await fetchMetadata();
expect(body.scopes_supported).not.toContain("offline_access");
});
test("advertises offline_access when the deployment opts in", async () => {
process.env.OIDC_OFFLINE_ACCESS = "true";
const body = await fetchMetadata();
expect(body.scopes_supported).toContain("offline_access");
});
test("still advertises the audience scope when offline_access is enabled", async () => {
// Regression guard: the audience scope is what makes `aud` appear on the
// token at all. Dropping it would break authentication outright.
process.env.OIDC_OFFLINE_ACCESS = "true";
const body = await fetchMetadata();
expect(body.scopes_supported).toContain("aud-test-audience");
expect(body.scopes_supported).toEqual(
expect.arrayContaining(["openid", "profile", "email"]),
);
});
test("honours an explicit audience scope name alongside offline_access", async () => {
process.env.OIDC_OFFLINE_ACCESS = "true";
process.env.OIDC_AUDIENCE_SCOPE = "custom-aud-scope";
const body = await fetchMetadata();
expect(body.scopes_supported).toContain("custom-aud-scope");
expect(body.scopes_supported).toContain("offline_access");
});
});
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { mcpIssuer } from "@/lib/auth/jwt";
import { buildResourceMetadata, publicOrigin } from "@/lib/auth/resource-metadata";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -10,25 +9,12 @@ export const dynamic = "force-dynamic";
*
* MCP clients discover the authorization server (Authentik) via this
* endpoint after receiving a 401 with `WWW-Authenticate: resource_metadata=...`.
*
* This is the root form of the document, describing the deployment origin as
* the protected resource. Clients that derive the metadata URL from the MCP
* endpoint URL instead of following the header land on the path-suffixed form
* (§3.1) served by the sibling `[...path]` route.
*/
export function GET() {
const resource = env().PUBLIC_URL.replace(/\/$/, "");
// The audience scope MUST be advertised. Authentik only evaluates a scope
// mapping when the client requests that scope by name, and the client only
// learns scope names from this document. Omit it and every access token
// arrives without `aud`, which jwt.ts rejects as "claim invalid: aud".
const audienceScope =
env().OIDC_AUDIENCE_SCOPE ?? `aud-${env().OIDC_AUDIENCE}`;
return NextResponse.json({
resource,
// The MCP application's issuer, which is not necessarily the Web UI's —
// see mcpIssuer(). Advertising the wrong one sends clients to a discovery
// document whose tokens this endpoint will then reject on `iss`.
authorization_servers: [mcpIssuer()], // as configured, slash and all
scopes_supported: ["openid", "profile", "email", audienceScope],
bearer_methods_supported: ["header"],
resource_documentation: `${resource}/`,
});
return NextResponse.json(buildResourceMetadata(publicOrigin()));
}
+10 -5
View File
@@ -5,12 +5,17 @@
Three retrieval signals - vector, full-text, tags - converging on a single
memory. The direct match runs straight through at full strength; the two
ranked neighbours fall back, which is the fusion the search actually does.
Opacity is held equal on the outer pair so the mark stays balanced at 16px.
Every proportion here is set by the 16px browser-tab case. The outer pair
is held at .7 (not .55) because below that it rasterises away entirely,
the strokes stop at x=30 so the gap to the node doesn't fill in, and the
node is r=8.5 so it still reads as a disc. Geometry is identical to
public/logo.svg; only the palette differs.
-->
<g fill="none" stroke-linecap="round" stroke-width="7">
<path d="M13 15C25 15 26 32 35 32" stroke="#0092fd" opacity=".55"/>
<path d="M13 32H35" stroke="#49a9ff"/>
<path d="M13 49C25 49 26 32 35 32" stroke="#0092fd" opacity=".55"/>
<path d="M12 16C24 16 25 32 30 32" stroke="#0092fd" opacity=".7"/>
<path d="M12 32H30" stroke="#49a9ff"/>
<path d="M12 48C24 48 25 32 30 32" stroke="#0092fd" opacity=".7"/>
</g>
<circle cx="45" cy="32" r="7.5" fill="#76c0ff"/>
<circle cx="46" cy="32" r="8.5" fill="#76c0ff"/>
</svg>

Before

Width:  |  Height:  |  Size: 847 B

After

Width:  |  Height:  |  Size: 1.1 KiB

+20 -25
View File
@@ -1,7 +1,6 @@
import NextAuth from "next-auth";
import { env } from "@/lib/env";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
import { oidClaim, resolveUserId } from "@/lib/auth/identity";
import { syncUserGroupsFromClaim } from "@/lib/auth/sync-groups";
/**
@@ -41,27 +40,18 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
const iss = (profile.iss as string | undefined) ?? env().OIDC_ISSUER;
if (!sub) throw new Error("OIDC profile missing `sub` claim");
const row = await db
.insert(users)
.values({
oidcSub: sub,
oidcIss: iss,
email: profile.email ?? null,
name: profile.name ?? null,
picture: (profile.picture as string | undefined) ?? null,
})
.onConflictDoUpdate({
target: [users.oidcIss, users.oidcSub],
set: {
email: profile.email ?? null,
name: profile.name ?? null,
picture: (profile.picture as string | undefined) ?? null,
lastSeenAt: new Date(),
},
})
.returning({ id: users.id });
// Shared with the MCP path (lib/mcp/context.ts). On EntraID the `oid`
// claim is what keeps the two surfaces resolving to one account —
// `sub` differs per app registration there. See lib/auth/identity.ts.
const userId = await resolveUserId({
iss,
sub,
oid: oidClaim(profile),
email: profile.email ?? null,
name: profile.name ?? null,
picture: (profile.picture as string | undefined) ?? null,
});
const userId = row[0]?.id;
token.userId = userId;
token.sub = sub;
token.iss = iss;
@@ -71,10 +61,15 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
// wipes the user's existing memberships, which is the conservative
// choice (don't keep stale grants alive if the IdP stopped
// asserting them).
//
// The whole profile goes in, not just `profile.groups`: an absent
// claim means one thing on its own and something else entirely next
// to EntraID's overage markers, and only the second case must abort.
// A GroupsOverageError thrown here fails the sign-in, which is the
// intent — it leaves the user's existing memberships untouched
// instead of silently deleting them.
if (userId) {
// `profile.groups` is untyped at the next-auth boundary — coerce.
const claimGroups = (profile as { groups?: unknown }).groups;
await syncUserGroupsFromClaim(userId, iss, claimGroups);
await syncUserGroupsFromClaim(userId, iss, profile);
}
}
return token;
+29
View File
@@ -0,0 +1,29 @@
-- EntraID identity: key users on `oid` when the IdP emits it.
--
-- EntraID's `sub` is a PAIRWISE identifier — derived from the token recipient,
-- so the Web UI app registration and the MCP app registration hand out
-- different `sub` values for the same person. Both `auth.ts` and
-- `lib/mcp/context.ts` upsert on (oidc_iss, oidc_sub), so on EntraID one human
-- resolves to two rows: they sign into the Web UI, connect an MCP client, and
-- land in an empty account with their memories nowhere to be seen. Nothing
-- errors, which is what makes it dangerous.
--
-- `oid` is the user's directory object id, which Microsoft documents as
-- constant for a user across every application in a tenant. Recording it gives
-- us a key that holds across both surfaces.
--
-- Nullable on purpose: Authentik, Keycloak and Okta emit no `oid`, and there
-- `sub` is already application-independent. Those deployments keep using
-- (oidc_iss, oidc_sub) and are untouched by this migration.
ALTER TABLE "users" ADD COLUMN "oidc_oid" text;
-- Partial index. Every non-EntraID row holds NULL here; a plain unique index
-- would treat those as colliding and permit exactly one such user.
CREATE UNIQUE INDEX "users_iss_oid_uq"
ON "users" ("oidc_iss", "oidc_oid")
WHERE "oidc_oid" IS NOT NULL;
-- No backfill. `oid` is only knowable from a token, so existing rows adopt
-- theirs on the owner's next sign-in (see `adoptLegacyRow` in
-- lib/auth/identity.ts). Backfilling would mean guessing.
+33
View File
@@ -0,0 +1,33 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { FlatCompat } from "@eslint/eslintrc";
/**
* ESLint flat config.
*
* `next lint` is deprecated (removed in Next 16) and was never configured
* here, so `pnpm lint` used to drop into an interactive setup prompt and
* exit non-zero. This runs the ESLint CLI directly instead.
*
* `eslint-config-next` is still published in the legacy .eslintrc format,
* so FlatCompat bridges it into flat config. That bridge goes away when
* the config ships a native flat export.
*/
const compat = new FlatCompat({
baseDirectory: dirname(fileURLToPath(import.meta.url)),
});
const config = [
{
ignores: [
".next/**",
"node_modules/**",
"next-env.d.ts",
// Generated SQL/journal artifacts from drizzle-kit.
"drizzle/**",
],
},
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default config;
+165
View File
@@ -0,0 +1,165 @@
import { afterAll, beforeEach, describe, expect, test } from "vitest";
/**
* Identity resolution across the two surfaces.
*
* The bug these guard against is silent: on EntraID the Web UI and the MCP
* endpoint see different `sub` values for the same person, both code paths
* UPSERT rather than fail, and the result is two accounts — the user signs in,
* connects an MCP client, and finds their memories gone. Nothing errors, so
* only a test that asserts "same person ⇒ same row id" catches it.
*/
const { db, pg } = await import("@/lib/db/client");
const { users } = await import("@/lib/db/schema");
const { resolveUserId, oidClaim } = await import("@/lib/auth/identity");
const { eq } = await import("drizzle-orm");
/** `noUncheckedIndexedAccess` is on; narrow once rather than at every use. */
function first<T>(rows: T[]): T {
const row = rows[0];
if (!row) throw new Error("expected at least one row");
return row;
}
const ISS = "https://login.microsoftonline.com/test-tenant/v2.0";
/** Distinct `sub` values, as EntraID's pairwise identifiers would be. */
const WEB_SUB = "pairwise-sub-for-web-registration";
const MCP_SUB = "pairwise-sub-for-mcp-registration";
const OID = "00000000-1111-2222-3333-444444444444";
function claims(overrides: Record<string, unknown> = {}) {
return {
iss: ISS,
sub: WEB_SUB,
oid: OID,
email: "person@example.com",
name: "Person",
picture: null,
...overrides,
} as Parameters<typeof resolveUserId>[0];
}
beforeEach(async () => {
await db.delete(users).where(eq(users.oidcIss, ISS));
});
afterAll(async () => {
await db.delete(users).where(eq(users.oidcIss, ISS));
await pg.end();
});
describe("oidClaim", () => {
test("reads a string oid", () => {
expect(oidClaim({ oid: OID })).toBe(OID);
});
test("is null when absent, blank, or not a string", () => {
expect(oidClaim({})).toBeNull();
expect(oidClaim({ oid: " " })).toBeNull();
expect(oidClaim({ oid: 42 })).toBeNull();
expect(oidClaim(null)).toBeNull();
});
});
describe("resolveUserId with an oid (EntraID)", () => {
test("both surfaces resolve to ONE row despite different subs", async () => {
const fromWeb = await resolveUserId(claims({ sub: WEB_SUB }));
const fromMcp = await resolveUserId(claims({ sub: MCP_SUB }));
expect(fromMcp).toBe(fromWeb);
const rows = await db.select().from(users).where(eq(users.oidcIss, ISS));
expect(rows).toHaveLength(1);
});
test("does not rewrite oidc_sub once the row exists", async () => {
await resolveUserId(claims({ sub: WEB_SUB }));
await resolveUserId(claims({ sub: MCP_SUB }));
const rows = await db.select().from(users).where(eq(users.oidcIss, ISS));
// Whichever arrived first stays put; flip-flopping it on every request
// could collide with the (iss, sub) unique index.
expect(first(rows).oidcSub).toBe(WEB_SUB);
expect(first(rows).oidcOid).toBe(OID);
});
test("adopts a pre-migration row instead of stranding it", async () => {
// A deployment that signed this person in before 0005 ran: correct sub,
// no oid recorded.
const legacy = first(
await db
.insert(users)
.values({ oidcIss: ISS, oidcSub: WEB_SUB, email: "old@example.com" })
.returning({ id: users.id }),
);
const resolved = await resolveUserId(claims({ sub: WEB_SUB }));
expect(resolved).toBe(legacy.id);
const rows = await db.select().from(users).where(eq(users.oidcIss, ISS));
expect(rows).toHaveLength(1);
expect(first(rows).oidcOid).toBe(OID);
});
test("refreshes profile fields on an existing row", async () => {
await resolveUserId(claims({ name: "Old Name" }));
await resolveUserId(claims({ sub: MCP_SUB, name: "New Name" }));
const rows = await db.select().from(users).where(eq(users.oidcIss, ISS));
expect(first(rows).name).toBe("New Name");
});
test("concurrent first-contact from both surfaces yields one row", async () => {
const [a, b] = await Promise.all([
resolveUserId(claims({ sub: WEB_SUB })),
resolveUserId(claims({ sub: MCP_SUB })),
]);
expect(a).toBe(b);
const rows = await db.select().from(users).where(eq(users.oidcIss, ISS));
expect(rows).toHaveLength(1);
});
test("different people in one tenant stay separate", async () => {
const one = await resolveUserId(claims());
const two = await resolveUserId(
claims({ sub: "other-sub", oid: "99999999-1111-2222-3333-444444444444" }),
);
expect(two).not.toBe(one);
const rows = await db.select().from(users).where(eq(users.oidcIss, ISS));
expect(rows).toHaveLength(2);
});
});
describe("resolveUserId without an oid (Authentik and friends)", () => {
test("keys on (iss, sub) exactly as before", async () => {
const initial = await resolveUserId(claims({ oid: null }));
const again = await resolveUserId(claims({ oid: null, name: "Renamed" }));
expect(again).toBe(initial);
const rows = await db.select().from(users).where(eq(users.oidcIss, ISS));
expect(rows).toHaveLength(1);
expect(first(rows).oidcOid).toBeNull();
expect(first(rows).name).toBe("Renamed");
});
test("distinct subs are distinct people", async () => {
await resolveUserId(claims({ oid: null, sub: "a" }));
await resolveUserId(claims({ oid: null, sub: "b" }));
const rows = await db.select().from(users).where(eq(users.oidcIss, ISS));
expect(rows).toHaveLength(2);
});
test("several oid-less users coexist — the unique index is partial", async () => {
// A non-partial unique index on (iss, oid) would allow exactly one NULL
// pair and reject everyone after the first.
for (const sub of ["u1", "u2", "u3"]) {
await resolveUserId(claims({ oid: null, sub }));
}
const rows = await db.select().from(users).where(eq(users.oidcIss, ISS));
expect(rows).toHaveLength(3);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { and, eq, isNull } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
/**
* Resolving OIDC claims to the internal `users.id`.
*
* Both surfaces come through here — the Web UI (`auth.ts`) and the MCP
* endpoint (`lib/mcp/context.ts`) — and that is the point. They each used to
* carry their own copy of this upsert, which is how the two drifted into
* disagreeing about who a user is.
*
* ## Why `oid` exists here
*
* Authentik's `sub` is `user.uid`, identical across every provider, so
* (iss, sub) identifies a person. EntraID's `sub` is PAIRWISE: Microsoft
* derives it from the token recipient, so the Web UI app registration and the
* MCP app registration emit different `sub` values for the same human. Keyed
* on `sub`, that person gets two rows — they sign into the Web UI, connect
* Claude Code, and find an empty account. Both paths upsert, so nothing
* errors; the split is completely silent.
*
* `oid` is the directory object id, which Microsoft documents as constant for
* a user across every application in a tenant. When it's present it wins.
* When it's absent (Authentik, Keycloak, Okta) behaviour is exactly as before.
*/
/** Extract a usable EntraID `oid` claim, or null on IdPs that don't emit one. */
export function oidClaim(claims: unknown): string | null {
const raw = (claims as { oid?: unknown } | null | undefined)?.oid;
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : null;
}
export interface IdentityInput {
iss: string;
sub: string;
/** EntraID object id, or null. */
oid: string | null;
email: string | null;
name: string | null;
picture: string | null;
}
/** Postgres unique-violation. */
function isUniqueViolation(err: unknown): boolean {
return (err as { code?: unknown } | null)?.code === "23505";
}
/**
* Resolve (creating if needed) the `users` row for a set of verified claims.
*
* Resolution order when `oid` is present:
*
* 1. a row already keyed on this `oid` — the steady state
* 2. a pre-`oid` row for the same (iss, sub), which gets its `oid`
* backfilled in place. This is how a deployment that ran before the
* 0005 migration keeps its accounts instead of stranding them.
* 3. insert
*
* Without `oid` this collapses to the original (iss, sub) upsert.
*/
export async function resolveUserId(input: IdentityInput): Promise<string> {
const { iss, sub, oid, email, name, picture } = input;
const profile = { email, name, picture, lastSeenAt: new Date() };
if (oid) {
// 1. Steady state.
//
// Deliberately does NOT touch `oidc_sub`. The stored value is whichever
// app registration this person first arrived through; rewriting it on
// every request would flip it back and forth between the Web and MCP
// values and could collide with the (iss, sub) unique index.
const byOid = await db
.update(users)
.set(profile)
.where(and(eq(users.oidcIss, iss), eq(users.oidcOid, oid)))
.returning({ id: users.id });
if (byOid[0]) return byOid[0].id;
// 2. Adopt a row created before `oid` was recorded.
const adopted = await db
.update(users)
.set({ ...profile, oidcOid: oid })
.where(
and(eq(users.oidcIss, iss), eq(users.oidcSub, sub), isNull(users.oidcOid)),
)
.returning({ id: users.id });
if (adopted[0]) return adopted[0].id;
}
// 3. Insert. The conflict target stays (iss, sub) because that is the index
// every row has; a concurrent writer racing us on `oid` instead is caught
// below.
try {
const inserted = await db
.insert(users)
.values({ oidcIss: iss, oidcSub: sub, oidcOid: oid, email, name, picture })
.onConflictDoUpdate({
target: [users.oidcIss, users.oidcSub],
set: profile,
})
.returning({ id: users.id });
if (inserted[0]) return inserted[0].id;
} catch (err) {
// Two requests for the same person arriving together through DIFFERENT
// app registrations: same `oid`, different `sub`, so the (iss, sub)
// conflict target doesn't fire and the partial (iss, oid) index rejects
// the loser. Fall through and read the winner's row.
if (!isUniqueViolation(err)) throw err;
}
const existing = await db
.select({ id: users.id })
.from(users)
.where(
oid
? and(eq(users.oidcIss, iss), eq(users.oidcOid, oid))
: and(eq(users.oidcIss, iss), eq(users.oidcSub, sub)),
)
.limit(1);
if (!existing[0]) throw new Error("user upsert failed and not found on re-read");
return existing[0].id;
}
+226
View File
@@ -0,0 +1,226 @@
import { createServer, type Server } from "node:http";
import { AddressInfo } from "node:net";
import { SignJWT, exportJWK, generateKeyPair } from "jose";
import type { JWK, KeyLike } from "jose";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
/**
* The JWKS location is discovered, not assumed.
*
* `${issuer}/jwks/` used to be hardcoded here. That is an Authentik
* convention — EntraID serves its keys at
* `https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys`, so on
* EntraID the hardcoded path 404s and NO MCP token can ever verify. These
* tests pin the three behaviours that make the discovery path safe to ship:
* discovery is honoured, failure degrades to the old path rather than to a
* broken deployment, and it happens once rather than per request.
*
* A real loopback HTTP server is used rather than a `fetch` mock because jose
* fetches the key set through `node:http` directly, not through global
* `fetch` — a mocked `fetch` would silently never be consulted for the JWKS
* request, and the assertion about *which* URL was used would prove nothing.
*/
const TENANT = "11111111-2222-3333-4444-555555555555";
const AUDIENCE = "99999999-8888-7777-6666-555555555555";
/** Paths the fake IdP was asked for, in order. */
let requested: string[] = [];
/** Response the fake IdP gives for the discovery document. */
let discoveryResponse: { status: number; body: string };
let server: Server;
let origin: string;
let privateKey: KeyLike;
let publicJwk: JWK;
/** Authentik-shaped issuer: application-scoped path, trailing slash. */
function authentikIssuer(): string {
return `${origin}/application/o/shared-memory-mcp/`;
}
/** EntraID-shaped issuer: tenant-scoped, no trailing slash. */
function entraIssuer(): string {
return `${origin}/${TENANT}/v2.0`;
}
/** Where an EntraID discovery document points for keys. */
function entraKeysPath(): string {
return `/${TENANT}/discovery/v2.0/keys`;
}
beforeEach(async () => {
const pair = await generateKeyPair("RS256");
privateKey = pair.privateKey;
publicJwk = { ...(await exportJWK(pair.publicKey)), kid: "test-key", alg: "RS256", use: "sig" };
requested = [];
server = createServer((req, res) => {
requested.push(req.url ?? "");
if (req.url?.endsWith("/.well-known/openid-configuration")) {
res.writeHead(discoveryResponse.status, { "content-type": "application/json" });
res.end(discoveryResponse.body);
return;
}
// Every other path is treated as a key set endpoint. Which path the
// request actually arrived on is the thing under test.
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ keys: [publicJwk] }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
// The key set is memoised on globalThis, which survives vi.resetModules().
// Without clearing it, the second test in this file would silently reuse
// the first test's resolution and assert nothing.
delete (globalThis as Record<string, unknown>).__sharedMemoryJwks;
delete (globalThis as Record<string, unknown>).__sharedMemoryJwksRetryAt;
delete process.env.OIDC_ISSUER_MCP;
delete process.env.OIDC_AUDIENCE;
await new Promise<void>((resolve) => server.close(() => resolve()));
});
/**
* Import `authenticateBearer` fresh so it observes the env vars this test set
* (lib/env.ts caches its parse in a module singleton).
*/
async function loadAuthenticateBearer() {
vi.resetModules();
const mod = await import("@/lib/auth/jwt");
return mod.authenticateBearer;
}
async function signAccessToken(issuer: string): Promise<string> {
return new SignJWT({ groups: ["memory-users"] })
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
.setIssuer(issuer)
.setAudience(AUDIENCE)
.setSubject("user-object-id")
.setIssuedAt()
.setExpirationTime("5m")
.sign(privateKey);
}
function discoveryPathsSeen(): string[] {
return requested.filter((p) => p.endsWith("/.well-known/openid-configuration"));
}
describe("MCP JWKS resolution", () => {
test("uses the jwks_uri from discovery, so EntraID's key endpoint is reached", async () => {
process.env.OIDC_ISSUER_MCP = entraIssuer();
process.env.OIDC_AUDIENCE = AUDIENCE;
discoveryResponse = {
status: 200,
// Shape of https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration
body: JSON.stringify({
issuer: entraIssuer(),
jwks_uri: `${origin}${entraKeysPath()}`,
token_endpoint: `${origin}/${TENANT}/oauth2/v2.0/token`,
}),
};
const authenticateBearer = await loadAuthenticateBearer();
const claims = await authenticateBearer(`Bearer ${await signAccessToken(entraIssuer())}`);
expect(claims.sub).toBe("user-object-id");
expect(requested).toContain(entraKeysPath());
// The Authentik convention must NOT have been tried.
expect(requested).not.toContain(`/${TENANT}/v2.0/jwks/`);
});
test("tolerates a trailing slash on the issuer while still honouring discovery", async () => {
// acceptedIssuers() takes both spellings; discovery must not regress that.
process.env.OIDC_ISSUER_MCP = `${entraIssuer()}/`;
process.env.OIDC_AUDIENCE = AUDIENCE;
discoveryResponse = {
status: 200,
body: JSON.stringify({ jwks_uri: `${origin}${entraKeysPath()}` }),
};
const authenticateBearer = await loadAuthenticateBearer();
// Token carries the un-slashed spelling; the env var carries the slashed one.
const claims = await authenticateBearer(`Bearer ${await signAccessToken(entraIssuer())}`);
expect(claims.sub).toBe("user-object-id");
expect(requested).toContain(entraKeysPath());
});
test("falls back to ${issuer}/jwks/ when discovery is unreachable", async () => {
process.env.OIDC_ISSUER_MCP = authentikIssuer();
process.env.OIDC_AUDIENCE = AUDIENCE;
discoveryResponse = { status: 500, body: "upstream exploded" };
const authenticateBearer = await loadAuthenticateBearer();
const claims = await authenticateBearer(`Bearer ${await signAccessToken(authentikIssuer())}`);
// Existing Authentik deployments keep working with no discovery document.
expect(claims.sub).toBe("user-object-id");
expect(requested).toContain("/application/o/shared-memory-mcp/jwks/");
});
test("falls back to ${issuer}/jwks/ when discovery omits jwks_uri", async () => {
process.env.OIDC_ISSUER_MCP = authentikIssuer();
process.env.OIDC_AUDIENCE = AUDIENCE;
// 200 OK, valid JSON, no usable key set pointer — the malformed case that
// a naive `doc.jwks_uri` read would turn into `new URL(undefined)`.
discoveryResponse = { status: 200, body: JSON.stringify({ issuer: authentikIssuer() }) };
const authenticateBearer = await loadAuthenticateBearer();
const claims = await authenticateBearer(`Bearer ${await signAccessToken(authentikIssuer())}`);
expect(claims.sub).toBe("user-object-id");
expect(requested).toContain("/application/o/shared-memory-mcp/jwks/");
});
test("falls back when discovery returns non-JSON", async () => {
process.env.OIDC_ISSUER_MCP = authentikIssuer();
process.env.OIDC_AUDIENCE = AUDIENCE;
discoveryResponse = { status: 200, body: "<html>login page</html>" };
const authenticateBearer = await loadAuthenticateBearer();
const claims = await authenticateBearer(`Bearer ${await signAccessToken(authentikIssuer())}`);
expect(claims.sub).toBe("user-object-id");
expect(requested).toContain("/application/o/shared-memory-mcp/jwks/");
});
test("discovers once across many verifications, not once per request", async () => {
// The MCP endpoint verifies a token on essentially every request. A
// discovery fetch per request would add a round-trip to every tool call.
process.env.OIDC_ISSUER_MCP = entraIssuer();
process.env.OIDC_AUDIENCE = AUDIENCE;
discoveryResponse = {
status: 200,
body: JSON.stringify({ jwks_uri: `${origin}${entraKeysPath()}` }),
};
const authenticateBearer = await loadAuthenticateBearer();
for (let i = 0; i < 3; i++) {
await authenticateBearer(`Bearer ${await signAccessToken(entraIssuer())}`);
}
expect(discoveryPathsSeen()).toHaveLength(1);
});
test("concurrent cold requests share a single discovery fetch", async () => {
// Caching the settled value rather than the in-flight promise would let
// every request that arrives before the first one resolves start its own
// discovery fetch — a thundering herd at process start.
process.env.OIDC_ISSUER_MCP = entraIssuer();
process.env.OIDC_AUDIENCE = AUDIENCE;
discoveryResponse = {
status: 200,
body: JSON.stringify({ jwks_uri: `${origin}${entraKeysPath()}` }),
};
const authenticateBearer = await loadAuthenticateBearer();
const token = await signAccessToken(entraIssuer());
await Promise.all(
Array.from({ length: 5 }, () => authenticateBearer(`Bearer ${token}`)),
);
expect(discoveryPathsSeen()).toHaveLength(1);
});
});
+119 -14
View File
@@ -2,13 +2,14 @@ import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
import type { JWTPayload } from "jose";
import { env } from "@/lib/env";
import { CLI_TOKEN_KID, tokenKid, verifyCliToken } from "./cli-token";
import { detectGroupsOverage } from "./sync-groups";
/**
* Authenticates a bearer token presented to the MCP endpoint. Two token
* kinds are accepted, dispatched by the JWT `kid` header:
*
* - Authentik-issued OIDC access tokens (any kid) — verified against
* Authentik's JWKS over the network.
* - IdP-issued OIDC access tokens (any kid) — verified against the
* issuer's JWKS over the network, located via OIDC discovery.
* - CLI tokens minted at /connect (kid="cli-v1") — verified locally
* with the HMAC CLI_TOKEN_SECRET.
*
@@ -18,8 +19,25 @@ import { CLI_TOKEN_KID, tokenKid, verifyCliToken } from "./cli-token";
* This is distinct from the NextAuth session cookie path used by the Web UI.
*/
type JwkSet = ReturnType<typeof createRemoteJWKSet>;
type GlobalWithJwks = typeof globalThis & {
__sharedMemoryJwks?: ReturnType<typeof createRemoteJWKSet>;
/**
* Resolved key set, cached as a *promise* rather than a value.
*
* Resolution now involves a network round-trip (OIDC discovery), and the
* MCP endpoint verifies a token on essentially every request. Caching the
* settled value would leave a window in which N concurrent cold requests
* each start their own discovery fetch; caching the in-flight promise means
* the first caller does the work and everyone else awaits the same result.
*/
__sharedMemoryJwks?: Promise<JwkSet>;
/**
* Epoch ms after which discovery should be re-attempted, set only when we
* had to fall back (see `jwks()`). Undefined means the cached set came from
* a successful discovery and is good indefinitely.
*/
__sharedMemoryJwksRetryAt?: number;
};
const g = globalThis as GlobalWithJwks;
@@ -27,6 +45,10 @@ const g = globalThis as GlobalWithJwks;
* Issuer of MCP access tokens. The MCP endpoint is a separate application in
* the IdP from the Web UI, and Authentik stamps each token with its own
* application slug, so this is NOT interchangeable with OIDC_ISSUER.
*
* Not every IdP works that way: EntraID has one issuer per tenant regardless
* of how many app registrations you create, so OIDC_ISSUER_MCP is left unset
* there and this falls through to OIDC_ISSUER.
*/
export function mcpIssuer(): string {
return env().OIDC_ISSUER_MCP ?? env().OIDC_ISSUER;
@@ -50,16 +72,88 @@ function acceptedIssuers(): [string, string] {
return [bare, `${bare}/`];
}
function jwks() {
if (g.__sharedMemoryJwks) return g.__sharedMemoryJwks;
// Authentik discovery is at `${issuer}/.well-known/openid-configuration`;
// the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`.
// Authentik canonically serves `${issuer}/jwks/`.
const url = new URL(`${mcpIssuer().replace(/\/$/, "")}/jwks/`);
g.__sharedMemoryJwks = createRemoteJWKSet(url, {
cacheMaxAge: 10 * 60 * 1000, // 10 min
cooldownDuration: 30 * 1000,
});
const JWKS_OPTIONS = {
cacheMaxAge: 10 * 60 * 1000, // 10 min
cooldownDuration: 30 * 1000,
} as const;
/** How long to keep serving a fallback key set before retrying discovery. */
const DISCOVERY_RETRY_COOLDOWN_MS = 60 * 1000;
/** Discovery can hang; every MCP request waits on it, so bound it. */
const DISCOVERY_TIMEOUT_MS = 5 * 1000;
/**
* The pre-discovery convention: `${issuer}/jwks/`.
*
* This is Authentik's canonical JWKS path and was hardcoded here. It stays as
* the fallback so that a deployment whose discovery document is unreachable
* behaves exactly as it did before this change.
*/
function fallbackJwksUri(): string {
return `${mcpIssuer().replace(/\/$/, "")}/jwks/`;
}
/**
* Read `jwks_uri` out of the MCP issuer's OIDC discovery document.
*
* `${issuer}/jwks/` is an Authentik convention, not a standard — RFC 8414
* says the key set lives wherever `jwks_uri` points, and providers disagree
* wildly. EntraID serves keys at
* `https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys`, nowhere
* near `${issuer}/jwks/`, so with the path hardcoded every EntraID-issued MCP
* token fails verification with a 404 on the key set — authentication is
* simply impossible, not merely misconfigured. Ask the issuer where its keys
* are instead of guessing.
*
* Returns null (never throws) on any failure, so the caller can fall back.
*/
async function discoverJwksUri(): Promise<string | null> {
const url = `${mcpIssuer().replace(/\/$/, "")}/.well-known/openid-configuration`;
try {
const res = await fetch(url, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
});
if (!res.ok) return null;
const doc: unknown = await res.json();
const uri = (doc as { jwks_uri?: unknown } | null)?.jwks_uri;
if (typeof uri !== "string" || uri.trim().length === 0) return null;
// A malformed jwks_uri must not blow up the request path.
new URL(uri);
return uri;
} catch {
return null;
}
}
/**
* The key set MCP access tokens are verified against, resolved once per
* process.
*
* Async because discovery is a network call. The cached promise is installed
* synchronously — before the first `await` inside the IIFE runs — so
* concurrent callers always join the existing resolution rather than racing
* to start their own.
*
* When discovery fails we serve the legacy fallback but arm a retry: a single
* blip at process start would otherwise pin the wrong URL for the lifetime of
* the container, which on EntraID means MCP auth stays broken until someone
* restarts it. The cooldown keeps a persistently-unreachable discovery
* endpoint from being hit on every request.
*/
function jwks(): Promise<JwkSet> {
const retryAt = g.__sharedMemoryJwksRetryAt;
const dueForRetry = retryAt !== undefined && Date.now() >= retryAt;
if (g.__sharedMemoryJwks && !dueForRetry) return g.__sharedMemoryJwks;
g.__sharedMemoryJwksRetryAt = undefined;
g.__sharedMemoryJwks = (async () => {
const discovered = await discoverJwksUri();
if (discovered) return createRemoteJWKSet(new URL(discovered), JWKS_OPTIONS);
g.__sharedMemoryJwksRetryAt = Date.now() + DISCOVERY_RETRY_COOLDOWN_MS;
return createRemoteJWKSet(new URL(fallbackJwksUri()), JWKS_OPTIONS);
})();
return g.__sharedMemoryJwks;
}
@@ -143,7 +237,7 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
} as AuthenticatedClaims;
}
const { payload } = await jwtVerify(token, jwks(), {
const { payload } = await jwtVerify(token, await jwks(), {
issuer: acceptedIssuers(),
audience: env().OIDC_AUDIENCE,
});
@@ -153,6 +247,17 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
buildWwwAuthenticate("invalid_token", "missing sub"),
);
}
// Groups overage: the IdP is telling us it holds memberships it declined
// to list. `extractGroupsClaim` would read that as "no claim emitted" and
// userContextFromClaims would fall back to the DB snapshot — granting
// project access from a stale record while the live state is admittedly
// unknown. Refuse; the operator fix is in the description.
if (detectGroupsOverage(payload)) {
const desc =
"groups overage: IdP did not enumerate group membership " +
"(set groupMembershipClaims=ApplicationGroup on EntraID)";
throw new UnauthorizedError(desc, buildWwwAuthenticate("invalid_token", desc));
}
// Normalize the issuer for identity purposes.
//
// The token was just verified against mcpIssuer() — that check is done.
+73
View File
@@ -0,0 +1,73 @@
import { env } from "@/lib/env";
import { mcpIssuer } from "@/lib/auth/jwt";
/**
* The protected-resource metadata document, RFC 9728 §2.
*
* Shared by both metadata routes — the root `/.well-known/oauth-protected-
* resource` and the path-suffixed `/.well-known/oauth-protected-resource/
* <resource path>` form of §3.1 — because the two documents differ ONLY in
* the `resource` identifier they describe. Anything else drifting between
* them is a bug: a client that discovers us through the suffixed URL would
* be told to request a different scope set than one that follows the
* `WWW-Authenticate: resource_metadata=...` header, and whichever of the two
* lost the audience scope would hand back tokens with no `aud` claim.
*/
export interface ResourceMetadata {
resource: string;
authorization_servers: string[];
scopes_supported: string[];
bearer_methods_supported: string[];
resource_documentation: string;
}
/**
* The public origin, with any trailing slash stripped.
*
* `resource` values are compared as exact strings by clients (RFC 9728 §3.3),
* so `https://host/` and `https://host` are not interchangeable — PUBLIC_URL
* is written both ways in the wild and only the stripped form is emitted.
*/
export function publicOrigin(): string {
return env().PUBLIC_URL.replace(/\/$/, "");
}
/**
* Build the metadata document for `resource`.
*
* The caller supplies the resource identifier because it depends on which
* URL the document was fetched from; everything else is deployment config.
*/
export function buildResourceMetadata(resource: string): ResourceMetadata {
// The audience scope MUST be advertised. Authentik only evaluates a scope
// mapping when the client requests that scope by name, and the client only
// learns scope names from this document. Omit it and every access token
// arrives without `aud`, which jwt.ts rejects as "claim invalid: aud".
const audienceScope =
env().OIDC_AUDIENCE_SCOPE ?? `aud-${env().OIDC_AUDIENCE}`;
// Same mechanism as the audience scope, different consequence: a client
// only requests `offline_access` if it sees the name here, and without
// that request the IdP returns no refresh token — so the client cannot
// renew and the user gets kicked back to an interactive login whenever
// the access token expires.
//
// Opt-in, because the IdP needs a matching scope mapping; advertising one
// it doesn't offer can fail the whole authorization request.
const scopes = ["openid", "profile", "email", audienceScope];
if (env().OIDC_OFFLINE_ACCESS) scopes.push("offline_access");
return {
resource,
// The MCP application's issuer, which is not necessarily the Web UI's —
// see mcpIssuer(). Advertising the wrong one sends clients to a discovery
// document whose tokens this endpoint will then reject on `iss`.
authorization_servers: [mcpIssuer()], // as configured, slash and all
scopes_supported: scopes,
bearer_methods_supported: ["header"],
// Documentation lives at the site root regardless of which resource this
// document describes, so it is always derived from the public origin and
// not from `resource`.
resource_documentation: `${publicOrigin()}/`,
};
}
+126
View File
@@ -0,0 +1,126 @@
import { afterAll, beforeEach, describe, expect, test } from "vitest";
/**
* Group sync, and specifically the overage case.
*
* `syncUserGroupsFromClaim` deletes every membership when it sees no groups.
* That is correct for "the IdP says zero groups" and catastrophic for "the
* IdP declined to enumerate them" — EntraID past 200 groups. The two look
* identical if you only inspect `claims.groups`, which is why the function
* takes the whole claims object.
*/
const { db, pg } = await import("@/lib/db/client");
const { users, groups, userGroups } = await import("@/lib/db/schema");
const { syncUserGroupsFromClaim, detectGroupsOverage, GroupsOverageError } =
await import("@/lib/auth/sync-groups");
const { eq } = await import("drizzle-orm");
const ISS = "https://login.microsoftonline.com/sync-test/v2.0";
let userId: string;
async function memberships(): Promise<string[]> {
const rows = await db
.select({ name: groups.name })
.from(userGroups)
.innerJoin(groups, eq(userGroups.groupId, groups.id))
.where(eq(userGroups.userId, userId));
return rows.map((r) => r.name).sort();
}
beforeEach(async () => {
await db.delete(users).where(eq(users.oidcIss, ISS));
await db.delete(groups).where(eq(groups.oidcIss, ISS));
const rows = await db
.insert(users)
.values({ oidcIss: ISS, oidcSub: "sync-test-sub" })
.returning({ id: users.id });
const row = rows[0];
if (!row) throw new Error("failed to seed test user");
userId = row.id;
});
afterAll(async () => {
await db.delete(users).where(eq(users.oidcIss, ISS));
await db.delete(groups).where(eq(groups.oidcIss, ISS));
await pg.end();
});
describe("detectGroupsOverage", () => {
test("spots the JWT overage pointer", () => {
expect(
detectGroupsOverage({
_claim_names: { groups: "src1" },
_claim_sources: { src1: { endpoint: "https://graph.windows.net/x" } },
}),
).toBe(true);
});
test("spots the implicit-flow indicator", () => {
expect(detectGroupsOverage({ hasgroups: true })).toBe(true);
});
test("is false for ordinary claims", () => {
expect(detectGroupsOverage({ groups: ["a"] })).toBe(false);
expect(detectGroupsOverage({})).toBe(false);
expect(detectGroupsOverage(null)).toBe(false);
// A _claim_names for some OTHER claim is not a groups overage.
expect(detectGroupsOverage({ _claim_names: { roles: "src1" } })).toBe(false);
expect(detectGroupsOverage({ hasgroups: false })).toBe(false);
});
});
describe("syncUserGroupsFromClaim", () => {
test("stores the claim's names", async () => {
await syncUserGroupsFromClaim(userId, ISS, { groups: ["eng", "ops"] });
expect(await memberships()).toEqual(["eng", "ops"]);
});
test("an empty claim really does clear memberships", async () => {
await syncUserGroupsFromClaim(userId, ISS, { groups: ["eng"] });
await syncUserGroupsFromClaim(userId, ISS, { groups: [] });
expect(await memberships()).toEqual([]);
});
test("an absent claim clears memberships", async () => {
// Unchanged behaviour: the IdP has stopped asserting groups, so we stop
// honouring them rather than keeping stale grants alive.
await syncUserGroupsFromClaim(userId, ISS, { groups: ["eng"] });
await syncUserGroupsFromClaim(userId, ISS, {});
expect(await memberships()).toEqual([]);
});
test("overage throws instead of clearing", async () => {
await syncUserGroupsFromClaim(userId, ISS, { groups: ["eng", "ops"] });
await expect(
syncUserGroupsFromClaim(userId, ISS, {
_claim_names: { groups: "src1" },
_claim_sources: { src1: { endpoint: "https://graph.windows.net/x" } },
}),
).rejects.toBeInstanceOf(GroupsOverageError);
// The whole point: the snapshot survives, so the operator can fix the IdP
// and the user comes back with their access intact.
expect(await memberships()).toEqual(["eng", "ops"]);
});
test("the overage error names the fix", async () => {
let err: Error | null = null;
try {
await syncUserGroupsFromClaim(userId, ISS, { hasgroups: true });
} catch (e) {
err = e as Error;
}
expect(err).toBeInstanceOf(GroupsOverageError);
expect(err?.message).toContain("groupMembershipClaims");
expect(err?.message).toContain("ApplicationGroup");
});
test("non-string entries are dropped, names are de-duplicated", async () => {
await syncUserGroupsFromClaim(userId, ISS, {
groups: ["eng", 7, null, " eng ", "ops"],
});
expect(await memberships()).toEqual(["eng", "ops"]);
});
});
+75 -10
View File
@@ -2,34 +2,99 @@ import { and, eq, notInArray, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { groups, userGroups } from "@/lib/db/schema";
/**
* Raised when the IdP signals that it holds group memberships it declined to
* enumerate (EntraID's "groups overage"). Callers must abort — see
* `detectGroupsOverage` for why this cannot be treated as "no groups".
*/
export class GroupsOverageError extends Error {
constructor() {
super(
"OIDC groups overage: the identity provider signalled group membership " +
"it did not enumerate, so the user's groups cannot be determined. On " +
"EntraID, set the app registration's `groupMembershipClaims` to " +
'"ApplicationGroup" (portal: "Groups assigned to the application") and ' +
"assign the groups you share projects with. See docs/oidc-entra-id.md " +
"§10b.",
);
this.name = "GroupsOverageError";
}
}
/**
* Does this token say "there are groups, but I'm not listing them"?
*
* EntraID stops emitting `groups` past 200 entries in a JWT (150 in SAML, 5 in
* implicit flow) and substitutes a pointer:
*
* "_claim_names": { "groups": "src1" },
* "_claim_sources": { "src1": { "endpoint": "https://graph.windows.net/…" } }
*
* or, for implicit flow, `"hasgroups": true`.
*
* This is NOT a truncated list — it is no list at all, and it is materially
* different from "this user belongs to zero groups". Conflating the two is
* what made this dangerous: the absent-claim branch below deletes every one of
* the user's memberships, so a user crossing the 200-group line would silently
* lose access to every shared project on both surfaces, with no error raised
* anywhere.
*
* We refuse instead. Group state gates `readableProjectIds` / `canWriteProject`,
* and granting or revoking access on state we know we don't have is guesswork
* either way. Failing loudly destroys nothing and names its own fix.
*/
export function detectGroupsOverage(claims: unknown): boolean {
const c = claims as
| { _claim_names?: unknown; hasgroups?: unknown }
| null
| undefined;
if (!c || typeof c !== "object") return false;
if (c.hasgroups === true) return true;
const names = c._claim_names;
return (
typeof names === "object" &&
names !== null &&
"groups" in (names as Record<string, unknown>)
);
}
/**
* Sync a user's group memberships from the OIDC `groups` claim on sign-in.
*
* Takes the whole claims object, not just the claim value, because deciding
* what an absent `groups` means requires seeing the overage markers that sit
* beside it.
*
* Claim shape: `string[]`. Authentik emits group *names* directly here;
* Keycloak and Okta likewise (with the right mappers configured). EntraID,
* when correctly configured per README, emits names too — but the default
* "groups" optional-claim variant emits object-id GUIDs instead, and if the
* user is in too many groups EntraID switches to a "groups overage"
* indicator (no group list at all). We take the conservative path:
* Keycloak and Okta likewise (with the right mappers configured). EntraID
* emits object-id GUIDs by default — `cloud_displayname` gets you names, but
* only under `groupMembershipClaims: "ApplicationGroup"`, and only for
* directly assigned groups. See docs/oidc-entra-id.md §10a.
*
* - whatever strings appear in the claim are treated as names verbatim
* and stored as-is. If your IdP emits GUIDs, the UI will show GUIDs;
* fix it at the IdP layer (we don't attempt resolution).
* - if the claim is missing/empty, the user is treated as having zero
* groups and all existing memberships are deleted.
* - groups overage (where EntraID emits `_claim_names.groups` instead of
* `groups`) is not handled in v1 — the user appears as having no
* groups. Documented limit; revisit if it bites someone.
* groups and all existing memberships are deleted. That is the
* conservative reading: don't keep stale grants alive once the IdP has
* stopped asserting them.
* - if the IdP signals an overage, we throw rather than apply either
* reading. See `detectGroupsOverage`.
*
* The whole operation runs in a single transaction so the membership
* snapshot is atomic (no window where a user partially has new memberships
* and still has stale ones).
*
* @throws {GroupsOverageError} when the claims carry an overage indicator.
*/
export async function syncUserGroupsFromClaim(
userId: string,
oidcIss: string,
rawClaim: unknown,
claims: unknown,
): Promise<void> {
if (detectGroupsOverage(claims)) throw new GroupsOverageError();
const rawClaim = (claims as { groups?: unknown } | null | undefined)?.groups;
const names = normalizeGroupsClaim(rawClaim);
await db.transaction(async (tx) => {
+20
View File
@@ -53,6 +53,21 @@ export const users = pgTable(
oidcSub: text("oidc_sub").notNull(),
// OIDC `iss` so we can disambiguate if we ever federate.
oidcIss: text("oidc_iss").notNull(),
/**
* EntraID `oid` — the user's directory object id.
*
* Null on IdPs that don't emit it (Authentik, Keycloak, Okta), where
* `sub` is already stable across applications and remains the key.
*
* EntraID's `sub` is PAIRWISE: it is derived from the token recipient,
* so the Web UI app registration and the MCP app registration produce
* different `sub` values for the same human. Keying on `sub` there
* silently creates two accounts for one person — sign in on the web,
* connect an MCP client, find an empty account. `oid` is the identifier
* Microsoft documents as constant for a user across every application in
* a tenant, so it takes precedence whenever it's present.
*/
oidcOid: text("oidc_oid"),
email: text("email"),
name: text("name"),
picture: text("picture"),
@@ -61,6 +76,11 @@ export const users = pgTable(
},
(t) => ({
uniqueIss: uniqueIndex("users_iss_sub_uq").on(t.oidcIss, t.oidcSub),
// Partial: rows from IdPs that emit no `oid` all hold NULL here, and a
// plain unique index would collapse them into a single allowed row.
uniqueOid: uniqueIndex("users_iss_oid_uq")
.on(t.oidcIss, t.oidcOid)
.where(sql`${t.oidcOid} IS NOT NULL`),
}),
);
+15
View File
@@ -52,6 +52,20 @@ const envSchema = z.object({
// Defaults to the `aud-<audience>` convention used in the README setup.
OIDC_AUDIENCE_SCOPE: optional(z.string().min(1)),
// Advertise `offline_access` in the protected-resource metadata.
//
// Without it the IdP issues an access token and NO refresh token, so an
// MCP client cannot renew silently — it has to send the user back through
// an interactive login every time the access token expires. Turning this
// on is what makes long-lived sessions stop dropping out.
//
// Defaults OFF because it is only half the fix: the IdP must also have an
// `offline_access` scope mapping on the provider. Advertising a scope the
// IdP doesn't offer risks an `invalid_scope` rejection that would break
// authentication outright, so a deployment opts in only after configuring
// its IdP. See README -> "Keeping sessions alive".
OIDC_OFFLINE_ACCESS: Bool.optional().default(false),
// Database
DATABASE_URL: z.string().url(),
@@ -119,6 +133,7 @@ function buildPhaseStub(): Env {
CLI_TOKEN_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
PLUGIN_MARKETPLACE_NAME: "shared-memory",
ALLOW_INSECURE_HTTP: false,
OIDC_OFFLINE_ACCESS: false,
};
}
+15 -33
View File
@@ -1,6 +1,7 @@
import { db } from "@/lib/db/client";
import { users, groups, userGroups } from "@/lib/db/schema";
import { and, eq } from "drizzle-orm";
import { groups, userGroups } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { oidClaim, resolveUserId } from "@/lib/auth/identity";
import type { AuthenticatedClaims } from "@/lib/auth/jwt";
/**
@@ -55,37 +56,18 @@ export async function userContextFromClaims(
const name = (claims.name as string | undefined) ?? null;
const picture = (claims.picture as string | undefined) ?? null;
const row = await db
.insert(users)
.values({
oidcSub: claims.sub,
oidcIss: claims.iss,
email,
name,
picture,
})
.onConflictDoUpdate({
target: [users.oidcIss, users.oidcSub],
set: {
email,
name,
picture,
lastSeenAt: new Date(),
},
})
.returning({ id: users.id });
let userId = row[0]?.id;
if (!userId) {
// Race against another upsert — fall back to a select.
const existing = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.oidcIss, claims.iss), eq(users.oidcSub, claims.sub)))
.limit(1);
if (!existing[0]) throw new Error("user upsert failed and not found on re-read");
userId = existing[0].id;
}
// Shared with the Web UI sign-in path (auth.ts). Keeping one resolver is
// what stops the two surfaces disagreeing about who a user is — on EntraID
// they see different `sub` values for the same person and would otherwise
// each create their own account. See lib/auth/identity.ts.
const userId = await resolveUserId({
iss: claims.iss,
sub: claims.sub,
oid: oidClaim(claims),
email,
name,
picture,
});
// OIDC bearer tokens carry a `groups` claim (when the IdP is configured to
// emit it). CLI tokens never do — they go through verifyCliToken which
+258
View File
@@ -0,0 +1,258 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
/**
* Integration tests for memory.get and memory.patch against a REAL
* Postgres (pgvector). See CONTRIBUTING/README for spinning up the test
* database; without it these tests fail to connect rather than silently
* passing.
*
* The embedder sidecar is the one thing stubbed — it's an external HTTP
* service running an ML model. The stub is deterministic per-text, which
* lets the re-embedding test assert on the STORED VECTOR CHANGING (real
* DB state) rather than on "was the mock called".
*/
vi.mock("@/lib/embedder", () => ({
embedText: async (text: string) => {
// Deterministic pseudo-vector: distinct texts produce distinct vectors.
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
return Array.from({ length: 384 }, (_, i) => ((h + i * 7919) % 1000) / 1000);
},
embedTexts: async (texts: string[]) => texts.map(() => Array(384).fill(0.1)),
embedderReady: async () => true,
EmbedderError: class extends Error {},
}));
const { db, pg } = await import("@/lib/db/client");
const { memories, projects, users } = await import("@/lib/db/schema");
const { toolMap } = await import("@/lib/mcp/tools");
const { eq } = await import("drizzle-orm");
type UserContext = import("@/lib/mcp/context").UserContext;
const ORIGINAL = [
"# Roadmap",
"",
"## RECENTLY SHIPPED",
"- v1.0 initial release",
"",
"## IN PROGRESS",
"- patch primitive",
"",
].join("\n");
let userId: string;
let projectId: string;
let memoryId: string;
let ctx: UserContext;
async function seedMemory(content = ORIGINAL): Promise<string> {
const row = await db
.insert(memories)
.values({
userId,
projectId,
scope: "project",
content,
tags: ["roadmap"],
embedding: Array(384).fill(0.5),
})
.returning({ id: memories.id });
return row[0]!.id;
}
async function readContent(id: string): Promise<string> {
const r = await db
.select({ content: memories.content })
.from(memories)
.where(eq(memories.id, id));
return r[0]!.content;
}
beforeAll(async () => {
const u = await db
.insert(users)
.values({ oidcSub: "test-sub", oidcIss: "http://test", email: "t@example.com" })
.onConflictDoNothing()
.returning({ id: users.id });
userId =
u[0]?.id ??
(await db.select({ id: users.id }).from(users).limit(1))[0]!.id;
const p = await db
.insert(projects)
.values({ userId, key: "test-project", displayName: "Test Project" })
.onConflictDoNothing()
.returning({ id: projects.id });
projectId =
p[0]?.id ??
(await db.select({ id: projects.id }).from(projects).limit(1))[0]!.id;
ctx = {
userId,
sub: "test-sub",
iss: "http://test",
email: null,
name: null,
groups: [],
};
});
beforeEach(async () => {
memoryId = await seedMemory();
});
afterAll(async () => {
await db.delete(memories);
await pg.end();
});
describe("memory.get response shape (P1)", () => {
test("does not leak the embedding or the tsvector to the caller", async () => {
const res = await toolMap["memory.get"]!.handler({ id: memoryId }, ctx);
const fields = Object.keys(res.structuredContent as object);
expect(fields).not.toContain("embedding");
expect(fields).not.toContain("contentTsv");
});
test("returns exactly the same 9 fields as memory.list", async () => {
const res = await toolMap["memory.get"]!.handler({ id: memoryId }, ctx);
const fields = Object.keys(res.structuredContent as object).sort();
expect(fields).toEqual(
[
"content",
"createdAt",
"id",
"lastEditedBy",
"projectId",
"scope",
"tags",
"updatedAt",
"version",
].sort(),
);
});
test("still returns the full content", async () => {
const res = await toolMap["memory.get"]!.handler({ id: memoryId }, ctx);
expect((res.structuredContent as { content: string }).content).toBe(ORIGINAL);
});
});
describe("memory.patch (P2)", () => {
test("applies a unique patch and increments version by exactly 1", async () => {
const before = await db
.select({ version: memories.version })
.from(memories)
.where(eq(memories.id, memoryId));
const res = await toolMap["memory.patch"]!.handler(
{
id: memoryId,
old_string: "## RECENTLY SHIPPED",
new_string: "## RECENTLY SHIPPED\n- v1.1 patch primitive",
},
ctx,
);
expect(res.isError).toBeFalsy();
const after = res.structuredContent as { version: number };
expect(after.version).toBe(before[0]!.version + 1);
expect(await readContent(memoryId)).toContain("- v1.1 patch primitive");
// The rest of the document survived.
expect(await readContent(memoryId)).toContain("- v1.0 initial release");
expect(await readContent(memoryId)).toContain("## IN PROGRESS");
});
test("refuses an absent old_string and leaves content byte-identical", async () => {
const res = await toolMap["memory.patch"]!.handler(
{ id: memoryId, old_string: "## NOT PRESENT", new_string: "x" },
ctx,
);
expect(res.isError).toBe(true);
expect(await readContent(memoryId)).toBe(ORIGINAL);
});
test("refuses an ambiguous old_string, naming the count, leaving content unchanged", async () => {
const id = await seedMemory("alpha\nalpha\nbeta\n");
const res = await toolMap["memory.patch"]!.handler(
{ id, old_string: "alpha", new_string: "gamma" },
ctx,
);
expect(res.isError).toBe(true);
expect(res.content[0]!.text).toMatch(/2/);
expect(await readContent(id)).toBe("alpha\nalpha\nbeta\n");
});
test("refuses a stale version and leaves content unchanged", async () => {
const current = await db
.select({ version: memories.version })
.from(memories)
.where(eq(memories.id, memoryId));
const res = await toolMap["memory.patch"]!.handler(
{
id: memoryId,
old_string: "## IN PROGRESS",
new_string: "## DONE",
version: current[0]!.version + 99,
},
ctx,
);
expect(res.isError).toBe(true);
expect(await readContent(memoryId)).toBe(ORIGINAL);
});
test("rejects a patch that would push content past the 64,000-char limit", async () => {
const id = await seedMemory("A".repeat(63_950) + "ANCHOR");
const res = await toolMap["memory.patch"]!.handler(
{ id, old_string: "ANCHOR", new_string: "B".repeat(100) },
ctx,
);
expect(res.isError).toBe(true);
expect(await readContent(id)).toBe("A".repeat(63_950) + "ANCHOR");
});
test("re-embeds: the stored vector changes after a patch", async () => {
const before = await pg<{ embedding: string }[]>`
SELECT embedding::text AS embedding FROM memories WHERE id = ${memoryId}
`;
await toolMap["memory.patch"]!.handler(
{ id: memoryId, old_string: "- patch primitive", new_string: "- shipped it" },
ctx,
);
const after = await pg<{ embedding: string }[]>`
SELECT embedding::text AS embedding FROM memories WHERE id = ${memoryId}
`;
expect(after[0]!.embedding).not.toBe(before[0]!.embedding);
});
test("full-text index updates itself, because content_tsv is a generated column", async () => {
// This is the claim that a patch cannot rot FTS. Postgres maintains
// content_tsv; only the embedding needs an explicit recompute.
await toolMap["memory.patch"]!.handler(
{
id: memoryId,
old_string: "- patch primitive",
new_string: "- kumquat marmalade",
},
ctx,
);
const hit = await pg<{ n: number }[]>`
SELECT count(*)::int AS n FROM memories
WHERE id = ${memoryId} AND content_tsv @@ plainto_tsquery('english', 'kumquat')
`;
expect(hit[0]!.n).toBe(1);
});
});
+244
View File
@@ -0,0 +1,244 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
/**
* Integration cover for the memory MUTATION paths (write / update / delete)
* against a real Postgres. These exist mainly as a safety net for the
* shared-mutation refactor: the MCP tools and the Web UI Server Actions
* used to reimplement the same authorize → CAS → re-embed → audit sequence
* separately, and these assertions pin the behaviour that must survive
* being pulled into one place.
*/
vi.mock("@/lib/embedder", () => ({
embedText: async (text: string) => {
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
return Array.from({ length: 384 }, (_, i) => ((h + i * 7919) % 1000) / 1000);
},
embedTexts: async (texts: string[]) => texts.map(() => Array(384).fill(0.1)),
embedderReady: async () => true,
EmbedderError: class extends Error {},
}));
const { db, pg } = await import("@/lib/db/client");
const { memories, projects, users, groups, userGroups, projectShares } = await import(
"@/lib/db/schema"
);
const { toolMap } = await import("@/lib/mcp/tools");
const { eq } = await import("drizzle-orm");
type UserContext = import("@/lib/mcp/context").UserContext;
const ISS = "http://test";
let author: UserContext;
let projectOwnerId: string;
let ownProjectId: string;
let sharedProjectId: string;
let sharedGroupId: string;
function ctxFor(userId: string, sub: string, groupNames: string[] = []): UserContext {
return { userId, sub, iss: ISS, email: null, name: null, groups: groupNames };
}
async function upsertUser(sub: string): Promise<string> {
const r = await db
.insert(users)
.values({ oidcSub: sub, oidcIss: ISS })
.onConflictDoUpdate({ target: [users.oidcIss, users.oidcSub], set: { oidcSub: sub } })
.returning({ id: users.id });
return r[0]!.id;
}
async function seedMemory(
userId: string,
projectId: string | null,
content = "seed content",
): Promise<string> {
const r = await db
.insert(memories)
.values({
userId,
projectId,
scope: projectId ? "project" : "user",
content,
tags: [],
embedding: Array(384).fill(0.5),
lastEditedBy: userId,
})
.returning({ id: memories.id });
return r[0]!.id;
}
async function setShareAccess(access: "ro" | "rw") {
await db
.insert(projectShares)
.values({ projectId: sharedProjectId, groupId: sharedGroupId, access })
.onConflictDoUpdate({
target: [projectShares.projectId, projectShares.groupId],
set: { access },
});
}
async function isDeleted(id: string): Promise<boolean> {
const r = await db
.select({ deletedAt: memories.deletedAt })
.from(memories)
.where(eq(memories.id, id));
return r[0]!.deletedAt !== null;
}
beforeAll(async () => {
const authorId = await upsertUser("author-sub");
projectOwnerId = await upsertUser("owner-sub");
const own = await db
.insert(projects)
.values({ userId: authorId, key: "author-own", displayName: "Author Own" })
.onConflictDoNothing()
.returning({ id: projects.id });
ownProjectId =
own[0]?.id ??
(
await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.key, "author-own"))
)[0]!.id;
const shared = await db
.insert(projects)
.values({ userId: projectOwnerId, key: "team-shared", displayName: "Team Shared" })
.onConflictDoNothing()
.returning({ id: projects.id });
sharedProjectId =
shared[0]?.id ??
(
await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.key, "team-shared"))
)[0]!.id;
const g = await db
.insert(groups)
.values({ oidcIss: ISS, name: "team" })
.onConflictDoNothing()
.returning({ id: groups.id });
sharedGroupId =
g[0]?.id ??
(await db.select({ id: groups.id }).from(groups).where(eq(groups.name, "team")))[0]!
.id;
await db
.insert(userGroups)
.values({ userId: authorId, groupId: sharedGroupId })
.onConflictDoNothing();
author = ctxFor(authorId, "author-sub", ["team"]);
});
beforeEach(async () => {
await db.delete(memories);
await setShareAccess("rw");
});
afterAll(async () => {
await db.delete(memories);
await pg.end();
});
describe("memory.write", () => {
test("writes into a project the caller owns", async () => {
const res = await toolMap["memory.write"]!.handler(
{ content: "hello", scope: "project", project: "author-own" },
author,
);
expect(res.isError).toBeFalsy();
});
test("refuses an unknown project rather than creating one", async () => {
const res = await toolMap["memory.write"]!.handler(
{ content: "hello", scope: "project", project: "does-not-exist" },
author,
);
expect(res.isError).toBe(true);
expect(res.content[0]!.text).toMatch(/project\.identify/);
});
});
describe("memory.update", () => {
test("updates content and increments version", async () => {
const id = await seedMemory(author.userId, ownProjectId);
const before = await db
.select({ version: memories.version })
.from(memories)
.where(eq(memories.id, id));
const res = await toolMap["memory.update"]!.handler(
{ id, content: "revised content" },
author,
);
expect(res.isError).toBeFalsy();
expect((res.structuredContent as { version: number }).version).toBe(
before[0]!.version + 1,
);
});
test("refuses a stale version", async () => {
const id = await seedMemory(author.userId, ownProjectId);
const res = await toolMap["memory.update"]!.handler(
{ id, content: "revised", version: 99 },
author,
);
expect(res.isError).toBe(true);
});
test("denies updating a memory in a project shared read-only", async () => {
const id = await seedMemory(author.userId, sharedProjectId);
await setShareAccess("ro");
const res = await toolMap["memory.update"]!.handler(
{ id, content: "sneaky edit" },
author,
);
expect(res.isError).toBe(true);
});
});
describe("memory.delete authorization", () => {
test("allows deleting a memory in a project shared read-write", async () => {
const id = await seedMemory(author.userId, sharedProjectId);
const res = await toolMap["memory.delete"]!.handler({ id }, author);
expect(res.isError).toBeFalsy();
expect(await isDeleted(id)).toBe(true);
});
test("denies deleting a memory in a project shared read-only, even to its author", async () => {
// The realistic path here: the memory was written while the share was
// rw, then an owner downgraded the group to ro. Authoring the row must
// not grant a standing write privilege the project ACL has revoked —
// memory.update already refuses this, and delete must agree.
const id = await seedMemory(author.userId, sharedProjectId);
await setShareAccess("ro");
const res = await toolMap["memory.delete"]!.handler({ id }, author);
expect(res.isError).toBe(true);
expect(await isDeleted(id)).toBe(false);
});
test("denies deleting another user's user-scope memory", async () => {
const id = await seedMemory(projectOwnerId, null);
const res = await toolMap["memory.delete"]!.handler({ id }, author);
expect(res.isError).toBe(true);
expect(await isDeleted(id)).toBe(false);
});
});
+109 -222
View File
@@ -11,6 +11,7 @@ import {
MemoryIdInput,
MemoryDeleteInput,
MemoryListInput,
MemoryPatchInput,
MemorySearchInput,
MemoryUpdateInput,
MemoryWriteInput,
@@ -20,20 +21,22 @@ import {
SnippetListInput,
SnippetDeleteInput,
} from "@shared-memory/schemas";
import { embedText } from "@/lib/embedder";
import { searchMemories } from "@/lib/memories";
import {
createMemory,
patchMemory,
softDeleteMemory,
updateMemory,
type Actor,
type ProjectResolver,
} from "@/lib/memory-mutations";
import {
getSnippet,
putSnippet,
listSnippets,
softDeleteSnippet,
} from "@/lib/snippets";
import {
CONCURRENT_EDIT_ERROR,
canWriteProject,
getProjectAccess,
readableProjectIds,
} from "@/lib/access";
import { getProjectAccess, readableProjectIds } from "@/lib/access";
import type { UserContext } from "./context";
/**
@@ -151,6 +154,27 @@ function withDefaultProject(
return { ...obj, project: ctx.defaultProjectKey };
}
/** Adapt an MCP request context to the shared mutation layer. */
function mcpActor(ctx: UserContext): Actor {
return { userId: ctx.userId, groups: ctx.groups, via: "mcp" };
}
/**
* Project resolution for MCP writes. Unlike the Web UI, the MCP surface
* never auto-creates a project — an unknown key is an error telling the
* caller to run project.identify first, which keeps agents from silently
* spawning near-miss projects off a typo'd key.
*/
function mcpProjectResolver(ctx: UserContext): ProjectResolver {
return async (key: string) => {
const id = await resolveProjectId(ctx, key);
if (!id) {
return { ok: false, error: `unknown project '${key}'; call project.identify first` };
}
return { ok: true, value: id };
};
}
// ---------- tools ----------
const projectIdentify: ToolDef = {
@@ -373,55 +397,18 @@ const memoryWrite: ToolDef = {
const parsed = MemoryWriteInput.safeParse(withDefaultProject(args, ctx));
if (!parsed.success) return err(parsed.error.message);
const scope = parsed.data.scope;
let projectId: string | null = null;
let projectKey: string | undefined = undefined;
if (scope === "project") {
projectKey = projectKeyOrDefault(ctx, parsed.data.project);
if (!projectKey) {
return err("scope=project requires `project` key (or X-Project-Key header)");
}
projectId = await resolveProjectId(ctx, projectKey);
if (!projectId) {
return err(`unknown project '${projectKey}'; call project.identify first`);
}
// Authorize write. Owner always allowed; otherwise require rw.
const allowed = await canWriteProject(ctx.userId, ctx.groups, projectId);
if (!allowed) {
return err(`no write access to project '${projectKey}'`);
}
// Fold the X-Project-Key fallback in before the shared path sees it.
const input = {
...parsed.data,
project: projectKeyOrDefault(ctx, parsed.data.project),
};
if (input.scope === "project" && !input.project) {
return err("scope=project requires `project` key (or X-Project-Key header)");
}
// Embed inline so the new memory is searchable immediately. Slower
// writes (~50–150 ms) are an acceptable price for that guarantee; if
// embedder pressure ever forces an async path, only this section
// needs to change.
const embedding = await embedText(parsed.data.content);
const inserted = await db
.insert(memories)
.values({
userId: ctx.userId,
projectId,
scope,
content: parsed.data.content,
tags: parsed.data.tags ?? [],
embedding,
lastEditedBy: ctx.userId,
})
.returning({ id: memories.id, createdAt: memories.createdAt });
const m = inserted[0]!;
await db.insert(auditLog).values({
userId: ctx.userId,
actor: "mcp",
action: "memory.write",
entityType: "memory",
entityId: m.id,
payload: { scope, projectKey: projectKey ?? null, tags: parsed.data.tags ?? [] },
});
return ok({ id: m.id, createdAt: m.createdAt }, `wrote memory ${m.id}`);
const res = await createMemory(mcpActor(ctx), input, mcpProjectResolver(ctx));
if (!res.ok) return err(res.error);
return ok(res.value, `wrote memory ${res.value.id}`);
},
};
@@ -508,8 +495,28 @@ const memoryGet: ToolDef = {
const parsed = MemoryIdInput.safeParse(args);
if (!parsed.success) return err(parsed.error.message);
// Project explicitly rather than `select()`-ing the raw row. The
// table carries `embedding` (384 floats) and `content_tsv` (the full
// lexeme index, which outgrows `content` itself on large memories) —
// both are Postgres retrieval internals that no MCP client can use,
// and together they were the majority of every response. Returning
// them also pushed large memories past the tool-output cap. This is
// the same 9-field shape memory.list and memory.search return.
const row = await db
.select()
.select({
id: memories.id,
scope: memories.scope,
projectId: memories.projectId,
content: memories.content,
tags: memories.tags,
version: memories.version,
lastEditedBy: memories.lastEditedBy,
createdAt: memories.createdAt,
updatedAt: memories.updatedAt,
// Needed for the authorization check below; stripped before the
// response so the payload matches list/search exactly.
userId: memories.userId,
})
.from(memories)
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
@@ -518,8 +525,8 @@ const memoryGet: ToolDef = {
// Authorize read: own row, OR project-scope row in an accessible
// project. Anything else looks "not found" to the caller.
const m = row[0];
if (m.userId !== ctx.userId) {
const { userId, ...m } = row[0];
if (userId !== ctx.userId) {
if (!m.projectId) return err("not found");
const access = await getProjectAccess(ctx.userId, ctx.groups, m.projectId);
if (access === null) return err("not found");
@@ -550,57 +557,9 @@ const memoryDelete: ToolDef = {
const parsed = MemoryDeleteInput.safeParse(args);
if (!parsed.success) return err(parsed.error.message);
// Look up the row first to authorize and capture its current version
// for the CAS. Shared-project writes need a per-project access check.
const target = await db
.select({
id: memories.id,
userId: memories.userId,
projectId: memories.projectId,
scope: memories.scope,
version: memories.version,
})
.from(memories)
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
const m = target[0];
if (!m) return err("not found");
if (m.userId !== ctx.userId) {
// Not the owner. User-scope memories can only be deleted by their
// owner; project-scope require rw access on the project.
if (m.scope === "user" || !m.projectId) return err("not found");
const allowed = await canWriteProject(ctx.userId, ctx.groups, m.projectId);
if (!allowed) return err("no write access to this project");
}
// Optimistic-lock CAS: pin to the caller-supplied version when given,
// else the version we just read in this handler. The 0-row response
// tells us a peer raced us.
const expectedVersion = parsed.data.version ?? m.version;
const updated = await db
.update(memories)
.set({ deletedAt: new Date(), lastEditedBy: ctx.userId })
.where(
and(
eq(memories.id, parsed.data.id),
eq(memories.version, expectedVersion),
isNull(memories.deletedAt),
),
)
.returning({ id: memories.id });
if (!updated[0]) return err(CONCURRENT_EDIT_ERROR);
await db.insert(auditLog).values({
userId: ctx.userId,
actor: "mcp",
action: "memory.delete",
entityType: "memory",
entityId: updated[0].id,
});
return ok({ id: updated[0].id, deleted: true }, `deleted memory ${updated[0].id}`);
const res = await softDeleteMemory(mcpActor(ctx), parsed.data);
if (!res.ok) return err(res.error);
return ok({ id: res.value.id, deleted: true }, `deleted memory ${res.value.id}`);
},
};
@@ -638,124 +597,51 @@ const memoryUpdate: ToolDef = {
const parsed = MemoryUpdateInput.safeParse(withDefaultProject(args, ctx));
if (!parsed.success) return err(parsed.error.message);
const existingRows = await db
.select({
id: memories.id,
content: memories.content,
scope: memories.scope,
projectId: memories.projectId,
projectKey: projects.key,
version: memories.version,
userId: memories.userId,
})
.from(memories)
.leftJoin(projects, eq(memories.projectId, projects.id))
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
const existing = existingRows[0];
if (!existing) return err("not found");
const res = await updateMemory(mcpActor(ctx), parsed.data, mcpProjectResolver(ctx));
if (!res.ok) return err(res.error);
return ok(res.value, `updated memory ${res.value.id}`);
},
};
// Authorize write.
if (existing.scope === "user") {
if (existing.userId !== ctx.userId) return err("not found");
} else if (existing.projectId) {
const allowed = await canWriteProject(ctx.userId, ctx.groups, existing.projectId);
if (!allowed) return err("no write access to this project");
}
const memoryPatch: ToolDef = {
name: "memory.patch",
description:
"Replace one exact snippet of a memory's content, leaving the rest untouched — the same mental model as editing a file. Use this INSTEAD of memory.update whenever you're making a small edit to a large memory: adding an entry under a heading, correcting a line, updating a status. memory.update requires you to resend the entire document, which risks silently dropping content you didn't mean to touch; memory.patch only needs the fragment you're changing. `old_string` must appear EXACTLY once — if it's missing or ambiguous the call fails and nothing is changed, so include enough surrounding context to make it unique. Pass an empty `new_string` to delete the matched text. Re-embeds automatically, preserves the memory's id, and accepts `version` for the same concurrent-edit protection as memory.update.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
old_string: {
type: "string",
description:
"The exact text to replace. Must occur exactly once in the memory's content — include surrounding lines if the fragment alone would be ambiguous.",
},
new_string: {
type: "string",
description:
"The replacement text. May be empty to delete the matched text (the memory itself may not be left empty).",
},
version: {
type: "integer",
minimum: 0,
description:
"Optimistic-locking token from memory.get / memory.list. When supplied, the patch is rejected if the row was edited by someone else since you read it.",
},
},
required: ["id", "old_string", "new_string"],
},
async handler(args, ctx) {
const parsed = MemoryPatchInput.safeParse(args);
if (!parsed.success) return err(parsed.error.message);
const update: Record<string, unknown> = {
updatedAt: new Date(),
lastEditedBy: ctx.userId,
version: existing.version + 1,
};
if (parsed.data.tags !== undefined) update.tags = parsed.data.tags;
if (parsed.data.content !== undefined && parsed.data.content !== existing.content) {
update.content = parsed.data.content;
update.embedding = await embedText(parsed.data.content);
}
const res = await patchMemory(mcpActor(ctx), parsed.data);
if (!res.ok) return err(res.error);
let scopeChanged = false;
let projectChanged = false;
let newProjectKey: string | null = existing.projectKey ?? null;
if (parsed.data.scope !== undefined) {
if (parsed.data.scope === "user") {
if (existing.scope !== "user") {
update.scope = "user";
scopeChanged = true;
}
if (existing.projectId !== null) {
update.projectId = null;
projectChanged = true;
newProjectKey = null;
}
} else {
// scope === 'project' — schema refine guarantees `project` is set.
const projectKey = parsed.data.project!;
const projectId = await resolveProjectId(ctx, projectKey);
if (!projectId) {
return err(`unknown project '${projectKey}'; call project.identify first`);
}
// Moving INTO a project requires write access there.
const allowedTarget = await canWriteProject(ctx.userId, ctx.groups, projectId);
if (!allowedTarget) {
return err(`no write access to project '${projectKey}'`);
}
if (existing.scope !== "project") {
update.scope = "project";
scopeChanged = true;
}
if (existing.projectId !== projectId) {
update.projectId = projectId;
projectChanged = true;
newProjectKey = projectKey;
}
}
}
const expectedVersion = parsed.data.version ?? existing.version;
const updated = await db
.update(memories)
.set(update)
.where(
and(
eq(memories.id, parsed.data.id),
eq(memories.version, expectedVersion),
),
)
.returning({
id: memories.id,
updatedAt: memories.updatedAt,
version: memories.version,
});
if (!updated[0]) return err(CONCURRENT_EDIT_ERROR);
const auditFields = Object.keys(update).filter(
(k) => k !== "updatedAt" && k !== "version" && k !== "lastEditedBy",
const { id, delta, contentLength } = res.value;
return ok(
res.value,
`patched memory ${id} (${delta >= 0 ? "+" : ""}${delta} chars, now ${contentLength})`,
);
const auditPayload: Record<string, unknown> = { fields: auditFields };
if (scopeChanged || projectChanged) {
auditPayload.scope = {
from: existing.scope,
to: update.scope ?? existing.scope,
};
auditPayload.projectKey = {
from: existing.projectKey ?? null,
to: newProjectKey,
};
}
await db.insert(auditLog).values({
userId: ctx.userId,
actor: "mcp",
action: "memory.update",
entityType: "memory",
entityId: updated[0]!.id,
payload: auditPayload,
});
return ok(updated[0]!, `updated memory ${updated[0]!.id}`);
},
};
@@ -1113,6 +999,7 @@ export const tools: ToolDef[] = [
projectIdentify,
memoryWrite,
memoryUpdate,
memoryPatch,
memoryList,
memoryGet,
memorySearch,
+72 -289
View File
@@ -2,37 +2,36 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { and, eq, inArray, isNull } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { memories, projects, auditLog } from "@/lib/db/schema";
import { embedText } from "@/lib/embedder";
import { projects } from "@/lib/db/schema";
import { resolveProjectId, upsertProject } from "@/lib/projects";
import {
MemoryWriteInput,
MemoryUpdateInput,
MemoryDeleteInput,
} from "@shared-memory/schemas";
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
import {
CONCURRENT_EDIT_ERROR,
canWriteProject,
getUserGroupNames,
readableProjectIds,
} from "@/lib/access";
createMemory,
softDeleteMemory,
updateMemory,
type Actor,
type Outcome,
type ProjectResolver,
} from "@/lib/memory-mutations";
/**
* Server Actions for memory CRUD from the Web UI. Mirrors the MCP tools
* but writes through the same DB layer, so updates and deletes here are
* indistinguishable from those made via Claude Code.
* Server Actions for memory CRUD from the Web UI.
*
* These are thin adapters: form parsing, then `lib/memory-mutations`,
* then revalidate/redirect. The authorize → mutate → re-embed → CAS →
* audit sequence lives in that shared module so this surface and the MCP
* tools cannot drift apart — they previously did, and the sharing rules
* ended up subtly different between them.
*
* `actor` is "web" in audit_log so we can tell the two paths apart later.
*
* Sharing: project-scope memories may live under projects shared with
* the user's groups. Reads include those projects; writes require the
* user to own the project or have an `rw` share. Cross-user concurrent
* edits use the `version` column for optimistic locking — if the stored
* version no longer matches what the form submitted, we surface
* `CONCURRENT_EDIT_ERROR` rather than clobber.
*/
async function requireUserId(): Promise<string> {
@@ -41,6 +40,52 @@ async function requireUserId(): Promise<string> {
return session.user.id;
}
/** Server Actions signal failure by throwing; the shared layer returns Outcome. */
function must<T>(outcome: Outcome<T>): T {
if (!outcome.ok) throw new Error(outcome.error);
return outcome.value;
}
async function webActor(): Promise<{ actor: Actor; resolveProject: ProjectResolver }> {
const userId = await requireUserId();
const groups = await getUserGroupNames(userId);
return {
actor: { userId, groups, via: "web" },
resolveProject: webProjectResolver(userId, groups),
};
}
/**
* Project resolution for Web UI writes. Unlike the MCP surface, an
* unknown key is CREATED rather than rejected — a person typing a project
* name into a form means to make one. Shared projects are matched only
* within the set the user can actually read, because `projects.key` is
* unique per user rather than globally: an unscoped key match could
* otherwise select someone else's project.
*
* Write access to whatever this returns is enforced centrally by the
* mutation layer, so it deliberately isn't re-checked here.
*/
function webProjectResolver(userId: string, groupNames: string[]): ProjectResolver {
return async (key: string) => {
const owned = await resolveProjectId(userId, key);
if (owned) return { ok: true, value: owned };
const readableIds = await readableProjectIds(userId, groupNames);
const shared =
readableIds.length > 0
? await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.key, key), inArray(projects.id, readableIds)))
.limit(1)
: [];
if (shared[0]) return { ok: true, value: shared[0].id };
return { ok: true, value: await upsertProject(userId, key) };
};
}
function parseTags(raw: FormDataEntryValue | null): string[] {
if (typeof raw !== "string") return [];
return raw
@@ -50,95 +95,26 @@ function parseTags(raw: FormDataEntryValue | null): string[] {
}
export async function createMemoryAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const { actor, resolveProject } = await webActor();
const payload = {
const parsed = MemoryWriteInput.safeParse({
content: String(formData.get("content") ?? "").trim(),
scope: (formData.get("scope") as "project" | "user") || "project",
project: (formData.get("project") as string | null)?.trim() || undefined,
tags: parseTags(formData.get("tags")),
};
const parsed = MemoryWriteInput.safeParse(payload);
});
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
let projectId: string | null = null;
if (parsed.data.scope === "project") {
if (!parsed.data.project) throw new Error("scope=project requires `project`");
// Same priority as memory.update's reclassification path: prefer an
// owned project; otherwise check for a shared one we have rw on;
// otherwise auto-upsert as owner.
const owned = await resolveProjectId(userId, parsed.data.project);
if (owned) {
projectId = owned;
} else {
// Restrict the by-key lookup to projects the user can actually
// read. Without this, a different user's project with the same
// key string could be selected (`projects.key` is unique per user,
// not globally), opening a cross-user write hazard.
const readableIds = await readableProjectIds(userId, groupNames);
const sharedRow =
readableIds.length > 0
? await db
.select({ id: projects.id })
.from(projects)
.where(
and(
eq(projects.key, parsed.data.project),
inArray(projects.id, readableIds),
),
)
.limit(1)
: [];
if (sharedRow[0]) {
const allowed = await canWriteProject(userId, groupNames, sharedRow[0].id);
if (!allowed) {
throw new Error(`no write access to project '${parsed.data.project}'`);
}
projectId = sharedRow[0].id;
} else {
projectId = await upsertProject(userId, parsed.data.project);
}
}
}
const embedding = await embedText(parsed.data.content);
const inserted = await db
.insert(memories)
.values({
userId,
projectId,
scope: parsed.data.scope,
content: parsed.data.content,
tags: parsed.data.tags ?? [],
embedding,
lastEditedBy: userId,
})
.returning({ id: memories.id });
await db.insert(auditLog).values({
userId,
actor: "web",
action: "memory.write",
entityType: "memory",
entityId: inserted[0]!.id,
payload: {
scope: parsed.data.scope,
projectKey: parsed.data.project ?? null,
tags: parsed.data.tags ?? [],
},
});
const created = must(await createMemory(actor, parsed.data, resolveProject));
revalidatePath("/memories");
redirect(`/memories/${inserted[0]!.id}`);
redirect(`/memories/${created.id}`);
}
export async function updateMemoryAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const { actor, resolveProject } = await webActor();
const id = String(formData.get("id") ?? "");
const rawScope = formData.get("scope");
@@ -164,153 +140,7 @@ export async function updateMemoryAction(formData: FormData) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
// Fetch the row regardless of ownership — we may be editing a shared
// memory. Authorization is enforced below against the project, not
// by `user_id`.
const existingRows = await db
.select({
id: memories.id,
content: memories.content,
scope: memories.scope,
projectId: memories.projectId,
projectKey: projects.key,
version: memories.version,
userId: memories.userId,
})
.from(memories)
.leftJoin(projects, eq(memories.projectId, projects.id))
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
const existing = existingRows[0];
if (!existing) throw new Error("not found");
// Authorize write. For user-scope memories, only the owner can edit.
// For project-scope memories, owner OR a group with rw access.
if (existing.scope === "user") {
if (existing.userId !== userId) throw new Error("not found");
} else if (existing.projectId) {
const allowed = await canWriteProject(userId, groupNames, existing.projectId);
if (!allowed) {
throw new Error("you don't have write access to this project");
}
}
const update: Record<string, unknown> = {
updatedAt: new Date(),
lastEditedBy: userId,
version: existing.version + 1,
};
if (parsed.data.tags !== undefined) update.tags = parsed.data.tags;
if (parsed.data.content !== undefined && parsed.data.content !== existing.content) {
update.content = parsed.data.content;
update.embedding = await embedText(parsed.data.content);
}
let scopeChanged = false;
let projectChanged = false;
let newProjectKey: string | null = existing.projectKey ?? null;
if (parsed.data.scope !== undefined) {
if (parsed.data.scope === "user") {
if (existing.scope !== "user") {
update.scope = "user";
scopeChanged = true;
}
if (existing.projectId !== null) {
update.projectId = null;
projectChanged = true;
newProjectKey = null;
}
} else {
// scope === 'project' — schema refine guarantees project is set.
// Moving INTO a project requires write access there. Owners get
// a fresh project upsert; non-owners must target an existing one
// they have rw on.
const projectKey = parsed.data.project!;
let projectId: string;
const existingId = await resolveProjectId(userId, projectKey);
if (existingId) {
projectId = existingId;
} else {
// Restrict the shared-project lookup to projects the user can
// actually read (`projects.key` is unique per user, not globally,
// so an unscoped key match could resolve another user's project).
const readableIds = await readableProjectIds(userId, groupNames);
const sharedRow =
readableIds.length > 0
? await db
.select({ id: projects.id })
.from(projects)
.where(
and(eq(projects.key, projectKey), inArray(projects.id, readableIds)),
)
.limit(1)
: [];
if (sharedRow[0]) {
const allowed = await canWriteProject(userId, groupNames, sharedRow[0].id);
if (!allowed) {
throw new Error(`no write access to project '${projectKey}'`);
}
projectId = sharedRow[0].id;
} else {
// Auto-upsert as owner — user becomes the project owner of a
// brand-new private project.
projectId = await upsertProject(userId, projectKey);
}
}
if (existing.scope !== "project") {
update.scope = "project";
scopeChanged = true;
}
if (existing.projectId !== projectId) {
update.projectId = projectId;
projectChanged = true;
newProjectKey = projectKey;
}
}
}
// Optimistic-locking guard. When `version` is supplied, the UPDATE
// matches on (id, version); a 0-row result means the caller's view
// is stale. When `version` is NOT supplied, we still match on the
// pre-fetched version to keep behaviour deterministic.
const expectedVersion = parsed.data.version ?? existing.version;
const updated = await db
.update(memories)
.set(update)
.where(
and(
eq(memories.id, parsed.data.id),
eq(memories.version, expectedVersion),
),
)
.returning({ id: memories.id });
if (!updated[0]) throw new Error(CONCURRENT_EDIT_ERROR);
const auditFields = Object.keys(update).filter(
(k) => k !== "updatedAt" && k !== "version" && k !== "lastEditedBy",
);
const auditPayload: Record<string, unknown> = { fields: auditFields };
if (scopeChanged || projectChanged) {
auditPayload.scope = {
from: existing.scope,
to: update.scope ?? existing.scope,
};
auditPayload.projectKey = {
from: existing.projectKey ?? null,
to: newProjectKey,
};
}
await db.insert(auditLog).values({
userId,
actor: "web",
action: "memory.update",
entityType: "memory",
entityId: parsed.data.id,
payload: auditPayload,
});
must(await updateMemory(actor, parsed.data, resolveProject));
revalidatePath(`/memories/${parsed.data.id}`);
revalidatePath("/memories");
@@ -318,8 +148,7 @@ export async function updateMemoryAction(formData: FormData) {
}
export async function deleteMemoryAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const { actor } = await webActor();
const id = String(formData.get("id") ?? "");
const rawVersion = formData.get("version");
const version =
@@ -332,53 +161,7 @@ export async function deleteMemoryAction(formData: FormData) {
});
if (!parsed.success) throw new Error(parsed.error.issues[0]!.message);
// Authorize delete: same rule as update — owner OR rw on the project.
const existing = await db
.select({
id: memories.id,
scope: memories.scope,
projectId: memories.projectId,
userId: memories.userId,
version: memories.version,
})
.from(memories)
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
const row = existing[0];
if (!row) throw new Error("not found");
if (row.scope === "user") {
if (row.userId !== userId) throw new Error("not found");
} else if (row.projectId) {
const allowed = await canWriteProject(userId, groupNames, row.projectId);
if (!allowed) throw new Error("you don't have write access to this project");
}
// CAS on version so a peer's concurrent edit can't be silently overwritten
// by this delete. Form may or may not supply version; fall back to the row
// we just read to keep behaviour deterministic.
const expectedVersion = parsed.data.version ?? row.version;
const updated = await db
.update(memories)
.set({ deletedAt: new Date(), lastEditedBy: userId })
.where(
and(
eq(memories.id, parsed.data.id),
eq(memories.version, expectedVersion),
isNull(memories.deletedAt),
),
)
.returning({ id: memories.id });
if (!updated[0]) throw new Error(CONCURRENT_EDIT_ERROR);
await db.insert(auditLog).values({
userId,
actor: "web",
action: "memory.delete",
entityType: "memory",
entityId: updated[0].id,
});
must(await softDeleteMemory(actor, parsed.data));
revalidatePath("/memories");
redirect("/memories");
+263
View File
@@ -0,0 +1,263 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
/**
* Tests for the shared mutation layer itself — the code both the MCP
* tools and the Web UI Server Actions now route through.
*
* Two things matter here:
* 1. The ProjectResolver seam really is the ONLY behavioural difference
* between the two surfaces.
* 2. The authorization rule is uniform across update / patch / delete.
* It previously wasn't: delete-over-MCP let a row's author bypass the
* project ACL.
*/
vi.mock("@/lib/embedder", () => ({
embedText: async (text: string) => {
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
return Array.from({ length: 384 }, (_, i) => ((h + i * 7919) % 1000) / 1000);
},
embedTexts: async (texts: string[]) => texts.map(() => Array(384).fill(0.1)),
embedderReady: async () => true,
EmbedderError: class extends Error {},
}));
const { db, pg } = await import("@/lib/db/client");
const { memories, projects, users, groups, userGroups, projectShares } = await import(
"@/lib/db/schema"
);
const { createMemory, updateMemory, patchMemory, softDeleteMemory } = await import(
"@/lib/memory-mutations"
);
const { and, eq } = await import("drizzle-orm");
type Actor = import("@/lib/memory-mutations").Actor;
type ProjectResolver = import("@/lib/memory-mutations").ProjectResolver;
const ISS = "http://test-mutations";
let actor: Actor;
let otherUserId: string;
let sharedProjectId: string;
let sharedGroupId: string;
/** Mirrors the MCP surface: unknown project keys are refused. */
const refusingResolver: ProjectResolver = async (key) => ({
ok: false,
error: `unknown project '${key}'; call project.identify first`,
});
/** Mirrors the Web UI surface: unknown project keys are created. */
function creatingResolver(userId: string): ProjectResolver {
return async (key) => {
const existing = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.key, key), eq(projects.userId, userId)))
.limit(1);
if (existing[0]) return { ok: true, value: existing[0].id };
const created = await db
.insert(projects)
.values({ userId, key, displayName: key })
.returning({ id: projects.id });
return { ok: true, value: created[0]!.id };
};
}
async function seedMemory(userId: string, projectId: string | null): Promise<string> {
const r = await db
.insert(memories)
.values({
userId,
projectId,
scope: projectId ? "project" : "user",
content: "line one\nline two\n",
tags: [],
embedding: Array(384).fill(0.5),
lastEditedBy: userId,
})
.returning({ id: memories.id });
return r[0]!.id;
}
async function setShareAccess(access: "ro" | "rw") {
await db
.insert(projectShares)
.values({ projectId: sharedProjectId, groupId: sharedGroupId, access })
.onConflictDoUpdate({
target: [projectShares.projectId, projectShares.groupId],
set: { access },
});
}
beforeAll(async () => {
const me = await db
.insert(users)
.values({ oidcSub: "mut-me", oidcIss: ISS })
.onConflictDoNothing()
.returning({ id: users.id });
const myId =
me[0]?.id ??
(
await db.select({ id: users.id }).from(users).where(eq(users.oidcSub, "mut-me"))
)[0]!.id;
const other = await db
.insert(users)
.values({ oidcSub: "mut-other", oidcIss: ISS })
.onConflictDoNothing()
.returning({ id: users.id });
otherUserId =
other[0]?.id ??
(
await db.select({ id: users.id }).from(users).where(eq(users.oidcSub, "mut-other"))
)[0]!.id;
const p = await db
.insert(projects)
.values({ userId: otherUserId, key: "mut-shared", displayName: "Shared" })
.onConflictDoNothing()
.returning({ id: projects.id });
sharedProjectId =
p[0]?.id ??
(
await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.key, "mut-shared"))
)[0]!.id;
const g = await db
.insert(groups)
.values({ oidcIss: ISS, name: "mut-team" })
.onConflictDoNothing()
.returning({ id: groups.id });
sharedGroupId =
g[0]?.id ??
(
await db.select({ id: groups.id }).from(groups).where(eq(groups.name, "mut-team"))
)[0]!.id;
await db
.insert(userGroups)
.values({ userId: myId, groupId: sharedGroupId })
.onConflictDoNothing();
actor = { userId: myId, groups: ["mut-team"], via: "mcp" };
});
beforeEach(async () => {
await db.delete(memories);
await setShareAccess("rw");
});
afterAll(async () => {
await db.delete(memories);
await pg.end();
});
describe("the ProjectResolver seam", () => {
test("a refusing resolver rejects an unknown project without creating one", async () => {
const res = await createMemory(
actor,
{ content: "x", scope: "project", project: "brand-new-key", tags: [] },
refusingResolver,
);
expect(res.ok).toBe(false);
const rows = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.key, "brand-new-key"));
expect(rows).toHaveLength(0);
});
test("a creating resolver makes the project and writes into it", async () => {
const res = await createMemory(
actor,
{ content: "x", scope: "project", project: "made-on-demand", tags: [] },
creatingResolver(actor.userId),
);
expect(res.ok).toBe(true);
const rows = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.key, "made-on-demand"));
expect(rows).toHaveLength(1);
});
});
describe("authorization is uniform across mutations", () => {
// Each of these seeds a memory the actor AUTHORED, then downgrades the
// share to read-only. Authoring must not survive as a write privilege.
test("update is denied on a read-only share", async () => {
const id = await seedMemory(actor.userId, sharedProjectId);
await setShareAccess("ro");
const res = await updateMemory(actor, { id, content: "edited" }, refusingResolver);
expect(res.ok).toBe(false);
});
test("patch is denied on a read-only share", async () => {
const id = await seedMemory(actor.userId, sharedProjectId);
await setShareAccess("ro");
const res = await patchMemory(actor, {
id,
old_string: "line one",
new_string: "line uno",
});
expect(res.ok).toBe(false);
});
test("delete is denied on a read-only share", async () => {
const id = await seedMemory(actor.userId, sharedProjectId);
await setShareAccess("ro");
const res = await softDeleteMemory(actor, { id });
expect(res.ok).toBe(false);
});
test("all three are allowed again once the share is read-write", async () => {
const id = await seedMemory(actor.userId, sharedProjectId);
expect((await updateMemory(actor, { id, content: "a\nb\n" }, refusingResolver)).ok).toBe(
true,
);
expect((await patchMemory(actor, { id, old_string: "a", new_string: "c" })).ok).toBe(
true,
);
expect((await softDeleteMemory(actor, { id })).ok).toBe(true);
});
test("another user's user-scope memory is invisible to all three", async () => {
const id = await seedMemory(otherUserId, null);
expect((await updateMemory(actor, { id, content: "x" }, refusingResolver)).ok).toBe(
false,
);
expect(
(await patchMemory(actor, { id, old_string: "line one", new_string: "y" })).ok,
).toBe(false);
expect((await softDeleteMemory(actor, { id })).ok).toBe(false);
});
});
describe("audit trail records the originating surface", () => {
test("via: 'web' and via: 'mcp' are both preserved", async () => {
const webRes = await createMemory(
{ ...actor, via: "web" },
{ content: "from the web", scope: "user", tags: [] },
refusingResolver,
);
expect(webRes.ok).toBe(true);
const rows = await pg<{ actor: string }[]>`
SELECT actor FROM audit_log WHERE action = 'memory.write' ORDER BY created_at DESC LIMIT 1
`;
expect(rows[0]!.actor).toBe("web");
});
});
+357
View File
@@ -0,0 +1,357 @@
import { and, eq, isNull } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { memories, projects, auditLog } from "@/lib/db/schema";
import { embedText } from "@/lib/embedder";
import { applyPatch } from "@/lib/memory-patch";
import { CONCURRENT_EDIT_ERROR, canWriteProject } from "@/lib/access";
import type {
MemoryDeleteInput,
MemoryPatchInput,
MemoryUpdateInput,
MemoryWriteInput,
} from "@shared-memory/schemas";
/**
* The single write path for memories.
*
* Both surfaces — the MCP tools and the Web UI Server Actions — used to
* reimplement authorize → mutate → re-embed → CAS → audit independently.
* They drifted: `memory.delete` over MCP skipped the project ACL whenever
* the caller happened to author the row, which `memory.update` and the
* whole Web UI did not. Consolidating here is what keeps those rules in
* one place, so a change to the sharing model can't be half-applied.
*
* Callers keep their own presentation concerns: MCP maps Outcome to a
* ToolResult, the Web UI throws and then revalidates/redirects.
*/
export interface Actor {
userId: string;
/** Group names, for project-share authorization. */
groups: string[];
/** Recorded as audit_log.actor so the two surfaces stay distinguishable. */
via: "web" | "mcp";
}
export type Outcome<T> = { ok: true; value: T } | { ok: false; error: string };
const fail = (error: string): Outcome<never> => ({ ok: false, error });
const succeed = <T>(value: T): Outcome<T> => ({ ok: true, value });
/**
* Resolves a project key to an id for a write. Injected because this is
* the one place the two surfaces genuinely, deliberately differ: MCP
* refuses unknown projects (the caller is expected to run project.identify
* first), while the Web UI creates one owned by the user. Everything else
* about a write is identical.
*/
export type ProjectResolver = (key: string) => Promise<Outcome<string>>;
interface WriteTarget {
scope: "project" | "user";
projectId: string | null;
userId: string;
}
/**
* The authorization rule for every mutating operation:
* - user-scope → only the owner may write (anything else reads as 404)
* - project-scope → owner of the project, or a group with `rw`
*
* Authoring a row grants nothing on its own. A memory you wrote while a
* share was `rw` becomes read-only to you when an owner downgrades that
* share to `ro` — the project ACL is the authority, not the byline.
*/
async function authorizeWrite(actor: Actor, row: WriteTarget): Promise<Outcome<null>> {
if (row.scope === "user") {
return row.userId === actor.userId ? succeed(null) : fail("not found");
}
if (row.projectId) {
const allowed = await canWriteProject(actor.userId, actor.groups, row.projectId);
if (!allowed) return fail("no write access to this project");
}
return succeed(null);
}
export async function createMemory(
actor: Actor,
input: MemoryWriteInput,
resolveProject: ProjectResolver,
): Promise<Outcome<{ id: string; createdAt: Date }>> {
let projectId: string | null = null;
const projectKey = input.scope === "project" ? input.project : undefined;
if (input.scope === "project") {
if (!projectKey) return fail("scope=project requires `project`");
const resolved = await resolveProject(projectKey);
if (!resolved.ok) return resolved;
projectId = resolved.value;
const allowed = await canWriteProject(actor.userId, actor.groups, projectId);
if (!allowed) return fail(`no write access to project '${projectKey}'`);
}
// Embed inline so the new memory is searchable immediately. Slower
// writes (~50–150 ms) are an acceptable price for that guarantee.
const embedding = await embedText(input.content);
const inserted = await db
.insert(memories)
.values({
userId: actor.userId,
projectId,
scope: input.scope,
content: input.content,
tags: input.tags ?? [],
embedding,
lastEditedBy: actor.userId,
})
.returning({ id: memories.id, createdAt: memories.createdAt });
const row = inserted[0]!;
await db.insert(auditLog).values({
userId: actor.userId,
actor: actor.via,
action: "memory.write",
entityType: "memory",
entityId: row.id,
payload: {
scope: input.scope,
projectKey: projectKey ?? null,
tags: input.tags ?? [],
},
});
return succeed(row);
}
export interface MutatedMemory {
id: string;
updatedAt: Date;
version: number;
}
export async function updateMemory(
actor: Actor,
input: MemoryUpdateInput,
resolveProject: ProjectResolver,
): Promise<Outcome<MutatedMemory>> {
const existingRows = await db
.select({
id: memories.id,
content: memories.content,
scope: memories.scope,
projectId: memories.projectId,
projectKey: projects.key,
version: memories.version,
userId: memories.userId,
})
.from(memories)
.leftJoin(projects, eq(memories.projectId, projects.id))
.where(and(eq(memories.id, input.id), isNull(memories.deletedAt)))
.limit(1);
const existing = existingRows[0];
if (!existing) return fail("not found");
const authorized = await authorizeWrite(actor, existing);
if (!authorized.ok) return authorized;
const update: Record<string, unknown> = {
updatedAt: new Date(),
lastEditedBy: actor.userId,
version: existing.version + 1,
};
if (input.tags !== undefined) update.tags = input.tags;
if (input.content !== undefined && input.content !== existing.content) {
update.content = input.content;
update.embedding = await embedText(input.content);
}
let scopeChanged = false;
let projectChanged = false;
let newProjectKey: string | null = existing.projectKey ?? null;
if (input.scope !== undefined) {
if (input.scope === "user") {
if (existing.scope !== "user") {
update.scope = "user";
scopeChanged = true;
}
if (existing.projectId !== null) {
update.projectId = null;
projectChanged = true;
newProjectKey = null;
}
} else {
// scope === 'project' — the schema refine guarantees `project` is set.
// Moving INTO a project requires write access there.
const projectKey = input.project!;
const resolved = await resolveProject(projectKey);
if (!resolved.ok) return resolved;
const targetId = resolved.value;
const allowed = await canWriteProject(actor.userId, actor.groups, targetId);
if (!allowed) return fail(`no write access to project '${projectKey}'`);
if (existing.scope !== "project") {
update.scope = "project";
scopeChanged = true;
}
if (existing.projectId !== targetId) {
update.projectId = targetId;
projectChanged = true;
newProjectKey = projectKey;
}
}
}
const updated = await casUpdate(input.id, update, input.version ?? existing.version);
if (!updated) return fail(CONCURRENT_EDIT_ERROR);
const auditFields = Object.keys(update).filter(
(k) => k !== "updatedAt" && k !== "version" && k !== "lastEditedBy",
);
const auditPayload: Record<string, unknown> = { fields: auditFields };
if (scopeChanged || projectChanged) {
auditPayload.scope = { from: existing.scope, to: update.scope ?? existing.scope };
auditPayload.projectKey = { from: existing.projectKey ?? null, to: newProjectKey };
}
await db.insert(auditLog).values({
userId: actor.userId,
actor: actor.via,
action: "memory.update",
entityType: "memory",
entityId: updated.id,
payload: auditPayload,
});
return succeed(updated);
}
export async function patchMemory(
actor: Actor,
input: MemoryPatchInput,
): Promise<Outcome<MutatedMemory & { contentLength: number; delta: number }>> {
const existingRows = await db
.select({
content: memories.content,
scope: memories.scope,
projectId: memories.projectId,
version: memories.version,
userId: memories.userId,
})
.from(memories)
.where(and(eq(memories.id, input.id), isNull(memories.deletedAt)))
.limit(1);
const existing = existingRows[0];
if (!existing) return fail("not found");
const authorized = await authorizeWrite(actor, existing);
if (!authorized.ok) return authorized;
const patch = applyPatch(existing.content, input.old_string, input.new_string);
if (!patch.ok) return fail(patch.error);
const updated = await casUpdate(
input.id,
{
content: patch.content,
embedding: await embedText(patch.content),
updatedAt: new Date(),
lastEditedBy: actor.userId,
version: existing.version + 1,
},
input.version ?? existing.version,
);
if (!updated) return fail(CONCURRENT_EDIT_ERROR);
await db.insert(auditLog).values({
userId: actor.userId,
actor: actor.via,
action: "memory.patch",
entityType: "memory",
entityId: updated.id,
payload: {
fields: ["content"],
patch: {
offset: existing.content.indexOf(input.old_string),
removed: input.old_string.length,
added: input.new_string.length,
},
},
});
return succeed({
...updated,
contentLength: patch.content.length,
delta: patch.content.length - existing.content.length,
});
}
export async function softDeleteMemory(
actor: Actor,
input: MemoryDeleteInput,
): Promise<Outcome<{ id: string }>> {
const rows = await db
.select({
id: memories.id,
userId: memories.userId,
projectId: memories.projectId,
scope: memories.scope,
version: memories.version,
})
.from(memories)
.where(and(eq(memories.id, input.id), isNull(memories.deletedAt)))
.limit(1);
const existing = rows[0];
if (!existing) return fail("not found");
const authorized = await authorizeWrite(actor, existing);
if (!authorized.ok) return authorized;
const updated = await db
.update(memories)
.set({ deletedAt: new Date(), lastEditedBy: actor.userId })
.where(
and(
eq(memories.id, input.id),
eq(memories.version, input.version ?? existing.version),
isNull(memories.deletedAt),
),
)
.returning({ id: memories.id });
if (!updated[0]) return fail(CONCURRENT_EDIT_ERROR);
await db.insert(auditLog).values({
userId: actor.userId,
actor: actor.via,
action: "memory.delete",
entityType: "memory",
entityId: updated[0].id,
});
return succeed(updated[0]);
}
/**
* Compare-and-set on `version`. A zero-row result means a peer edited the
* row between our read and this write. Callers that omit an explicit
* version pass the one they just read, which still closes the read-
* modify-write window inside a single handler.
*/
async function casUpdate(
id: string,
update: Record<string, unknown>,
expectedVersion: number,
): Promise<MutatedMemory | null> {
const rows = await db
.update(memories)
.set(update)
.where(and(eq(memories.id, id), eq(memories.version, expectedVersion)))
.returning({
id: memories.id,
updatedAt: memories.updatedAt,
version: memories.version,
});
return rows[0] ?? null;
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import { applyPatch } from "@/lib/memory-patch";
describe("applyPatch", () => {
test("replaces an old_string that occurs exactly once", () => {
const result = applyPatch("alpha beta gamma", "beta", "BETA");
expect(result.ok).toBe(true);
if (result.ok) expect(result.content).toBe("alpha BETA gamma");
});
test("refuses when old_string is absent, rather than silently doing nothing", () => {
const result = applyPatch("alpha beta gamma", "delta", "DELTA");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/not found/i);
});
test("refuses when old_string is ambiguous, and reports the match count", () => {
const result = applyPatch("x marks the spot, x marks it twice", "x", "y");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/2/);
expect(result.error).toMatch(/match/i);
}
});
test("treats old_string literally, not as a regular expression", () => {
// A naive RegExp implementation would match "axb" here.
const result = applyPatch("axb and a.b", "a.b", "REPLACED");
expect(result.ok).toBe(true);
if (result.ok) expect(result.content).toBe("axb and REPLACED");
});
test("replaces a multi-line old_string, preserving surrounding text", () => {
const content = "## HEADING\n- one\n- two\n\n## OTHER\n";
const result = applyPatch(content, "## HEADING\n- one", "## HEADING\n- zero\n- one");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.content).toBe("## HEADING\n- zero\n- one\n- two\n\n## OTHER\n");
}
});
test("rejects a patch whose result would exceed the 64,000-char content limit", () => {
// The anchor must be unique, or the ambiguity check fires first and
// this stops testing the length limit at all.
const content = "A".repeat(63_950) + "ANCHOR";
const result = applyPatch(content, "ANCHOR", "B".repeat(100));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/64,?000|limit/i);
});
test("rejects a no-op patch where new_string equals old_string", () => {
const result = applyPatch("alpha beta", "beta", "beta");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/identical|no-op|unchanged/i);
});
test("allows a patch that deletes text by replacing with an empty string", () => {
const result = applyPatch("keep this, drop this", ", drop this", "");
expect(result.ok).toBe(true);
if (result.ok) expect(result.content).toBe("keep this");
});
test("rejects a patch that would empty the memory entirely", () => {
const result = applyPatch("all of it", "all of it", "");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/empty/i);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { MEMORY_CONTENT_MAX } from "@shared-memory/schemas";
/**
* Pure string-level semantics for `memory.patch`.
*
* Kept free of any DB or auth dependency so both the MCP tool handler and
* the Web UI can share it, and so the refuse-rather-than-clobber rules
* below are directly testable.
*
* The contract mirrors the file-editing primitive coding agents already
* use: an `old_string` that is absent or ambiguous is an ERROR, never a
* silent no-op and never an arbitrary pick. That refusal is the property
* that makes the operation safe to hand to an agent editing a shared
* document it cannot afford to corrupt.
*/
export type PatchOutcome =
| { ok: true; content: string }
| { ok: false; error: string };
function countOccurrences(haystack: string, needle: string): number {
let count = 0;
let from = 0;
for (;;) {
const at = haystack.indexOf(needle, from);
if (at === -1) return count;
count += 1;
// Advance past this match so overlapping matches aren't double-counted.
from = at + needle.length;
}
}
export function applyPatch(
content: string,
oldString: string,
newString: string,
): PatchOutcome {
if (oldString === newString) {
return {
ok: false,
error: "old_string and new_string are identical; the patch would change nothing",
};
}
const first = content.indexOf(oldString);
if (first === -1) {
return {
ok: false,
error:
"old_string not found in the memory content; nothing was changed. Fetch the memory with memory.get and copy the exact text you mean to replace.",
};
}
// Only pay for a full count once we know there's more than one match.
if (content.indexOf(oldString, first + oldString.length) !== -1) {
const count = countOccurrences(content, oldString);
return {
ok: false,
error: `old_string matches ${count} times; it must match exactly once. Nothing was changed — include more surrounding context to identify the one you mean.`,
};
}
const patched =
content.slice(0, first) + newString + content.slice(first + oldString.length);
if (patched.length === 0) {
return { ok: false, error: "the patch would leave the memory empty" };
}
if (patched.length > MEMORY_CONTENT_MAX) {
return {
ok: false,
error: `the patched content would be ${patched.length.toLocaleString("en-US")} characters, over the ${MEMORY_CONTENT_MAX.toLocaleString("en-US")}-character limit`,
};
}
return { ok: true, content: patched };
}
+7 -3
View File
@@ -7,7 +7,9 @@
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start --port 3000",
"lint": "next lint",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx ./scripts/migrate.ts",
@@ -26,16 +28,18 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.6",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@tailwindcss/postcss": "^4.0.0",
"drizzle-kit": "^0.30.1",
"esbuild": "^0.24.2",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"vitest": "^2"
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
export default {
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283 64" width="283" height="64" role="img" aria-label="shared-memory">
<title>shared-memory</title>
<!--
Horizontal lockup for headers, docs, and the OAuth consent screen, where a
bare 64px glyph is too little and the full app header is too much.
currentColor, so it inherits the surrounding text colour. That does NOT
survive being referenced as <img src>, which resolves currentColor to
black - use docs/assets/lockup.svg for anything outside the app.
`textLength` is not decoration: the wordmark is set in whatever monospace
the viewer has, and without a locked advance width the text overruns the
viewBox on wider fonts.
-->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="7">
<path d="M12 16C24 16 25 32 30 32" opacity=".7"/>
<path d="M12 32H30"/>
<path d="M12 48C24 48 25 32 30 32" opacity=".7"/>
</g>
<circle cx="46" cy="32" r="8.5" fill="currentColor"/>
<text x="72" y="41" fill="currentColor" textLength="203" lengthAdjust="spacingAndGlyphs"
font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
font-size="26" letter-spacing="-0.8">shared<tspan opacity=".45">-</tspan>memory</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+13 -5
View File
@@ -3,12 +3,20 @@
<!--
Transparent, currentColor variant of the mark for in-app use - inherits
the surrounding text color so it works on any surface. The tile version
used as the favicon lives at app/icon.svg.
used as the favicon lives at app/icon.svg, and the horizontal lockup at
public/lockup.svg.
Three retrieval signals converging on a single memory. Proportions are
set by the 16px case, which is the one that breaks: the strokes stop at
x=30 so the gap to the node survives rasterisation, the outer pair sits
at .7 rather than .45 so it doesn't drop out, and the node is r=8.5.
The earlier proportions rendered as an indeterminate smear in a browser
tab.
-->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="7">
<path d="M13 15C25 15 27 32 37 32" opacity=".45"/>
<path d="M13 32H37"/>
<path d="M13 49C25 49 27 32 37 32" opacity=".7"/>
<path d="M12 16C24 16 25 32 30 32" opacity=".7"/>
<path d="M12 32H30"/>
<path d="M12 48C24 48 25 32 30 32" opacity=".7"/>
</g>
<circle cx="43" cy="32" r="8" fill="currentColor"/>
<circle cx="46" cy="32" r="8.5" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 648 B

After

Width:  |  Height:  |  Size: 1.1 KiB

+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./", import.meta.url)),
},
},
test: {
environment: "node",
include: ["lib/**/*.test.ts", "app/**/*.test.ts"],
setupFiles: ["./vitest.setup.ts"],
// Integration tests share one Postgres database; running files in
// parallel would let them clobber each other's rows.
fileParallelism: false,
},
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Test environment. `lib/env.ts` validates a full production config at
* import time, so integration tests that touch the DB need these set
* before any module under test is loaded.
*
* Only DATABASE_URL points at anything real — a throwaway Postgres with
* pgvector. The OIDC/secret values exist purely to satisfy validation;
* tests construct a UserContext directly rather than going through auth.
*/
// NODE_ENV is set to "test" by vitest itself.
//
// DATABASE_URL points at a throwaway pgvector instance. The default assumes
// the published port is reachable on localhost; when the test runner is
// itself inside a container, export DATABASE_URL with the database
// container's address instead. See README → Running the tests.
process.env.DATABASE_URL ??= "postgres://test:test@127.0.0.1:55432/shared_memory_test";
process.env.PUBLIC_URL ??= "http://localhost:3000";
process.env.OIDC_ISSUER ??= "http://localhost:9000/application/o/test/";
process.env.OIDC_CLIENT_ID_WEB ??= "test-web";
process.env.OIDC_CLIENT_SECRET_WEB ??= "test-web-secret";
process.env.OIDC_CLIENT_ID_MCP ??= "test-mcp";
process.env.OIDC_AUDIENCE ??= "test-audience";
process.env.EMBEDDER_URL ??= "http://localhost:8080";
process.env.NEXTAUTH_SECRET ??= "test-nextauth-secret-at-least-32-chars-long";
process.env.CLI_TOKEN_SECRET ??= "test-cli-token-secret-at-least-32-chars-long";
+1
View File
@@ -117,6 +117,7 @@ services:
# added to .env but not listed here never reaches the container.
OIDC_ISSUER_MCP: ${OIDC_ISSUER_MCP:-}
OIDC_AUDIENCE_SCOPE: ${OIDC_AUDIENCE_SCOPE:-}
OIDC_OFFLINE_ACCESS: ${OIDC_OFFLINE_ACCESS:-}
PLUGIN_MARKETPLACE_URL: ${PLUGIN_MARKETPLACE_URL:-}
PLUGIN_MARKETPLACE_NAME: ${PLUGIN_MARKETPLACE_NAME:-shared-memory}
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283 64" width="283" height="64" role="img" aria-label="shared-memory">
<title>shared-memory</title>
<!--
Horizontal lockup for headers, docs, and the OAuth consent screen, where a
bare 64px glyph is too little and the full app header is too much.
Fixed-colour twin of apps/web/public/lockup.svg, for README and any other
context that references the file as <img src> - there currentColor resolves
to black and vanishes on a dark page. #0092fd holds on both git-host themes.
`textLength` is not decoration: the wordmark is set in whatever monospace
the viewer has, and without a locked advance width the text overruns the
viewBox on wider fonts.
-->
<g fill="none" stroke="#0092fd" stroke-linecap="round" stroke-width="7">
<path d="M12 16C24 16 25 32 30 32" opacity=".7"/>
<path d="M12 32H30"/>
<path d="M12 48C24 48 25 32 30 32" opacity=".7"/>
</g>
<circle cx="46" cy="32" r="8.5" fill="#0092fd"/>
<text x="72" y="41" fill="#0092fd" textLength="203" lengthAdjust="spacingAndGlyphs"
font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
font-size="26" letter-spacing="-0.8">shared<tspan opacity=".45">-</tspan>memory</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+159
View File
@@ -0,0 +1,159 @@
# Logo concepts
**Status:** exploration, nothing adopted
**Drafted:** 2026-08-12
Ten directions for the `shared-memory` mark. Eight are fresh concepts, two are
refinements of the mark currently shipping in `apps/web/public/logo.svg` and
`apps/web/app/icon.svg`.
Every concept is drafted as a real SVG in `docs/logo-concepts/`, authored the
same way the shipped mark is: a 64×64 viewBox, `currentColor` so it inherits the
surrounding text colour, round caps, and opacity rather than a second hue for
depth. That means any of them can be dropped straight into the app, and the tile
(favicon) variant is a mechanical derivation — wrap it in the rounded `#11151b`
rect and substitute the accent blues.
## How these were judged
A mark for this project has to survive three places: the browser tab at 16px,
the app header at 32–64px, and a README at whatever size GitHub renders. Small
size is the brutal filter — it is where most of the concepts below die, and it
is where the *currently shipped* mark is weakest, which is the single most
useful finding here.
Each was rendered at 120px, 24px and 16px and looked at, not just reasoned
about. The failures noted below are observed, not predicted.
---
## The concepts
### 01 · Venn core
Two outlined circles; the lens where they overlap is filled solid. The overlap
*is* the shared memory.
The most literal, most immediately-readable statement of "shared" in the set —
nobody needs it explained. That is also the problem: Venn diagrams are visual
public domain, and this would not be ownable. At 16px the two outlines fuse into
a single blob with a bright centre, which is legible but says nothing.
### 02 · Handoff
Two brackets facing each other around a single node — one session passing a
memory to the next.
Calm and symmetric, and the metaphor is exactly right for what the MCP does
across sessions. It reads as `( • )`, which unfortunately is also how a hundred
focus / aperture / eye icons read. Holds together at 16px better than 01.
### 03 · Braid
Two threads woven through each other with real over/under crossings, neither
subordinate to the other.
The most distinctive mark here by a wide margin, and the only one that looks
*crafted* rather than assembled from primitives. It also has the cleanest
meaning: two agents, one durable strand, and the weave is what makes it hold.
**It fails as a favicon.** At 16px the crossings collapse and it becomes an
unreadable smudge. This is not fixable by thickening strokes — the weave needs
the crossings to be visible, and they need room. Use it as a hero graphic or a
wordmark companion, not as the app icon.
### 04 · Return arc
An open ring with a solid node inside it: write it down, leave, come back to it.
The gap in the ring is where the next session enters.
The best small-size performer of the fresh concepts — completely legible at
16px, and the interior node keeps it from being just a shape. The risk is
literal: at small sizes it reads as a **©**. Worth checking against that
association before committing.
### 05 · Anchor
One fixed point that three separate agents reference from wherever they are.
Clean, balanced, and conceptually accurate — the memory is the fixed thing and
the clients are transient. But a three-spoke hub at this weight lands very close
to the Mercedes-Benz mark, and adjacent to the peace symbol. Both would come up
in any trademark review. Included for completeness; hard to recommend.
### 06 · Spine
A vertical spine with entries branching off it, the active one reaching furthest
to a node. An index, a ledger.
The only rectilinear mark in the set, so it stands apart from the others
immediately. It is also the most honest about what the product actually is —
a queryable index, not an abstraction. The risk is that it reads as a bar chart,
which pushes the association toward analytics rather than memory.
### 07 · Knot
A single continuous thread with no start and no end.
Renders flawlessly at every size tested — genuinely the most legible mark here.
That is its only advantage. It is the infinity symbol, which means near-zero
ownability, and it says "endless", not "shared". Kept in the set as the
legibility baseline the others are measured against.
### 08 · Triad
Three nodes joined by edges, one lit brighter than the others: the smallest
possible shared graph, with one participant currently active.
The strongest *conceptual* fit after the braid — it shows plurality, connection,
and activity in one figure, and the lit node gives it somewhere to go for a
loading or live state. Degrades gracefully: at 16px the edges thin out but three
dots in a triangle still reads. The concern is category crowding — triangular
node graphs are the default visual language of network and blockchain branding.
### 09 · Current mark, rebalanced
The shipped mark with three targeted changes, all aimed at 16px:
| | Shipped | Rebalanced |
|---|---|---|
| Converging strokes end at | `x=35` (2.5px from the node) | `x=30` (4px clear) |
| Outer stroke opacity | `.45` / `.55` | `.7` |
| Node radius | `7.5` | `8.5` |
At 16px the shipped mark loses its outer strokes to rasterisation and the
remaining gap between stroke and node fills in, so it renders as an
indeterminate horizontal smear. The rebalanced version resolves as an arrow
meeting a node at the same size.
This is the lowest-risk option on the table: it is not a rebrand, it keeps
whatever recognition the current mark has already earned, and it fixes a real
defect. It is worth doing *regardless* of what happens with the eight concepts
above.
### 10 · Lockup
Not an alternative mark — the missing asset. The rebalanced mark set against the
wordmark in the project's monospace stack, on a shared baseline, with the hyphen
dropped to 45% so `shared` and `memory` read as two things joined.
For README headers, the docs site, and the OAuth consent screen, where a bare
64px glyph is too little and the full app header is too much.
---
## Recommendation
Three separate decisions, not one:
1. **Ship 09 now.** The small-size failure of the current mark is a real defect
and the fix is a five-line diff. It does not depend on choosing a new
direction.
2. **Adopt 10** for the README and docs headers, using whichever mark wins.
3. **If a genuine replacement is wanted**, the shortlist is **08 Triad** (best
concept-to-legibility ratio) and **04 Return arc** (best legibility, pending
the © check). **03 Braid** is the most beautiful and should be used
*somewhere* — as a hero graphic — but cannot be the icon.
Concepts 01, 05 and 07 are documented here so the reasoning against them is on
record, not because they are live candidates.
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Venn core">
<title>Venn core</title>
<!-- Two participants; the filled lens is the memory they hold in common. -->
<g fill="none" stroke="currentColor" stroke-width="5" opacity=".5">
<circle cx="24" cy="32" r="15"/>
<circle cx="40" cy="32" r="15"/>
</g>
<path d="M32 19.31A15 15 0 0 1 32 44.69 15 15 0 0 1 32 19.31Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Handoff">
<title>Handoff</title>
<!-- Two sessions facing each other, passing one memory between them. -->
<g fill="none" stroke="currentColor" stroke-width="6.5" stroke-linecap="round" opacity=".65">
<path d="M26 16Q10 32 26 48"/>
<path d="M38 16Q54 32 38 48"/>
</g>
<circle cx="32" cy="32" r="7" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 451 B

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Braid">
<title>Braid</title>
<!--
Two independent threads woven into one strand - neither is subordinate,
and the weave is what holds. Crossings computed at (20,32) and (44,32);
the mask punches the first one so strand A reads as passing over it.
-->
<mask id="sm-braid-gap">
<rect width="64" height="64" fill="#fff"/>
<circle cx="20" cy="32" r="6.25" fill="#000"/>
</mask>
<g fill="none" stroke="currentColor" stroke-width="6.5" stroke-linecap="round">
<path d="M8 20C20 20 20 44 32 44 44 44 44 20 56 20" opacity=".75"/>
<path d="M8 44C20 44 20 20 32 20 44 20 44 44 56 44" mask="url(#sm-braid-gap)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 762 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Return arc">
<title>Return arc</title>
<!-- Write, leave, come back to it. The gap is where the next session enters. -->
<path d="M42.3 46.7A18 18 0 1 1 42.3 17.3" fill="none" stroke="currentColor"
stroke-width="6.5" stroke-linecap="round" opacity=".7"/>
<circle cx="30" cy="32" r="7.5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 438 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Anchor">
<title>Anchor</title>
<!-- One fixed point that separate agents reference from wherever they are. -->
<g fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round" opacity=".6">
<path d="M32 8V20"/>
<path d="M11.2 44L21.6 38"/>
<path d="M52.8 44L42.4 38"/>
</g>
<circle cx="32" cy="32" r="8.5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 475 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Spine">
<title>Spine</title>
<!-- An index: many entries on one shared spine, the active one reaching furthest. -->
<g fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round">
<path d="M16 13V51"/>
<path d="M16 21H34" opacity=".5"/>
<path d="M16 32H42"/>
<path d="M16 43H34" opacity=".5"/>
</g>
<circle cx="52" cy="32" r="5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Knot">
<title>Knot</title>
<!-- One continuous thread with no start and no end; it persists past any session. -->
<path d="M32 32C38 20 50 20 50 32 50 44 38 44 32 32 26 20 14 20 14 32 14 44 26 44 32 32Z"
fill="none" stroke="currentColor" stroke-width="6.5" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Triad">
<title>Triad</title>
<!-- The smallest possible shared graph: three peers, one currently speaking. -->
<path d="M32 16L17 44H47Z" fill="none" stroke="currentColor" stroke-width="5"
stroke-linejoin="round" opacity=".4"/>
<g fill="currentColor">
<circle cx="32" cy="16" r="7"/>
<circle cx="17" cy="44" r="7" opacity=".55"/>
<circle cx="47" cy="44" r="7" opacity=".55"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 524 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Current mark, rebalanced">
<title>Current mark, rebalanced</title>
<!--
The shipped mark with three changes aimed at 16px: strokes stop earlier so
the gap to the node survives rasterisation, outer opacity raised .45 -> .7
so the neighbours don't drop out, node enlarged 7.5 -> 8.5.
-->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="7">
<path d="M12 16C24 16 25 32 30 32" opacity=".7"/>
<path d="M12 32H30"/>
<path d="M12 48C24 48 25 32 30 32" opacity=".7"/>
</g>
<circle cx="46" cy="32" r="8.5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 694 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 292 64" width="292" height="64" role="img" aria-label="shared-memory">
<title>shared-memory lockup</title>
<!-- Horizontal lockup: rebalanced mark + monospace wordmark on a shared baseline. -->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="7">
<path d="M12 16C24 16 25 32 30 32" opacity=".7"/>
<path d="M12 32H30"/>
<path d="M12 48C24 48 25 32 30 32" opacity=".7"/>
</g>
<circle cx="46" cy="32" r="8.5" fill="currentColor"/>
<text x="72" y="41" fill="currentColor"
font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
font-size="26" letter-spacing="-0.8">shared<tspan opacity=".45">-</tspan>memory</text>
</svg>

After

Width:  |  Height:  |  Size: 751 B

+262
View File
@@ -0,0 +1,262 @@
# Decision record: memory read payloads and patch-style updates
**Status:** P1 shipped · P2 shipped · P3 declined
**Originated:** 2026-08-11, from live use of the deployed server
**Revised:** 2026-08-11, against source
**Closed:** 2026-08-11 — implemented in `feat/memory-patch-and-lean-get` (PR #19)
This started as a proposal written from black-box observation of the deployed
server. It is kept as a decision record because the reasoning behind what was
built — and behind what was deliberately *not* built — is not recoverable from
the diff.
---
## 0. Orientation
Tools are defined internally with dots (`memory.get`) and surfaced to clients
with underscores (`memory_get`). Don't let it confuse a grep.
### Data model
- Memories have `id` (uuid), `content`, `tags[]`, `scope` (`project` | `user`),
`projectId`, `visibility`, `version`, `lastEditedBy`, `createdAt`,
`updatedAt`, `deletedAt`, plus `userId`, `embedding`, and `contentTsv`
(`apps/web/lib/db/schema.ts`).
- `scope: project` requires `project`; `scope: user` requires it omitted.
- `version` is an optimistic-locking token, bumped on every successful update.
A stale `version` returns a concurrent-edit error.
- Content limit is 64,000 chars (`packages/schemas/src/index.ts`).
- Shared projects allow anyone with rw access to edit any memory — hence the
locking.
---
## 1. P1 — `memory_get` returned the embedding and the tsvector ✅ SHIPPED
**Problem.** `memory_get` used a bare `select()` and returned the raw DB row,
including the `embedding` vector and the `contentTsv` lexeme index. Both are
Postgres retrieval internals with zero value to a model consumer.
`memory_list` and `memory_search` already projected an explicit 9-field shape —
`memory_get` was the only outlier.
**Measured against the live server** on a ~13k-char memory:
| | chars | share |
|---|---|---|
| `content` | 27,662 | 44.1% |
| `contentTsv` | 29,912 | 47.7% |
| `embedding` | 4,688 | 7.5% |
| other 12 fields | 433 | 0.7% |
| **total** | **62,695** | |
Two distinct failure shapes, which the original draft had flattened together:
- `embedding` is a **fixed** ~4,690-char tax on every read — 384 dims
regardless of content length. Nearly invisible on large memories (7.5%),
dominant on small ones (~58% of an ~8k response). Most memories are small.
- `contentTsv` scales **super-linearly** with content (frequent lexemes
accumulate long position lists, ~21 chars/entry) and was the largest single
component of the large payload — larger than the content itself.
**This was a correctness problem, not an efficiency nit.** Fetching that memory
exceeded the MCP tool-output cap and spilled to a file, even though `content`
alone is comfortably under the limit. `memory_get` was unusable on exactly the
large living documents P2 exists to serve. At the 64,000-char content ceiling a
response would land near 140,000 characters, under half of it content.
**Shipped:** `memory_get` returns the same 9 fields as `memory_list` /
`memory_search`. `userId` is still selected for the authorization check and
stripped before responding.
**Rejected: an `include: ("embedding" | "tsv")[]` opt-in.** The original draft
proposed gating the fields behind a flag in case some caller needed them. No
caller can: no MCP client can consume a 384-float vector or a lexeme index, and
the Web UI never goes through the MCP tools (it reads via `lib/memories.ts` and
writes via `lib/memory-actions.ts`). The parameter would have been dead on
arrival.
---
## 2. P2 — patch-style updates ✅ SHIPPED
**Problem.** `memory_update` accepted only full replacement. Adding four lines
to a 13,000-char living document meant reproducing the entire document.
**The evidence this was a real blocker.** The WHP roadmap mirror was three
weeks and two shipped releases out of date. The agent that noticed **declined
to fix it**, on the grounds that hand-reproducing 13k characters of shared team
history to add one entry risked silently dropping some of it — a worse outcome
than leaving it stale.
That is the failure mode this was designed against: **when the only safe way to
make a small edit is expensive, the edit doesn't happen.**
**Shipped:** `memory_patch(id, old_string, new_string, version?)`
| condition | behaviour |
|---|---|
| `old_string` absent | error — never a silent no-op |
| `old_string` matches >1 | error naming the count — ambiguity never resolves arbitrarily |
| matches exactly once | replace, bump `version`, re-embed |
| stale `version` | concurrent-edit error |
Both failure modes refuse rather than clobber. That is the property that makes
the operation safe to hand to an agent editing a shared document it cannot
afford to corrupt. Semantics live in `lib/memory-patch.ts` as a pure function,
free of DB and auth, so both surfaces share them.
**Locking came nearly free.** `memory.update` already computed
`expectedVersion = version ?? existing.version`, falling back to the version
read in the same handler. The CAS therefore already guarded the server-side
read-modify-write; patch copies that shape and is race-safe even when the
caller omits `version`. No explicit transaction was required.
**Rejected: `memory_append` with a `section` parameter.** Proposed as sugar for
heading-structured logs. Patch already covers that case exactly —
`memory_patch(id, "## RECENTLY SHIPPED", "## RECENTLY SHIPPED\n- entry")` — and
does so *with* the uniqueness guarantee: if the heading appears twice you get an
error instead of an arbitrary insert. Implementing `section` would have meant
defining heading-match semantics, insert position within a section, and
duplicate-heading behaviour, for something patch handles for free.
---
## 3. P3 — deriving mirrors rather than relying on convention ❌ DECLINED
**The problem as stated.** Some memories mirror local files. The sync is
enforced only by a note in the file's own header: *"MIRRORED to shared-memory
MCP … when you update this file, also `memory_update` that record."* That
depends on whoever edits the file noticing the note and performing a second
write. It went three weeks without one.
Proposed shapes were: a server-side `memory_sync_from_file`, a staleness signal
via `sourcePath` + content hash, or leaving it manual but cheap via P2.
**Declined, 2026-08-11.** Three reasons, in order of weight:
1. **The cause we have evidence for is now fixed.** The evidence was specific:
an agent *noticed* the drift and *declined* to fix it because the edit was
expensive and risky. That is a cost failure, not an attention failure. P2
makes that edit a single call. We have direct evidence for the cost cause
and none yet for any other.
2. **It would likely be a mechanism for N=1.** One mirror is known to exist.
A `sourcePath` column, hash computation, and staleness plumbing is real
schema-and-sync work; building it to police a single document is
disproportionate.
3. **If drift recurs, the better fix probably isn't sync machinery.** P3
assumes the mirror should exist and be kept honest. That assumption deserves
scrutiny first: the memory is a condensed prose rendition of a file that
lives in a container, existing separately only because the memory is
cross-machine and the file is not. Two hand-maintained sources of truth plus
a drift detector is strictly more machinery than one source of truth. The
cheaper answer would be to remove the duplication — make the memory
canonical and drop the file, or generate one from the other.
**What would reopen this:** the roadmap going stale *again* now that patching
is cheap. That is the clean experiment and it costs nothing to run. If it
drifts again, the cause was attention rather than cost, and option 2 above — a
staleness signal that makes drift *visible* rather than trying to fix it
automatically — becomes worth its weight.
Note for whoever picks this up: the mirror is **not** a byte copy of its local
file. It is a condensed prose rendition with different headings and no
wiki-links. A naive file-sync would destroy its established form. Any solution
has to preserve that distinction or deliberately abandon it.
---
## 4. Structural work this depended on ✅ SHIPPED
Neither of these was in the original proposal; both were found once the source
was available.
### 4.1 The write path was duplicated
`updateMemoryAction` (Web UI) and the `memory.update` MCP handler each
reimplemented authorize → mutate → re-embed → CAS → audit. Neither delegated to
a shared helper, and they had **drifted**: `memory.delete` over MCP skipped the
project ACL whenever the caller authored the row, so a memory written while a
share was `rw` stayed deletable by its author after an owner downgraded that
share to `ro`. `memory.update` and the entire Web UI always checked.
Both surfaces now route through `lib/memory-mutations.ts`. Authoring a row
grants no standing write privilege on any path — the project ACL is the
authority, not the byline. This was a behaviour change, shipped deliberately,
and is covered by a test that fails against the old code.
The one genuine difference between the surfaces is injected as a
`ProjectResolver`: MCP refuses an unknown project key (`call project.identify
first`) so an agent cannot spawn near-miss projects off a typo, while the Web
UI creates one, because a person typing a name into a form means to.
### 4.2 There was no test infrastructure
No vitest, no jest, no test files, no `test` script. Added vitest, with
integration tests running against a real Postgres rather than a mocked DB.
Setup is documented in the README.
---
## 5. Invariants — preserve these
- **Optimistic locking.** `version` must keep working, and every new mutating
primitive must accept it. Shared projects have concurrent editors.
- **Scope/project rules.** `scope: project` ⇒ `project` required and must
already exist on the MCP path; `scope: user` ⇒ `project` omitted.
- **Re-embedding on content change — and note what this does NOT cover.**
`content_tsv` is `GENERATED ALWAYS AS (to_tsvector('english',
coalesce(content, ''))) STORED` (`apps/web/drizzle/0000_init.sql`), so
Postgres maintains it and **full-text search cannot rot**. Only `embedding`
requires an explicit recompute. Any new mutation path must re-embed, or
*semantic* retrieval degrades silently while FTS keeps working — which is
exactly what makes the failure hard to notice.
- **Stable `id` across edits.** Never implement an edit as delete + recreate.
- **64,000-char content limit** enforced after a patch is applied, not just on
the incoming fragment.
- **Audit trail.** Partial edits record match offset and length delta, not just
the changed field names.
---
## 6. Acceptance checks
**P1** — verified in tests; the payload figures need a deploy to confirm.
- ✅ Response contains neither `embedding` nor `contentTsv`.
- ✅ Field set matches `memory_list` / `memory_search` exactly (9, down from 14).
- ⏳ Large specimen drops 62,695 → ~28,100 chars (−55%); small specimen ~−75%.
- ⏳ `memory_get` on `aaea192c-edce-4372-b011-5113a02dea16` returns inline
instead of spilling to a file. **This is the check that matters** — it is the
difference between the tool working and not working on large memories.
**P2** — all verified against a real Postgres.
- ✅ Unique `old_string` → applied; `version` incremented by exactly 1.
- ✅ Absent `old_string` → error; content byte-identical afterward.
- ✅ `old_string` occurring twice → error naming the count; content unchanged.
- ✅ Stale `version` → concurrent-edit error; content unchanged.
- ✅ Content exceeding 64,000 chars post-patch → rejected.
- ✅ Re-embedding — see the caveat below.
**A trap in the re-embedding check.** The original draft proposed "after a
patch, `memory_search` finds text introduced by that patch." *That test does not
work.* Search fuses three rankers via RRF, and because `content_tsv` is a
generated column the FTS ranker finds the literal inserted text **even if the
patch skipped re-embedding entirely** — it would pass on a broken
implementation. The shipped test asserts the **stored vector changed**, using a
deterministic per-text embedder stub. A separate test pins that `content_tsv`
updates itself, documenting why the naive check is misleading.
---
## 7. Test specimen
Memory `aaea192c-edce-4372-b011-5113a02dea16` (project
`cloud-hosting-platform/whp`, tags `roadmap` / `progress-tracker` / `planning`)
is a good real-world subject: ~13k chars, heading-structured, and its local
counterpart is
`/home/claude/.claude/projects/-workspace/memory/project_roadmap.md`.
**It is live team data.** Check its `updatedAt` and `version` before using it as
a fixture, and prefer a scratch memory for destructive tests.
+692
View File
@@ -0,0 +1,692 @@
# OIDC provider setup: Microsoft Entra ID
Companion to the **OIDC provider setup** section in [`README.md`](../README.md),
which walks through Authentik. The structure here deliberately mirrors it —
app registration A (Web UI), app registration B (MCP resource server), env var
mapping, verification — so the two are diffable. Where Entra genuinely differs
from Authentik, the difference is called out rather than smoothed over.
Everything below assumes a **single-tenant** deployment (`signInAudience` =
"Accounts in this organizational directory only"). Multitenant is possible but
the `iss` verification in `apps/web/lib/auth/jwt.ts` compares against a fixed
string, so it would need code changes — see [Multitenant](#13-multitenant-is-not-supported)
at the end.
---
## 0. Prerequisite: your build must have JWKS discovery
**Check this first. Nothing else in this document works without it.**
Until recently `apps/web/lib/auth/jwt.ts` hardcoded the JWKS location as
`${issuer}/jwks/`. That is an *Authentik* convention, not a standard — RFC 8414
says the key set lives wherever the discovery document's `jwks_uri` points, and
Entra puts it somewhere else entirely:
| IdP | JWKS URL |
|---|---|
| Authentik | `https://auth.example.com/application/o/<slug>/jwks/` |
| Entra ID | `https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys` |
With the path hardcoded, every Entra-issued MCP access token fails verification
because the key set fetch 404s. There is no configuration that works around it;
MCP authentication is simply impossible.
The current code resolves `jwks_uri` from
`${OIDC_ISSUER_MCP or OIDC_ISSUER}/.well-known/openid-configuration`, caches the
result for the process lifetime, and falls back to `${issuer}/jwks/` only if
discovery is unreachable (so existing Authentik deployments are untouched).
Confirm your deployment has it before debugging anything else:
```bash
# Should return the Entra keys endpoint, not a 404.
curl -s "https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration" \
| jq -r .jwks_uri
# → https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys
```
If MCP calls 401 with `error_description="verification failed"` and your app
logs show a fetch to `.../v2.0/jwks/`, you are on an older build.
---
## 1. Concepts, Authentik → Entra
| Concept here | Authentik | Entra ID |
|---|---|---|
| OAuth2 client | Provider + Application | App registration |
| Issuer | Per-application (`.../application/o/<slug>/`) | **Per-tenant only** — one issuer for the whole directory |
| Audience claim | Scope mapping returning `{"aud": …}` | "Expose an API" scope on the resource app; `aud` is set automatically |
| Redirect URI matching | Regex allowed (any port) | **Exact string match**, with one loopback exception |
| Dynamic client registration | Not implemented | Not implemented |
The **issuer** row is the one that reshapes the setup. On Authentik, the Web UI
and MCP endpoint are separate applications with separate issuers, which is why
`OIDC_ISSUER_MCP` exists. Entra has exactly one issuer per tenant no matter how
many app registrations you create, so **`OIDC_ISSUER_MCP` is left unset on
Entra** and `mcpIssuer()` falls through to `OIDC_ISSUER`.
You still create **two app registrations**, for the same reason as on Authentik:
one confidential client for the browser sign-in, one resource server that owns
the audience the MCP endpoint validates. (A third participant — the *public
PKCE client* Claude Code uses — is covered in §4; you can fold it into
registration B or split it out.)
---
## 2. Find your tenant ID and use it explicitly
Everywhere below, `<tenant-id>` is your directory (tenant) GUID, from
**Entra admin center → Overview → Tenant ID**.
**Do not use the `common` or `organizations` authority.** Their discovery
documents return a *templated* issuer — the literal string, verified live:
```bash
curl -s https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration | jq -r .issuer
# → https://login.microsoftonline.com/{tenantid}/v2.0
```
That `{tenantid}` is not a formatting artifact; it is what the endpoint really
returns. `jwt.ts` compares `iss` by exact string (via `acceptedIssuers()`), so
against a templated issuer **no token can ever match** and every MCP call fails
with `claim invalid: iss`.
Microsoft's documented pattern for multitenant apps is to substitute the token's
`tid` claim into the placeholder and then compare — this app does not do that
(see [Multitenant](#13-multitenant-is-not-supported)). For single-tenant, the fix
is simply to use the tenant-specific authority, whose discovery document
returns a concrete issuer:
```bash
curl -s "https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration" | jq -r .issuer
# → https://login.microsoftonline.com/<tenant-id>/v2.0 (no trailing slash)
```
A verified domain (`contoso.onmicrosoft.com`) also works as the authority
segment — Entra resolves it server-side and returns the GUID form in both
`issuer` and `jwks_uri`. Since the *returned* issuer is what tokens carry,
`OIDC_ISSUER` must be the GUID form regardless of which you typed.
---
## 3. App registration A — Web UI (confidential client)
**Entra admin center → Entra ID → App registrations → New registration**
- **Name:** `shared-memory-web`
- **Supported account types:** Accounts in this organizational directory only
- **Redirect URI:** platform **Web**, value:
```
https://memory.example.com/api/auth/callback/oidc
```
(replace with your `PUBLIC_URL`; the `/oidc` suffix comes from the provider
id in `apps/web/auth.ts` and is not configurable without a code change)
Register, then collect:
- **Overview → Application (client) ID** → `.env` as `OIDC_CLIENT_ID_WEB`
- **Certificates & secrets → New client secret** → `.env` as
`OIDC_CLIENT_SECRET_WEB` (copy the *Value*, not the Secret ID; it is shown
once)
**API permissions:** `openid`, `profile`, `email` are Microsoft Graph delegated
permissions and are present by default via `User.Read`. Add `profile`
explicitly if it is missing — it gates the `oid` and `tid` claims, which
matter for §7.
No "Expose an API" configuration is needed on this registration. Auth.js only
consumes the ID token here.
---
## 4. App registration B — MCP resource server
This registration is what `OIDC_AUDIENCE` refers to. It owns the API scope that
Claude Code requests, and the MCP endpoint validates that tokens were minted
for it.
**App registrations → New registration**
- **Name:** `shared-memory-mcp`
- **Supported account types:** same as A
### 4a. Set the access token version — the single most common failure
**Manage → Manifest**, find and set:
```json
"api": {
"requestedAccessTokenVersion": 2
}
```
There is no checkbox for this; it is a manifest edit.
> **Note on the property name.** Older guides (and older versions of this
> project's notes) call this `accessTokenAcceptedVersion` at the top level of
> the manifest. That is the **retired Azure AD Graph** manifest format —
> Microsoft removed it from the portal's manifest editor on 2025-01-07, so you
> will not find that property. The current Microsoft Graph app manifest nests
> it as `api.requestedAccessTokenVersion`. The semantics are identical:
> `null` or `1` → v1.0 tokens, `2` → v2.0 tokens.
Leave it at the default `null` and Entra issues **v1.0** access tokens, whose
issuer is:
```
https://sts.windows.net/<tenant-id>/ ← note the trailing slash
```
not `https://login.microsoftonline.com/<tenant-id>/v2.0`. Verification then
fails with `claim invalid: iss`, and — because everything else in the OAuth
handshake succeeded — it looks like a mysterious 401 rather than a
configuration error.
The setting lives on the **resource** app and wins over whichever endpoint the
client used: with `requestedAccessTokenVersion: 2`, a client hitting the v1.0
endpoint still receives a v2.0 access token.
### 4b. Expose an API
**Manage → Expose an API**
1. **Application ID URI** → *Add* → accept the default `api://<client-id-of-B>`.
2. **Add a scope**:
- **Scope name:** `access_as_user`
- **Who can consent:** **Admins and users** (see §9)
- Fill in the admin/user consent display strings; they appear on the consent
prompt.
The resulting full scope string is `api://<client-id-of-B>/access_as_user`.
### 4c. The public PKCE client (Claude Code)
Claude Code is a public client using PKCE. Add a platform to registration B
(or to a third registration if you prefer them separated — then that
registration's client ID is `OIDC_CLIENT_ID_MCP`, and it needs
`api://<client-id-of-B>/access_as_user` under **API permissions**):
**Manage → Authentication → Add a platform → Mobile and desktop applications**
> **The platform type is not cosmetic.** A redirect URI registered under the
> **Web** platform classifies the app as a *confidential* client, and the
> token exchange then demands a `client_secret` or `client_assertion` —
> Claude Code has neither, so the flow dies with
> `AADSTS7000218: The request body must contain the following parameter:
> 'client_assertion' or 'client_secret'`. The portal will happily accept
> `http://localhost:33418/callback` as a Web redirect URI, which is what makes
> this trap easy to fall into. **Mobile and desktop applications**
> (`publicClient` in the manifest) is the correct platform. SPA is not an
> option either — Entra rejects SPA redirect URIs for non-SPA flows.
Under **Custom redirect URIs**, register:
```
http://localhost/callback
https://memory.example.com/auth/cli-callback
```
The first covers the loopback listener from README → *B. OAuth flow*; the
second is the manual-paste fallback from *C*. "Mobile and desktop
applications" permits arbitrary `https://` URIs alongside the loopback one, so
both live on the same platform. If the server will also be added as a claude.ai
custom connector, that flow is brokered by claude.ai and comes back to
`https://claude.ai/api/mcp/auth_callback` — register it here too, or the
connector stops at the IdP's redirect-URI mismatch error before any login
prompt. See README → *Which redirect URIs to register*.
**Note the missing port.** Entra ignores the port component when matching
`http://localhost` redirect URIs, so the single registration
`http://localhost/callback` matches `http://localhost:33418/callback`,
`http://localhost:9999/callback`, and any other port. This is Entra's
equivalent of the Authentik regex (`^http://(127\.0\.0\.1|localhost):\d+(/.*)?$`)
the README mentions — users can pick any `--callback-port` without
re-registering.
Three constraints on that convenience:
- **The path is still matched exactly.** Registering bare `http://localhost`
does *not* match `http://localhost:33418/callback`. The `/callback` suffix
must be there, and paths are case-sensitive.
- **Do not register several localhost URIs differing only by port.** Entra
picks one arbitrarily when matching.
- **Port-agnostic matching is documented for `localhost` only**, not for
`127.0.0.1` — and the portal text box refuses the `http://127.0.0.1` form
anyway (it requires a manifest edit). Use `localhost`. `[::1]` is not
supported at all.
You do **not** need to enable **Allow public client flows**
(`allowPublicClient`). That toggle is a *fallback* for flows where Entra can't
infer the client type from a redirect URI — device code, ROPC, Windows
Integrated Auth. Authorization code + PKCE with a registered
mobile-and-desktop redirect URI is inferred correctly without it. (Entra's own
`reply-url` doc says otherwise in one sentence; the manifest reference and the
AADSTS7000218 troubleshooting article agree it is a fallback. Leave it off
unless you hit a problem — Microsoft warns that flipping a confidential client
to public has security implications.)
**Application (client) ID** of whichever registration Claude Code
authenticates as → `.env` as `OIDC_CLIENT_ID_MCP`.
---
## 5. `OIDC_AUDIENCE` vs. `OIDC_AUDIENCE_SCOPE`
On Authentik these two look redundant — the scope mapping is named
`aud-shared-memory` and it emits `aud: shared-memory`, so the values track each
other. On Entra they are **necessarily different strings**, and swapping them is
the easiest mistake to make here.
| Var | What it is | Entra value |
|---|---|---|
| `OIDC_AUDIENCE` | The `aud` claim `jwt.ts` requires on the token | `<client-id-of-B>` — a bare GUID |
| `OIDC_AUDIENCE_SCOPE` | The scope string the *client* asks for, advertised in `/.well-known/oauth-protected-resource` | `api://<client-id-of-B>/access_as_user` |
Why they differ: the client requests a scope by its full URI
(*Application ID URI* + `/` + scope name), but Entra does not put that URI in the
token. For **v2.0** access tokens it splits the request into `aud` (the API's
**client-ID GUID**) and `scp` (the **short** scope name, `access_as_user`).
Three distinct strings for what feels like one concept.
> **Do not trust this document — decode a real token.** Microsoft's own
> [access-tokens](https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens)
> page says web APIs "must only accept tokens containing one of their AppId
> URIs as the `aud` claim", which contradicts the authoritative
> [access token claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference)
> ("In v2.0 tokens, this value is always the client ID of the API"). The
> claims reference is correct for v2.0, but given that Microsoft's docs
> disagree with each other, verify empirically — see §8.
`OIDC_AUDIENCE_SCOPE` **must** be set explicitly on Entra. Left unset, the code
defaults to `aud-${OIDC_AUDIENCE}`, which is an Authentik naming convention and
means nothing to Entra — the client would request a nonexistent scope and the
authorize request fails outright.
---
## 6. `offline_access`
Set `OIDC_OFFLINE_ACCESS=true` from the start. Unlike Authentik — where you must
first attach an `offline_access` scope mapping to the provider — Entra treats
`offline_access` as one of its well-defined platform scopes (`openid`, `email`,
`profile`, `offline_access`). Nothing to create, and it is **implicitly
granted**: if any delegated permission is consented, `offline_access` is too.
Two caveats:
- It must still be *requested* at runtime, which is exactly what
`OIDC_OFFLINE_ACCESS=true` achieves — the flag adds it to `scopes_supported`
in `/.well-known/oauth-protected-resource`, and MCP clients only request
scopes they see advertised there.
- A refresh token comes back only on authorization-code-style flows. That is
what Claude Code uses, so this is satisfied; implicit flow would not be.
The `.env` comment on this var warns that advertising a scope the IdP doesn't
offer risks `invalid_scope`. On Entra that risk doesn't apply.
---
## 7. Identity: `sub` splits accounts across app registrations
**Handled as of migration `0005_user_oid.sql`. Read this anyway — it explains
why `oid` is in your database, and what happens if you deploy the migration
late.**
The app keys the `users` row on `(oidc_iss, oidc_sub)` — see the upsert in
`apps/web/lib/mcp/context.ts` and the one in `apps/web/auth.ts`. On Authentik
that is safe, because Authentik's `sub` is `user.uid`, a user-level value that
is identical across providers.
Entra's `sub` is a **pairwise identifier**. Microsoft documents it as *"based on
a combination of the token recipient, tenant, and user"* — so the value is
scoped to the app registration in the `aud` position of that particular token:
- Web UI sign-in → ID token with `aud` = registration **A** → `sub` = *X*
- MCP access token → `aud` = registration **B** → `sub` = *Y*
*X ≠ Y*, by design, for privacy. `iss` is identical for both (one tenant, one
issuer), so `(iss, sub)` yields **two different keys for the same human**. Both
code paths *upsert* rather than fail, so nothing looks broken: the person signs
into the Web UI, sees their memories, connects Claude Code, and finds an empty
account. Writes land in the second row.
There is no configuration fix. `sub` is in Entra's restricted claim set (no
claims-mapping policy can alter it), `subject_types_supported` advertises only
`pairwise`, and Microsoft has stated that `sector_identifier_uri` is not used to
generate it.
**How it's handled.** Identity is keyed on `oid` — the directory object id,
which Microsoft documents as constant for a user across every application in a
tenant (*"all apps get the same `oid` and `tid` claims for a user acting in a
tenant"*). It is emitted by default in v2.0 ID *and* access tokens as long as
the `profile` scope is requested, which it is.
`apps/web/lib/auth/identity.ts` holds the single resolver both surfaces call.
Resolution order when `oid` is present:
1. an existing row keyed on `(oidc_iss, oidc_oid)` — the steady state
2. a pre-migration row matching `(oidc_iss, oidc_sub)` with no `oid` yet, which
gets its `oid` backfilled in place
3. insert
IdPs that emit no `oid` (Authentik, Keycloak, Okta) skip straight to the
original `(iss, sub)` behaviour, unchanged.
> **One upgrade-ordering caveat.** Step 2 adopts a legacy row by matching
> `sub`, and the only `sub` that can match is the one that created it — the
> **Web UI** one, since MCP auth against Entra was impossible before the JWKS
> fix in §0. So if you already had Entra users signing into the Web UI, have
> them **sign into the Web UI once** after deploying this migration, before
> connecting an MCP client. Connecting MCP first creates a fresh row keyed on
> `oid` and leaves the original stranded, with the memories in it invisible.
> Deployments that have never run Entra are unaffected.
The single-registration layout (making registration A the resource server too)
also works and needs no migration, but you lose audience separation between the
Web UI and MCP.
---
## 8. Verification
Run these in order; each one isolates a different failure.
**1. The issuer is concrete, not templated.**
```bash
curl -s "https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration" \
| jq '{issuer, jwks_uri}'
```
`issuer` must be a GUID URL, not `{tenantid}`. Copy it verbatim into
`OIDC_ISSUER`.
**2. Our metadata advertises the right scopes.**
```bash
curl -s https://memory.example.com/.well-known/oauth-protected-resource | jq
```
`scopes_supported` must contain `api://<client-id-of-B>/access_as_user` (not
`aud-…`), plus `offline_access` if you enabled it. `authorization_servers[0]`
must be the tenant-specific v2.0 issuer.
**3. Decode a real access token.** This is the only step that proves the
`aud`/`iss`/version questions. Get a token (from Claude Code's stored
credentials, or by running the flow manually) and inspect the payload:
```bash
TOKEN='eyJ...'
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq '{ver, iss, aud, sub, oid, tid, scp, groups}'
```
Expected:
| Field | Expected value | If wrong |
|---|---|---|
| `ver` | `"2.0"` | `api.requestedAccessTokenVersion` is not `2` (§4a) |
| `iss` | `https://login.microsoftonline.com/<tenant-id>/v2.0` | v1 token, or `common` authority (§2, §4a) |
| `aud` | `<client-id-of-B>`, a bare GUID | set `OIDC_AUDIENCE` to whatever is actually here (§5) |
| `scp` | `access_as_user` | the scope wasn't requested or consented (§9) |
| `groups` | array of GUIDs, or absent | see §10 |
**4. Confirm the 401 reason** when something is still wrong — the MCP endpoint
names the failing claim:
```bash
curl -s -i -H "Authorization: Bearer $TOKEN" https://memory.example.com/api/mcp | head -20
```
Look at `WWW-Authenticate`: `error_description="claim invalid: iss"` →
§2/§4a. `"claim invalid: aud"` → §5. `"verification failed"` → JWKS could not
be fetched, §0.
---
## 9. Consent for the API scope
A custom scope is not inherently admin-only. Two levers decide it:
- **The scope's own setting.** "Who can consent?" on the scope — **Admins and
users** lets users self-consent; **Admins only** always requires an admin.
Select "Admins and users" (§4b). Microsoft's docs don't state which radio the
portal preselects, so set it deliberately rather than assuming.
- **The tenant's user-consent policy.** The default is *"users are allowed to
consent to applications for permissions that don't require administrator
consent"*, but many tenants tighten this to "verified publishers only" or
disable user consent entirely, in which case an admin must consent regardless
of the scope setting.
Admin consent becomes **mandatory** if: the scope is "Admins only"; the tenant
policy restricts user consent; or — the one that catches people — the enterprise
application is set to **require user assignment**, which forces admin consent
even when tenant policy would otherwise permit self-consent.
**To grant it:** App registrations → *the client app* (the one Claude Code uses,
not the API) → **API permissions** → **Grant admin consent for \<tenant\>**. The
button is disabled if you aren't an admin or no permissions are configured.
Alternatively, suppress the prompt entirely with **pre-authorization**: on
registration B, **Expose an API → Authorized client applications → Add a client
application**, select the MCP client ID and tick `access_as_user`. Consent is
then implicit. Reasonable here, since you control both registrations.
If you prefer the URL form of admin consent, note it needs the `/v2.0/` segment
and must not use `common`:
```
https://login.microsoftonline.com/<tenant-id>/v2.0/adminconsent
?client_id=<OIDC_CLIENT_ID_MCP>
&scope=api://<client-id-of-B>/access_as_user
&redirect_uri=https://memory.example.com/auth/cli-callback
&state=12345
```
---
## 10. Groups
Group memberships gate access to shared projects (`readableProjectIds` /
`canWriteProject` in `apps/web/lib/mcp/tools.ts` and
`apps/web/lib/memory-mutations.ts`). Entra's groups claim needs care on two
independent axes: **what the values look like**, and **what happens when the
claim goes missing**.
### 10a. By default you get GUIDs, not names
Entra emits `groups` as a **JSON array of group object-ID GUIDs**. Not display
names. `apps/web/lib/auth/sync-groups.ts` stores whatever strings arrive
verbatim and makes no attempt to resolve them, so the Web UI will list
memberships like `8f4c…-b21a` and your project ACLs must be written against
those GUIDs.
There *is* a supported way to get display names for cloud-only groups —
contrary to the older note in `sync-groups.ts`, which says names are available
only for AD-synced groups. That was true of the `sam_account_name` family
(those attributes genuinely exist only on groups synced from on-premises AD via
Entra Connect 1.2.70+), but Entra also has `cloud_displayname`:
**App registrations → \<B\> → Token configuration → Add groups claim**, select
**Groups assigned to the application**, then tick the cloud-only display name
option. In the manifest:
```json
"groupMembershipClaims": "ApplicationGroup",
"optionalClaims": {
"accessToken": [
{ "name": "groups",
"additionalProperties": ["cloud_displayname"] }
],
"idToken": [
{ "name": "groups",
"additionalProperties": ["cloud_displayname"] }
]
}
```
Both collections matter: `idToken` feeds the Web UI sign-in path (`auth.ts`),
`accessToken` feeds the MCP path (`jwt.ts`). Configure only one and the two
surfaces disagree about your group names.
Constraints, all of them load-bearing:
- `cloud_displayname` **only works with `groupMembershipClaims:
"ApplicationGroup"`**. Microsoft's stated reason is that group display names
aren't unique, so they only emit them for groups explicitly assigned to the
application.
- Only **directly assigned** groups appear. **Nested groups are excluded.**
- Assign the groups under **Enterprise applications → \<B\> → Users and
groups**, or they simply won't be emitted.
- Microsoft's published `cloud_displayname` examples cover `idToken` and
`saml2Token`; we found no official example pairing it with `accessToken`.
It is a documented-valid collection, but **decode a real access token (§8)
and confirm `groups` contains names before relying on it** rather than
assuming symmetry.
A claims-mapping policy cannot fix this instead: `groups` is a restricted
claim, so its data source can't be changed and no transformation applies.
If none of this appeals, Microsoft's own recommendation is to use **app roles**
rather than groups for authorization — but this app reads `groups`, so that
would need a code change.
### 10b. Groups overage — now refused rather than obeyed
**This was the sharpest edge in this document. It is now a hard failure with a
readable message, which is a much better outcome than what it used to do.**
Past a limit, Entra stops emitting `groups` altogether and substitutes an
overage indicator:
| Token | Limit | What you get past it |
|---|---|---|
| JWT (access + ID) | **200** groups | `groups` absent; `_claim_names` / `_claim_sources` present |
| SAML | 150 groups | same |
| Implicit flow | **5** groups | `"hasgroups": true` |
The indicator looks like this — note it is *not* a truncated list, it is no
list at all:
```json
{
"_claim_names": { "groups": "src1" },
"_claim_sources": { "src1": { "endpoint": "https://graph.windows.net/…" } }
}
```
(That endpoint is an **Azure AD Graph** URL, not Microsoft Graph. Don't follow
it; Microsoft says to construct
`https://graph.microsoft.com/v1.0/users/{id}/getMemberObjects` yourself.
Limits are inclusive of nested groups.)
**What this used to do.** An absent `groups` claim and a claim saying "zero
groups" were indistinguishable to `normalizeGroupsClaim`, which returned `[]`
for both — and the `names.length === 0` branch **deletes every one of that
user's `user_groups` rows**. So a user crossing 200 groups signed into the Web
UI once and silently lost access to every shared project, on both surfaces,
with no error anywhere. (The MCP path never deleted anything, but it then read
the snapshot the Web sign-in had just emptied.)
**What happens now.** `detectGroupsOverage` looks for `_claim_names.groups` and
`hasgroups`, and both surfaces refuse the token rather than acting on group
state they know they don't have:
- **Web sign-in** throws `GroupsOverageError`, which fails the sign-in. Existing
memberships are left completely untouched.
- **MCP** returns 401 with
`error_description="groups overage: IdP did not enumerate group membership …"`.
The user is blocked until an admin fixes the claim configuration — and then
signs in and finds their access exactly as it was. Nothing to restore, because
nothing was destroyed. Granting access from a stale snapshot, or revoking it on
a claim the IdP never made, are both guesses; refusing is the only honest
answer available.
An absent `groups` claim with **no** overage marker still clears memberships.
That is unchanged and deliberate: the IdP has genuinely stopped asserting the
groups, so we stop honouring them.
#### Getting a blocked user back in
1. App registration → **Token configuration** (or the manifest) → set
`groupMembershipClaims` to **`ApplicationGroup`** — the portal labels this
**"Groups assigned to the application"**. It emits only the groups
explicitly assigned to *this* application, which for a memory server is a
handful, so the 200-group ceiling stops being reachable. Microsoft
recommends it for exactly this reason, and it is the same setting
`cloud_displayname` requires — §10a and §10b have one shared fix.
2. Enterprise applications → your app → **Users and groups** → assign the
groups you actually share projects with. `ApplicationGroup` emits **directly
assigned groups only**; nested and transitive membership is excluded, so
assign the real groups rather than a parent.
3. Confirm the `groups` optional claim is configured for the **access token**,
not only the ID token — the MCP path reads the access token.
4. The user signs in again. Their memberships were never deleted, so their
access returns as it was.
Leaving `groupMembershipClaims` at `All` or `SecurityGroup` in a large tenant is
what makes this bite in the first place.
If a group genuinely must exceed the limit, the other way out is **app roles**,
which are app-scoped and never overage — but they arrive in a `roles` claim and
this codebase reads `groups`, so that is a code change, not a config change.
---
## 11. Connecting Claude Code
Everything in README → **Connecting Claude Code** applies unchanged, with one
Entra-specific confirmation: **the pre-registered client-id path is
mandatory.**
Entra does not implement RFC 7591 Dynamic Client Registration. Its discovery
document publishes no `registration_endpoint`, it serves no RFC 8414
authorization-server metadata at all (only OIDC discovery), and it does not
advertise `client_id_metadata_document_supported` — so neither DCR nor the CIMD
mechanism that superseded it in the MCP spec is available. Microsoft states this
plainly in its own MCP guidance ("Microsoft Entra ID doesn't currently support
client registration") and has said it is not on the near-term roadmap.
Practically, this means:
- Use the plugin (`plugin/.mcp.json` ships a pre-registered `clientId`), or
- Pass `--client-id <OIDC_CLIENT_ID_MCP>` explicitly on `claude mcp add`.
A client that expects to self-register will fail. This is the same situation as
Authentik, so the README's guidance needs no adjustment.
---
## 12. Env var reference card
Straight from Entra's UI labels to `.env` keys. `<A>` is app registration A
(Web UI, §3); `<B>` is app registration B (MCP resource server, §4).
| `.env` key | Where it comes from in Entra | Example |
|---|---|---|
| `OIDC_ISSUER` | `issuer` from the **tenant-specific** discovery document (§2). Not the authority you typed — the value the endpoint returns. | `https://login.microsoftonline.com/<tenant-id>/v2.0` |
| `OIDC_ISSUER_MCP` | **Leave unset.** Entra has one issuer per tenant; there is no per-application issuer to point at. `mcpIssuer()` falls back to `OIDC_ISSUER`. | *(unset)* |
| `OIDC_CLIENT_ID_WEB` | `<A>` → **Overview → Application (client) ID** | `1111…-aaaa` |
| `OIDC_CLIENT_SECRET_WEB` | `<A>` → **Certificates & secrets → Client secrets → Value** (not Secret ID; shown once) | `abc8Q~…` |
| `OIDC_CLIENT_ID_MCP` | Client ID of the **public PKCE** registration Claude Code authenticates as (§4c) | `3333…-cccc` |
| `OIDC_AUDIENCE` | `<B>` → **Overview → Application (client) ID**. The bare GUID, *not* the `api://` URI. Confirm by decoding a token (§8). | `2222…-bbbb` |
| `OIDC_AUDIENCE_SCOPE` | `<B>` → **Expose an API** → the scope's full string: Application ID URI + `/` + scope name. Must be set explicitly; the `aud-…` default is Authentik-only. | `api://2222…-bbbb/access_as_user` |
| `OIDC_OFFLINE_ACCESS` | Nothing to configure in Entra — set it to `true` (§6). | `true` |
`PUBLIC_URL` and the non-OIDC vars are unchanged from the README.
---
## 13. Multitenant is not supported
`acceptedIssuers()` in `apps/web/lib/auth/jwt.ts` compares `iss` against a
fixed pair of strings (with and without a trailing slash). Multitenant Entra
apps require substituting each token's `tid` claim into the `{tenantid}`
placeholder before comparing, and separately validating the signing key's own
issuer. Neither is implemented.
Beyond `iss`, multitenant would also need the identity keying in §7 resolved,
since Microsoft is explicit that `oid` and `sub` differ per tenant by design and
that a guest user authenticating in another tenant *"should be treated as if
they're a brand new user to the service."*
Single-tenant is the supported configuration.
+1
View File
@@ -12,6 +12,7 @@
"build": "pnpm -r build",
"dev": "pnpm --filter @shared-memory/web dev",
"lint": "pnpm -r lint",
"test": "pnpm -r test",
"typecheck": "pnpm -r typecheck",
"db:generate": "pnpm --filter @shared-memory/web db:generate",
"db:migrate": "pnpm --filter @shared-memory/web db:migrate"
+15 -1
View File
@@ -18,7 +18,8 @@ export const ProjectKey = z
.regex(/^[a-zA-Z0-9._\-/]+$/, "project key may only contain alphanumerics, ._-/");
export type ProjectKey = z.infer<typeof ProjectKey>;
export const MemoryContent = z.string().min(1).max(64_000);
export const MEMORY_CONTENT_MAX = 64_000;
export const MemoryContent = z.string().min(1).max(MEMORY_CONTENT_MAX);
export const Tags = z
.array(z.string().min(1).max(64).regex(/^[a-zA-Z0-9._\-]+$/, "tag must be alphanumeric ._-"))
@@ -84,6 +85,19 @@ export const MemoryUpdateInput = z.object({
});
export type MemoryUpdateInput = z.infer<typeof MemoryUpdateInput>;
// memory.patch replaces ONE exact occurrence of `old_string`. Absent or
// ambiguous matches are errors, never silent no-ops — see applyPatch.
// `new_string` may be empty (a deletion); the resulting content still has
// to satisfy MemoryContent, which is checked after the patch is applied.
export const MemoryPatchInput = z.object({
id: z.string().uuid(),
old_string: z.string().min(1).max(MEMORY_CONTENT_MAX),
new_string: z.string().max(MEMORY_CONTENT_MAX),
// Same optimistic-locking token as memory.update.
version: z.number().int().nonnegative().optional(),
});
export type MemoryPatchInput = z.infer<typeof MemoryPatchInput>;
export const MemorySearchInput = z.object({
query: z.string().min(1).max(2000),
project: ProjectKey.optional(),
+814 -8
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -72,6 +72,16 @@ The redirect URI you register on the Web UI client is
`https://${domain_name}/api/auth/callback/oidc`, so plan the domain name
*before* configuring the IdP.
The MCP client needs its own list, and most of it does not depend on the
domain: a loopback URI for the Claude Code CLI, plus
`https://claude.ai/api/mcp/auth_callback` if anyone will add the server as a
claude.ai custom connector. Only the manual-paste fallback,
`https://${domain_name}/auth/cli-callback`, follows the domain. See
[Which redirect URIs to register](../README.md#which-redirect-uris-to-register)
— a client can only register its own redirect URI against an IdP that offers
Dynamic Client Registration, which Authentik gates behind an enterprise
licence, so plan on adding all of these by hand.
---
## Quick start