Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87475e60d0 | ||
|
|
3be9135aee | ||
|
|
f46f54d50b | ||
|
|
29f5673eac | ||
|
|
c68d72857f | ||
|
|
391d8e0360 | ||
|
|
bd7a1ca59a | ||
|
|
a3f28c52a5 | ||
|
|
423a3fa7fe | ||
|
|
5d06095883 | ||
|
|
2c127886ff | ||
|
|
4d6694620a | ||
|
|
bbea0f74f3 | ||
|
|
80341c558d | ||
|
|
1a44362202 | ||
|
|
e0d0d3b2b3 | ||
|
|
db17e0890e | ||
|
|
d4d478d2f3 | ||
|
|
6e9628b073 | ||
|
|
c3bbea5134 |
@@ -53,6 +53,15 @@ OIDC_AUDIENCE=shared-memory
|
|||||||
# named the scope mapping something else.
|
# named the scope mapping something else.
|
||||||
#OIDC_AUDIENCE_SCOPE=aud-shared-memory
|
#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)
|
# Database (Postgres 16 + pgvector — pgvector/pgvector:pg16 image)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
A self-hosted MCP server that gives Claude Code sessions a **shared, persistent
|
||||||
memory** plus a **reusable snippet library**, behind your own OIDC login.
|
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
|
The same container serves both the MCP endpoint (under `/api/mcp`) and the
|
||||||
Web UI. Users authenticate via your OIDC provider with pre-registered
|
Web UI. Users authenticate via your OIDC provider with pre-registered
|
||||||
confidential clients. Identity is keyed on the OIDC `sub` + `iss` so
|
confidential clients. Identity is keyed on the OIDC `iss` + `sub` so memories
|
||||||
memories are scoped per user.
|
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" |
|
| 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 |
|
| 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
|
### A. Web UI provider
|
||||||
|
|
||||||
**Admin → Applications → Providers → Create → OAuth2/OpenID 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
|
or `Confidential` if you prefer to issue a secret to each Claude Code
|
||||||
install — both work. Phase 1 expects Public.
|
install — both work. Phase 1 expects Public.
|
||||||
- **Client ID:** auto-generated → copy to `.env` as `OIDC_CLIENT_ID_MCP`
|
- **Client ID:** auto-generated → copy to `.env` as `OIDC_CLIENT_ID_MCP`
|
||||||
- **Redirect URIs:** Claude Code prints the exact value when it first
|
- **Redirect URIs:** more than one, and which ones depends on how people
|
||||||
connects to the MCP endpoint. Paste it into Authentik then.
|
reach the server — see **Which redirect URIs to register** below.
|
||||||
- **Scopes:** `openid`, `profile`, `email`
|
- **Scopes:** `openid`, `profile`, `email` (plus `offline_access` — see
|
||||||
|
**Keeping sessions alive** below)
|
||||||
- **Signing Key:** same cert as the Web provider
|
- **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
|
#### Setting the `aud` claim
|
||||||
|
|
||||||
The MCP endpoint requires the access token's `aud` claim to equal
|
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:
|
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)
|
### A. Plugin (recommended — one command, no flags to remember)
|
||||||
|
|
||||||
This repo doubles as a Claude Code plugin marketplace. `plugin/.mcp.json` ships a
|
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
|
`--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
|
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
|
your MCP client's **Redirect URIs** list. Authentik users with the regex
|
||||||
pattern from the setup step (`^http://(127\.0\.0\.1|localhost):\d+/.*$`)
|
entry from the setup step (`^http://(127\.0\.0\.1|localhost):\d+(/.*)?$`) can
|
||||||
can use any port without re-registering.
|
use any port without re-registering.
|
||||||
|
|
||||||
### C. Manual-paste fallback (when loopback isn't reachable)
|
### 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
|
## Troubleshooting
|
||||||
|
|
||||||
- **`401 claim invalid: aud`** from `/api/mcp` — your MCP client isn't
|
- **`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
|
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
|
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.
|
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`
|
- **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
|
doesn't match the redirect URI your IdP is configured with. They must be
|
||||||
exactly equal, scheme and trailing slash included.
|
exactly equal, scheme and trailing slash included.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { db } from "@/lib/db/client";
|
|||||||
import { memories, projects, projectShares } from "@/lib/db/schema";
|
import { memories, projects, projectShares } from "@/lib/db/schema";
|
||||||
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
|
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
|
||||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
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 { Badge } from "@/app/_components/ui/badge";
|
||||||
import { Button } from "@/app/_components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import { EmptyState } from "@/app/_components/ui/empty-state";
|
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 { projects, users } from "@/lib/db/schema";
|
||||||
import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access";
|
import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access";
|
||||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
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 { Badge } from "@/app/_components/ui/badge";
|
||||||
import { Button } from "@/app/_components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import { EmptyState } from "@/app/_components/ui/empty-state";
|
import { EmptyState } from "@/app/_components/ui/empty-state";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
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 { auth } from "@/auth";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import { db } from "@/lib/db/client";
|
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 { NextResponse } from "next/server";
|
||||||
import { env } from "@/lib/env";
|
import { buildResourceMetadata, publicOrigin } from "@/lib/auth/resource-metadata";
|
||||||
import { mcpIssuer } from "@/lib/auth/jwt";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -10,25 +9,12 @@ export const dynamic = "force-dynamic";
|
|||||||
*
|
*
|
||||||
* MCP clients discover the authorization server (Authentik) via this
|
* MCP clients discover the authorization server (Authentik) via this
|
||||||
* endpoint after receiving a 401 with `WWW-Authenticate: resource_metadata=...`.
|
* 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() {
|
export function GET() {
|
||||||
const resource = env().PUBLIC_URL.replace(/\/$/, "");
|
return NextResponse.json(buildResourceMetadata(publicOrigin()));
|
||||||
|
|
||||||
// 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}/`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@
|
|||||||
Three retrieval signals - vector, full-text, tags - converging on a single
|
Three retrieval signals - vector, full-text, tags - converging on a single
|
||||||
memory. The direct match runs straight through at full strength; the two
|
memory. The direct match runs straight through at full strength; the two
|
||||||
ranked neighbours fall back, which is the fusion the search actually does.
|
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">
|
<g fill="none" stroke-linecap="round" stroke-width="7">
|
||||||
<path d="M13 15C25 15 26 32 35 32" stroke="#0092fd" opacity=".55"/>
|
<path d="M12 16C24 16 25 32 30 32" stroke="#0092fd" opacity=".7"/>
|
||||||
<path d="M13 32H35" stroke="#49a9ff"/>
|
<path d="M12 32H30" stroke="#49a9ff"/>
|
||||||
<path d="M13 49C25 49 26 32 35 32" stroke="#0092fd" opacity=".55"/>
|
<path d="M12 48C24 48 25 32 30 32" stroke="#0092fd" opacity=".7"/>
|
||||||
</g>
|
</g>
|
||||||
<circle cx="45" cy="32" r="7.5" fill="#76c0ff"/>
|
<circle cx="46" cy="32" r="8.5" fill="#76c0ff"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 847 B After Width: | Height: | Size: 1.1 KiB |
@@ -1,7 +1,6 @@
|
|||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import { db } from "@/lib/db/client";
|
import { oidClaim, resolveUserId } from "@/lib/auth/identity";
|
||||||
import { users } from "@/lib/db/schema";
|
|
||||||
import { syncUserGroupsFromClaim } from "@/lib/auth/sync-groups";
|
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;
|
const iss = (profile.iss as string | undefined) ?? env().OIDC_ISSUER;
|
||||||
if (!sub) throw new Error("OIDC profile missing `sub` claim");
|
if (!sub) throw new Error("OIDC profile missing `sub` claim");
|
||||||
|
|
||||||
const row = await db
|
// Shared with the MCP path (lib/mcp/context.ts). On EntraID the `oid`
|
||||||
.insert(users)
|
// claim is what keeps the two surfaces resolving to one account —
|
||||||
.values({
|
// `sub` differs per app registration there. See lib/auth/identity.ts.
|
||||||
oidcSub: sub,
|
const userId = await resolveUserId({
|
||||||
oidcIss: iss,
|
iss,
|
||||||
email: profile.email ?? null,
|
sub,
|
||||||
name: profile.name ?? null,
|
oid: oidClaim(profile),
|
||||||
picture: (profile.picture as string | undefined) ?? null,
|
email: profile.email ?? null,
|
||||||
})
|
name: profile.name ?? null,
|
||||||
.onConflictDoUpdate({
|
picture: (profile.picture as string | undefined) ?? null,
|
||||||
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 });
|
|
||||||
|
|
||||||
const userId = row[0]?.id;
|
|
||||||
token.userId = userId;
|
token.userId = userId;
|
||||||
token.sub = sub;
|
token.sub = sub;
|
||||||
token.iss = iss;
|
token.iss = iss;
|
||||||
@@ -71,10 +61,15 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
|||||||
// wipes the user's existing memberships, which is the conservative
|
// wipes the user's existing memberships, which is the conservative
|
||||||
// choice (don't keep stale grants alive if the IdP stopped
|
// choice (don't keep stale grants alive if the IdP stopped
|
||||||
// asserting them).
|
// 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) {
|
if (userId) {
|
||||||
// `profile.groups` is untyped at the next-auth boundary — coerce.
|
await syncUserGroupsFromClaim(userId, iss, profile);
|
||||||
const claimGroups = (profile as { groups?: unknown }).groups;
|
|
||||||
await syncUserGroupsFromClaim(userId, iss, claimGroups);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,13 +2,14 @@ import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
|
|||||||
import type { JWTPayload } from "jose";
|
import type { JWTPayload } from "jose";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import { CLI_TOKEN_KID, tokenKid, verifyCliToken } from "./cli-token";
|
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
|
* Authenticates a bearer token presented to the MCP endpoint. Two token
|
||||||
* kinds are accepted, dispatched by the JWT `kid` header:
|
* kinds are accepted, dispatched by the JWT `kid` header:
|
||||||
*
|
*
|
||||||
* - Authentik-issued OIDC access tokens (any kid) — verified against
|
* - IdP-issued OIDC access tokens (any kid) — verified against the
|
||||||
* Authentik's JWKS over the network.
|
* issuer's JWKS over the network, located via OIDC discovery.
|
||||||
* - CLI tokens minted at /connect (kid="cli-v1") — verified locally
|
* - CLI tokens minted at /connect (kid="cli-v1") — verified locally
|
||||||
* with the HMAC CLI_TOKEN_SECRET.
|
* 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.
|
* This is distinct from the NextAuth session cookie path used by the Web UI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
type JwkSet = ReturnType<typeof createRemoteJWKSet>;
|
||||||
|
|
||||||
type GlobalWithJwks = typeof globalThis & {
|
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;
|
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
|
* 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
|
* the IdP from the Web UI, and Authentik stamps each token with its own
|
||||||
* application slug, so this is NOT interchangeable with OIDC_ISSUER.
|
* 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 {
|
export function mcpIssuer(): string {
|
||||||
return env().OIDC_ISSUER_MCP ?? env().OIDC_ISSUER;
|
return env().OIDC_ISSUER_MCP ?? env().OIDC_ISSUER;
|
||||||
@@ -50,16 +72,88 @@ function acceptedIssuers(): [string, string] {
|
|||||||
return [bare, `${bare}/`];
|
return [bare, `${bare}/`];
|
||||||
}
|
}
|
||||||
|
|
||||||
function jwks() {
|
const JWKS_OPTIONS = {
|
||||||
if (g.__sharedMemoryJwks) return g.__sharedMemoryJwks;
|
cacheMaxAge: 10 * 60 * 1000, // 10 min
|
||||||
// Authentik discovery is at `${issuer}/.well-known/openid-configuration`;
|
cooldownDuration: 30 * 1000,
|
||||||
// the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`.
|
} as const;
|
||||||
// Authentik canonically serves `${issuer}/jwks/`.
|
|
||||||
const url = new URL(`${mcpIssuer().replace(/\/$/, "")}/jwks/`);
|
/** How long to keep serving a fallback key set before retrying discovery. */
|
||||||
g.__sharedMemoryJwks = createRemoteJWKSet(url, {
|
const DISCOVERY_RETRY_COOLDOWN_MS = 60 * 1000;
|
||||||
cacheMaxAge: 10 * 60 * 1000, // 10 min
|
|
||||||
cooldownDuration: 30 * 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;
|
return g.__sharedMemoryJwks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +237,7 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
|
|||||||
} as AuthenticatedClaims;
|
} as AuthenticatedClaims;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { payload } = await jwtVerify(token, jwks(), {
|
const { payload } = await jwtVerify(token, await jwks(), {
|
||||||
issuer: acceptedIssuers(),
|
issuer: acceptedIssuers(),
|
||||||
audience: env().OIDC_AUDIENCE,
|
audience: env().OIDC_AUDIENCE,
|
||||||
});
|
});
|
||||||
@@ -153,6 +247,17 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
|
|||||||
buildWwwAuthenticate("invalid_token", "missing sub"),
|
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.
|
// Normalize the issuer for identity purposes.
|
||||||
//
|
//
|
||||||
// The token was just verified against mcpIssuer() — that check is done.
|
// The token was just verified against mcpIssuer() — that check is done.
|
||||||
|
|||||||
@@ -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()}/`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,34 +2,99 @@ import { and, eq, notInArray, sql } from "drizzle-orm";
|
|||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { groups, userGroups } from "@/lib/db/schema";
|
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.
|
* 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;
|
* Claim shape: `string[]`. Authentik emits group *names* directly here;
|
||||||
* Keycloak and Okta likewise (with the right mappers configured). EntraID,
|
* Keycloak and Okta likewise (with the right mappers configured). EntraID
|
||||||
* when correctly configured per README, emits names too — but the default
|
* emits object-id GUIDs by default — `cloud_displayname` gets you names, but
|
||||||
* "groups" optional-claim variant emits object-id GUIDs instead, and if the
|
* only under `groupMembershipClaims: "ApplicationGroup"`, and only for
|
||||||
* user is in too many groups EntraID switches to a "groups overage"
|
* directly assigned groups. See docs/oidc-entra-id.md §10a.
|
||||||
* indicator (no group list at all). We take the conservative path:
|
|
||||||
*
|
*
|
||||||
* - whatever strings appear in the claim are treated as names verbatim
|
* - 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;
|
* 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).
|
* fix it at the IdP layer (we don't attempt resolution).
|
||||||
* - if the claim is missing/empty, the user is treated as having zero
|
* - if the claim is missing/empty, the user is treated as having zero
|
||||||
* groups and all existing memberships are deleted.
|
* groups and all existing memberships are deleted. That is the
|
||||||
* - groups overage (where EntraID emits `_claim_names.groups` instead of
|
* conservative reading: don't keep stale grants alive once the IdP has
|
||||||
* `groups`) is not handled in v1 — the user appears as having no
|
* stopped asserting them.
|
||||||
* groups. Documented limit; revisit if it bites someone.
|
* - 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
|
* The whole operation runs in a single transaction so the membership
|
||||||
* snapshot is atomic (no window where a user partially has new memberships
|
* snapshot is atomic (no window where a user partially has new memberships
|
||||||
* and still has stale ones).
|
* and still has stale ones).
|
||||||
|
*
|
||||||
|
* @throws {GroupsOverageError} when the claims carry an overage indicator.
|
||||||
*/
|
*/
|
||||||
export async function syncUserGroupsFromClaim(
|
export async function syncUserGroupsFromClaim(
|
||||||
userId: string,
|
userId: string,
|
||||||
oidcIss: string,
|
oidcIss: string,
|
||||||
rawClaim: unknown,
|
claims: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (detectGroupsOverage(claims)) throw new GroupsOverageError();
|
||||||
|
|
||||||
|
const rawClaim = (claims as { groups?: unknown } | null | undefined)?.groups;
|
||||||
const names = normalizeGroupsClaim(rawClaim);
|
const names = normalizeGroupsClaim(rawClaim);
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
|||||||
@@ -53,6 +53,21 @@ export const users = pgTable(
|
|||||||
oidcSub: text("oidc_sub").notNull(),
|
oidcSub: text("oidc_sub").notNull(),
|
||||||
// OIDC `iss` so we can disambiguate if we ever federate.
|
// OIDC `iss` so we can disambiguate if we ever federate.
|
||||||
oidcIss: text("oidc_iss").notNull(),
|
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"),
|
email: text("email"),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
picture: text("picture"),
|
picture: text("picture"),
|
||||||
@@ -61,6 +76,11 @@ export const users = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
uniqueIss: uniqueIndex("users_iss_sub_uq").on(t.oidcIss, t.oidcSub),
|
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`),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,20 @@ const envSchema = z.object({
|
|||||||
// Defaults to the `aud-<audience>` convention used in the README setup.
|
// Defaults to the `aud-<audience>` convention used in the README setup.
|
||||||
OIDC_AUDIENCE_SCOPE: optional(z.string().min(1)),
|
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
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
|
|
||||||
@@ -119,6 +133,7 @@ function buildPhaseStub(): Env {
|
|||||||
CLI_TOKEN_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
|
CLI_TOKEN_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
|
||||||
PLUGIN_MARKETPLACE_NAME: "shared-memory",
|
PLUGIN_MARKETPLACE_NAME: "shared-memory",
|
||||||
ALLOW_INSECURE_HTTP: false,
|
ALLOW_INSECURE_HTTP: false,
|
||||||
|
OIDC_OFFLINE_ACCESS: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { users, groups, userGroups } from "@/lib/db/schema";
|
import { groups, userGroups } from "@/lib/db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { oidClaim, resolveUserId } from "@/lib/auth/identity";
|
||||||
import type { AuthenticatedClaims } from "@/lib/auth/jwt";
|
import type { AuthenticatedClaims } from "@/lib/auth/jwt";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,37 +56,18 @@ export async function userContextFromClaims(
|
|||||||
const name = (claims.name as string | undefined) ?? null;
|
const name = (claims.name as string | undefined) ?? null;
|
||||||
const picture = (claims.picture as string | undefined) ?? null;
|
const picture = (claims.picture as string | undefined) ?? null;
|
||||||
|
|
||||||
const row = await db
|
// Shared with the Web UI sign-in path (auth.ts). Keeping one resolver is
|
||||||
.insert(users)
|
// what stops the two surfaces disagreeing about who a user is — on EntraID
|
||||||
.values({
|
// they see different `sub` values for the same person and would otherwise
|
||||||
oidcSub: claims.sub,
|
// each create their own account. See lib/auth/identity.ts.
|
||||||
oidcIss: claims.iss,
|
const userId = await resolveUserId({
|
||||||
email,
|
iss: claims.iss,
|
||||||
name,
|
sub: claims.sub,
|
||||||
picture,
|
oid: oidClaim(claims),
|
||||||
})
|
email,
|
||||||
.onConflictDoUpdate({
|
name,
|
||||||
target: [users.oidcIss, users.oidcSub],
|
picture,
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// OIDC bearer tokens carry a `groups` claim (when the IdP is configured to
|
// OIDC bearer tokens carry a `groups` claim (when the IdP is configured to
|
||||||
// emit it). CLI tokens never do — they go through verifyCliToken which
|
// emit it). CLI tokens never do — they go through verifyCliToken which
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
MemoryIdInput,
|
MemoryIdInput,
|
||||||
MemoryDeleteInput,
|
MemoryDeleteInput,
|
||||||
MemoryListInput,
|
MemoryListInput,
|
||||||
|
MemoryPatchInput,
|
||||||
MemorySearchInput,
|
MemorySearchInput,
|
||||||
MemoryUpdateInput,
|
MemoryUpdateInput,
|
||||||
MemoryWriteInput,
|
MemoryWriteInput,
|
||||||
@@ -20,20 +21,22 @@ import {
|
|||||||
SnippetListInput,
|
SnippetListInput,
|
||||||
SnippetDeleteInput,
|
SnippetDeleteInput,
|
||||||
} from "@shared-memory/schemas";
|
} from "@shared-memory/schemas";
|
||||||
import { embedText } from "@/lib/embedder";
|
|
||||||
import { searchMemories } from "@/lib/memories";
|
import { searchMemories } from "@/lib/memories";
|
||||||
|
import {
|
||||||
|
createMemory,
|
||||||
|
patchMemory,
|
||||||
|
softDeleteMemory,
|
||||||
|
updateMemory,
|
||||||
|
type Actor,
|
||||||
|
type ProjectResolver,
|
||||||
|
} from "@/lib/memory-mutations";
|
||||||
import {
|
import {
|
||||||
getSnippet,
|
getSnippet,
|
||||||
putSnippet,
|
putSnippet,
|
||||||
listSnippets,
|
listSnippets,
|
||||||
softDeleteSnippet,
|
softDeleteSnippet,
|
||||||
} from "@/lib/snippets";
|
} from "@/lib/snippets";
|
||||||
import {
|
import { getProjectAccess, readableProjectIds } from "@/lib/access";
|
||||||
CONCURRENT_EDIT_ERROR,
|
|
||||||
canWriteProject,
|
|
||||||
getProjectAccess,
|
|
||||||
readableProjectIds,
|
|
||||||
} from "@/lib/access";
|
|
||||||
import type { UserContext } from "./context";
|
import type { UserContext } from "./context";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,6 +154,27 @@ function withDefaultProject(
|
|||||||
return { ...obj, project: ctx.defaultProjectKey };
|
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 ----------
|
// ---------- tools ----------
|
||||||
|
|
||||||
const projectIdentify: ToolDef = {
|
const projectIdentify: ToolDef = {
|
||||||
@@ -373,55 +397,18 @@ const memoryWrite: ToolDef = {
|
|||||||
const parsed = MemoryWriteInput.safeParse(withDefaultProject(args, ctx));
|
const parsed = MemoryWriteInput.safeParse(withDefaultProject(args, ctx));
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
const scope = parsed.data.scope;
|
// Fold the X-Project-Key fallback in before the shared path sees it.
|
||||||
let projectId: string | null = null;
|
const input = {
|
||||||
let projectKey: string | undefined = undefined;
|
...parsed.data,
|
||||||
if (scope === "project") {
|
project: projectKeyOrDefault(ctx, parsed.data.project),
|
||||||
projectKey = projectKeyOrDefault(ctx, parsed.data.project);
|
};
|
||||||
if (!projectKey) {
|
if (input.scope === "project" && !input.project) {
|
||||||
return err("scope=project requires `project` key (or X-Project-Key header)");
|
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}'`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed inline so the new memory is searchable immediately. Slower
|
const res = await createMemory(mcpActor(ctx), input, mcpProjectResolver(ctx));
|
||||||
// writes (~50–150 ms) are an acceptable price for that guarantee; if
|
if (!res.ok) return err(res.error);
|
||||||
// embedder pressure ever forces an async path, only this section
|
return ok(res.value, `wrote memory ${res.value.id}`);
|
||||||
// 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}`);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -508,8 +495,28 @@ const memoryGet: ToolDef = {
|
|||||||
const parsed = MemoryIdInput.safeParse(args);
|
const parsed = MemoryIdInput.safeParse(args);
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
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
|
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)
|
.from(memories)
|
||||||
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
|
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -518,8 +525,8 @@ const memoryGet: ToolDef = {
|
|||||||
|
|
||||||
// Authorize read: own row, OR project-scope row in an accessible
|
// Authorize read: own row, OR project-scope row in an accessible
|
||||||
// project. Anything else looks "not found" to the caller.
|
// project. Anything else looks "not found" to the caller.
|
||||||
const m = row[0];
|
const { userId, ...m } = row[0];
|
||||||
if (m.userId !== ctx.userId) {
|
if (userId !== ctx.userId) {
|
||||||
if (!m.projectId) return err("not found");
|
if (!m.projectId) return err("not found");
|
||||||
const access = await getProjectAccess(ctx.userId, ctx.groups, m.projectId);
|
const access = await getProjectAccess(ctx.userId, ctx.groups, m.projectId);
|
||||||
if (access === null) return err("not found");
|
if (access === null) return err("not found");
|
||||||
@@ -550,57 +557,9 @@ const memoryDelete: ToolDef = {
|
|||||||
const parsed = MemoryDeleteInput.safeParse(args);
|
const parsed = MemoryDeleteInput.safeParse(args);
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
// Look up the row first to authorize and capture its current version
|
const res = await softDeleteMemory(mcpActor(ctx), parsed.data);
|
||||||
// for the CAS. Shared-project writes need a per-project access check.
|
if (!res.ok) return err(res.error);
|
||||||
const target = await db
|
return ok({ id: res.value.id, deleted: true }, `deleted memory ${res.value.id}`);
|
||||||
.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}`);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -638,124 +597,51 @@ const memoryUpdate: ToolDef = {
|
|||||||
const parsed = MemoryUpdateInput.safeParse(withDefaultProject(args, ctx));
|
const parsed = MemoryUpdateInput.safeParse(withDefaultProject(args, ctx));
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
const existingRows = await db
|
const res = await updateMemory(mcpActor(ctx), parsed.data, mcpProjectResolver(ctx));
|
||||||
.select({
|
if (!res.ok) return err(res.error);
|
||||||
id: memories.id,
|
return ok(res.value, `updated memory ${res.value.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");
|
|
||||||
|
|
||||||
// Authorize write.
|
const memoryPatch: ToolDef = {
|
||||||
if (existing.scope === "user") {
|
name: "memory.patch",
|
||||||
if (existing.userId !== ctx.userId) return err("not found");
|
description:
|
||||||
} else if (existing.projectId) {
|
"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.",
|
||||||
const allowed = await canWriteProject(ctx.userId, ctx.groups, existing.projectId);
|
inputSchema: {
|
||||||
if (!allowed) return err("no write access to this project");
|
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> = {
|
const res = await patchMemory(mcpActor(ctx), parsed.data);
|
||||||
updatedAt: new Date(),
|
if (!res.ok) return err(res.error);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
let scopeChanged = false;
|
const { id, delta, contentLength } = res.value;
|
||||||
let projectChanged = false;
|
return ok(
|
||||||
let newProjectKey: string | null = existing.projectKey ?? null;
|
res.value,
|
||||||
|
`patched memory ${id} (${delta >= 0 ? "+" : ""}${delta} chars, now ${contentLength})`,
|
||||||
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 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,
|
projectIdentify,
|
||||||
memoryWrite,
|
memoryWrite,
|
||||||
memoryUpdate,
|
memoryUpdate,
|
||||||
|
memoryPatch,
|
||||||
memoryList,
|
memoryList,
|
||||||
memoryGet,
|
memoryGet,
|
||||||
memorySearch,
|
memorySearch,
|
||||||
|
|||||||
@@ -2,37 +2,36 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
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 { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { memories, projects, auditLog } from "@/lib/db/schema";
|
import { projects } from "@/lib/db/schema";
|
||||||
import { embedText } from "@/lib/embedder";
|
|
||||||
import { resolveProjectId, upsertProject } from "@/lib/projects";
|
import { resolveProjectId, upsertProject } from "@/lib/projects";
|
||||||
import {
|
import {
|
||||||
MemoryWriteInput,
|
MemoryWriteInput,
|
||||||
MemoryUpdateInput,
|
MemoryUpdateInput,
|
||||||
MemoryDeleteInput,
|
MemoryDeleteInput,
|
||||||
} from "@shared-memory/schemas";
|
} from "@shared-memory/schemas";
|
||||||
|
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
|
||||||
import {
|
import {
|
||||||
CONCURRENT_EDIT_ERROR,
|
createMemory,
|
||||||
canWriteProject,
|
softDeleteMemory,
|
||||||
getUserGroupNames,
|
updateMemory,
|
||||||
readableProjectIds,
|
type Actor,
|
||||||
} from "@/lib/access";
|
type Outcome,
|
||||||
|
type ProjectResolver,
|
||||||
|
} from "@/lib/memory-mutations";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server Actions for memory CRUD from the Web UI. Mirrors the MCP tools
|
* Server Actions for memory CRUD from the Web UI.
|
||||||
* but writes through the same DB layer, so updates and deletes here are
|
*
|
||||||
* indistinguishable from those made via Claude Code.
|
* 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.
|
* `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> {
|
async function requireUserId(): Promise<string> {
|
||||||
@@ -41,6 +40,52 @@ async function requireUserId(): Promise<string> {
|
|||||||
return session.user.id;
|
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[] {
|
function parseTags(raw: FormDataEntryValue | null): string[] {
|
||||||
if (typeof raw !== "string") return [];
|
if (typeof raw !== "string") return [];
|
||||||
return raw
|
return raw
|
||||||
@@ -50,95 +95,26 @@ function parseTags(raw: FormDataEntryValue | null): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createMemoryAction(formData: FormData) {
|
export async function createMemoryAction(formData: FormData) {
|
||||||
const userId = await requireUserId();
|
const { actor, resolveProject } = await webActor();
|
||||||
const groupNames = await getUserGroupNames(userId);
|
|
||||||
|
|
||||||
const payload = {
|
const parsed = MemoryWriteInput.safeParse({
|
||||||
content: String(formData.get("content") ?? "").trim(),
|
content: String(formData.get("content") ?? "").trim(),
|
||||||
scope: (formData.get("scope") as "project" | "user") || "project",
|
scope: (formData.get("scope") as "project" | "user") || "project",
|
||||||
project: (formData.get("project") as string | null)?.trim() || undefined,
|
project: (formData.get("project") as string | null)?.trim() || undefined,
|
||||||
tags: parseTags(formData.get("tags")),
|
tags: parseTags(formData.get("tags")),
|
||||||
};
|
});
|
||||||
const parsed = MemoryWriteInput.safeParse(payload);
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
|
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
|
||||||
}
|
}
|
||||||
|
|
||||||
let projectId: string | null = null;
|
const created = must(await createMemory(actor, parsed.data, resolveProject));
|
||||||
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 ?? [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/memories");
|
revalidatePath("/memories");
|
||||||
redirect(`/memories/${inserted[0]!.id}`);
|
redirect(`/memories/${created.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMemoryAction(formData: FormData) {
|
export async function updateMemoryAction(formData: FormData) {
|
||||||
const userId = await requireUserId();
|
const { actor, resolveProject } = await webActor();
|
||||||
const groupNames = await getUserGroupNames(userId);
|
|
||||||
|
|
||||||
const id = String(formData.get("id") ?? "");
|
const id = String(formData.get("id") ?? "");
|
||||||
const rawScope = formData.get("scope");
|
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("; "));
|
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the row regardless of ownership — we may be editing a shared
|
must(await updateMemory(actor, parsed.data, resolveProject));
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath(`/memories/${parsed.data.id}`);
|
revalidatePath(`/memories/${parsed.data.id}`);
|
||||||
revalidatePath("/memories");
|
revalidatePath("/memories");
|
||||||
@@ -318,8 +148,7 @@ export async function updateMemoryAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMemoryAction(formData: FormData) {
|
export async function deleteMemoryAction(formData: FormData) {
|
||||||
const userId = await requireUserId();
|
const { actor } = await webActor();
|
||||||
const groupNames = await getUserGroupNames(userId);
|
|
||||||
const id = String(formData.get("id") ?? "");
|
const id = String(formData.get("id") ?? "");
|
||||||
const rawVersion = formData.get("version");
|
const rawVersion = formData.get("version");
|
||||||
const version =
|
const version =
|
||||||
@@ -332,53 +161,7 @@ export async function deleteMemoryAction(formData: FormData) {
|
|||||||
});
|
});
|
||||||
if (!parsed.success) throw new Error(parsed.error.issues[0]!.message);
|
if (!parsed.success) throw new Error(parsed.error.issues[0]!.message);
|
||||||
|
|
||||||
// Authorize delete: same rule as update — owner OR rw on the project.
|
must(await softDeleteMemory(actor, parsed.data));
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/memories");
|
revalidatePath("/memories");
|
||||||
redirect("/memories");
|
redirect("/memories");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,7 +7,9 @@
|
|||||||
"dev": "next dev --port 3000",
|
"dev": "next dev --port 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start --port 3000",
|
"start": "next start --port 3000",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx ./scripts/migrate.ts",
|
"db:migrate": "tsx ./scripts/migrate.ts",
|
||||||
@@ -26,16 +28,18 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.3.6",
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
|
||||||
"drizzle-kit": "^0.30.1",
|
"drizzle-kit": "^0.30.1",
|
||||||
"esbuild": "^0.24.2",
|
"esbuild": "^0.24.2",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-next": "^15.1.0",
|
"eslint-config-next": "^15.1.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export default {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
"@tailwindcss/postcss": {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|||||||
@@ -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 |
@@ -3,12 +3,20 @@
|
|||||||
<!--
|
<!--
|
||||||
Transparent, currentColor variant of the mark for in-app use - inherits
|
Transparent, currentColor variant of the mark for in-app use - inherits
|
||||||
the surrounding text color so it works on any surface. The tile version
|
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">
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="7">
|
||||||
<path d="M13 15C25 15 27 32 37 32" opacity=".45"/>
|
<path d="M12 16C24 16 25 32 30 32" opacity=".7"/>
|
||||||
<path d="M13 32H37"/>
|
<path d="M12 32H30"/>
|
||||||
<path d="M13 49C25 49 27 32 37 32" opacity=".7"/>
|
<path d="M12 48C24 48 25 32 30 32" opacity=".7"/>
|
||||||
</g>
|
</g>
|
||||||
<circle cx="43" cy="32" r="8" fill="currentColor"/>
|
<circle cx="46" cy="32" r="8.5" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 648 B After Width: | Height: | Size: 1.1 KiB |
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
@@ -117,6 +117,7 @@ services:
|
|||||||
# added to .env but not listed here never reaches the container.
|
# added to .env but not listed here never reaches the container.
|
||||||
OIDC_ISSUER_MCP: ${OIDC_ISSUER_MCP:-}
|
OIDC_ISSUER_MCP: ${OIDC_ISSUER_MCP:-}
|
||||||
OIDC_AUDIENCE_SCOPE: ${OIDC_AUDIENCE_SCOPE:-}
|
OIDC_AUDIENCE_SCOPE: ${OIDC_AUDIENCE_SCOPE:-}
|
||||||
|
OIDC_OFFLINE_ACCESS: ${OIDC_OFFLINE_ACCESS:-}
|
||||||
PLUGIN_MARKETPLACE_URL: ${PLUGIN_MARKETPLACE_URL:-}
|
PLUGIN_MARKETPLACE_URL: ${PLUGIN_MARKETPLACE_URL:-}
|
||||||
PLUGIN_MARKETPLACE_NAME: ${PLUGIN_MARKETPLACE_NAME:-shared-memory}
|
PLUGIN_MARKETPLACE_NAME: ${PLUGIN_MARKETPLACE_NAME:-shared-memory}
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -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.
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
"dev": "pnpm --filter @shared-memory/web dev",
|
"dev": "pnpm --filter @shared-memory/web dev",
|
||||||
"lint": "pnpm -r lint",
|
"lint": "pnpm -r lint",
|
||||||
|
"test": "pnpm -r test",
|
||||||
"typecheck": "pnpm -r typecheck",
|
"typecheck": "pnpm -r typecheck",
|
||||||
"db:generate": "pnpm --filter @shared-memory/web db:generate",
|
"db:generate": "pnpm --filter @shared-memory/web db:generate",
|
||||||
"db:migrate": "pnpm --filter @shared-memory/web db:migrate"
|
"db:migrate": "pnpm --filter @shared-memory/web db:migrate"
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export const ProjectKey = z
|
|||||||
.regex(/^[a-zA-Z0-9._\-/]+$/, "project key may only contain alphanumerics, ._-/");
|
.regex(/^[a-zA-Z0-9._\-/]+$/, "project key may only contain alphanumerics, ._-/");
|
||||||
export type ProjectKey = z.infer<typeof ProjectKey>;
|
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
|
export const Tags = z
|
||||||
.array(z.string().min(1).max(64).regex(/^[a-zA-Z0-9._\-]+$/, "tag must be alphanumeric ._-"))
|
.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>;
|
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({
|
export const MemorySearchInput = z.object({
|
||||||
query: z.string().min(1).max(2000),
|
query: z.string().min(1).max(2000),
|
||||||
project: ProjectKey.optional(),
|
project: ProjectKey.optional(),
|
||||||
|
|||||||
@@ -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
|
`https://${domain_name}/api/auth/callback/oidc`, so plan the domain name
|
||||||
*before* configuring the IdP.
|
*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
|
## Quick start
|
||||||
|
|||||||