Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 5 8361a5b8e2 docs: describe project sharing as shipped on the settings page
The Groups card still called sharing "the upcoming sharing feature". It has
shipped — memory_visibility, groups, user_groups and project_shares are all
live — so the card now describes what group membership actually does:
read access for member groups, write access for read-write groups.

Authored on the fix/memory-list-project-filter branch on 2026-07-05 and never
committed; that branch is otherwise fully merged via PR #7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:35:42 -07:00
jknapp 04b964a866 Merge pull request 'feat: add the logo, and point people at the plugin before they mint a token' (#15) from feat/logo-and-tokens-copy into main 2026-07-27 13:22:52 +00:00
shadowdaoandClaude Opus 5 6b28e1d6c8 feat: add the logo, and point people at the plugin before they mint a token
Logo: the app had no icon at all — public/ held only .gitkeep and the page
emitted no <link rel="icon">, so browsers requested /favicon.ico, got a 404
and showed a blank tab. app/icon.svg is picked up automatically by the App
Router; public/logo.svg is a currentColor variant for in-app use.

The mark is three retrieval signals converging on a single memory, which is
what the search actually does (vector + full-text + tags fused by RRF) and
what the product does (many sessions, one store). Checked at 16px: the outer
strokes are held at equal opacity because asymmetry read as a rendering
artifact rather than as ranking.

Tokens page: reframed so a bearer token is the exception rather than the
default. A token is a credential to store and rotate; the plugin just signs
you in. The install hint renders only when PLUGIN_MARKETPLACE_URL is set —
a copyable command pointing nowhere is worse than no command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:22:51 -07:00
jknapp 73391a5823 Merge pull request 'fix: key MCP identity on the canonical issuer' (#14) from fix/identity-issuer-normalization into main 2026-07-27 13:19:18 +00:00
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
20 changed files with 605 additions and 59 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."
}
]
}
+17
View File
@@ -31,11 +31,28 @@ ACME_EMAIL=you@example.com
# resource server). See README.md for exact provider settings. # resource server). See README.md for exact provider settings.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
OIDC_ISSUER=https://auth.example.com/application/o/shared-memory/ 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_ID_WEB=replace-me
OIDC_CLIENT_SECRET_WEB=replace-me OIDC_CLIENT_SECRET_WEB=replace-me
OIDC_CLIENT_ID_MCP=replace-me OIDC_CLIENT_ID_MCP=replace-me
OIDC_AUDIENCE=shared-memory OIDC_AUDIENCE=shared-memory
# Marketplace this instance's Claude Code plugin is published from. When set,
# the CLI tokens page shows the one-command plugin install so people only mint
# a bearer token when a browser sign-in genuinely isn't possible.
#PLUGIN_MARKETPLACE_URL=https://your-git-host/you/shared-memory.git
#PLUGIN_MARKETPLACE_NAME=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) # 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.
+96 -10
View File
@@ -144,6 +144,10 @@ Copy `.env.example` and fill in the values below.
| `OIDC_CLIENT_SECRET_WEB` | both | Client secret of the Web-UI client. | | `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_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_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`. |
| `PLUGIN_MARKETPLACE_URL` | optional | Marketplace URL for this instance's plugin. Shown as a one-command install on the CLI tokens page. Hidden when unset. |
| `PLUGIN_MARKETPLACE_NAME` | optional | Marketplace name used in `shared-memory@<name>`. Defaults to `shared-memory`. |
| `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. | | `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | both | Local Postgres credentials. |
| `NEXTAUTH_SECRET` | both | Session-cookie signing key. Generate with `openssl rand -base64 32`. | | `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. | | `EMBEDDER_URL` | both | Phase 2 embedder sidecar. Leave empty in Phase 1. |
@@ -297,16 +301,52 @@ The MCP endpoint requires the access token's `aud` claim to equal
reliable pattern: reliable pattern:
1. Create a **scope mapping** (Customisation → Property Mappings → Create → 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 ```python
return {"aud": "shared-memory"} return {"aud": "shared-memory"}
``` ```
2. On the MCP provider, add this scope mapping under **Scopes** and tick it 2. On the MCP provider, add this scope mapping under **Scopes**.
so it's emitted for the default scope. 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 > **Attaching the mapping is not sufficient.** Authentik evaluates a scope
> `error_description="claim invalid: aud"`. Check `docker compose logs app` > mapping only when the client explicitly *requests* that scope, and an MCP
> for the exact failure. > 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 Then create an **Application** for the MCP provider (same as Step A), slug
e.g. `shared-memory-mcp`. e.g. `shared-memory-mcp`.
@@ -322,9 +362,55 @@ prompt, never reaching the app.
## Connecting Claude Code ## 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 ```bash
claude mcp add --transport http --scope user \ claude mcp add --transport http --scope user \
@@ -350,7 +436,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+/.*$`) pattern from the setup step (`^http://(127\.0\.0\.1|localhost):\d+/.*$`)
can use any port without re-registering. 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 Sealed containers, devboxes without port forwarding, etc. The redirect URI
in this case is hosted by *this* server: in this case is hosted by *this* server:
@@ -372,7 +458,7 @@ Claude Code's prompt to complete the flow.
The manual-fallback URI must be registered on your MCP client too: The manual-fallback URI must be registered on your MCP client too:
`https://memory.example.com/auth/cli-callback`. `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 For fully headless / CI scenarios, mint a long-lived HMAC token at
`https://memory.example.com/connect` and pass it via `--header`. See `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 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 { auth } from "@/auth";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { memories, projects, projectShares } from "@/lib/db/schema"; import { memories, projects, projectShares } from "@/lib/db/schema";
import { searchMemories } from "@/lib/memories"; 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 { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody } 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";
@@ -50,12 +55,12 @@ async function fetchMemoriesByIds(
async function listRecent( async function listRecent(
userId: string, userId: string,
groupNames: string[], accessible: AccessibleProject[],
scope?: Scope, scope?: Scope,
project?: string, project?: string,
): Promise<MemoryRow[]> { ): Promise<MemoryRow[]> {
// Visibility: own rows OR rows in an accessible project. // Visibility: own rows OR rows in an accessible project.
const accessibleIds = await readableProjectIds(userId, groupNames); const accessibleIds = accessible.map((p) => p.projectId);
const visibility = const visibility =
accessibleIds.length > 0 accessibleIds.length > 0
? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds)) ? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds))
@@ -63,17 +68,18 @@ async function listRecent(
const filters = [visibility!, isNull(memories.deletedAt)]; const filters = [visibility!, isNull(memories.deletedAt)];
if (scope) filters.push(eq(memories.scope, scope)); if (scope) filters.push(eq(memories.scope, scope));
if (project) { if (project) {
// Project filter — match the project key against any project the // Project filter — resolve the typed key against the projects the
// user can read (owned or shared). When the key matches none of // user can read (owned or shared), owned winning on a key collision
// those, return empty. // to match project.identify / the search path. Filtering by the
filters.push( // resolved id keeps the WHERE clause a plain equality — no raw-SQL
sql`${memories.projectId} IN ( // array binding (the source of the earlier memory.list crash). When
SELECT id FROM ${projects} // the key matches no accessible project, return empty.
WHERE ${projects.key} = ${project} const matches = accessible.filter((p) => p.projectKey === project);
AND (${projects.userId} = ${userId} const resolvedId =
OR ${projects.id} = ANY(${accessibleIds}::uuid[])) matches.find((p) => p.access === "owner")?.projectId ??
)`, matches[0]?.projectId;
); if (!resolvedId) return [];
filters.push(eq(memories.projectId, resolvedId));
} }
const rows = await db const rows = await db
.select({ .select({
@@ -120,6 +126,15 @@ export default async function MemoriesPage({
const scope = params.scope === "user" || params.scope === "project" ? params.scope : undefined; const scope = params.scope === "user" || params.scope === "project" ? params.scope : undefined;
const project = params.project?.trim() || 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 rows: MemoryRow[] = [];
let debug: { vec: number; fts: number; tag: number } | null = null; let debug: { vec: number; fts: number; tag: number } | null = null;
@@ -138,7 +153,7 @@ export default async function MemoriesPage({
}); });
debug = result.debug; debug = result.debug;
} else { } 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. // 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]" className="flex-1 min-w-[200px]"
/> />
<FilterSelect name="scope" value={scope} options={["", "project", "user"]} placeholder="Any scope" /> <FilterSelect name="scope" value={scope} options={["", "project", "user"]} placeholder="Any scope" />
<Input <ProjectCombobox
name="project" name="project"
placeholder="Project key…"
defaultValue={project ?? ""} defaultValue={project ?? ""}
options={projectKeys}
className="w-44" className="w-44"
/> />
<Button type="submit" variant="secondary">Apply</Button> <Button type="submit" variant="secondary">Apply</Button>
+35 -18
View File
@@ -1,8 +1,9 @@
import Link from "next/link"; 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 { auth } from "@/auth";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { memories, projects } from "@/lib/db/schema"; import { memories, projects } from "@/lib/db/schema";
import { getAccessibleProjects, getUserGroupNames } from "@/lib/access";
import { Container, PageHeader } from "@/app/_components/ui/container"; import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card } 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";
@@ -13,24 +14,37 @@ export const dynamic = "force-dynamic";
export default async function ProjectsPage() { export default async function ProjectsPage() {
const session = await auth(); const session = await auth();
const userId = session!.user.id; 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
.select({ // projects shared with one of their groups (any access). Visibility was
id: projects.id, // previously owner-only (`eq(projects.userId, userId)`), which hid
key: projects.key, // projects another user shared in via project_shares even though
displayName: projects.displayName, // project.identify already reported them as {shared, access}.
createdAt: projects.createdAt, const accessible = await getAccessibleProjects(userId, groupNames);
memoryCount: sql<number>`count(${memories.id})::int`, const accessById = new Map(accessible.map((p) => [p.projectId, p.access]));
lastActivity: sql<Date | null>`max(${memories.createdAt})`, const accessibleIds = accessible.map((p) => p.projectId);
})
.from(projects) const rows =
.leftJoin( accessibleIds.length === 0
memories, ? []
and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)), : await db
) .select({
.where(eq(projects.userId, userId)) id: projects.id,
.groupBy(projects.id) key: projects.key,
.orderBy(desc(sql`max(${memories.createdAt})`)); displayName: projects.displayName,
createdAt: projects.createdAt,
memoryCount: sql<number>`count(${memories.id})::int`,
lastActivity: sql<Date | null>`max(${memories.createdAt})`,
})
.from(projects)
.leftJoin(
memories,
and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)),
)
.where(inArray(projects.id, accessibleIds))
.groupBy(projects.id)
.orderBy(desc(sql`max(${memories.createdAt})`));
return ( return (
<Container className="pt-6"> <Container className="pt-6">
@@ -57,6 +71,9 @@ export default async function ProjectsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg truncate">{p.key}</span> <span className="font-mono text-sm text-fg truncate">{p.key}</span>
<Badge>{p.memoryCount}</Badge> <Badge>{p.memoryCount}</Badge>
{accessById.get(p.id) !== "owner" ? (
<Badge tone="accent">shared · {accessById.get(p.id)}</Badge>
) : null}
</div> </div>
{p.displayName && p.displayName !== p.key ? ( {p.displayName && p.displayName !== p.key ? (
<div className="text-xs text-fg-muted truncate mt-0.5">{p.displayName}</div> <div className="text-xs text-fg-muted truncate mt-0.5">{p.displayName}</div>
+3 -1
View File
@@ -63,7 +63,9 @@ export default async function SettingsPage() {
</CardHeader> </CardHeader>
<CardBody className="text-sm text-fg-muted"> <CardBody className="text-sm text-fg-muted">
OIDC group memberships from your IdP, refreshed at sign-in. Used OIDC group memberships from your IdP, refreshed at sign-in. Used
by the upcoming sharing feature to scope project visibility. to scope project sharing &mdash; memories and snippets in a shared
project are readable by member groups and editable by read-write
groups.
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
+44 -1
View File
@@ -108,6 +108,44 @@ async function revokeTokenAction(formData: FormData) {
revalidatePath("/settings/tokens"); revalidatePath("/settings/tokens");
} }
/**
* Points people at the plugin before they mint a token they don't need.
*
* Rendered only when this instance knows which marketplace it's published
* from — showing a copyable command that points nowhere is worse than showing
* nothing.
*/
function PluginHint({
marketplaceUrl,
marketplaceName,
}: {
marketplaceUrl: string | undefined;
marketplaceName: string;
}) {
if (!marketplaceUrl) return null;
return (
<Card className="mb-6">
<CardHeader className="text-sm font-medium text-fg">
If this machine has a browser, install the plugin instead
</CardHeader>
<CardBody>
<p className="text-sm text-fg-muted mb-3">
The plugin signs you in through {" "}
<span className="text-fg">your usual login</span>, so there&apos;s no
token to copy, store, or rotate. Generate a token below only when a
browser sign-in isn&apos;t possible.
</p>
<pre className="text-xs !whitespace-pre-wrap !break-all select-all">
{[
`claude plugin marketplace add ${marketplaceUrl}`,
`claude plugin install shared-memory@${marketplaceName}`,
].join("\n")}
</pre>
</CardBody>
</Card>
);
}
export default async function TokensPage() { export default async function TokensPage() {
const session = await auth(); const session = await auth();
const userId = session!.user.id; const userId = session!.user.id;
@@ -144,7 +182,12 @@ export default async function TokensPage() {
<Container className="pt-6 max-w-3xl"> <Container className="pt-6 max-w-3xl">
<PageHeader <PageHeader
title="CLI tokens" title="CLI tokens"
description={`Long-lived bearer tokens for MCP clients without browser access. ${ttlDays}-day expiry per token.`} description={`For machines that can't complete a browser sign-in — headless containers, CI runners, sealed devboxes. Tokens last ${ttlDays} days and can be revoked one at a time.`}
/>
<PluginHint
marketplaceUrl={env().PLUGIN_MARKETPLACE_URL}
marketplaceName={env().PLUGIN_MARKETPLACE_NAME}
/> />
<Card className="mb-6"> <Card className="mb-6">
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { mcpIssuer } from "@/lib/auth/jwt";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,10 +13,21 @@ export const dynamic = "force-dynamic";
*/ */
export function GET() { export function GET() {
const resource = env().PUBLIC_URL.replace(/\/$/, ""); 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({ return NextResponse.json({
resource, resource,
authorization_servers: [env().OIDC_ISSUER], // The MCP application's issuer, which is not necessarily the Web UI's —
scopes_supported: ["openid", "profile", "email"], // 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"], bearer_methods_supported: ["header"],
resource_documentation: `${resource}/`, resource_documentation: `${resource}/`,
}); });
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="shared-memory">
<title>shared-memory</title>
<rect width="64" height="64" rx="14" fill="#11151b"/>
<!--
Three retrieval signals - vector, full-text, tags - converging on a single
memory. The direct match runs straight through at full strength; the two
ranked neighbours fall back, which is the fusion the search actually does.
Opacity is held equal on the outer pair so the mark stays balanced at 16px.
-->
<g fill="none" stroke-linecap="round" stroke-width="7">
<path d="M13 15C25 15 26 32 35 32" stroke="#0092fd" opacity=".55"/>
<path d="M13 32H35" stroke="#49a9ff"/>
<path d="M13 49C25 49 26 32 35 32" stroke="#0092fd" opacity=".55"/>
</g>
<circle cx="45" cy="32" r="7.5" fill="#76c0ff"/>
</svg>

After

Width:  |  Height:  |  Size: 847 B

+29 -4
View File
@@ -23,13 +23,21 @@ type GlobalWithJwks = typeof globalThis & {
}; };
const g = globalThis as GlobalWithJwks; 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() { function jwks() {
if (g.__sharedMemoryJwks) return g.__sharedMemoryJwks; if (g.__sharedMemoryJwks) return g.__sharedMemoryJwks;
// Authentik discovery is at `${issuer}/.well-known/openid-configuration`; // Authentik discovery is at `${issuer}/.well-known/openid-configuration`;
// the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`. // the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`.
// Authentik canonically serves `${issuer}/jwks/`. // Authentik canonically serves `${issuer}/jwks/`.
const issuer = env().OIDC_ISSUER.replace(/\/$/, ""); const url = new URL(`${mcpIssuer()}/jwks/`);
const url = new URL(`${issuer}/jwks/`);
g.__sharedMemoryJwks = createRemoteJWKSet(url, { g.__sharedMemoryJwks = createRemoteJWKSet(url, {
cacheMaxAge: 10 * 60 * 1000, // 10 min cacheMaxAge: 10 * 60 * 1000, // 10 min
cooldownDuration: 30 * 1000, cooldownDuration: 30 * 1000,
@@ -118,7 +126,7 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
} }
const { payload } = await jwtVerify(token, jwks(), { const { payload } = await jwtVerify(token, jwks(), {
issuer: env().OIDC_ISSUER, issuer: mcpIssuer(),
audience: env().OIDC_AUDIENCE, audience: env().OIDC_AUDIENCE,
}); });
if (!payload.sub) { if (!payload.sub) {
@@ -127,7 +135,24 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
buildWwwAuthenticate("invalid_token", "missing sub"), 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) { } catch (err) {
if (err instanceof UnauthorizedError) throw err; if (err instanceof UnauthorizedError) throw err;
const desc = const desc =
+42
View File
@@ -4,6 +4,16 @@ const Bool = z
.union([z.boolean(), z.enum(["true", "false", "1", "0"])]) .union([z.boolean(), z.enum(["true", "false", "1", "0"])])
.transform((v) => v === true || v === "true" || v === "1"); .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({ const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"), NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
@@ -13,11 +23,35 @@ const envSchema = z.object({
// Authentik OIDC // Authentik OIDC
OIDC_ISSUER: z.string().url(), 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_ID_WEB: z.string().min(1),
OIDC_CLIENT_SECRET_WEB: z.string().min(1), OIDC_CLIENT_SECRET_WEB: z.string().min(1),
OIDC_CLIENT_ID_MCP: z.string().min(1), OIDC_CLIENT_ID_MCP: z.string().min(1),
OIDC_AUDIENCE: 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
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
@@ -33,6 +67,13 @@ const envSchema = z.object({
// every issued CLI token at once. // every issued CLI token at once.
CLI_TOKEN_SECRET: z.string().min(32, "CLI_TOKEN_SECRET must be at least 32 chars"), CLI_TOKEN_SECRET: z.string().min(32, "CLI_TOKEN_SECRET must be at least 32 chars"),
// Plugin marketplace this instance is published from. When set, the CLI
// tokens page shows the one-command plugin install, so people only mint a
// bearer token when their machine genuinely can't complete a browser
// sign-in. Left unset, that hint is hidden rather than shown wrong.
PLUGIN_MARKETPLACE_URL: optional(z.string().url()),
PLUGIN_MARKETPLACE_NAME: z.string().min(1).default("shared-memory"),
// Behavior flags // Behavior flags
ALLOW_INSECURE_HTTP: Bool.optional().default(false), ALLOW_INSECURE_HTTP: Bool.optional().default(false),
}); });
@@ -76,6 +117,7 @@ function buildPhaseStub(): Env {
EMBEDDING_DIM: 384, EMBEDDING_DIM: 384,
NEXTAUTH_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx", NEXTAUTH_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
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",
ALLOW_INSECURE_HTTP: false, ALLOW_INSECURE_HTTP: false,
}; };
} }
+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 { db } from "@/lib/db/client";
import { import {
memories, memories,
@@ -465,7 +465,13 @@ const memoryList: ToolDef = {
} }
if (parsed.data.tags && parsed.data.tags.length > 0) { 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 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 { db } from "@/lib/db/client";
import { snippets, projects } from "@/lib/db/schema"; import { snippets, projects } from "@/lib/db/schema";
import type { Snippet } from "@/lib/db/schema"; import type { Snippet } from "@/lib/db/schema";
@@ -349,7 +349,13 @@ export async function listSnippets(
} }
if (tags && tags.length > 0) { 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 const rows = await db
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="shared-memory">
<title>shared-memory</title>
<!--
Transparent, currentColor variant of the mark for in-app use - inherits
the surrounding text color so it works on any surface. The tile version
used as the favicon lives at app/icon.svg.
-->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="7">
<path d="M13 15C25 15 27 32 37 32" opacity=".45"/>
<path d="M13 32H37"/>
<path d="M13 49C25 49 27 32 37 32" opacity=".7"/>
</g>
<circle cx="43" cy="32" r="8" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 648 B

+11 -1
View File
@@ -113,6 +113,12 @@ services:
OIDC_CLIENT_SECRET_WEB: ${OIDC_CLIENT_SECRET_WEB:?required} OIDC_CLIENT_SECRET_WEB: ${OIDC_CLIENT_SECRET_WEB:?required}
OIDC_CLIENT_ID_MCP: ${OIDC_CLIENT_ID_MCP:?required} OIDC_CLIENT_ID_MCP: ${OIDC_CLIENT_ID_MCP:?required}
OIDC_AUDIENCE: ${OIDC_AUDIENCE:?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:-}
PLUGIN_MARKETPLACE_URL: ${PLUGIN_MARKETPLACE_URL:-}
PLUGIN_MARKETPLACE_NAME: ${PLUGIN_MARKETPLACE_NAME:-shared-memory}
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
@@ -129,7 +135,11 @@ services:
# but you can bind to 127.0.0.1 only by setting APP_BIND=127.0.0.1. # 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" - "${APP_BIND:-0.0.0.0}:${APP_PORT:-3000}:3000"
healthcheck: 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 interval: 15s
timeout: 5s timeout: 5s
retries: 5 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
}
}
}
}