Compare commits

...
Author SHA1 Message Date
shadowdaoandClaude Opus 5 9486518832 fix: key MCP identity on the canonical issuer, not the token issuer
Verifying against OIDC_ISSUER_MCP fixed the 401, but would have introduced a
quieter bug. Identity is keyed on (oidc_iss, oidc_sub) and
userContextFromClaims UPSERTS rather than failing, so a token carrying the MCP
application's issuer would have created a SECOND user row for the same person:
MCP calls would succeed against an account holding none of their memories, and
nothing would appear broken.

Authentik's `sub` is `user.uid`, a user-level value that is identical across
providers (verified against the live instance), so the issuer is the only
differing component. Pin it to OIDC_ISSUER after verification.

No stray rows exist to clean up — verification failed before this path could
ever create one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:19:17 -07:00
jknapp b35a465303 Merge pull request 'fix: pass new optional env vars into the container' (#13) from fix/pass-new-env-vars into main 2026-07-27 13:14:18 +00:00
shadowdaoandClaude Opus 5 54c29d182d fix: actually pass the new optional env vars into the container
OIDC_ISSUER_MCP and OIDC_AUDIENCE_SCOPE were added to .env and read by the
app, but never reached it: the compose `environment:` block is an explicit
allow-list, not env_file, so anything not named there is silently dropped.
The aud scope only worked because its computed default happened to be right.

Also make optional vars tolerate the empty string. compose renders `${VAR:-}`
as "" rather than omitting the key, so an unset optional var would arrive as
"" and fail .url()/.min(1) validation — taking the app down at boot rather
than falling back to its default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:14:16 -07:00
jknapp 1728752ce1 Merge pull request 'fix: verify MCP tokens against the MCP application-s issuer' (#12) from fix/mcp-issuer into main 2026-07-27 13:10:37 +00:00
shadowdaoandClaude Opus 5 1fed65a187 fix: verify MCP tokens against the MCP application's issuer
Second failure on the same path. With the aud fix in place, tokens now carry
`aud: shared-memory` correctly but are still rejected — this time on `iss`.

The MCP endpoint is a separate application in the IdP from the Web UI, and
Authentik's default per_provider issuer mode stamps each token with its own
application slug. MCP tokens therefore carry
`.../application/o/shared-memory-mcp/` while OIDC_ISSUER points at
`.../application/o/shared-memory/`, so jwtVerify throws "claim invalid: iss".

Introduce OIDC_ISSUER_MCP (defaults to OIDC_ISSUER) and use it for both the
issuer check and the JWKS URL. The protected-resource metadata now advertises
that same issuer — previously it pointed clients at the Web UI's discovery
document while the tokens came from the MCP provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:10:35 -07:00
jknapp 7d4e8daaaf Merge pull request 'fix: advertise the audience scope so tokens actually carry aud' (#11) from fix/oauth-aud-scope into main 2026-07-27 12:58:57 +00:00
shadowdaoandClaude Opus 5 60cfb19772 fix: advertise the audience scope so tokens actually carry aud
The OAuth path to /api/mcp has never worked end to end. Every access token
arrived without an `aud` claim and jwt.ts rejected it with
"claim invalid: aud" (401), even though the handshake, consent and PKCE all
succeeded. Only the CLI HMAC path worked, because cli-token.ts sets the
audience itself — which is why this went unnoticed.

Cause: Authentik evaluates a scope mapping only when the client REQUESTS
that scope by name. An MCP client learns which scopes to request from
`scopes_supported` in our RFC 9728 protected-resource metadata, and we only
advertised openid/profile/email. So the `aud-shared-memory` mapping was
attached to the provider but never evaluated.

Advertise the audience scope in that metadata. Name is derived as
`aud-<OIDC_AUDIENCE>` to match the README convention, overridable with the
new optional OIDC_AUDIENCE_SCOPE for deployments that named it differently.

Also documents that Claude Code's RFC 8707 `resource` parameter is ignored
by Authentik 2026.5, so it cannot be relied on for audience binding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 05:58:56 -07:00
jknapp 2a1e81d80a Merge pull request 'chore: genericize plugin manifests for public release' (#10) from chore/genericize-plugin into main 2026-07-27 04:41:22 +00:00
shadowdaoandClaude Opus 5 8dff061faf chore: genericize plugin manifests for public release
main is now the shareable artifact: placeholder host and clientId, no
instance-specific hostnames, marketplace renamed to cybercove-labs.

The filled-in manifest for the live instance lives on branch
instance/dnspegasus and is installed with a #ref fragment, which
`claude plugin marketplace add` honors and persists even though it is
absent from --help (verified on Claude Code 2.1.220).

README: plugin install is now path A, documenting both the fork-and-edit
and the #ref branch approaches; remaining paths renumbered B/C/D.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:41:20 -07:00
jknapp 02fadb0955 Merge pull request 'chore: add MIT license' (#9) from chore/mit-license into main 2026-07-27 03:37:16 +00:00
shadowdaoandClaude Opus 5 063dc3ca00 chore: add MIT license
Prerequisite for making the repository public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:35:44 -07:00
jknapp 0cb749806c Merge pull request 'feat: add Claude Code plugin with pre-registered Authentik OAuth client' (#8) from feat/claude-code-plugin into main 2026-07-27 03:21:17 +00:00
shadowdaoandClaude Opus 5 d7182820f5 feat: add Claude Code plugin with pre-registered Authentik OAuth client
Claude Code's .mcp.json now accepts an `oauth` block with a pre-registered
clientId, so the plugin no longer depends on RFC 7591 Dynamic Client
Registration (still unshipped in Authentik — goauthentik/authentik#8751,
milestoned for 2026.8.0). This lets users install shared-memory as a plugin
instead of running the `claude mcp add --client-id ...` one-liner by hand.

OIDC_CLIENT_ID_MCP is a Public PKCE client, so committing it is safe; no
secret is involved. callbackPort 33418 matches the documented one-liner and
is covered by the loopback redirect regex on the Authentik provider.

Verified: both manifests pass `claude plugin validate`, and a local-path
marketplace install on Claude Code 2.1.220 preserves the oauth block through
to the installed cache. Remote (git-sourced) marketplace install is still
untested — see anthropics/claude-ai-mcp#359.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:19:20 -07:00
jknapp cb1bd31de6 Merge pull request 'fix: repair memory-list project filter + add type-ahead project dropdown' (#7) from fix/memory-list-project-filter into main 2026-07-01 23:22:13 +00:00
shadowdaoandClaude Opus 4.8 8d51fbff7a fix: repair memory-list project filter + add type-ahead project dropdown
The memory list page threw a Next.js server-side exception whenever a
project filter was applied. The filter built a raw Drizzle `sql` fragment
that interpolated a JS string[] into `ANY(${accessibleIds}::uuid[])`,
which doesn't bind as a Postgres array literal — the same array-binding
bug class already fixed in #1 (commits 3019446, b3f7e60) for
memory.list/snippet.list. The search path avoided it by using the `pg`
tag; the plain list path did not.

Fix: resolve the typed project key to a single project id from the
user's accessible set (owned ∪ shared, owned winning on key collision to
match project.identify and the search path), then filter with a plain
`eq(memories.projectId, resolvedId)` — fully parameterized, no raw array
interpolation. Returns empty when the key matches no readable project.
This also makes the list view's collision semantics consistent with the
search view.

Feature: replace the plain "Project key…" text input with a type-ahead
combobox (_project-combobox.tsx) populated with the user's accessible
project keys, narrowing as they type; selecting a suggestion applies the
filter immediately. Free-typed keys still submit. getAccessibleProjects
is hoisted to the page and reused for both the dropdown options and the
list WHERE clause (no extra query on the list path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:17:47 -07:00
jknapp 4c1cff160d Merge pull request 'docs: add CLAUDE.md memory + snippet reuse policy' (#6) from docs/memory-snippet-reuse-policy into main 2026-06-18 15:45:32 +00:00
jknapp 73bac01b4e Merge branch 'main' into docs/memory-snippet-reuse-policy 2026-06-18 15:42:55 +00:00
shadowdaoandClaude Opus 4.8 43fd99c808 docs: add CLAUDE.md memory + snippet reuse policy
Document the on-demand memory workflow and snippet (boilerplate/template)
reuse workflow for agents working in this repo: query shared-memory only
when detail is needed (don't bulk-load), and browse snippet_list before
recreating known boilerplate, fetching by exact name with snippet_get.

Mirrors the user-scope `consult-memory-before-work` shared-memory snippet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:42:25 -07:00
jknapp 41149fe709 Merge pull request 'fix(compose): app healthcheck uses 127.0.0.1 not localhost (fixes false unhealthy)' (#5) from fix/app-healthcheck-ipv4 into main 2026-06-12 19:19:47 +00:00
shadowdaoandClaude Opus 4.8 af1a6c8165 fix(compose): app healthcheck uses 127.0.0.1 not localhost
Inside the app container localhost resolves to ::1 (IPv6) first, but the
Next.js standalone server listens only on 0.0.0.0 (IPv4). The healthcheck
probed http://localhost:3000/api/health and got Connection refused on ::1,
so the container reported unhealthy for weeks despite serving 200 on both
/ and /api/health. Switch the probe to 127.0.0.1 to match the bound iface.

The db and embedder healthchecks already avoid localhost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:16:25 -07:00
jknapp 2a94acddf3 Merge pull request 'feat: configurable CLI token TTL (CLI_TOKEN_TTL_DAYS, default 90d)' (#4) from feat/configurable-cli-token-ttl into main 2026-06-12 19:01:40 +00:00
jknapp 0c11869af8 Merge pull request 'fix: memory.list tag filter (#1) + Web UI shared-project visibility (#2)' (#3) from fix/list-tag-filter-and-shared-project-visibility into main 2026-06-12 19:01:33 +00:00
shadowdaoandClaude Opus 4.8 b3f7e6006e fix: snippet.list tag filter (same array-binding bug as memory.list) (#1)
Replace the raw `${snippets.tags} @> ${tags}::text[]` template with
Drizzle's arrayContains, matching the memory.list fix. The raw template
expanded the JS array into positional params, producing a malformed
array literal (one tag) / record-cast error (two tags) at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:46:44 -07:00
shadowdaoandClaude Opus 4.8 86433afe1f fix: list shared projects (owned ∪ shared) in Web UI project list (#2)
The Projects page filtered with eq(projects.userId, userId), so a user
with an rw (or ro) share on someone else's project never saw it in the
list — even though project.identify already returned {shared, access}
for the same project. Switch to getAccessibleProjects(userId,
groupNames) (owner ∪ group-shared, the same helper search/memories use)
and aggregate counts over that id set, and label non-owned rows with a
'shared · ro|rw' badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:38:22 -07:00
shadowdaoandClaude Opus 4.8 30194463b5 fix: bind memory.list tag filter as a single text[] param (#1)
memory.list built its tag filter with a raw sql template:
  sql`${memories.tags} @> ${tags}::text[]`
Drizzle expands a JS array embedded in a sql template into positional
params, so one tag produced `@> ($1)::text[]` (Postgres rejected the
bound string as a malformed array literal) and two tags produced
`@> ($1,$2)::text[]` (a record, hence "cannot cast type record to
text[]"). Switch to arrayContains(memories.tags, tags), which binds the
array as one text[] param via the column's toDriver and preserves the
"require ALL tags" (@>) semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:38:13 -07:00
16 changed files with 510 additions and 57 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "cybercove-labs",
"description": "Open-source Claude Code plugins released by CyberCove Labs.",
"owner": {
"name": "CyberCove Labs",
"url": "https://repo.anhonesthost.net/cybercove-labs/shared-memory"
},
"plugins": [
{
"name": "shared-memory",
"source": "./plugin",
"description": "Shared persistent memory and snippet library for Claude Code sessions, backed by your own shared-memory server and authenticated with OIDC."
}
]
}
+11
View File
@@ -31,10 +31,21 @@ ACME_EMAIL=you@example.com
# resource server). See README.md for exact provider settings.
# -----------------------------------------------------------------------------
OIDC_ISSUER=https://auth.example.com/application/o/shared-memory/
# Issuer of MCP access tokens, when the MCP endpoint is a separate application
# in your IdP (it usually is). Authentik stamps each token with its own app
# slug, so verifying MCP tokens against OIDC_ISSUER fails with
# "claim invalid: iss". Defaults to OIDC_ISSUER.
OIDC_ISSUER_MCP=https://auth.example.com/application/o/shared-memory-mcp/
OIDC_CLIENT_ID_WEB=replace-me
OIDC_CLIENT_SECRET_WEB=replace-me
OIDC_CLIENT_ID_MCP=replace-me
OIDC_AUDIENCE=shared-memory
# Scope whose IdP mapping emits `aud: <OIDC_AUDIENCE>`. Advertised in
# /.well-known/oauth-protected-resource so MCP clients request it — without
# that, Authentik never evaluates the mapping and every token 401s with
# "claim invalid: aud". Defaults to aud-<OIDC_AUDIENCE>; set only if you
# named the scope mapping something else.
#OIDC_AUDIENCE_SCOPE=aud-shared-memory
# -----------------------------------------------------------------------------
# Database (Postgres 16 + pgvector — pgvector/pgvector:pg16 image)
+25
View File
@@ -0,0 +1,25 @@
# CLAUDE.md — shared-memory
Project key (for the shared-memory MCP): `shared-memory` (set by the `.shared-memory-project` marker at the repo root).
## Consulting memory before substantive work
There are TWO memory stores. The file-based memory (`MEMORY.md` + topic files) is auto-loaded into context every session. The **shared-memory MCP** (`mcp__shared-memory__*`) is NOT auto-loaded — you must query it. Query on demand when you need detail; do not bulk-load everything (that wastes context).
When a request draws on accumulated project knowledge — an architecture/development overview, debugging, planning, reviewing, or implementing a feature, or any question about how the system works — do this BEFORE answering or acting:
1. Use what's already in the auto-loaded `MEMORY.md` index.
2. ALSO check the shared-memory MCP: call `project_identify` once per session (resolves the key from `.shared-memory-project`), then `memory_search` with the task topic in natural language (a couple of queries if the task spans areas). Fetch full bodies with `memory_get` when a hit looks relevant.
3. Fold both sources into your answer; note when something came from saved memory.
## Reusing saved snippets (boilerplate / templates) before recreating work
Snippets (`mcp__shared-memory__snippet_*`) hold reusable artifacts — boilerplate, standard formats, checklists, established workflows. They are pull-only and, unlike memory, **NOT searchable**: `snippet_get` fetches by EXACT name, and the `description` shown by `snippet_list` is the ONLY discovery surface (tags are just for human browsing).
Before hand-writing standard/boilerplate code or re-deriving a known workflow, check whether a template already exists:
1. Call `snippet_list` once — it's cheap: it returns names + descriptions + tags, **no bodies**. Scan the descriptions for a match.
2. If one fits, `snippet_get <name>` to pull just that body and apply it, instead of recreating it from scratch.
3. When you produce a reusable artifact worth keeping, save it with `snippet_put` under a stable, predictable name (e.g. `boilerplate/<area>/<thing>`) and a concrete "when to use" `description` so a future agent can find it by scanning `snippet_list`.
Skip both checks only for trivial, self-contained requests (a quick edit, a one-off shell command, casual conversation) where prior project context can't matter. If the MCP server isn't connected in this session, proceed with file memory and say so.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Josh Knapp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE OTHER DEALINGS IN THE
SOFTWARE.
+94 -10
View File
@@ -144,6 +144,8 @@ Copy `.env.example` and fill in the values below.
| `OIDC_CLIENT_SECRET_WEB` | both | Client secret of the Web-UI client. |
| `OIDC_CLIENT_ID_MCP` | both | Client ID of the MCP resource-server client in your IdP. |
| `OIDC_AUDIENCE` | both | Audience string the MCP access token must carry in its `aud` claim. Recommended: `shared-memory`. |
| `OIDC_ISSUER_MCP` | optional | Issuer of MCP access tokens when the MCP endpoint is a separate IdP application (Authentik stamps each app's tokens with its own slug). Defaults to `OIDC_ISSUER`. |
| `OIDC_AUDIENCE_SCOPE` | optional | Name of the IdP scope whose mapping emits that `aud` claim. Advertised in `scopes_supported` so clients request it. Defaults to `aud-<OIDC_AUDIENCE>`. |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | both | Local Postgres credentials. |
| `NEXTAUTH_SECRET` | both | Session-cookie signing key. Generate with `openssl rand -base64 32`. |
| `EMBEDDER_URL` | both | Phase 2 embedder sidecar. Leave empty in Phase 1. |
@@ -297,16 +299,52 @@ The MCP endpoint requires the access token's `aud` claim to equal
reliable pattern:
1. Create a **scope mapping** (Customisation → Property Mappings → Create →
Scope Mapping) named `aud-shared-memory` with expression:
Scope Mapping) named `aud-shared-memory`, **scope name** `aud-shared-memory`,
with expression:
```python
return {"aud": "shared-memory"}
```
2. On the MCP provider, add this scope mapping under **Scopes** and tick it
so it's emitted for the default scope.
2. On the MCP provider, add this scope mapping under **Scopes**.
3. Make sure the app advertises that scope name. It is derived automatically
as `aud-<OIDC_AUDIENCE>`; override with `OIDC_AUDIENCE_SCOPE` if you named
the mapping differently.
> If you skip this, the MCP route will return 401 with
> `error_description="claim invalid: aud"`. Check `docker compose logs app`
> for the exact failure.
> **Attaching the mapping is not sufficient.** Authentik evaluates a scope
> mapping only when the client explicitly *requests* that scope, and an MCP
> client only requests the scopes listed in `scopes_supported` from
> `/.well-known/oauth-protected-resource`. If the audience scope isn't
> advertised there, the mapping silently never runs, the access token carries
> no `aud`, and every MCP call fails with 401
> `error_description="claim invalid: aud"` — even though the OAuth handshake,
> consent, and PKCE all succeeded. Verify with:
>
> ```bash
> curl -s https://memory.example.com/.well-known/oauth-protected-resource \
> | jq .scopes_supported # must include aud-<your audience>
> ```
>
> **Second trap, same failure surface:** the MCP endpoint is a *separate
> application* from the Web UI, and Authentik's default `per_provider` issuer
> mode stamps each token with its own application slug. So MCP tokens carry
> `iss: .../application/o/shared-memory-mcp/` while `OIDC_ISSUER` points at
> `.../application/o/shared-memory/`, and verification fails with
> `claim invalid: iss` even once `aud` is correct. Set `OIDC_ISSUER_MCP` to the
> MCP application's issuer. Confirm which one your tokens actually carry:
>
> ```bash
> curl -s https://auth.example.com/application/o/shared-memory-mcp/.well-known/openid-configuration | jq .issuer
> ```
>
> **Identity note:** the app verifies MCP tokens against `OIDC_ISSUER_MCP` but
> keys the user record on `OIDC_ISSUER`. Authentik's `sub` is `user.uid`, which
> is stable across providers, so the same person resolves to the same row
> whether they arrive via the Web UI or the MCP endpoint. Without that
> normalization the MCP path silently creates a second, empty account instead
> of failing visibly.
>
> Note also that Claude Code sends an RFC 8707 `resource` parameter on the
> authorize request; Authentik 2026.5 ignores it, so it cannot be relied on
> for audience binding. The scope mapping is what sets `aud`.
Then create an **Application** for the MCP provider (same as Step A), slug
e.g. `shared-memory-mcp`.
@@ -322,9 +360,55 @@ prompt, never reaching the app.
## Connecting Claude Code
Two paths, in order of preference:
Three paths, in order of preference:
### A. OAuth flow (recommended — picks up your IdP credentials)
### A. Plugin (recommended — one command, no flags to remember)
This repo doubles as a Claude Code plugin marketplace. `plugin/.mcp.json` ships a
**pre-registered** OAuth client, so Claude Code never needs RFC 7591 Dynamic Client
Registration — which matters because most self-hosted IdPs (Authentik included, as of
2026.5) don't implement it.
`main` carries placeholders, so install from `main` only after pointing it at your own
instance. Fork or clone, then edit `plugin/.mcp.json`:
```json
{
"mcpServers": {
"shared-memory": {
"type": "http",
"url": "https://memory.example.com/api/mcp",
"oauth": {
"clientId": "<OIDC_CLIENT_ID_MCP>",
"callbackPort": 33418
}
}
}
}
```
`clientId` is the **Public** (PKCE) client from step B above — it is not a secret and is
meant to be committed. `callbackPort` must match a redirect URI your IdP accepts; with
the loopback regex from the setup step, any port works.
Then:
```bash
claude plugin marketplace add https://your-git-host/you/shared-memory.git
claude plugin install shared-memory@cybercove-labs
```
To keep a filled-in copy on a branch instead of forking, commit it to e.g.
`instance/<name>` and install with a `#ref` fragment:
```bash
claude plugin marketplace add https://your-git-host/you/shared-memory.git#instance/<name>
```
The `#ref` suffix is undocumented in `claude plugin marketplace add --help` but is
honored and persisted in `known_marketplaces.json` (verified on Claude Code 2.1.220).
### B. OAuth flow (manual, per-machine)
```bash
claude mcp add --transport http --scope user \
@@ -350,7 +434,7 @@ your MCP client's **Redirect URIs** list. Authentik users with the regex
pattern from the setup step (`^http://(127\.0\.0\.1|localhost):\d+/.*$`)
can use any port without re-registering.
### B. Manual-paste fallback (when loopback isn't reachable)
### C. Manual-paste fallback (when loopback isn't reachable)
Sealed containers, devboxes without port forwarding, etc. The redirect URI
in this case is hosted by *this* server:
@@ -372,7 +456,7 @@ Claude Code's prompt to complete the flow.
The manual-fallback URI must be registered on your MCP client too:
`https://memory.example.com/auth/cli-callback`.
### C. Static bearer token (no browser at all)
### D. Static bearer token (no browser at all)
For fully headless / CI scenarios, mint a long-lived HMAC token at
`https://memory.example.com/connect` and pass it via `--header`. See
@@ -0,0 +1,151 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
const field =
"block w-full rounded-md bg-surface-1 border border-border " +
"text-fg placeholder:text-fg-subtle " +
"focus:border-accent-400 focus:outline-none " +
"disabled:opacity-50 transition-colors h-9 px-3 text-sm";
/**
* Type-ahead project filter. A text input whose value submits as `name`
* (default "project") via the enclosing GET form, plus a dropdown of the
* projects the user can read that narrows as they type. Selecting a
* suggestion fills the box and submits the form so the filter applies
* immediately; free text is still allowed (the input value is what
* submits), so an arbitrary key keeps working even if it isn't listed.
*/
export function ProjectCombobox({
name = "project",
defaultValue = "",
options,
className = "",
}: {
name?: string;
defaultValue?: string;
options: string[];
className?: string;
}) {
const [value, setValue] = useState(defaultValue);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listId = useId();
// Case-insensitive substring match. An empty box shows the full list so
// the control doubles as a "browse my projects" dropdown.
const q = value.trim().toLowerCase();
const matches = q
? options.filter((o) => o.toLowerCase().includes(q))
: options;
// Close when focus/click leaves the widget.
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
function commit(next: string) {
setValue(next);
setOpen(false);
// Write the DOM value synchronously before submitting: setValue only
// schedules a re-render (React batches it), so the input's serialized
// value would still be the pre-selection text when requestSubmit reads
// it. The upcoming render sets the same value, so there's no flicker.
if (inputRef.current) inputRef.current.value = next;
// requestSubmit fires a real submit (unlike form.submit()).
inputRef.current?.form?.requestSubmit();
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) {
setOpen(true);
setActive(0);
} else {
setActive((i) => Math.min(i + 1, matches.length - 1));
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
// Only intercept Enter to pick a highlighted suggestion; otherwise
// let it fall through and submit the form with the typed value.
if (open && matches[active]) {
e.preventDefault();
commit(matches[active]);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
}
}
}
const showList = open && matches.length > 0;
return (
<div ref={rootRef} className={`relative ${className}`}>
<input
ref={inputRef}
type="text"
name={name}
value={value}
placeholder="Project key…"
autoComplete="off"
spellCheck={false}
role="combobox"
aria-expanded={showList}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={
showList ? `${listId}-opt-${active}` : undefined
}
className={field}
onChange={(e) => {
setValue(e.target.value);
setOpen(true);
setActive(0);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
/>
{showList ? (
<ul
id={listId}
role="listbox"
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
>
{matches.map((opt, i) => (
<li
key={opt}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={i === active}
className={`cursor-pointer px-3 py-1.5 font-mono text-sm text-fg ${
i === active ? "bg-surface-2" : ""
}`}
// pointerdown (not click) so the choice registers before the
// input's blur/outside-pointerdown handler closes the list.
onPointerDown={(e) => {
e.preventDefault();
commit(opt);
}}
onMouseEnter={() => setActive(i)}
>
{opt}
</li>
))}
</ul>
) : null}
</div>
);
}
+33 -18
View File
@@ -1,10 +1,15 @@
import Link from "next/link";
import { and, desc, eq, isNull, inArray, or, sql } from "drizzle-orm";
import { and, desc, eq, isNull, inArray, or } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { memories, projects, projectShares } from "@/lib/db/schema";
import { searchMemories } from "@/lib/memories";
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
import {
getAccessibleProjects,
getUserGroupNames,
type AccessibleProject,
} from "@/lib/access";
import { ProjectCombobox } from "./_project-combobox";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
@@ -50,12 +55,12 @@ async function fetchMemoriesByIds(
async function listRecent(
userId: string,
groupNames: string[],
accessible: AccessibleProject[],
scope?: Scope,
project?: string,
): Promise<MemoryRow[]> {
// Visibility: own rows OR rows in an accessible project.
const accessibleIds = await readableProjectIds(userId, groupNames);
const accessibleIds = accessible.map((p) => p.projectId);
const visibility =
accessibleIds.length > 0
? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds))
@@ -63,17 +68,18 @@ async function listRecent(
const filters = [visibility!, isNull(memories.deletedAt)];
if (scope) filters.push(eq(memories.scope, scope));
if (project) {
// Project filter — match the project key against any project the
// user can read (owned or shared). When the key matches none of
// those, return empty.
filters.push(
sql`${memories.projectId} IN (
SELECT id FROM ${projects}
WHERE ${projects.key} = ${project}
AND (${projects.userId} = ${userId}
OR ${projects.id} = ANY(${accessibleIds}::uuid[]))
)`,
);
// Project filter — resolve the typed key against the projects the
// user can read (owned or shared), owned winning on a key collision
// to match project.identify / the search path. Filtering by the
// resolved id keeps the WHERE clause a plain equality — no raw-SQL
// array binding (the source of the earlier memory.list crash). When
// the key matches no accessible project, return empty.
const matches = accessible.filter((p) => p.projectKey === project);
const resolvedId =
matches.find((p) => p.access === "owner")?.projectId ??
matches[0]?.projectId;
if (!resolvedId) return [];
filters.push(eq(memories.projectId, resolvedId));
}
const rows = await db
.select({
@@ -120,6 +126,15 @@ export default async function MemoriesPage({
const scope = params.scope === "user" || params.scope === "project" ? params.scope : undefined;
const project = params.project?.trim() || undefined;
// Projects the user can read (owned shared). Reused both to build the
// list-path WHERE clause and to populate the project filter's type-ahead
// suggestions. Keys are deduped (a key can appear once per owned/shared
// project) and sorted for a stable dropdown order.
const accessible = await getAccessibleProjects(userId, groupNames);
const projectKeys = [...new Set(accessible.map((p) => p.projectKey))].sort(
(a, b) => a.localeCompare(b),
);
let rows: MemoryRow[] = [];
let debug: { vec: number; fts: number; tag: number } | null = null;
@@ -138,7 +153,7 @@ export default async function MemoriesPage({
});
debug = result.debug;
} else {
rows = await listRecent(userId, groupNames, scope, project);
rows = await listRecent(userId, accessible, scope, project);
}
// Annotate which rows belong to projects that have any active share.
@@ -182,10 +197,10 @@ export default async function MemoriesPage({
className="flex-1 min-w-[200px]"
/>
<FilterSelect name="scope" value={scope} options={["", "project", "user"]} placeholder="Any scope" />
<Input
<ProjectCombobox
name="project"
placeholder="Project key…"
defaultValue={project ?? ""}
options={projectKeys}
className="w-44"
/>
<Button type="submit" variant="secondary">Apply</Button>
+20 -3
View File
@@ -1,8 +1,9 @@
import Link from "next/link";
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { memories, projects } from "@/lib/db/schema";
import { getAccessibleProjects, getUserGroupNames } from "@/lib/access";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
@@ -13,8 +14,21 @@ export const dynamic = "force-dynamic";
export default async function ProjectsPage() {
const session = await auth();
const userId = session!.user.id;
const groupNames = await getUserGroupNames(userId);
const rows = await db
// The project list is owned shared: projects the user owns PLUS
// projects shared with one of their groups (any access). Visibility was
// previously owner-only (`eq(projects.userId, userId)`), which hid
// projects another user shared in via project_shares even though
// project.identify already reported them as {shared, access}.
const accessible = await getAccessibleProjects(userId, groupNames);
const accessById = new Map(accessible.map((p) => [p.projectId, p.access]));
const accessibleIds = accessible.map((p) => p.projectId);
const rows =
accessibleIds.length === 0
? []
: await db
.select({
id: projects.id,
key: projects.key,
@@ -28,7 +42,7 @@ export default async function ProjectsPage() {
memories,
and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)),
)
.where(eq(projects.userId, userId))
.where(inArray(projects.id, accessibleIds))
.groupBy(projects.id)
.orderBy(desc(sql`max(${memories.createdAt})`));
@@ -57,6 +71,9 @@ export default async function ProjectsPage() {
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg truncate">{p.key}</span>
<Badge>{p.memoryCount}</Badge>
{accessById.get(p.id) !== "owner" ? (
<Badge tone="accent">shared · {accessById.get(p.id)}</Badge>
) : null}
</div>
{p.displayName && p.displayName !== p.key ? (
<div className="text-xs text-fg-muted truncate mt-0.5">{p.displayName}</div>
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { mcpIssuer } from "@/lib/auth/jwt";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -12,10 +13,21 @@ export const dynamic = "force-dynamic";
*/
export function GET() {
const resource = env().PUBLIC_URL.replace(/\/$/, "");
// The audience scope MUST be advertised. Authentik only evaluates a scope
// mapping when the client requests that scope by name, and the client only
// learns scope names from this document. Omit it and every access token
// arrives without `aud`, which jwt.ts rejects as "claim invalid: aud".
const audienceScope =
env().OIDC_AUDIENCE_SCOPE ?? `aud-${env().OIDC_AUDIENCE}`;
return NextResponse.json({
resource,
authorization_servers: [env().OIDC_ISSUER],
scopes_supported: ["openid", "profile", "email"],
// 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()],
scopes_supported: ["openid", "profile", "email", audienceScope],
bearer_methods_supported: ["header"],
resource_documentation: `${resource}/`,
});
+29 -4
View File
@@ -23,13 +23,21 @@ type GlobalWithJwks = typeof globalThis & {
};
const g = globalThis as GlobalWithJwks;
/**
* Issuer of MCP access tokens. The MCP endpoint is a separate application in
* the IdP from the Web UI, and Authentik stamps each token with its own
* application slug, so this is NOT interchangeable with OIDC_ISSUER.
*/
export function mcpIssuer(): string {
return (env().OIDC_ISSUER_MCP ?? env().OIDC_ISSUER).replace(/\/$/, "");
}
function jwks() {
if (g.__sharedMemoryJwks) return g.__sharedMemoryJwks;
// Authentik discovery is at `${issuer}/.well-known/openid-configuration`;
// the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`.
// Authentik canonically serves `${issuer}/jwks/`.
const issuer = env().OIDC_ISSUER.replace(/\/$/, "");
const url = new URL(`${issuer}/jwks/`);
const url = new URL(`${mcpIssuer()}/jwks/`);
g.__sharedMemoryJwks = createRemoteJWKSet(url, {
cacheMaxAge: 10 * 60 * 1000, // 10 min
cooldownDuration: 30 * 1000,
@@ -118,7 +126,7 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
}
const { payload } = await jwtVerify(token, jwks(), {
issuer: env().OIDC_ISSUER,
issuer: mcpIssuer(),
audience: env().OIDC_AUDIENCE,
});
if (!payload.sub) {
@@ -127,7 +135,24 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
buildWwwAuthenticate("invalid_token", "missing sub"),
);
}
return { ...payload, groups: extractGroupsClaim(payload) } as AuthenticatedClaims;
// Normalize the issuer for identity purposes.
//
// The token was just verified against mcpIssuer() — that check is done.
// But identity is keyed on (oidc_iss, oidc_sub), and the Web UI signs
// people in through a DIFFERENT application whose tokens carry
// OIDC_ISSUER. Authentik's `sub` is stable across providers (it is
// `user.uid`, a user-level value), so the only thing that differs is the
// issuer.
//
// Leave it un-normalized and userContextFromClaims — which UPSERTS rather
// than failing — quietly creates a SECOND user row for the same human:
// MCP writes would land in an account with none of their memories, and
// nothing would look broken. Pin identity to the canonical issuer.
return {
...payload,
iss: env().OIDC_ISSUER,
groups: extractGroupsClaim(payload),
} as AuthenticatedClaims;
} catch (err) {
if (err instanceof UnauthorizedError) throw err;
const desc =
+34
View File
@@ -4,6 +4,16 @@ const Bool = z
.union([z.boolean(), z.enum(["true", "false", "1", "0"])])
.transform((v) => v === true || v === "true" || v === "1");
/**
* Treat an empty string as "not set".
*
* docker-compose renders `${VAR:-}` as an empty string rather than omitting
* the key, so an unset optional var arrives as "" and would otherwise fail
* `.url()` / `.min(1)` validation and take the whole app down at boot.
*/
const optional = <T extends z.ZodTypeAny>(schema: T) =>
z.preprocess((v) => (v === "" ? undefined : v), schema.optional());
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
@@ -13,11 +23,35 @@ const envSchema = z.object({
// Authentik OIDC
OIDC_ISSUER: z.string().url(),
// Issuer of MCP *access tokens*, when it differs from OIDC_ISSUER.
//
// The Web UI and the MCP endpoint are two separate applications in the IdP,
// and Authentik's default `per_provider` issuer mode stamps each token with
// its own application slug. So the web app issues
// `.../application/o/<slug>/` while the MCP provider issues
// `.../application/o/<slug>-mcp/`, and verifying MCP tokens against
// OIDC_ISSUER fails with "claim invalid: iss".
//
// Set this to the MCP application's issuer. Defaults to OIDC_ISSUER for
// single-application setups.
OIDC_ISSUER_MCP: optional(z.string().url()),
OIDC_CLIENT_ID_WEB: z.string().min(1),
OIDC_CLIENT_SECRET_WEB: z.string().min(1),
OIDC_CLIENT_ID_MCP: z.string().min(1),
OIDC_AUDIENCE: z.string().min(1),
// Name of the IdP scope whose mapping emits `aud: <OIDC_AUDIENCE>`.
//
// Most IdPs (Authentik included) only evaluate a scope mapping when the
// client actually requests that scope. The MCP client learns which scopes
// to request from `scopes_supported` in our protected-resource metadata,
// so this name has to be advertised there or the mapping never runs and
// every token arrives without an `aud` claim (-> 401 "claim invalid: aud").
//
// Defaults to the `aud-<audience>` convention used in the README setup.
OIDC_AUDIENCE_SCOPE: optional(z.string().min(1)),
// Database
DATABASE_URL: z.string().url(),
+8 -2
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, arrayContains, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
memories,
@@ -465,7 +465,13 @@ const memoryList: ToolDef = {
}
if (parsed.data.tags && parsed.data.tags.length > 0) {
where.push(sql`${memories.tags} @> ${parsed.data.tags}::text[]`);
// Require ALL listed tags (array containment). Use Drizzle's
// arrayContains so the JS array binds as a single text[] param
// (via the column's toDriver) rather than being expanded into
// positional params — a raw `${tags}::text[]` template expands to
// `($1)::text[]` / `($1,$2)::text[]`, which Postgres rejects as a
// malformed array literal / record cast.
where.push(arrayContains(memories.tags, parsed.data.tags));
}
const rows = await db
+8 -2
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, arrayContains, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { snippets, projects } from "@/lib/db/schema";
import type { Snippet } from "@/lib/db/schema";
@@ -349,7 +349,13 @@ export async function listSnippets(
}
if (tags && tags.length > 0) {
where.push(sql`${snippets.tags} @> ${tags}::text[]`);
// Require ALL listed tags (array containment). Use Drizzle's
// arrayContains so the JS array binds as a single text[] param
// (via the column's toDriver) rather than being expanded into
// positional params — a raw `${tags}::text[]` template expands to
// `($1)::text[]` / `($1,$2)::text[]`, which Postgres rejects as a
// malformed array literal / record cast.
where.push(arrayContains(snippets.tags, tags));
}
const rows = await db
+9 -1
View File
@@ -113,6 +113,10 @@ services:
OIDC_CLIENT_SECRET_WEB: ${OIDC_CLIENT_SECRET_WEB:?required}
OIDC_CLIENT_ID_MCP: ${OIDC_CLIENT_ID_MCP:?required}
OIDC_AUDIENCE: ${OIDC_AUDIENCE:?required}
# Optional. This block is an explicit allow-list, not env_file — a var
# added to .env but not listed here never reaches the container.
OIDC_ISSUER_MCP: ${OIDC_ISSUER_MCP:-}
OIDC_AUDIENCE_SCOPE: ${OIDC_AUDIENCE_SCOPE:-}
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
@@ -129,7 +133,11 @@ services:
# but you can bind to 127.0.0.1 only by setting APP_BIND=127.0.0.1.
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-3000}:3000"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3000/api/health || exit 1"]
# Use 127.0.0.1, not localhost: inside the container localhost resolves
# to ::1 (IPv6) first, but the Next.js standalone server listens only on
# 0.0.0.0 (IPv4), so a localhost probe gets "Connection refused" and the
# container is reported unhealthy even though the app serves fine.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
"name": "shared-memory",
"version": "0.1.0",
"description": "Shared persistent memory and snippet library for Claude Code sessions, backed by your own shared-memory server and authenticated with OIDC.",
"author": {
"name": "CyberCove Labs"
},
"homepage": "https://repo.anhonesthost.net/cybercove-labs/shared-memory"
}
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"shared-memory": {
"type": "http",
"url": "https://memory.example.com/api/mcp",
"oauth": {
"clientId": "<OIDC_CLIENT_ID_MCP>",
"callbackPort": 33418
}
}
}
}