Foundational work for the upcoming group-scoped sharing feature.
Schema (migration 0003_groups.sql + drizzle schema):
- memory_access enum ('ro' | 'rw') reserved for Agent B's project_shares
- groups (id, oidc_iss, name, display_name, …) keyed by (oidc_iss, name)
so different IdPs can both have e.g. "platform" without colliding
- user_groups (user_id, group_id, synced_at) PK (user_id, group_id)
Auth (auth.ts + lib/auth/sync-groups.ts):
- jwt callback now syncs `profile.groups` after upserting the user
- syncUserGroupsFromClaim runs in a single tx: upserts each group,
inserts new memberships, deletes ones no longer in the claim
- missing/empty claim → user has zero groups (wipe memberships)
- EntraID GUID-vs-name edge case: we treat whatever strings the claim
emits as names verbatim; groups overage (>200 groups → no claim)
is documented as unsupported in v1
UserContext + JWT (lib/mcp/context.ts, lib/auth/jwt.ts):
- AuthenticatedClaims.groups surfaced from verified JWT payload
- UserContext.groups: string[] — live from OIDC token claim, falls
back to DB snapshot for CLI (HMAC) tokens which carry no claim
- UserContext.defaultProjectKey: optional, set from header
MCP route (app/api/mcp/route.ts):
- reads X-Project-Key header, validates against ProjectKey Zod schema,
400 on invalid; empty/missing leaves defaultProjectKey undefined
- auto-upserts the header-supplied project so first-use works without
a separate project.identify call
Tools (lib/mcp/tools.ts):
- withDefaultProject helper injects ctx.defaultProjectKey when the
caller omits `project`. Per-tool defaultScope hint avoids breaking
snippet.put (user-scope default) while making memory.write
(project-scope default) honor the header
- applied to memory.write/list/search/update and all snippet.* tools
Web UI:
- /settings/groups debug page lists current memberships with synced_at
and a clear empty state pointing at README troubleshooting
- /settings/tokens grows a "Pin to project" dropdown; selected key is
baked into the generated `claude mcp add` snippet as
`--header "X-Project-Key: <key>"`. The JWT itself stays
identity-only — pinning is purely a UX shortcut
- settings landing page links to /settings/groups
- README troubleshooting bullet covers the empty-groups path for
Authentik / EntraID / Keycloak
Refactor:
- extracted resolveProjectId + upsertProject from memory-actions.ts
into lib/projects.ts so the MCP route can reuse upsertProject
Verification:
- pnpm typecheck clean
- SKIP_ENV_VALIDATION=true pnpm build clean; /settings/groups in route table
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snippets are exact-name-keyed templates (PR formats, checklists, style
rules) — prescriptive artifacts the user wants applied consistently.
Distinct from memories, which are searched descriptive prose.
- Migration 0002 adds scope/project_id/deleted_at to snippets, partial
unique indexes per scope, and a scope/project CHECK constraint
mirroring memories_scope_project_chk.
- New @shared-memory/schemas: SnippetName/Put/Get/List/Delete inputs
with shared scope-project refinement.
- lib/snippets.ts: get/put/list/softDelete helpers used by both the
Web Server Actions and the MCP tool handlers.
- Four new MCP tools (snippet.put/get/list/delete) with directive
descriptions contrasting against memory.* (exact-name lookup vs
search; templates vs facts).
- /snippets pages: list, new, [name] with edit + delete and a
scope-picker when a name lives in multiple scopes.
- Nav: Snippets link between Memories and Projects.
Design note: when a name exists in both user and project scope,
snippet.get without an explicit scope prefers project (if project key
given) then falls back to user. The detail page shows a picker when
the name is ambiguous and no scope query param was supplied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends both the Web UI edit form and the memory.update MCP tool so
existing memories can be reclassified between user-global and
project-attached scopes without delete+rewrite. Schema refines enforce
the user/project consistency invariants; audit log captures from/to
scope and projectKey on transitions.
Server components can't pass function props (onSubmit handlers etc.) to
client-rendered HTML elements. The handler here was a no-op leftover —
removing it fixes the 500 'Event handlers cannot be passed to Client
Component props' on /memories/[id].
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the debug /me + /connect pages with a real authed app shell.
Pages
- / — anonymous landing; redirects to /dashboard once signed in
- /dashboard — recent memories + top projects, quick "new memory" action
- /memories — searchable list with hybrid (vector+FTS+tags) scoring;
per-result rank breakdown shown inline
- /memories/[id] — view + inline edit toggle + delete
- /memories/new — create form with project autocomplete
- /projects — list with memory counts and last-activity
- /projects/[key] — that project's memories
- /settings — read-only Authentik profile + link to tokens
- /settings/tokens — list / create / revoke CLI tokens
Old URLs preserved as redirects:
- /me → /dashboard
- /connect → /settings/tokens
Stack additions
- Tailwind v4 with CSS-first @theme tokens (dark only for now)
- App shell in app/(authed)/ — auth guard + top nav with global search box
- Lightweight UI primitives in app/_components/ui/ (Button, Input, Card,
Badge, EmptyState, Container, PageHeader)
- Search logic extracted from MCP tool into lib/memories.ts so Web UI and
MCP both call the same RRF code path
- Memory CRUD via Server Actions in lib/memory-actions.ts; audit_log
rows are tagged actor='web' to distinguish from MCP writes
Per-token revoke
- New cli_tokens table (id, user_id, jti unique, name, created_at,
last_used_at, expires_at, revoked_at) — migration 0001_cli_tokens.sql
- mintCliToken now records jti + name; verifyCliToken enforces revocation
for tracked tokens. Legacy tokens minted before this change (no jti)
are accepted on signature alone until they expire naturally.
- /settings/tokens lists active + revoked tokens with one-click revoke
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the manual-paste fallback page MCP clients hit when their OAuth
loopback callback isn't reachable (sealed containers, port-restricted
hosts). The page displays the authorization code, the full callback URL,
and the state parameter, all with copy buttons, plus instructions to
paste back into the waiting terminal. Single-use codes plus client-side
PKCE mean displaying the code here is safe — it isn't a credential by
itself.
README "Connecting Claude Code" rewritten to make OAuth the primary
path, with three options ordered by preference: (A) standard OAuth +
loopback, (B) manual paste via /auth/cli-callback, (C) static HMAC
bearer via /connect for fully headless setups. Also notes that the
zero-config plugin path is blocked on Authentik DCR (issue #8751,
expected later this year) so we're shipping the one-liner now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a second token kind alongside Authentik OIDC access tokens for MCP
authentication. When the user visits /connect after signing into the Web
UI, the server mints an HMAC-signed JWT (kid="cli-v1") carrying their
Authentik identity in oidc_iss / oidc_sub claims. The token is shown
once in React state — never put in the URL or persisted on the client.
The MCP endpoint's bearer-token verifier dispatches by JWT `kid` header:
CLI tokens are verified locally via HS256(CLI_TOKEN_SECRET); everything
else goes through Authentik JWKS. Both paths resolve to the same
AuthenticatedClaims shape so userContextFromClaims handles them
identically.
This unblocks MCP clients running in containers where the OAuth loopback
callback isn't reachable — paste the token into Claude Code as a static
Authorization header and skip the OAuth flow entirely.
Revocation in v1 is "rotate CLI_TOKEN_SECRET to invalidate every issued
CLI token at once." Per-token revocation can come later if needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end Phase 1 of shared-memory: a logged-in Authentik user can sign
into the Web UI (/me debug page), and an MCP client with an Authentik-
issued bearer token can call memory.write / memory.list / memory.get /
memory.delete plus project.identify against /api/mcp.
Stack:
- Next.js 15 (App Router) + React 19 + TypeScript, pnpm workspaces
- Drizzle ORM + Postgres 16 + pgvector + pg_trgm
- Auth.js v5 with Authentik provider (Web UI)
- jose + Authentik JWKS for MCP bearer-token validation
- JSON-RPC 2.0 dispatcher implementing the MCP wire protocol over plain
HTTP POST (hand-rolled to fit Next.js App Router; switches to SSE in a
later phase if server-initiated events are needed)
- bge-small embeddings sidecar deferred to Phase 2; the schema already
reserves the vector(384) column + IVFFlat index, FTS via a STORED
tsvector column, and the visibility enum (private/shared/team) so
cross-user memory sharing can be added without a future migration
Deployment supports two modes (set in .env, never committed):
- Behind an external reverse proxy (HAProxy / nginx / Cloudflare Tunnel /
Traefik) — DEFAULT; the app exposes APP_PORT on the host with
X-Forwarded-* trusted, no in-container TLS
- Built-in TLS via Caddy — opt-in with `docker compose --profile tls up`
Discovery endpoint at /.well-known/oauth-protected-resource (RFC 9728)
points MCP clients at the Authentik authorization server after a 401.
README walks through both Authentik providers (Web UI + MCP resource
server), the audience scope mapping, redirect URIs, and includes a worked
HAProxy config snippet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>