Compare commits

..
Author SHA1 Message Date
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 684ff03db2 feat: make CLI token TTL configurable (CLI_TOKEN_TTL_DAYS, default 90d)
CLI tokens were hardcoded to a 30-day expiry. Make the lifetime
configurable via the CLI_TOKEN_TTL_DAYS env var, with a longer default
of 90 days. The value must be a positive integer number of days; unset
or invalid input falls back to 90. All other token claims are unchanged.

Only affects newly minted tokens — already-issued tokens keep their
original exp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:47:20 -07: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
shadowdaoandClaude Opus 4.7 0afa9e86ae chore: anonymize deployment URL in docs and UI
Replace hardcoded memory.dnspegasus.net references throughout README
with the generic memory.example.com placeholder (matches .env.example).

In tokens-manager.tsx, the claude-mcp-add snippet shown to users now
derives the host from PUBLIC_URL via a server-side prop instead of a
hardcoded literal, so any deployer sees their own URL in the snippet.

Prepares the repo for public mirroring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:58:57 -07:00
shadowdaoandClaude Opus 4.7 d1d4c60f2d fix: address Phase-5 code review (4 findings)
Reviewer flagged two security-relevant items and two operational bugs
on the external-DB + Terraform module merge.

1. Terraform: `enable_execute_command = true` was hardcoded on the app
   and embedder services. Production attack surface (anyone with
   `ecs:ExecuteCommand` on the service gets a container shell) AND
   non-functional today since the task roles have no `ssmmessages:*`
   permission. Added a new `enable_execute_command` boolean input
   variable defaulting to `false`; when flipped on, the SSM messages
   policy is conditionally attached to both task roles so the feature
   actually works. README's variable description tells operators to
   flip on for incidents, off afterward.

2. Terraform: `secret_arns` output was not marked `sensitive`. The ARNs
   themselves aren't secrets, but the embedded secret names print to
   `terraform apply` stdout and CI logs. Marked sensitive on both the
   module output and the example output. Operators wanting the values
   can still `terraform output -json secret_arns`.

3. Terraform: embedder task definition was missing `HOST=0.0.0.0` and
   `PORT=8080`. Fargate awsvpc tasks each get their own ENI; default
   Node HTTP servers bind 127.0.0.1, which would make every
   app→embedder Service Connect call time out. Added both vars to
   `embedder_environment`. Also added `NEXT_TELEMETRY_DISABLED=1` to
   `app_environment` per the spec's hardening checklist.

4. Compose: docker-compose.external-db.yml uses the `!override` YAML
   tag, which requires Docker Compose >= 2.24.0. Silently ignored on
   older Compose, causing the `db` dependency to survive the merge and
   startup to fail. Documented the minimum version in the override
   file's header AND in the main README prerequisites with a deep
   link to the External Postgres section.

terraform fmt + validate (module + examples/basic) both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:48:04 -07:00
shadowdao a92504d799 Merge: Terraform module + AWS Fargate guide (Agent B) 2026-05-18 09:41:08 -07:00
shadowdao ce84099508 Merge: external-DB compose override + docs (Agent A) 2026-05-18 09:41:00 -07:00
shadowdaoandClaude Opus 4.7 08be60e661 feat(terraform): AWS Fargate deployment module
Adds a terraform/ directory with an opinionated module that deploys
shared-memory to ECS Fargate behind an ALB. The module assumes the
operator already provides the VPC, RDS Postgres, ACM cert, ECR images,
and OIDC clients, and creates everything else: ECS cluster + services,
ALB, Service Connect namespace for app-embedder discovery, EFS-backed
model cache for the embedder, Secrets Manager entries, IAM roles,
CloudWatch log groups, and a one-shot migrator task definition.

Includes examples/basic/ with a worked invocation and a README covering
prerequisites, quick start, the post-apply migrator run, image updates,
DNS setup, and a security note. Main README gains a short Mode C
pointer to the terraform/ guide.

Validated with `terraform fmt -check -recursive` and
`terraform validate` against AWS provider 5.x.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:40:12 -07:00
shadowdaoandClaude Opus 4.7 93b127f112 feat(compose): opt-in external-Postgres override
Adds `docker-compose.external-db.yml` so teams can point the stack at a
managed Postgres (RDS, Cloud SQL, etc.) without forking the base compose
file. Disables the bundled `db` service via an unreachable `profiles`
label and replaces `depends_on` / `DATABASE_URL` on `migrator` and `app`
with `!override`-tagged blocks that read `DATABASE_URL` straight from
`.env`. Default `docker compose up -d` flow is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:34:23 -07:00
shadowdaoandClaude Opus 4.7 f769daa48a feat(project.identify): setupHint when key wasn't read from the file
When Claude calls project.identify with `source` ∈ {explicit, header,
inferred, undefined}, the response now includes a `setupHint` field
with a short message + a copy-pasteable command to create the
`.shared-memory-project` file. When `source: 'file'` is passed,
no hint is emitted (the user already has the file).

Hint only fires on owned-project responses — you can't ask a viewer of
a shared project to commit to a repo they don't own.

Tool description tells Claude: "Pass `source` based on how you
resolved the key. If the response carries a setupHint, briefly relay
its message and command to the user." This gives us a one-prompt
nudge per session without auto-creating files or being pushy — the
user decides.

ProjectIdentifyInput in @shared-memory/schemas gains an optional
`source: 'file' | 'explicit' | 'header' | 'inferred'` field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:24:11 -07:00
shadowdaoandClaude Opus 4.7 b6e28b329d feat: .shared-memory-project file convention for repo-rooted project ID
Replaces "per-machine X-Project-Key header" as the default way to tell
Claude Code which shared-memory project a repo belongs to. Commit a
single-line `.shared-memory-project` text file at the repo root; every
collaborator's Claude Code reads it at session start and attaches all
memories + snippets to the same shared project. No per-machine config
required.

Changes:
- New file convention documented in README (resolution order, format,
  authoring snippet, rationale for plain-text over JSON).
- project.identify tool description now leads with "check
  .shared-memory-project at the repo root", with inference as fallback.
- memory.write description mentions the file as the canonical source for
  project keys.
- Project detail page in the Web UI shows a copy-paste `echo > file`
  command so users see exactly what to add to their repo.
- Added .shared-memory-project to this repo (content: `shared-memory`).

Resolution precedence is: explicit tool arg → .shared-memory-project →
X-Project-Key header → inference. The file beats the header because
repo context is more specific than machine context.

This is a soft convention — Claude has to read the file. Directive tool
descriptions make this very likely; a Claude Code skill would make it
bulletproof, deferred until we see whether the description alone is
enough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:17:04 -07:00
shadowdaoandClaude Opus 4.7 837c43f3d5 feat(web): per-project activity feed at /projects/[key]/activity
Closes Phase 4f from the deferred backlog. Renders every audit_log row
that pertains to a project — memory writes/updates/deletes, snippet
puts/deletes, share grants/changes/revocations, project.identify
collisions — newest first, capped at 150 rows.

Query is a three-leg UNION ALL joining audit_log against memories,
snippets, and the project itself. Avoids relying on payload->>projectKey
which isn't populated consistently across all action shapes.

Each row renders as "<actor> <verb-phrase>" with entity links where
applicable, plus a compact relative timestamp. memory.update entries
also show scope/project transitions inline when those changed. Share
events surface the group name and access level with the existing
tone-coded Badge.

Auth: viewable by anyone with read access to the project (owner +
ro/rw group members). Same guard as the project detail page.

Linked from the project page header so it's discoverable without typing
the URL. No nav-bar link — it's a per-project artifact, not a global
view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:33:10 -07:00
shadowdaoandClaude Opus 4.7 1b234069e3 feat(memory.search): optional minScore RRF threshold
Closes the long-standing backlog item from abfa5463. memory.search now
accepts an optional `minScore` parameter (Zod range 0..1) that drops hits
below the given Reciprocal Rank Fusion score. Default behavior is
unchanged — when minScore is unset, every fused result is returned, same
as today.

The original design memo suggested defaulting to 0.020, but that would
exclude valid pure-semantic matches (one ranker at rank 1 = 1/61 ≈
0.0164). Real-world Phase 2 testing surfaced exactly that case (the
"expose TLS" → HAProxy memory hit). Shipping unfiltered-by-default and
exposing the knob lets specific callers opt into stricter filtering
(e.g. ~0.025 to require two rankers to fire at rank 1) without
penalising legitimate semantic-only hits for the rest.

Tool description updated; per-source rank breakdown remains the primary
confidence signal for the model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:04:00 -07:00
shadowdaoandClaude Opus 4.7 a44b834a78 fix: address Phase 4 code review (5 findings + symmetric snippet fix)
1. lib/snippets.ts + lib/memory-actions.ts (×2): shared-project key
   lookups were querying `projects` by key with no visibility scope. Since
   `projects.key` is unique per user (not global), the unscoped match
   could resolve another user's project entirely. Restricted the lookups
   to `readableProjectIds(userId, groupNames)` — own + shared only.

2. lib/access.ts getAccessibleProjects: a stray `if (existing) continue`
   inside the share-collapse loop short-circuited on the first match,
   killing the rw-beats-ro upgrade path. Two-group cases where one share
   was ro and another rw on the same project were incorrectly resolved
   as ro. Replaced with explicit owner/rw skip.

3. lib/mcp/tools.ts snippetPut: removed a dead `void exists` block that
   looked like an authorization pre-flight but was actually a no-op —
   real write authorization lives inside putSnippet, called next. Added
   a comment at the call site documenting where the check is.

4. memory.delete + snippet.delete: previously had no optimistic-lock
   CAS, so a concurrent peer edit could be silently overwritten by a
   delete on a stale view. Added optional `version` to MemoryDeleteInput
   (new) and SnippetDeleteInput (extended); UPDATE WHERE now CASes on
   version; 0-row response surfaces CONCURRENT_EDIT_ERROR. Web detail
   pages pass `version` through hidden form inputs. When the caller
   doesn't supply a version, falls back to the version we just read in
   the same handler for in-handler consistency.

5. lib/mcp/tools.ts memorySearch re-fetch: missing `isNull(deletedAt)`
   on the post-search row hydration left a TOCTOU window where a row
   soft-deleted between the search and the re-fetch would be returned.
   Visibility is still enforced by searchMemories itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:06:10 -07:00
shadowdao 3f17a5b2d6 Merge: Phase 4c+d+e project sharing + co-edit + awareness (Agent B)
# Conflicts:
#	apps/web/lib/db/schema.ts
#	apps/web/lib/mcp/context.ts
2026-05-17 09:54:48 -07:00
shadowdao 8b807596ba Merge: Phase 4a+b groups foundation + X-Project-Key header (Agent A) 2026-05-17 09:51:44 -07:00
shadowdaoandClaude Opus 4.7 d5823ca78c feat(sharing): project shares, co-edit safety, awareness UI (Phase 4c+d+e)
Adds project-level sharing via the new project_shares table plus the
infrastructure that makes multi-user editing safe and visible.

Authorization (lib/access.ts):
  - getAccessibleProjects / getProjectAccess centralise the predicate
    used by every read and write path.
  - readableProjectIds / writableProjectIds drive listing-style queries.
  - Web UI Server Actions and pages source group memberships from the
    user_groups table so authorization works without depending on
    Agent A's session callback shape.

Optimistic locking:
  - memories + snippets gain version + last_edited_by columns. Every
    UPDATE bumps version and stamps the editor; UPDATE WHERE clauses
    require the caller's pre-fetched version, surfacing a clear
    "refresh and try again" error on lost-write races rather than
    silently clobbering.
  - MemoryUpdateInput / SnippetPutInput accept an optional version
    token.

MCP tools:
  - memory.write / .update / .delete / .get / .list / .search,
    snippet.put / .get / .list / .delete now respect shared-project
    access (read = owner | any share, write = owner | rw share).
  - project, defaults to ctx.defaultProjectKey from the X-Project-Key
    header (populated by the MCP route — Agent A's wiring).
  - project.identify returns shared projects you have access to and
    prefers an owned project on key collision, audit-logging the
    collision so an operator can debug it.
  - Tool descriptions for memory.update, memory.write, snippet.put,
    and project.identify updated with the co-edit / shared-project
    notes.

Web UI:
  - Project detail page: ownership badge, shared-with-N-groups badge,
    owner-only "Manage sharing" section (add/flip/remove shares via
    lib/share-actions.ts). Add-share is constrained to groups the
    granter is already in.
  - "Shared" chips on memory cards in /memories and /dashboard.
  - "Last edited by ..." on memory + snippet detail pages, shown only
    when the last editor isn't the row's original author so the chip
    stays informative.
  - Read-only viewers (ro shares) lose Edit/Delete affordances on
    memories and snippets.

Migration 0004_project_shares.sql adds project_shares + the two new
columns on memories and snippets; it depends on Agent A's
0003_groups.sql for the groups, user_groups, and memory_access enum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:50:59 -07:00
shadowdaoandClaude Opus 4.7 7712023c32 feat(phase-4a): groups sync + X-Project-Key header substrate
Foundational work for the upcoming group-scoped sharing feature.

Schema (migration 0003_groups.sql + drizzle schema):
  - memory_access enum ('ro' | 'rw') reserved for Agent B's project_shares
  - groups (id, oidc_iss, name, display_name, …) keyed by (oidc_iss, name)
    so different IdPs can both have e.g. "platform" without colliding
  - user_groups (user_id, group_id, synced_at) PK (user_id, group_id)

Auth (auth.ts + lib/auth/sync-groups.ts):
  - jwt callback now syncs `profile.groups` after upserting the user
  - syncUserGroupsFromClaim runs in a single tx: upserts each group,
    inserts new memberships, deletes ones no longer in the claim
  - missing/empty claim → user has zero groups (wipe memberships)
  - EntraID GUID-vs-name edge case: we treat whatever strings the claim
    emits as names verbatim; groups overage (>200 groups → no claim)
    is documented as unsupported in v1

UserContext + JWT (lib/mcp/context.ts, lib/auth/jwt.ts):
  - AuthenticatedClaims.groups surfaced from verified JWT payload
  - UserContext.groups: string[] — live from OIDC token claim, falls
    back to DB snapshot for CLI (HMAC) tokens which carry no claim
  - UserContext.defaultProjectKey: optional, set from header

MCP route (app/api/mcp/route.ts):
  - reads X-Project-Key header, validates against ProjectKey Zod schema,
    400 on invalid; empty/missing leaves defaultProjectKey undefined
  - auto-upserts the header-supplied project so first-use works without
    a separate project.identify call

Tools (lib/mcp/tools.ts):
  - withDefaultProject helper injects ctx.defaultProjectKey when the
    caller omits `project`. Per-tool defaultScope hint avoids breaking
    snippet.put (user-scope default) while making memory.write
    (project-scope default) honor the header
  - applied to memory.write/list/search/update and all snippet.* tools

Web UI:
  - /settings/groups debug page lists current memberships with synced_at
    and a clear empty state pointing at README troubleshooting
  - /settings/tokens grows a "Pin to project" dropdown; selected key is
    baked into the generated `claude mcp add` snippet as
    `--header "X-Project-Key: <key>"`. The JWT itself stays
    identity-only — pinning is purely a UX shortcut
  - settings landing page links to /settings/groups
  - README troubleshooting bullet covers the empty-groups path for
    Authentik / EntraID / Keycloak

Refactor:
  - extracted resolveProjectId + upsertProject from memory-actions.ts
    into lib/projects.ts so the MCP route can reuse upsertProject

Verification:
  - pnpm typecheck clean
  - SKIP_ENV_VALIDATION=true pnpm build clean; /settings/groups in route table

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:39:47 -07:00
shadowdaoandClaude Opus 4.7 5b2bf7d19d fix: address code review on merged feature set
Five high-confidence findings from the post-merge reviewer pass:

1. memory.update MCP tool description claimed "Project is upserted if it
   doesn't exist", but the handler used resolveProjectId and would error.
   Matched the description to the actual behavior (call project.identify
   first) — keeps parity with memory.write.

2. updateMemoryAction's UPDATE statement was missing the userId guard.
   The preceding scoped SELECT made it not exploitable in practice, but
   it diverged from deleteMemoryAction's pattern. Added the guard for
   defense in depth.

3. putSnippet's UPDATE statement had the same missing userId guard —
   fixed the same way.

4. MemoryUpdateInput's refine for scope='user' accepted both
   project=undefined AND project=""; the snippets refine only accepted
   undefined. Tightened MemoryUpdateInput to require undefined, matching
   the snippets rule. Web actions already coerce "" → undefined before
   parsing, so no caller is affected.

5. 0002_snippets_scope.sql created two indexes unconditionally —
   replaced with CREATE INDEX IF NOT EXISTS so re-runs after a
   drizzle-kit push won't trip.

Also adds .claude/ to .gitignore so worktree directories from
multi-agent builds aren't accidentally committed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 06:53:32 -07:00
shadowdao 5e57538719 Merge: snippets feature end-to-end (Agent B) 2026-05-17 06:48:48 -07:00
shadowdao f8c5a6a1be Merge: memory edit scope/project change (Agent A) 2026-05-17 06:48:48 -07:00
shadowdaoandClaude Opus 4.7 fcc8708dd5 feat(snippets): named reusable artifacts with scope mirroring memories
Snippets are exact-name-keyed templates (PR formats, checklists, style
rules) — prescriptive artifacts the user wants applied consistently.
Distinct from memories, which are searched descriptive prose.

- Migration 0002 adds scope/project_id/deleted_at to snippets, partial
  unique indexes per scope, and a scope/project CHECK constraint
  mirroring memories_scope_project_chk.
- New @shared-memory/schemas: SnippetName/Put/Get/List/Delete inputs
  with shared scope-project refinement.
- lib/snippets.ts: get/put/list/softDelete helpers used by both the
  Web Server Actions and the MCP tool handlers.
- Four new MCP tools (snippet.put/get/list/delete) with directive
  descriptions contrasting against memory.* (exact-name lookup vs
  search; templates vs facts).
- /snippets pages: list, new, [name] with edit + delete and a
  scope-picker when a name lives in multiple scopes.
- Nav: Snippets link between Memories and Projects.

Design note: when a name exists in both user and project scope,
snippet.get without an explicit scope prefers project (if project key
given) then falls back to user. The detail page shows a picker when
the name is ambiguous and no scope query param was supplied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 06:48:00 -07:00
shadowdao d65e1cb351 feat(memory): allow editing scope and project on memory.update
Extends both the Web UI edit form and the memory.update MCP tool so
existing memories can be reclassified between user-global and
project-attached scopes without delete+rewrite. Schema refines enforce
the user/project consistency invariants; audit log captures from/to
scope and projectKey on transitions.
2026-05-17 06:43:19 -07:00
shadowdaoandClaude Opus 4.7 af5e7c4680 fix(web): remove stray onSubmit on delete form
Server components can't pass function props (onSubmit handlers etc.) to
client-rendered HTML elements. The handler here was a no-op leftover —
removing it fixes the 500 'Event handlers cannot be passed to Client
Component props' on /memories/[id].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:56:49 -07:00
shadowdaoandClaude Opus 4.7 2e5c81c0ee docs(mcp): clarify that sensitive info IS appropriate for shared-memory
Per-user OIDC-gated storage is strictly safer than writing API keys /
credentials to local container files, so the tool description should
not discourage that use case. Adds an explicit allowlist for sensitive
data the user actively shares (vs. asking for them).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:29:58 -07:00
shadowdaoandClaude Opus 4.7 76d0159b3d feat(mcp): directive tool descriptions to drive proactive use
The Phase 1/2 descriptions explained WHAT each tool does. The model has
no other signal for WHEN to call them, so they only fire on explicit
user prompts ("remember that…", "search for…"). This rewrites each
description to lead with the trigger condition.

Highlights:
- memory.write now explicitly contrasts with the built-in file-based
  memory at ~/.claude/.../memory/, so transient or container-specific
  state stays there while user/project facts go here
- memory.search description tells the model to call it BEFORE answering
  any question that might touch a previously-shared fact ("cheap; lean
  toward calling it")
- project.identify becomes a session-start ritual when there's a repo
  context, anchoring all subsequent project-scoped writes
- update vs delete: explicit "prefer update" guidance to keep ids stable

No code or schema changes — descriptions are pure metadata that surface
in the MCP tools/list response on the next session of every connected
client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:27:11 -07:00
shadowdaoandClaude Opus 4.7 ff6baab393 feat: Phase 3b — proper Web UI for memories, projects, settings
Replaces the debug /me + /connect pages with a real authed app shell.

Pages
- /                       — anonymous landing; redirects to /dashboard once signed in
- /dashboard              — recent memories + top projects, quick "new memory" action
- /memories               — searchable list with hybrid (vector+FTS+tags) scoring;
                            per-result rank breakdown shown inline
- /memories/[id]          — view + inline edit toggle + delete
- /memories/new           — create form with project autocomplete
- /projects               — list with memory counts and last-activity
- /projects/[key]         — that project's memories
- /settings               — read-only Authentik profile + link to tokens
- /settings/tokens        — list / create / revoke CLI tokens

Old URLs preserved as redirects:
- /me      → /dashboard
- /connect → /settings/tokens

Stack additions
- Tailwind v4 with CSS-first @theme tokens (dark only for now)
- App shell in app/(authed)/ — auth guard + top nav with global search box
- Lightweight UI primitives in app/_components/ui/ (Button, Input, Card,
  Badge, EmptyState, Container, PageHeader)
- Search logic extracted from MCP tool into lib/memories.ts so Web UI and
  MCP both call the same RRF code path
- Memory CRUD via Server Actions in lib/memory-actions.ts; audit_log
  rows are tagged actor='web' to distinguish from MCP writes

Per-token revoke
- New cli_tokens table (id, user_id, jti unique, name, created_at,
  last_used_at, expires_at, revoked_at) — migration 0001_cli_tokens.sql
- mintCliToken now records jti + name; verifyCliToken enforces revocation
  for tracked tokens. Legacy tokens minted before this change (no jti)
  are accepted on signature alone until they expire naturally.
- /settings/tokens lists active + revoked tokens with one-click revoke

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:57:17 -07:00
shadowdaoandClaude Opus 4.7 609039f098 feat: /auth/cli-callback page + OAuth-first connect docs
Adds the manual-paste fallback page MCP clients hit when their OAuth
loopback callback isn't reachable (sealed containers, port-restricted
hosts). The page displays the authorization code, the full callback URL,
and the state parameter, all with copy buttons, plus instructions to
paste back into the waiting terminal. Single-use codes plus client-side
PKCE mean displaying the code here is safe — it isn't a credential by
itself.

README "Connecting Claude Code" rewritten to make OAuth the primary
path, with three options ordered by preference: (A) standard OAuth +
loopback, (B) manual paste via /auth/cli-callback, (C) static HMAC
bearer via /connect for fully headless setups. Also notes that the
zero-config plugin path is blocked on Authentik DCR (issue #8751,
expected later this year) so we're shipping the one-liner now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:37:08 -07:00
shadowdaoandClaude Opus 4.7 d7707dd158 refactor: generic OIDC provider — Auth.js no longer Authentik-specific
Replaces the Authentik-preset provider with an inline generic OIDC config
(id: "oidc"). The verifier path was already protocol-generic; the only
Authentik-named piece was the next-auth provider preset, which mapped to
the same OIDC fields anyway. Any compliant IdP — Authentik, EntraID,
Keycloak, Okta, Auth0, Zitadel, etc. — now works with just the existing
OIDC_* env vars.

Breaking change for existing deployments: the Auth.js callback path
changes from /api/auth/callback/authentik → /api/auth/callback/oidc.
Update the Web-UI client's redirect URI in your IdP before redeploying.

README rewritten to frame Authentik as the worked example, with a
concept-mapping table for EntraID and Keycloak, and audience-claim notes
for non-Authentik IdPs in the troubleshooting section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:15:44 -07:00
shadowdaoandClaude Opus 4.7 10a1e0062e fix(embedder): switch base image to node:20-slim
onnxruntime-node ships glibc-linked native binaries; loading them on
node:20-alpine (musl libc) fails with:

  Error loading shared library ld-linux-x86-64.so.2: No such file or
  directory (needed by …/onnxruntime-node/bin/napi-v3/linux/x64/
  libonnxruntime.so.1.14.0)

Using node:20-slim (Debian) provides glibc and the dynamic linker the
native bindings expect. Image is ~50 MB larger but actually works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:13:36 -07:00
shadowdaoandClaude Opus 4.7 a6f944b44d fix(docker): use pnpm deploy for embedder runtime image
The previous Dockerfile copied apps/embedder/node_modules straight from
the deps stage, but with pnpm's default isolated layout that directory
is a farm of relative symlinks pointing into the root .pnpm store. With
the root node_modules absent in the runtime image, every dependency
resolution failed (fastify, @xenova/transformers, ...).

pnpm deploy --prod writes a portable directory with flat node_modules
that resolves cleanly from a fresh WORKDIR. The `files` field on the
embedder package opts dist/ into the deployed output (the workspace
.gitignore would otherwise exclude it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:09:40 -07:00
shadowdaoandClaude Opus 4.7 9a8b504f51 feat: Phase 2 — embeddings + hybrid memory.search
Adds a small embedder sidecar (Xenova/bge-small-en-v1.5, ONNX, CPU-only)
that the web app calls inline on memory.write and memory.update, and on
demand from the new memory.search tool.

memory.search performs three candidate fetches in parallel — pgvector
cosine similarity, Postgres full-text via plainto_tsquery + ts_rank_cd,
and tag-set overlap — then fuses them with Reciprocal Rank Fusion
(k=60). Each result carries its per-source rank so the model can see
*why* a memory surfaced.

The migrator boot step gained an idempotent embedding backfill: any row
with embedding IS NULL is batched (32 at a time) through the embedder
after SQL migrations apply. Safe to run on every boot.

New tool memory.update fixes the missing edit path; centralises the
re-embed-on-content-change rule alongside write.

Stack additions:
- apps/embedder/ — Fastify server, persistent /data/models volume so the
  ~30 MB model only downloads once
- apps/web/lib/embedder.ts — typed HTTP client with batched embed +
  health probe
- packages/schemas — MemoryUpdateInput, MemorySearchInput
- docker-compose — embedder service, healthcheck, app + migrator both
  depend_on it healthy; EMBEDDER_URL promoted to a required env var

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:04:44 -07:00
shadowdaoandClaude Opus 4.7 e1fa1197f4 fix(compose): pass CLI_TOKEN_SECRET through to the app service
The explicit `environment:` map in compose filters which .env vars reach
the container. Adding CLI_TOKEN_SECRET so the new CLI-token verifier can
read it at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:38:05 -07:00
shadowdaoandClaude Opus 4.7 2bd8ea705d feat(auth): CLI tokens minted at /connect for containerized MCP clients
Adds a second token kind alongside Authentik OIDC access tokens for MCP
authentication. When the user visits /connect after signing into the Web
UI, the server mints an HMAC-signed JWT (kid="cli-v1") carrying their
Authentik identity in oidc_iss / oidc_sub claims. The token is shown
once in React state — never put in the URL or persisted on the client.

The MCP endpoint's bearer-token verifier dispatches by JWT `kid` header:
CLI tokens are verified locally via HS256(CLI_TOKEN_SECRET); everything
else goes through Authentik JWKS. Both paths resolve to the same
AuthenticatedClaims shape so userContextFromClaims handles them
identically.

This unblocks MCP clients running in containers where the OAuth loopback
callback isn't reachable — paste the token into Claude Code as a static
Authorization header and skip the OAuth flow entirely.

Revocation in v1 is "rotate CLI_TOKEN_SECRET to invalidate every issued
CLI token at once." Per-token revocation can come later if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:36:11 -07:00
shadowdaoandClaude Opus 4.7 7ab6dfe6f6 fix(auth): remove self-referential pages.signIn config
pages.signIn is meant to point at a CUSTOM sign-in page. Setting it to
'/api/auth/signin' — Auth.js's own built-in endpoint — makes Auth.js
redirect there whenever it wants the sign-in page, which is the same
endpoint, producing ERR_TOO_MANY_REDIRECTS in browsers.

Omitting the setting falls back to Auth.js's default sign-in handler,
which renders the provider-picker HTML at /api/auth/signin instead of
redirecting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:15:41 -07:00
shadowdaoandClaude Opus 4.7 3de1701175 fix(env): coerce empty EMBEDDER_URL to undefined
Zod's .url().optional() still rejects "" because the empty string is a
present value. EMBEDDER_URL is unused in Phase 1 and intentionally left
blank in .env, so preprocess "" to undefined before validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:10:47 -07:00
shadowdaoandClaude Opus 4.7 7218e69b1d fix(migration): drop invalid trigram index on tags[] column
gin_trgm_ops only applies to text, not text[]. The plain GIN index on
tags is sufficient for the `@>` / `<@` / `&&` set-containment operators
used by memory.list. pg_trgm stays loaded for future fuzzy search over
content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:08:22 -07:00
shadowdaoandClaude Opus 4.7 973cc68832 fix(migrate): resolve migrations dir alongside or above the script
When esbuild bundles migrate.ts to apps/web/migrate.mjs (one directory
higher than the source location), the relative `..` path in the previous
implementation pointed at apps/, not apps/web/. Try both layouts and
allow an explicit MIGRATIONS_DIR override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:06:06 -07:00
shadowdaoandClaude Opus 4.7 d5b7206bda fix(docker): use package-relative paths for esbuild bundle step
pnpm --filter runs in the package directory, so the input and output
paths must be relative to apps/web, not the repo root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:03:14 -07:00
shadowdaoandClaude Opus 4.7 3b191c449f fix(docker): declare esbuild as direct dev dependency
The Dockerfile uses `pnpm exec esbuild` to bundle the migrator into a
single ESM file before copying it into the runtime image. pnpm exec only
resolves binaries from declared dependencies, so the transitive esbuild
that tsx pulls in wasn't visible to the build stage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:59:11 -07:00
shadowdaoandClaude Opus 4.7 d4cd53ad2b fix(docker): copy packages/schemas/node_modules in builder stage
Local pnpm builds worked because all workspace packages had their
node_modules populated by `pnpm install`. The Docker builder stage was
only restoring `apps/web/node_modules` from the deps stage, leaving
`packages/schemas/node_modules` empty — so `next build` couldn't resolve
`zod` when transpiling the shared schemas package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:55:29 -07:00
shadowdaoandClaude Opus 4.7 077d0a0825 feat: Phase 1 — Authentik auth, MCP endpoint, persistent memory
End-to-end Phase 1 of shared-memory: a logged-in Authentik user can sign
into the Web UI (/me debug page), and an MCP client with an Authentik-
issued bearer token can call memory.write / memory.list / memory.get /
memory.delete plus project.identify against /api/mcp.

Stack:
- Next.js 15 (App Router) + React 19 + TypeScript, pnpm workspaces
- Drizzle ORM + Postgres 16 + pgvector + pg_trgm
- Auth.js v5 with Authentik provider (Web UI)
- jose + Authentik JWKS for MCP bearer-token validation
- JSON-RPC 2.0 dispatcher implementing the MCP wire protocol over plain
  HTTP POST (hand-rolled to fit Next.js App Router; switches to SSE in a
  later phase if server-initiated events are needed)
- bge-small embeddings sidecar deferred to Phase 2; the schema already
  reserves the vector(384) column + IVFFlat index, FTS via a STORED
  tsvector column, and the visibility enum (private/shared/team) so
  cross-user memory sharing can be added without a future migration

Deployment supports two modes (set in .env, never committed):
- Behind an external reverse proxy (HAProxy / nginx / Cloudflare Tunnel /
  Traefik) — DEFAULT; the app exposes APP_PORT on the host with
  X-Forwarded-* trusted, no in-container TLS
- Built-in TLS via Caddy — opt-in with `docker compose --profile tls up`

Discovery endpoint at /.well-known/oauth-protected-resource (RFC 9728)
points MCP clients at the Authentik authorization server after a 401.

README walks through both Authentik providers (Web UI + MCP resource
server), the audience scope mapping, redirect URIs, and includes a worked
HAProxy config snippet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:04:11 -07:00
shadowdaoandClaude Opus 4.7 d5be753cfb Initial commit: gitignore and env example
Establishes the secrets-handling contract for this repo: real env values
live in a local .env (gitignored from the first commit), and only the
sanitized .env.example with placeholder values is tracked.

.env.example documents the env surface for the v1 deployment: PUBLIC_URL,
Authentik OIDC clients (web + MCP resource server), Postgres connection,
embedder sidecar, NextAuth secret, and log level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:46:18 -07:00
107 changed files with 18387 additions and 36 deletions
+5 -5
View File
@@ -1,16 +1,16 @@
{ {
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "dnspegasus", "name": "cybercove-labs",
"description": "Self-hosted Claude Code plugins for the dnspegasus.net infrastructure.", "description": "Open-source Claude Code plugins released by CyberCove Labs.",
"owner": { "owner": {
"name": "jknapp", "name": "CyberCove Labs",
"url": "https://repo.anhonesthost.net/jknapp/shared-memory" "url": "https://repo.anhonesthost.net/cybercove-labs/shared-memory"
}, },
"plugins": [ "plugins": [
{ {
"name": "shared-memory", "name": "shared-memory",
"source": "./plugin", "source": "./plugin",
"description": "Shared persistent memory and snippet library for Claude Code sessions, backed by memory.dnspegasus.net and authenticated with Authentik OIDC." "description": "Shared persistent memory and snippet library for Claude Code sessions, backed by your own shared-memory server and authenticated with OIDC."
} }
] ]
} }
+28
View File
@@ -0,0 +1,28 @@
# VCS
.git
.gitignore
# Local env — must NEVER end up in image layers
.env
.env.*
!.env.example
# Build artifacts
**/node_modules
**/.next
**/dist
**/build
**/.turbo
**/coverage
# Editor / OS
.vscode
.idea
.DS_Store
# Logs
*.log
# Misc
*.md
!README.md
+95
View File
@@ -0,0 +1,95 @@
# =============================================================================
# shared-memory — example environment file
# Copy to `.env` and fill in real values. Never commit `.env`.
# =============================================================================
# -----------------------------------------------------------------------------
# Public URL the app is reached at.
# Used for OIDC redirect URIs, MCP discovery metadata, and Auth.js callbacks.
# -----------------------------------------------------------------------------
PUBLIC_URL=https://memory.example.com
# -----------------------------------------------------------------------------
# Deployment mode
# -----------------------------------------------------------------------------
# By default the app exposes a plain HTTP port to the host for use behind an
# external reverse proxy (HAProxy, nginx, Traefik, Cloudflare Tunnel, etc.).
APP_PORT=3000
# Bind interface for the exposed port. Use 127.0.0.1 to only accept traffic
# from a proxy on the same host. Default 0.0.0.0 accepts from anywhere.
APP_BIND=0.0.0.0
# The two settings below are ONLY consumed by the optional `caddy` service,
# which is started with: `docker compose --profile tls up -d`.
# Leave them as-is if you terminate TLS upstream (HAProxy, etc.).
APP_HOSTNAME=memory.example.com
ACME_EMAIL=you@example.com
# -----------------------------------------------------------------------------
# Authentik OIDC
# Create two Applications in Authentik (one for the Web UI, one for the MCP
# 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)
# -----------------------------------------------------------------------------
POSTGRES_USER=memory
POSTGRES_PASSWORD=replace-me-with-a-strong-password
POSTGRES_DB=memory
# Built automatically by docker-compose from the values above. Override only
# if you point at an external Postgres.
# DATABASE_URL=postgres://memory:...@db:5432/memory
# When using docker-compose.external-db.yml, set DATABASE_URL explicitly.
# Example for AWS RDS Postgres with SSL:
# DATABASE_URL=postgres://memory:STRONG_PASSWORD@your-rds.region.rds.amazonaws.com:5432/memory?sslmode=require
# -----------------------------------------------------------------------------
# Embedder sidecar. Default points at the in-compose service.
# -----------------------------------------------------------------------------
EMBEDDER_URL=http://embedder:8080
EMBEDDING_MODEL=Xenova/bge-small-en-v1.5
EMBEDDING_DIM=384
# -----------------------------------------------------------------------------
# NextAuth session signing — generate with: openssl rand -base64 32
# -----------------------------------------------------------------------------
NEXTAUTH_SECRET=replace-me-with-32-bytes-of-random
# -----------------------------------------------------------------------------
# CLI token signing key. Used to mint HMAC-signed JWTs from /connect for
# pasting into MCP clients (Claude Code etc.). Rotate to invalidate all
# outstanding CLI tokens at once. Generate with: openssl rand -base64 32
# -----------------------------------------------------------------------------
CLI_TOKEN_SECRET=replace-me-with-32-bytes-of-random
# Lifetime (in days) of newly minted CLI tokens. Positive integer; unset or
# invalid values fall back to 90. Only affects tokens minted after this is set —
# already-issued tokens keep their original expiry.
# CLI_TOKEN_TTL_DAYS=90
# -----------------------------------------------------------------------------
# App
# -----------------------------------------------------------------------------
LOG_LEVEL=info
# Optional: pin to a specific built image (e.g. for a registry-pushed build).
# IMAGE_REF=registry.example.com/shared-memory-web:0.1.0
+45
View File
@@ -0,0 +1,45 @@
# Environment files — never commit real secrets
.env
.env.*
!.env.example
!.env.*.example
# Node / Next.js
node_modules/
.next/
out/
dist/
build/
*.tsbuildinfo
next-env.d.ts
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Package manager state
.pnpm-store/
.yarn/
# IDE / OS
.vscode/
.idea/
.DS_Store
Thumbs.db
# Test / coverage
coverage/
.nyc_output/
# Docker / runtime
*.pid
*.seed
*.pid.lock
# Local data volumes (if anyone bind-mounts under repo)
data/
postgres-data/
.claude/
+5
View File
@@ -0,0 +1,5 @@
link-workspace-packages=true
prefer-workspace-packages=true
auto-install-peers=true
shamefully-hoist=false
strict-peer-dependencies=false
+1
View File
@@ -0,0 +1 @@
shared-memory
+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.
+33
View File
@@ -0,0 +1,33 @@
# Caddy config for shared-memory.
#
# Hostname and ACME email come from environment variables set by docker-compose
# (which loads them from .env). For local development without TLS, override
# this file or set APP_HOSTNAME=localhost and use a docker-compose override.
{
email {$ACME_EMAIL}
# Uncomment to use the Let's Encrypt staging directory while testing:
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
{$APP_HOSTNAME} {
encode gzip zstd
# Trust X-Forwarded-* from this proxy. Auth.js + Next.js use these to
# construct callback URLs that match PUBLIC_URL.
header {
# Tell upstream we terminated TLS.
# (`reverse_proxy` already sets X-Forwarded-* by default.)
}
reverse_proxy app:3000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
}
log {
output stdout
format console
}
}
+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.
+611 -26
View File
@@ -1,41 +1,626 @@
# instance/dnspegasus # shared-memory
This branch is **not** a fork of the project. It is an orphan branch holding A self-hosted MCP server that gives Claude Code sessions a **shared, persistent
exactly three files: the Claude Code plugin manifests for one live instance, memory** plus a **reusable snippet library**, behind your own OIDC login.
filled in with that instance's real server URL and OAuth client ID. Includes a Web UI for reviewing, editing, and deleting what's been stored.
Works with any OIDC-compliant identity provider — Authentik (the worked
example below), Microsoft Entra ID, Keycloak, Okta, Auth0, Zitadel, Google
Workspace. Anything that publishes a `/.well-known/openid-configuration`.
> **Status:** Phase 2 — memory with hybrid (vector + FTS + tags) search,
> OIDC-authed Web UI, MCP endpoint with JWKS-validated bearer tokens.
> Rich Web UI lands in Phase 3.
---
## Architecture
``` ```
.claude-plugin/marketplace.json marketplace named `dnspegasus` ┌─────────────────┐ ┌───────────────────────┐ ┌─────────────────┐
plugin/.claude-plugin/plugin.json plugin manifest │ Claude Code │──MCP──▶│ shared-memory app │◀──OIDC─│ Your OIDC IdP │
plugin/.mcp.json server URL + pre-registered OAuth client │ (many sessions)│ HTTP │ Next.js + MCP route │ │ (Authentik / │
└─────────────────┘ │ + Web UI │ │ EntraID / │
└───────────┬───────────┘ │ Keycloak/...) │
│ └─────────────────┘
│ ▲
┌──────▼──────┐ user logs in
│ Postgres 16 │ via web browser
│ + pgvector │
└─────────────┘
┌─────┴─────┐
│ embedder │ (bge-small via Xenova
│ sidecar │ transformers, on-CPU)
└───────────┘
``` ```
Install from it with a `#ref` fragment: The same container serves both the MCP endpoint (under `/api/mcp`) and the
Web UI. Users authenticate via your OIDC provider with pre-registered
confidential clients. Identity is keyed on the OIDC `sub` + `iss` so
memories are scoped per user.
---
## Prerequisites
- A host with **Docker** and **Docker Compose v2** installed (≥ 2.24.0 if you
plan to use the [external Postgres override](#external-postgres-rds-cloud-sql-etc)).
- An **OIDC identity provider** you control (Authentik, EntraID, Keycloak,
Okta, Auth0, Zitadel, …). The setup walkthrough below uses Authentik
because that's what we run; other IdPs need equivalent settings.
- A **public DNS record** for the chosen hostname pointing at your reverse
proxy (HAProxy, nginx, Cloudflare Tunnel, …) or at this host directly.
- A Postgres-friendly disk for the `db_data` volume.
---
## Deployment modes
Pick one based on how you handle TLS:
### Mode A — Behind an external reverse proxy (DEFAULT)
You already have HAProxy / nginx / Traefik / Cloudflare Tunnel terminating
TLS for your domain. The app exposes a plain HTTP port to the host; your
proxy forwards traffic to it.
```bash ```bash
claude plugin marketplace add "https://repo.anhonesthost.net/cybercove-labs/shared-memory.git#instance/dnspegasus" docker compose up -d
claude plugin install shared-memory@dnspegasus
``` ```
## Why it's an orphan branch The app listens on `${APP_PORT:-3000}` on the host. Point your proxy there.
See **HAProxy example** below.
It used to be a normal branch off `main`, which was wrong twice over: ### Mode B — Built-in TLS via Caddy
- It carried a full copy of the application it had no reason to have, so it The host directly faces the internet on ports 80/443 and you want
drifted behind `main` and a stale deploy could have been cut from it. auto-managed Let's Encrypt certs.
- Syncing it meant `git merge origin/main`, which **silently replaced these
three manifests** with `main`'s public placeholders. No conflict was raised,
because only `main` had touched those paths.
With no shared history there is nothing to sync and nothing to clobber. Never ```bash
merge `main` into this branch — if the manifest *format* changes upstream, docker compose --profile tls up -d
hand-edit these files and run `claude plugin validate .`. ```
`main` carries the same three files with placeholder values, as the public Caddy reads `APP_HOSTNAME` and `ACME_EMAIL` from `.env` and proxies to the
template. The real values live only here. app on the internal Docker network.
## The client ID is not a secret ### Mode C — AWS Fargate (Terraform)
`clientId` is a **Public** (PKCE) OAuth client. It is meant to be committed — For deployments where docker-compose on a VM isn't a fit (multi-AZ HA,
it identifies the client, it does not authenticate it. Access is controlled by managed RDS, no host to babysit), the [`terraform/`](terraform/) directory
the bindings on the IdP application, not by keeping this string private. ships a module that wires the same three components into ECS Fargate
behind an ALB:
```bash
cd terraform/examples/basic
$EDITOR main.tf terraform.tfvars # plug in your VPC, RDS, ACM, ECR, OIDC
terraform init && terraform apply
```
You bring the VPC, RDS Postgres, ACM cert, ECR images, and OIDC clients;
the module brings ECS, ALB, EFS (for the embedder model cache), Secrets
Manager, IAM, CloudWatch, and Service Connect for app↔embedder discovery.
Full walkthrough in [`terraform/README.md`](terraform/README.md), including
the post-apply migrator invocation and DNS setup.
---
## Quick start
```bash
git clone https://repo.anhonesthost.net/jknapp/shared-memory.git
cd shared-memory
cp .env.example .env
# edit .env — see "Configuration" and "OIDC provider setup" below
docker compose build
docker compose up -d # Mode A (behind external proxy)
# OR
docker compose --profile tls up -d # Mode B (built-in TLS)
# tail logs to watch migrations run + app come up
docker compose logs -f migrator app
```
When `app` reports `Listening on http://0.0.0.0:3000`, visit your
`PUBLIC_URL` and click **Sign in with OIDC**. You should land on
`/me` showing your OIDC session.
---
## Configuration
All runtime config is in `.env` at the repo root. Never commit this file.
Copy `.env.example` and fill in the values below.
| Variable | Mode | What it is |
|---|---|---|
| `PUBLIC_URL` | both | Full external URL of this app, e.g. `https://memory.example.com`. Used by Auth.js for callbacks and by the MCP route for resource metadata. |
| `APP_PORT` | A | Host port the app listens on for the external proxy. Default `3000`. |
| `APP_BIND` | A | Interface to bind on. Use `127.0.0.1` to only accept traffic from a proxy on the same host. Default `0.0.0.0`. |
| `APP_HOSTNAME` | B | Hostname only (no scheme). Caddy uses it for the TLS site block. |
| `ACME_EMAIL` | B | Email for Let's Encrypt registration. |
| `OIDC_ISSUER` | both | OIDC issuer URL for **this app**. Authentik uses `https://auth.example.com/application/o/<slug>/`; other IdPs vary. |
| `OIDC_CLIENT_ID_WEB` | both | Client ID of the Web-UI OAuth/OIDC client in your IdP. |
| `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. |
| `LOG_LEVEL` | both | `debug` / `info` / `warn` / `error`. |
Mode column: **A** = external proxy (default), **B** = built-in Caddy TLS.
---
## External Postgres (RDS, Cloud SQL, etc.)
By default the compose stack runs a bundled `pgvector/pgvector:pg16` container
with its data on a Docker volume. For production deployments you may prefer
a managed Postgres (AWS RDS, GCP Cloud SQL, Azure Database for PostgreSQL,
…). An opt-in override file disables the bundled `db` service and lets the
app point at any reachable Postgres.
### When to use
- You already have a managed Postgres you trust (point-in-time recovery,
automated snapshots, monitoring, IAM, etc.).
- You want to scale the database independently of the app host.
- Compliance / data-residency rules require the DB to live elsewhere.
If none of that applies, the bundled `db` is fine — keep using
`docker compose up -d` and skip this section.
### Connection requirements
- The DB must be reachable from wherever the app runs (security group /
firewall / VPC peering / private link as appropriate).
- SSL is strongly recommended. For RDS append `?sslmode=require` to the URL.
- The DB user needs enough privileges on first boot to install extensions
(`pgvector`, `pg_trgm`, `pgcrypto`). The migrator runs
`CREATE EXTENSION IF NOT EXISTS` for each — on RDS the user needs the
`rds_superuser` role, or have an admin pre-create the extensions and
grant the app's user `USAGE` on them.
### Extension requirements
- **pgvector** — vector search. RDS Postgres ≥ 15.5 ships pgvector as a
trusted extension; 16.x (what this project targets) supports it out of
the box. Cloud SQL and Azure Database for PostgreSQL also expose it as
a flagged / configurable extension.
- **pg_trgm** — trigram index for hybrid lexical search.
- **pgcrypto** — `gen_random_uuid()` for ID generation.
### Compose invocation
```bash
docker compose -f docker-compose.yml -f docker-compose.external-db.yml up -d
```
Set `DATABASE_URL` in `.env` to your managed-DB connection string before
running this — the `POSTGRES_*` variables are no longer consulted in this
mode. See `.env.example` for the RDS-style example URL.
Combine with the built-in TLS profile if you want Caddy as well:
```bash
docker compose -f docker-compose.yml -f docker-compose.external-db.yml --profile tls up -d
```
### What about backups?
You give up the `db_data` volume (which you'd back up with whatever volume
backup story you already use) and inherit your managed provider's backup
story instead — RDS automated snapshots + point-in-time recovery, Cloud SQL
automated backups, Azure server-level backups, etc. In practice this is the
main reason to switch: pushing backup-and-restore to a managed service that
already does it well.
### AWS Fargate / managed deploy
For a fully-managed deployment (Fargate app + RDS DB, no Docker host of
your own), see [`terraform/README.md`](terraform/README.md) for an
opinionated Terraform module that wires it all up.
---
## OIDC provider setup
You need **two** OAuth2 / OIDC clients on your identity provider:
- **Web UI client** — confidential, used when a human signs in through the
browser to the Web UI
- **MCP resource-server client** — public (PKCE), used by Claude Code or any
other MCP client to obtain access tokens scoped to the MCP endpoint
Reusing one client for both works, but the two-client setup keeps token
audiences cleanly separated and matches the rest of this doc.
The walkthrough below uses **Authentik** because that's what we run. The
shape is the same on any OIDC provider; the UI labels differ:
| Concept here | Authentik | Microsoft Entra ID | Keycloak |
|---|---|---|---|
| OAuth2 client | Provider + Application | App registration | Client |
| Redirect URI list | Provider's "Redirect URIs / Origins" | App's "Redirect URIs" | Client's "Valid Redirect URIs" |
| Audience claim | Scope mapping or property mapping | "Expose an API" + scope | Client scope with audience mapper |
### A. Web UI provider
**Admin → Applications → Providers → Create → OAuth2/OpenID Provider**
- **Name:** `shared-memory-web`
- **Authorization flow:** `default-provider-authorization-explicit-consent`
(or your standard auth flow)
- **Client type:** `Confidential`
- **Client ID:** auto-generated → copy to `.env` as `OIDC_CLIENT_ID_WEB`
- **Client Secret:** auto-generated → copy to `.env` as `OIDC_CLIENT_SECRET_WEB`
- **Redirect URIs / Origins:**
```
https://memory.example.com/api/auth/callback/oidc
```
(replace with your `PUBLIC_URL`)
- **Signing Key:** select your `authentik Self-signed Certificate`
- **Scopes:** `openid`, `profile`, `email`
Save. Then **Admin → Applications → Applications → Create**:
- **Name / Slug:** `shared-memory` (the slug becomes the path in the issuer URL)
- **Provider:** `shared-memory-web`
- **Launch URL:** `https://memory.example.com/`
The slug is what makes `OIDC_ISSUER` end with `.../application/o/shared-memory/`.
### B. MCP resource-server provider
The MCP endpoint validates **access tokens** issued by Authentik for a specific
audience (`OIDC_AUDIENCE`). This second provider exists so Claude Code's
tokens carry `aud: shared-memory` (or whatever value you chose).
**Admin → Applications → Providers → Create → OAuth2/OpenID Provider**
- **Name:** `shared-memory-mcp`
- **Authorization flow:** same as above
- **Client type:** `Public` (Claude Code runs PKCE without a static secret)
or `Confidential` if you prefer to issue a secret to each Claude Code
install — both work. Phase 1 expects Public.
- **Client ID:** auto-generated → copy to `.env` as `OIDC_CLIENT_ID_MCP`
- **Redirect URIs:** Claude Code prints the exact value when it first
connects to the MCP endpoint. Paste it into Authentik then.
- **Scopes:** `openid`, `profile`, `email`
- **Signing Key:** same cert as the Web provider
#### Setting the `aud` claim
The MCP endpoint requires the access token's `aud` claim to equal
`OIDC_AUDIENCE`. Authentik does not always emit `aud` by default. The
reliable pattern:
1. Create a **scope mapping** (Customisation → Property Mappings → Create →
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**.
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.
> **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
> ```
>
> 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`.
### C. Assign users
For each Authentik user who should have access, add them to the bound group
on both applications (or set the applications' authentication policy to
permit them). Anyone not granted access will fail at the Authentik login
prompt, never reaching the app.
---
## Connecting Claude Code
Three paths, in order of preference:
### 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 \
--client-id <OIDC_CLIENT_ID_MCP> \
--callback-port 33418 \
shared-memory https://memory.example.com/api/mcp
```
What happens:
1. Claude Code hits `/api/mcp`, gets 401 with our `WWW-Authenticate` header
2. It reads `/.well-known/oauth-protected-resource`, finds your OIDC issuer
3. It opens an authorize URL in your browser and starts a local listener
on the `--callback-port` you specified
4. You authenticate with your IdP in the browser
5. The IdP redirects back to `http://localhost:33418/callback?code=…`,
Claude Code's listener catches it, exchanges the code for an access
token, and stores it
`--callback-port` is required because your IdP only accepts pre-registered
redirect URIs. Pick any free port; just make sure the matching URI is in
your MCP client's **Redirect URIs** list. Authentik users with the regex
pattern from the setup step (`^http://(127\.0\.0\.1|localhost):\d+/.*$`)
can use any port without re-registering.
### 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:
```bash
claude mcp add --transport http --scope user \
--client-id <OIDC_CLIENT_ID_MCP> \
--callback-port 0 \
shared-memory https://memory.example.com/api/mcp
```
When the loopback listener times out, Claude Code prompts you to paste the
callback URL. Open the authorize URL Claude Code printed in your browser,
sign in, and your IdP redirects to
`https://memory.example.com/auth/cli-callback?code=…`. That page shows
the `code` and the full URL with copy buttons — paste either back into
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`.
### 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
the `/connect` page for the exact `claude mcp add` command it generates
for you.
### Why no zero-config plugin yet
Claude Code plugins can ship an MCP server entry that handles OAuth
without any flags — but only when the auth server supports Dynamic Client
Registration (RFC 7591). Authentik is tracking DCR in
[goauthentik/authentik#8751](https://github.com/goauthentik/authentik/issues/8751);
once it ships we'll publish a plugin so the entire flow above collapses
to `/plugin install shared-memory`. Other IdPs that already support DCR
(Asana-style) can wire this up sooner.
[mcp-auth]: https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
---
## Identifying the current project (`.shared-memory-project`)
When Claude Code calls memory.write / memory.search / etc., the server needs
to know *which* project the call belongs to. Resolution order, first match
wins:
1. **Explicit `project` argument** on the tool call.
2. **`.shared-memory-project` file** at the repo root — a single line of
plain text containing the project key. Claude is instructed to read this
first when a project context is present, before inferring or asking. This
is the recommended path for any repo: commit the file, and every
collaborator's Claude Code automatically attaches memories to the same
shared project.
3. **`X-Project-Key` request header** — per-MCP-registration default, set at
`claude mcp add` time with `--header "X-Project-Key: foo"`. Useful when a
machine works in one project across many repos.
4. **Inference** — repo name / git remote slug / working-directory basename,
as a last resort.
### Adding `.shared-memory-project` to your repo
```bash
echo "your-project-key" > .shared-memory-project
git add .shared-memory-project
git commit -m "chore: declare shared-memory project key"
```
The key must match the regex `^[a-zA-Z0-9._\-/]+$` (same constraint as the
`ProjectKey` Zod schema — alphanumerics plus `.`, `_`, `-`, `/`). Pick
something stable; renaming later is fine but breaks the implicit link with
any pre-existing memories you wrote against the old key.
### Why a flat-text file, not JSON
Matches the family of `.python-version`, `.nvmrc`, `.tool-versions` — easy
to grep, easy to author by hand, easy to read from any client without a
parser. If we ever need richer metadata (display name, default tags, etc.)
we'd graduate to a structured format, but the single-key case is the 95%.
---
## HAProxy example
If you run HAProxy at the edge (TLS terminator + reverse proxy), a minimal
config for this app looks like:
```haproxy
frontend https_in
bind *:443 ssl crt /etc/haproxy/certs/memory.example.com.pem alpn h2,http/1.1
http-request set-header X-Forwarded-Proto https
http-request set-header X-Forwarded-Host %[req.hdr(host)]
http-request set-header X-Forwarded-For %[src]
acl host_memory hdr(host) -i memory.example.com
use_backend shared_memory if host_memory
backend shared_memory
option forwardfor
# Replace 127.0.0.1 with the IP of the host running docker compose.
# Port is APP_PORT from .env (default 3000).
server app1 127.0.0.1:3000 check inter 5s
```
Things to verify:
- `PUBLIC_URL` in `.env` matches the public URL HAProxy serves (scheme + host).
- HAProxy is sending `X-Forwarded-Proto`, `X-Forwarded-Host`, and
`X-Forwarded-For` (the snippet above does). Auth.js reads these to build
the OIDC callback URL — without them, the callback may point at
`http://...:3000` and Authentik will reject it.
- The Authentik Web-UI provider's **Redirect URI** is the public callback,
not the internal one. E.g. `https://memory.example.com/api/auth/callback/oidc`.
If your HAProxy lives on a different host than Docker, change `127.0.0.1`
to the Docker host's address (and confirm `APP_BIND=0.0.0.0` so the port
listens on all interfaces).
---
## Local development (no TLS)
For development against a local IdP, you can skip Caddy and run the app
directly:
```bash
pnpm install
cp .env.example .env # set PUBLIC_URL=http://localhost:3000 etc.
docker compose up -d db embedder
pnpm db:migrate
pnpm dev
```
The OIDC client you use locally must accept
`http://localhost:3000/api/auth/callback/oidc` as a redirect URI.
---
## Troubleshooting
- **`401 claim invalid: aud`** from `/api/mcp` — your MCP client isn't
emitting an `aud` claim matching `OIDC_AUDIENCE`. On Authentik this is a
scope mapping; on EntraID it's the API "Application ID URI"; on Keycloak
it's a client-scope audience mapper. See **Setting the `aud` claim** above
for the Authentik recipe; other IdPs need the equivalent in their UI.
- **Auth.js callback fails with `OAUTH_CALLBACK_ERROR`** — your `PUBLIC_URL`
doesn't match the redirect URI your IdP is configured with. They must be
exactly equal, scheme and trailing slash included.
- **Caddy can't get a cert** — confirm DNS points to your host and ports
80/443 are reachable. Uncomment the staging CA line in `Caddyfile` while
testing to avoid hitting the production rate limit.
- **`pg_isready` healthcheck loops** — check that `POSTGRES_USER` /
`POSTGRES_PASSWORD` / `POSTGRES_DB` are all set in `.env`.
- **`/settings/groups` is empty even though I'm in groups** — your IdP isn't
emitting a `groups` claim. On Authentik, edit the OIDC provider and add
the built-in `authentik default OAuth Mapping: OpenID 'profile'` (or a
custom property mapping that returns `{"groups": [g.name for g in
request.user.ak_groups.all()]}`), then sign out and back in. On EntraID,
add a "groups" optional claim under **Token configuration → Optional
claims**; tick "Emit groups as group names" if you want names (we treat
GUIDs as opaque strings). Keycloak: add a Group Membership mapper with
"Full group path" off and the token claim name `groups`.
---
## Project layout
```
shared-memory/
├── apps/web/ # Next.js app (UI + MCP endpoint)
│ ├── app/
│ │ ├── page.tsx # landing
│ │ ├── me/page.tsx # auth debug page
│ │ ├── api/auth/[...nextauth]/ # NextAuth handler
│ │ ├── api/mcp/ # MCP streamable-HTTP endpoint
│ │ ├── api/health/ # /api/health for compose healthcheck
│ │ └── .well-known/oauth-protected-resource/ # RFC 9728
│ ├── auth.ts # NextAuth + Authentik provider config
│ ├── lib/
│ │ ├── env.ts # Zod env validation
│ │ ├── auth/jwt.ts # MCP bearer JWT verification (JWKS)
│ │ ├── db/ # Drizzle schema + client
│ │ └── mcp/ # MCP dispatcher + tools
│ ├── drizzle/0000_init.sql # initial migration (manual SQL)
│ ├── scripts/migrate.ts # migration runner
│ └── Dockerfile
├── packages/schemas/ # shared Zod schemas (UI ↔ MCP)
├── docker-compose.yml
├── Caddyfile
└── .env.example
```
## License
MIT.
+66
View File
@@ -0,0 +1,66 @@
# syntax=docker/dockerfile:1.7
# -----------------------------------------------------------------------------
# Embedder sidecar.
#
# Builds from the repo root: docker build -f apps/embedder/Dockerfile .
# -----------------------------------------------------------------------------
# node:20-slim instead of -alpine — onnxruntime-node ships glibc-linked
# binaries and crashes at dlopen time on musl.
FROM node:20-slim AS base
RUN corepack enable
WORKDIR /app
# ---------- deps ----------
FROM base AS deps
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
COPY apps/embedder/package.json ./apps/embedder/
COPY apps/web/package.json ./apps/web/
COPY packages/schemas/package.json ./packages/schemas/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# ---------- builder ----------
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/embedder/node_modules ./apps/embedder/node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/packages/schemas/node_modules ./packages/schemas/node_modules
COPY . .
# Compile TS → JS into apps/embedder/dist.
RUN cd apps/embedder \
&& pnpm exec tsc -p tsconfig.json --noEmit false --outDir dist
# `pnpm deploy` writes a self-contained tree to /deploy: package.json,
# dist/, and a flat node_modules with only production deps. The `files`
# field in apps/embedder/package.json is what tells deploy to include dist.
RUN pnpm --filter @shared-memory/embedder deploy --prod /deploy
# ---------- runner ----------
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=8080 \
HOST=0.0.0.0 \
MODEL_CACHE_DIR=/data/models
RUN apt-get update \
&& apt-get install -y --no-install-recommends wget ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs --no-create-home node-embedder \
&& mkdir -p /data/models \
&& chown -R node-embedder:nodejs /data
# /deploy is the self-contained output of `pnpm deploy --prod` — copy as-is.
COPY --from=builder --chown=node-embedder:nodejs /deploy ./
USER node-embedder
EXPOSE 8080
VOLUME ["/data/models"]
HEALTHCHECK --interval=15s --timeout=5s --start-period=180s --retries=8 \
CMD wget -q -O - http://127.0.0.1:8080/health | grep -q '"ready":true' || exit 1
CMD ["node", "--enable-source-maps", "dist/index.js"]
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@shared-memory/embedder",
"version": "0.1.0",
"private": true,
"type": "module",
"files": ["dist", "package.json"],
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --noEmit",
"start": "node --enable-source-maps dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@xenova/transformers": "^2.17.2",
"fastify": "^5.2.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Embedder sidecar — loads a small ONNX model once at boot and serves
* mean-pooled, L2-normalized sentence embeddings over HTTP.
*
* Endpoints:
* GET /health → { status, ready, model, dim }
* POST /embed → { vectors: number[][] } given { texts: string[] }
*
* Used by the web app's memory.write / memory.update / memory.search and
* by the migrator's one-shot backfill step.
*/
import Fastify from "fastify";
import { pipeline, env as txEnv } from "@xenova/transformers";
// Persist the downloaded model on a named docker volume so subsequent
// boots don't re-fetch ~30 MB.
txEnv.cacheDir = process.env.MODEL_CACHE_DIR ?? "/data/models";
txEnv.allowLocalModels = true;
txEnv.allowRemoteModels = true;
const MODEL_NAME = process.env.EMBEDDING_MODEL ?? "Xenova/bge-small-en-v1.5";
const EXPECTED_DIM = Number.parseInt(process.env.EMBEDDING_DIM ?? "384", 10);
const PORT = Number.parseInt(process.env.PORT ?? "8080", 10);
const HOST = process.env.HOST ?? "0.0.0.0";
// The pipeline()'s return type is a giant union covering every task; we
// only use feature-extraction, so a narrower call signature is much easier
// to work with than the upstream typing.
interface FeatureExtractor {
(
texts: string[],
options: { pooling: "mean" | "cls"; normalize: boolean },
): Promise<{ tolist: () => number[] | number[][] }>;
}
let extractor: FeatureExtractor | null = null;
async function loadModel() {
const start = Date.now();
console.log(`[embedder] loading ${MODEL_NAME}`);
// Quantized=true is the @xenova default and is fast enough; flip via env if
// we ever need the full-precision model.
extractor = (await pipeline("feature-extraction", MODEL_NAME, {
quantized: process.env.EMBEDDER_QUANTIZED !== "false",
})) as unknown as FeatureExtractor;
console.log(`[embedder] model ready in ${Date.now() - start}ms`);
}
const app = Fastify({
logger: { level: process.env.LOG_LEVEL ?? "info" },
bodyLimit: 5 * 1024 * 1024, // 5 MB — generous for batched embeds
});
app.get("/health", async () => ({
status: "ok",
ready: extractor !== null,
model: MODEL_NAME,
dim: EXPECTED_DIM,
}));
interface EmbedRequest {
texts: string[];
}
app.post("/embed", async (req, reply) => {
if (!extractor) {
return reply.code(503).send({ error: "model not loaded yet" });
}
const body = req.body as EmbedRequest | null;
if (!body || !Array.isArray(body.texts)) {
return reply.code(400).send({ error: "body must be { texts: string[] }" });
}
if (body.texts.length === 0) {
return { vectors: [] };
}
if (body.texts.length > 256) {
return reply.code(400).send({ error: "max 256 texts per request" });
}
if (body.texts.some((t) => typeof t !== "string")) {
return reply.code(400).send({ error: "every entry in texts must be a string" });
}
// Mean-pool the per-token hidden states and L2-normalize so cosine sim
// matches the inner-product distance we'll feed into pgvector.
const output = await extractor(body.texts, {
pooling: "mean",
normalize: true,
});
// Transformers.js returns a Tensor; .tolist() gives nested JS arrays.
// For batches the shape is [batch, dim]; for a single input the wrapper
// may collapse to [dim] — defensively re-wrap.
const raw = output.tolist();
const vectors: number[][] = Array.isArray(raw[0])
? (raw as number[][])
: [raw as number[]];
// Sanity-check the dimension once at runtime — catches a model swap that
// wasn't accompanied by an EMBEDDING_DIM bump.
if (vectors[0] && vectors[0].length !== EXPECTED_DIM) {
return reply.code(500).send({
error: `model produced dim=${vectors[0].length}, expected ${EXPECTED_DIM}`,
});
}
return { vectors };
});
async function start() {
await loadModel();
await app.listen({ host: HOST, port: PORT });
console.log(`[embedder] listening on http://${HOST}:${PORT}`);
}
start().catch((err) => {
console.error("[embedder] startup failed:", err);
process.exit(1);
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"noEmit": false,
"declaration": false,
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"]
},
"include": ["src/**/*.ts"]
}
+69
View File
@@ -0,0 +1,69 @@
# syntax=docker/dockerfile:1.7
# -----------------------------------------------------------------------------
# Multi-stage build for @shared-memory/web.
#
# deps — pnpm install with workspace context
# builder — next build (standalone) + bundled migrator
# runner — minimal Node runtime, non-root, runs server.js
#
# Build from the repo root:
# docker build -t shared-memory-web -f apps/web/Dockerfile .
# -----------------------------------------------------------------------------
FROM node:20-alpine AS base
RUN corepack enable
WORKDIR /app
# ---------- deps ----------
FROM base AS deps
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
COPY apps/web/package.json ./apps/web/
COPY packages/schemas/package.json ./packages/schemas/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# ---------- builder ----------
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/packages/schemas/node_modules ./packages/schemas/node_modules
COPY . .
# Build the Next.js standalone bundle. Env validation is bypassed here so
# the image can be built without real OIDC/DB secrets baked in; runtime
# validation in `env.ts` re-checks all vars on first request.
ENV SKIP_ENV_VALIDATION=true \
NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @shared-memory/web build
# Bundle the migrator into a single ESM file so the runtime image doesn't
# need tsx or the rest of devDependencies.
RUN pnpm --filter @shared-memory/web exec esbuild scripts/migrate.ts \
--bundle --platform=node --target=node20 --format=esm \
--outfile=migrate.mjs
# ---------- runner ----------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0 \
NEXT_TELEMETRY_DISABLED=1
# `wget` is alpine's tiny default; used by the docker healthcheck.
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nextjs
# Standalone bundle includes traced node_modules + server.js.
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/drizzle ./apps/web/drizzle
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/migrate.mjs ./apps/web/migrate.mjs
USER nextjs
EXPOSE 3000
# Default command runs the server. The compose `migrator` service overrides
# this to run migrations once before the app comes up.
CMD ["node", "apps/web/server.js"]
+45
View File
@@ -0,0 +1,45 @@
import Link from "next/link";
import { Container } from "@/app/_components/ui/container";
import { UserMenu } from "./_user-menu";
import { SearchBox } from "./_search-box";
import type { Session } from "next-auth";
export function Nav({ user }: { user: Session["user"] }) {
return (
<header className="fixed top-0 inset-x-0 z-20 h-14 bg-surface-1/80 backdrop-blur border-b border-border">
<Container className="h-full flex items-center gap-4">
<Link
href="/memories"
className="flex items-center gap-2 text-fg font-semibold tracking-tight no-underline"
>
<span className="inline-block size-2 rounded-full bg-accent-400" />
shared-memory
</Link>
<nav className="hidden md:flex items-center gap-1 ml-2">
<NavLink href="/memories">Memories</NavLink>
<NavLink href="/snippets">Snippets</NavLink>
<NavLink href="/projects">Projects</NavLink>
<NavLink href="/settings">Settings</NavLink>
</nav>
<div className="flex-1 max-w-md ml-auto">
<SearchBox />
</div>
<UserMenu user={user} />
</Container>
</header>
);
}
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link
href={href}
className="px-2.5 py-1.5 rounded-md text-sm text-fg-muted hover:text-fg hover:bg-surface-2 no-underline"
>
{children}
</Link>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Input } from "@/app/_components/ui/input";
/**
* Global search — submits a GET to /memories with `?q=`. Server-rendered
* results page handles the actual memory.search call.
*/
export function SearchBox() {
return (
<form action="/memories" method="GET" role="search">
<Input
type="search"
name="q"
placeholder="Search memories…"
aria-label="Search memories"
autoComplete="off"
/>
</form>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { signOut } from "@/auth";
import { Button } from "@/app/_components/ui/button";
import type { Session } from "next-auth";
async function signOutAction() {
"use server";
await signOut({ redirectTo: "/" });
}
export function UserMenu({ user }: { user: Session["user"] }) {
const label = user.email ?? user.name ?? user.id;
// Compact, single-line label; truncate on small screens via Tailwind.
return (
<div className="flex items-center gap-2">
<span
className="hidden sm:inline-block text-xs text-fg-muted max-w-[160px] truncate"
title={label}
>
{label}
</span>
<form action={signOutAction}>
<Button type="submit" variant="secondary" size="sm">
Sign out
</Button>
</form>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import Link from "next/link";
import { and, desc, eq, inArray, isNull, or, sql, count } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { memories, projects, projectShares } from "@/lib/db/schema";
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
import { Button } from "@/app/_components/ui/button";
import { EmptyState } from "@/app/_components/ui/empty-state";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const session = await auth();
const userId = session!.user.id;
const groupNames = await getUserGroupNames(userId);
// Visibility widening — recent + counts include memories under
// projects shared with the user's groups.
const accessibleIds = await readableProjectIds(userId, groupNames);
const visibility =
accessibleIds.length > 0
? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds))
: eq(memories.userId, userId);
// Dashboard's "Projects" card stays owned-only — the list of projects
// you actively own. Shared projects show up via the memory list and
// the per-project page; surfacing them here would make the panel
// confusing about who owns what.
const [counts, recent, topProjects] = await Promise.all([
db
.select({
total: count(memories.id),
})
.from(memories)
.where(and(visibility!, isNull(memories.deletedAt))),
db
.select({
id: memories.id,
content: memories.content,
scope: memories.scope,
tags: memories.tags,
createdAt: memories.createdAt,
projectId: memories.projectId,
projectKey: projects.key,
})
.from(memories)
.leftJoin(projects, eq(memories.projectId, projects.id))
.where(and(visibility!, isNull(memories.deletedAt)))
.orderBy(desc(memories.createdAt))
.limit(5),
db
.select({
id: projects.id,
key: projects.key,
displayName: projects.displayName,
memoryCount: sql<number>`count(${memories.id})::int`,
})
.from(projects)
.leftJoin(
memories,
and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)),
)
.where(eq(projects.userId, userId))
.groupBy(projects.id)
.orderBy(desc(sql`count(${memories.id})`))
.limit(4),
]);
// Annotate "Shared" chips on the recent panel.
const projectIds = recent
.map((r) => r.projectId)
.filter((p): p is string => p !== null);
const sharedProjects =
projectIds.length > 0
? new Set(
(
await db
.selectDistinct({ projectId: projectShares.projectId })
.from(projectShares)
.where(inArray(projectShares.projectId, projectIds))
).map((r) => r.projectId),
)
: new Set<string>();
const memoryTotal = counts[0]?.total ?? 0;
return (
<Container className="pt-6">
<PageHeader
title={`Welcome, ${session!.user.name ?? session!.user.email ?? "there"}`}
description={`${memoryTotal} memor${memoryTotal === 1 ? "y" : "ies"} across ${topProjects.length} project${topProjects.length === 1 ? "" : "s"}.`}
actions={
<Link href="/memories/new" className="no-underline">
<Button>New memory</Button>
</Link>
}
/>
<div className="grid gap-6 md:grid-cols-3">
<section className="md:col-span-2 space-y-2">
<h2 className="text-sm font-medium text-fg-muted mb-2">Recent</h2>
{recent.length === 0 ? (
<EmptyState
title="No memories yet"
description="Write one from the MCP, or create one here."
action={
<Link href="/memories/new" className="no-underline">
<Button>Create the first one</Button>
</Link>
}
/>
) : (
recent.map((m) => (
<Link
key={m.id}
href={`/memories/${m.id}`}
className="block no-underline"
>
<Card className="hover:border-border-strong transition-colors">
<CardBody className="space-y-2">
<div className="flex items-center gap-2 text-xs text-fg-subtle">
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
{m.scope}
</Badge>
{m.projectId && sharedProjects.has(m.projectId) ? (
<Badge tone="accent" title="Shared with one or more groups">
Shared
</Badge>
) : null}
{m.projectKey ? <span>· {m.projectKey}</span> : null}
<span className="ml-auto">
{new Date(m.createdAt).toLocaleDateString()}
</span>
</div>
<p className="text-sm text-fg line-clamp-2">{m.content}</p>
{m.tags.length ? (
<div className="flex gap-1 flex-wrap">
{m.tags.slice(0, 6).map((t) => (
<Badge key={t}>{t}</Badge>
))}
</div>
) : null}
</CardBody>
</Card>
</Link>
))
)}
</section>
<section>
<h2 className="text-sm font-medium text-fg-muted mb-2">Projects</h2>
{topProjects.length === 0 ? (
<p className="text-sm text-fg-subtle">No projects yet.</p>
) : (
<Card>
{topProjects.map((p, i) => (
<Link
key={p.id}
href={`/projects/${encodeURIComponent(p.key)}`}
className={`block px-4 py-3 hover:bg-surface-2 no-underline ${i > 0 ? "border-t border-border" : ""}`}
>
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg truncate">
{p.key}
</span>
<Badge className="ml-auto">{p.memoryCount}</Badge>
</div>
{p.displayName && p.displayName !== p.key ? (
<span className="block text-xs text-fg-muted truncate">
{p.displayName}
</span>
) : null}
</Link>
))}
<Link
href="/projects"
className="block px-4 py-2 text-xs text-fg-muted border-t border-border hover:bg-surface-2 no-underline"
>
All projects
</Link>
</Card>
)}
</section>
</div>
</Container>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { auth } from "@/auth";
import { Nav } from "./_nav";
export const dynamic = "force-dynamic";
export default async function AuthedLayout({ children }: { children: ReactNode }) {
const session = await auth();
if (!session?.user) {
redirect("/api/auth/signin?callbackUrl=/memories");
}
return (
<>
<Nav user={session.user} />
<div className="pt-16 pb-16">{children}</div>
</>
);
}
@@ -0,0 +1,247 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { memories, projects, projectShares, groups, users } from "@/lib/db/schema";
import { updateMemoryAction, deleteMemoryAction } from "@/lib/memory-actions";
import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
import { Input, Textarea, Label } from "@/app/_components/ui/input";
import { Button } from "@/app/_components/ui/button";
import { Badge } from "@/app/_components/ui/badge";
export const dynamic = "force-dynamic";
export default async function MemoryDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ edit?: string }>;
}) {
const session = await auth();
const userId = session!.user.id;
const groupNames = await getUserGroupNames(userId);
const { id } = await params;
const { edit } = await searchParams;
// Widen the visibility predicate: a user can see a memory they own,
// or any memory whose project is shared with them. Project_id filter
// uses the precomputed accessible-id list for parity with the search
// / list paths.
const accessibleProjectIds = await readableProjectIds(userId, groupNames);
const visibility =
accessibleProjectIds.length > 0
? or(
eq(memories.userId, userId),
inArray(memories.projectId, accessibleProjectIds),
)
: eq(memories.userId, userId);
const rows = await db
.select({
id: memories.id,
scope: memories.scope,
content: memories.content,
tags: memories.tags,
version: memories.version,
lastEditedBy: memories.lastEditedBy,
createdAt: memories.createdAt,
updatedAt: memories.updatedAt,
projectKey: projects.key,
projectId: memories.projectId,
ownerUserId: memories.userId,
})
.from(memories)
.leftJoin(projects, eq(memories.projectId, projects.id))
.where(and(eq(memories.id, id), isNull(memories.deletedAt), visibility!))
.limit(1);
const m = rows[0];
if (!m) notFound();
// Determine the viewer's write permission. user-scope memories =
// owner-only; project-scope = canWriteProject. Used to gate the
// Edit / Delete affordances.
let canWrite: boolean;
if (m.scope === "user") {
canWrite = m.ownerUserId === userId;
} else if (m.projectId) {
const access = await getProjectAccess(userId, groupNames, m.projectId);
canWrite = access === "owner" || access === "rw";
} else {
canWrite = false;
}
const isEditing = edit === "1" && canWrite;
// Shares on this project drive the "Shared" chip plus an editor-name
// lookup (we want to display who last edited, even if they're another
// member of the same group).
const shareRows = m.projectId
? await db
.select({ groupName: groups.name })
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.where(eq(projectShares.projectId, m.projectId))
: [];
const editorRow = m.lastEditedBy
? await db
.select({ name: users.name, email: users.email })
.from(users)
.where(eq(users.id, m.lastEditedBy))
.limit(1)
: [];
const editorLabel = editorRow[0]
? editorRow[0].name ?? editorRow[0].email ?? "unknown"
: null;
const projectList = isEditing
? await db
.select({ key: projects.key, displayName: projects.displayName })
.from(projects)
.where(eq(projects.userId, userId))
.orderBy(desc(projects.updatedAt))
.limit(50)
: [];
return (
<Container className="pt-6 max-w-3xl">
<PageHeader
title={isEditing ? "Edit memory" : "Memory"}
description={<span className="font-mono text-xs text-fg-subtle">{m.id}</span>}
actions={
<>
<Link href="/memories" className="no-underline">
<Button type="button" variant="secondary">Back</Button>
</Link>
{!isEditing && canWrite ? (
<Link href={`/memories/${m.id}?edit=1`} className="no-underline">
<Button>Edit</Button>
</Link>
) : null}
</>
}
/>
<Card className="mb-4">
<CardHeader className="flex items-center gap-2 text-xs text-fg-muted flex-wrap">
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>{m.scope}</Badge>
{m.projectKey ? <span className="font-mono">{m.projectKey}</span> : null}
{shareRows.length > 0 ? (
<Badge
tone="accent"
title={`Shared with ${shareRows.map((s) => s.groupName).join(", ")}`}
>
Shared
</Badge>
) : null}
<span>· Created {new Date(m.createdAt).toLocaleString()}</span>
{m.updatedAt.getTime() !== m.createdAt.getTime() ? (
<span>· Updated {new Date(m.updatedAt).toLocaleString()}</span>
) : null}
{editorLabel && m.lastEditedBy !== m.ownerUserId ? (
<span className="text-fg-subtle">
· Last edited by {editorLabel}
</span>
) : null}
</CardHeader>
{isEditing ? (
<CardBody>
<form action={updateMemoryAction} className="space-y-4">
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="version" value={m.version} />
<div>
<Label htmlFor="scope">Scope</Label>
<select
id="scope"
name="scope"
defaultValue={m.scope}
className="mt-1 h-9 px-2 rounded-md bg-surface-1 border border-border text-fg text-sm w-full"
>
<option value="project">Project attached to a project</option>
<option value="user">User global across all projects</option>
</select>
</div>
<div>
<Label htmlFor="project" hint="Required for project scope">
Project key
</Label>
<Input
id="project"
name="project"
defaultValue={m.projectKey ?? ""}
placeholder="repo name, slug, or any stable string"
list="project-list"
className="mt-1"
/>
{projectList.length > 0 ? (
<datalist id="project-list">
{projectList.map((p) => (
<option key={p.key} value={p.key}>
{p.displayName ?? p.key}
</option>
))}
</datalist>
) : null}
</div>
<div>
<Label htmlFor="content">Content</Label>
<Textarea
id="content"
name="content"
required
rows={12}
defaultValue={m.content}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="tags" hint="comma- or space-separated">Tags</Label>
<Input
id="tags"
name="tags"
defaultValue={m.tags.join(", ")}
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Link href={`/memories/${m.id}`} className="no-underline">
<Button type="button" variant="secondary">Cancel</Button>
</Link>
<Button type="submit">Save changes</Button>
</div>
</form>
</CardBody>
) : (
<CardBody>
<pre className="whitespace-pre-wrap break-words bg-transparent border-0 p-0 text-sm text-fg leading-relaxed">
{m.content}
</pre>
{m.tags.length ? (
<div className="flex gap-1 flex-wrap mt-4">
{m.tags.map((t) => (
<Badge key={t}>{t}</Badge>
))}
</div>
) : null}
</CardBody>
)}
</Card>
{!isEditing && canWrite ? (
<form action={deleteMemoryAction} className="flex justify-end">
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="version" value={m.version} />
<Button type="submit" variant="danger" size="sm">
Delete memory
</Button>
</form>
) : null}
</Container>
);
}
@@ -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>
);
}
+108
View File
@@ -0,0 +1,108 @@
import Link from "next/link";
import { desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { projects } from "@/lib/db/schema";
import { createMemoryAction } from "@/lib/memory-actions";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody } from "@/app/_components/ui/card";
import { Input, Textarea, Label } from "@/app/_components/ui/input";
import { Button } from "@/app/_components/ui/button";
export const dynamic = "force-dynamic";
export default async function NewMemoryPage({
searchParams,
}: {
searchParams: Promise<{ project?: string; scope?: string }>;
}) {
const session = await auth();
const userId = session!.user.id;
const params = await searchParams;
const initialProject = params.project ?? "";
const initialScope = params.scope === "user" ? "user" : "project";
const projectList = await db
.select({ key: projects.key, displayName: projects.displayName })
.from(projects)
.where(eq(projects.userId, userId))
.orderBy(desc(projects.updatedAt))
.limit(50);
return (
<Container className="pt-6 max-w-2xl">
<PageHeader
title="New memory"
description="Pick a scope, write content, optionally add tags."
/>
<Card>
<CardBody>
<form action={createMemoryAction} className="space-y-4">
<div>
<Label htmlFor="scope">Scope</Label>
<select
id="scope"
name="scope"
defaultValue={initialScope}
className="mt-1 h-9 px-2 rounded-md bg-surface-1 border border-border text-fg text-sm w-full"
>
<option value="project">Project attached to a project</option>
<option value="user">User global across all projects</option>
</select>
</div>
<div>
<Label htmlFor="project" hint="Required for project scope">
Project key
</Label>
<Input
id="project"
name="project"
defaultValue={initialProject}
placeholder="repo name, slug, or any stable string"
list="project-list"
className="mt-1"
/>
{projectList.length > 0 ? (
<datalist id="project-list">
{projectList.map((p) => (
<option key={p.key} value={p.key}>
{p.displayName ?? p.key}
</option>
))}
</datalist>
) : null}
</div>
<div>
<Label htmlFor="content">Content</Label>
<Textarea
id="content"
name="content"
required
rows={10}
placeholder="What should the next session know?"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="tags" hint="comma- or space-separated">
Tags
</Label>
<Input id="tags" name="tags" placeholder="auth, deployment, …" className="mt-1" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Link href="/memories" className="no-underline">
<Button type="button" variant="secondary">Cancel</Button>
</Link>
<Button type="submit">Save memory</Button>
</div>
</form>
</CardBody>
</Card>
</Container>
);
}
+300
View File
@@ -0,0 +1,300 @@
import Link from "next/link";
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 {
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";
import { Button } from "@/app/_components/ui/button";
import { Input } from "@/app/_components/ui/input";
import { EmptyState } from "@/app/_components/ui/empty-state";
export const dynamic = "force-dynamic";
type Scope = "project" | "user";
interface MemoryRow {
id: string;
scope: "project" | "user";
projectId: string | null;
projectKey: string | null;
content: string;
tags: string[];
createdAt: Date;
rank?: { rrfScore: number; vectorRank: number | null; ftsRank: number | null; tagRank: number | null };
shared?: boolean;
}
async function fetchMemoriesByIds(
ids: string[],
): Promise<Map<string, MemoryRow>> {
if (ids.length === 0) return new Map();
const rows = await db
.select({
id: memories.id,
scope: memories.scope,
content: memories.content,
tags: memories.tags,
createdAt: memories.createdAt,
projectId: memories.projectId,
projectKey: projects.key,
})
.from(memories)
.leftJoin(projects, eq(memories.projectId, projects.id))
.where(and(inArray(memories.id, ids), isNull(memories.deletedAt)));
return new Map(rows.map((r) => [r.id, r as MemoryRow]));
}
async function listRecent(
userId: string,
accessible: AccessibleProject[],
scope?: Scope,
project?: string,
): Promise<MemoryRow[]> {
// Visibility: own rows OR rows in an accessible project.
const accessibleIds = accessible.map((p) => p.projectId);
const visibility =
accessibleIds.length > 0
? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds))
: eq(memories.userId, userId);
const filters = [visibility!, isNull(memories.deletedAt)];
if (scope) filters.push(eq(memories.scope, scope));
if (project) {
// 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({
id: memories.id,
scope: memories.scope,
content: memories.content,
tags: memories.tags,
createdAt: memories.createdAt,
projectId: memories.projectId,
projectKey: projects.key,
})
.from(memories)
.leftJoin(projects, eq(memories.projectId, projects.id))
.where(and(...filters))
.orderBy(desc(memories.createdAt))
.limit(50);
return rows;
}
/**
* Lookup which projects in `projectIds` have any share rows. Used so
* we can show a "Shared" chip per memory card. One query covers every
* row on the page; per-row inspection would be N+1 here.
*/
async function sharedProjectSet(projectIds: string[]): Promise<Set<string>> {
if (projectIds.length === 0) return new Set();
const rows = await db
.selectDistinct({ projectId: projectShares.projectId })
.from(projectShares)
.where(inArray(projectShares.projectId, projectIds));
return new Set(rows.map((r) => r.projectId));
}
export default async function MemoriesPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; scope?: string; project?: string }>;
}) {
const session = await auth();
const userId = session!.user.id;
const groupNames = await getUserGroupNames(userId);
const params = await searchParams;
const q = params.q?.trim() || undefined;
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;
if (q) {
const result = await searchMemories(
userId,
q,
{ scope, projectKey: project, groupNames },
30,
);
const ids = result.hits.map((h) => h.id);
const byId = await fetchMemoriesByIds(ids);
rows = result.hits.flatMap((h) => {
const r = byId.get(h.id);
return r ? [{ ...r, rank: h.rank }] : [];
});
debug = result.debug;
} else {
rows = await listRecent(userId, accessible, scope, project);
}
// Annotate which rows belong to projects that have any active share.
// Done in a single query so the listing stays O(1) DB calls regardless
// of page size.
const projectIds = rows
.map((r) => r.projectId)
.filter((p): p is string => p !== null);
const sharedProjects = await sharedProjectSet(projectIds);
rows = rows.map((r) => ({
...r,
shared: r.projectId ? sharedProjects.has(r.projectId) : false,
}));
return (
<Container className="pt-6">
<PageHeader
title="Memories"
description={
q
? `${rows.length} result${rows.length === 1 ? "" : "s"} for "${q}"`
: "Most recent first."
}
actions={
<Link href="/memories/new" className="no-underline">
<Button>New memory</Button>
</Link>
}
/>
<form
method="GET"
action="/memories"
className="mb-6 flex flex-wrap items-center gap-2"
>
<Input
name="q"
placeholder="Search…"
defaultValue={q ?? ""}
aria-label="Search query"
className="flex-1 min-w-[200px]"
/>
<FilterSelect name="scope" value={scope} options={["", "project", "user"]} placeholder="Any scope" />
<ProjectCombobox
name="project"
defaultValue={project ?? ""}
options={projectKeys}
className="w-44"
/>
<Button type="submit" variant="secondary">Apply</Button>
</form>
{debug ? (
<p className="text-xs text-fg-subtle mb-3">
candidates · vector: {debug.vec} · fts: {debug.fts} · tag: {debug.tag}
</p>
) : null}
{rows.length === 0 ? (
<EmptyState
title={q ? "Nothing matched" : "No memories yet"}
description={q ? "Try a different query or remove filters." : "Create one or write via the MCP."}
action={
!q ? (
<Link href="/memories/new" className="no-underline">
<Button>Create the first one</Button>
</Link>
) : null
}
/>
) : (
<ul className="space-y-2">
{rows.map((m) => (
<li key={m.id}>
<Link href={`/memories/${m.id}`} className="block no-underline">
<Card className="hover:border-border-strong transition-colors">
<CardBody className="space-y-2">
<div className="flex items-center gap-2 text-xs text-fg-subtle flex-wrap">
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
{m.scope}
</Badge>
{m.shared ? (
<Badge tone="accent" title="Shared with one or more groups">
Shared
</Badge>
) : null}
{m.projectKey ? (
<span className="font-mono">· {m.projectKey}</span>
) : null}
<span>·</span>
<span>{new Date(m.createdAt).toLocaleString()}</span>
{m.rank ? (
<span className="ml-auto text-fg-subtle">
rrf {m.rank.rrfScore.toFixed(4)} · v
{m.rank.vectorRank ?? ""} · f
{m.rank.ftsRank ?? ""} · t
{m.rank.tagRank ?? ""}
</span>
) : null}
</div>
<p className="text-sm text-fg line-clamp-3">{m.content}</p>
{m.tags.length ? (
<div className="flex gap-1 flex-wrap">
{m.tags.map((t) => (
<Badge key={t}>{t}</Badge>
))}
</div>
) : null}
</CardBody>
</Card>
</Link>
</li>
))}
</ul>
)}
</Container>
);
}
function FilterSelect({
name,
value,
options,
placeholder,
}: {
name: string;
value: string | undefined;
options: string[];
placeholder: string;
}) {
return (
<select
name={name}
defaultValue={value ?? ""}
className="h-9 px-2 rounded-md bg-surface-1 border border-border text-fg text-sm"
>
{options.map((o) => (
<option key={o} value={o}>
{o === "" ? placeholder : o}
</option>
))}
</select>
);
}
@@ -0,0 +1,321 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { and, eq, inArray } from "drizzle-orm";
import { auth } from "@/auth";
import { db, pg } from "@/lib/db/client";
import { projects, users } from "@/lib/db/schema";
import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
import { Button } from "@/app/_components/ui/button";
import { EmptyState } from "@/app/_components/ui/empty-state";
export const dynamic = "force-dynamic";
const ROW_LIMIT = 150;
interface ActivityRow {
id: string;
action: string;
actor: "mcp" | "web" | "system";
entityType: "memory" | "snippet" | "project" | string;
entityId: string | null;
userId: string | null;
payload: Record<string, unknown> | null;
createdAt: Date;
}
/**
* Per-project activity feed. Surfaces every audit_log row that pertains
* to this project — memory writes/updates/deletes, snippet puts/deletes,
* share grants/revocations/changes, identify-collision warnings.
*
* Query strategy: three UNION ALL legs joined to a single audit_log
* source, ordered + limited at the end. Avoids relying on the audit
* payload's projectKey field, which isn't populated for every action
* shape today.
*/
export default async function ProjectActivityPage({
params,
}: {
params: Promise<{ key: string }>;
}) {
const session = await auth();
const userId = session!.user.id;
const { key: rawKey } = await params;
const key = decodeURIComponent(rawKey);
const groupNames = await getUserGroupNames(userId);
// Resolve the project: prefer owned, fall back to shared.
const ownedRow = await db
.select()
.from(projects)
.where(and(eq(projects.userId, userId), eq(projects.key, key)))
.limit(1);
let project = ownedRow[0];
if (!project) {
if (groupNames.length === 0) notFound();
const readableIds = await readableProjectIds(userId, groupNames);
if (readableIds.length === 0) notFound();
const sharedRow = await db
.select()
.from(projects)
.where(and(eq(projects.key, key), inArray(projects.id, readableIds)))
.limit(1);
if (!sharedRow[0]) notFound();
project = sharedRow[0];
}
const access = await getProjectAccess(userId, groupNames, project.id);
if (access === null) notFound();
// Pull every audit_log row that concerns this project. Three legs:
// - memory rows joined by entity_id → memories.id where the memory's
// current project_id matches
// - snippet rows joined likewise
// - project rows where entity_id is the project itself (share grants
// and project.identify.collision)
// postgres-js's tagged template returns parsed JSON for jsonb columns.
const rows = await pg<ActivityRow[]>`
SELECT al.id, al.action, al.actor, al.entity_type AS "entityType",
al.entity_id AS "entityId", al.user_id AS "userId",
al.payload, al.created_at AS "createdAt"
FROM audit_log al
JOIN memories m ON al.entity_id = m.id
WHERE al.entity_type = 'memory'
AND m.project_id = ${project.id}
UNION ALL
SELECT al.id, al.action, al.actor, al.entity_type,
al.entity_id, al.user_id, al.payload, al.created_at
FROM audit_log al
JOIN snippets s ON al.entity_id = s.id
WHERE al.entity_type = 'snippet'
AND s.project_id = ${project.id}
UNION ALL
SELECT al.id, al.action, al.actor, al.entity_type,
al.entity_id, al.user_id, al.payload, al.created_at
FROM audit_log al
WHERE al.entity_type = 'project'
AND al.entity_id = ${project.id}
ORDER BY "createdAt" DESC
LIMIT ${ROW_LIMIT}
`;
// Bulk-resolve user display names. `userId` can be null for system
// entries (project.identify.collision); skip those.
const userIds = [...new Set(rows.map((r) => r.userId).filter((id): id is string => Boolean(id)))];
const userById = new Map<string, { name: string | null; email: string | null }>();
if (userIds.length > 0) {
const userRows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(inArray(users.id, userIds));
for (const u of userRows) {
userById.set(u.id, { name: u.name, email: u.email });
}
}
function actorLabel(row: ActivityRow): string {
if (row.actor === "system") return "system";
if (!row.userId) return "(unknown user)";
const u = userById.get(row.userId);
if (!u) return "(unknown user)";
return u.name ?? u.email ?? row.userId;
}
return (
<Container className="pt-6 max-w-4xl">
<PageHeader
title="Activity"
description={
<>
<span className="font-mono">{project.key}</span>
{" · "}
<span>{rows.length} event{rows.length === 1 ? "" : "s"}</span>
{rows.length === ROW_LIMIT ? <span> (most recent first)</span> : null}
</>
}
actions={
<Link
href={`/projects/${encodeURIComponent(project.key)}`}
className="no-underline"
>
<Button type="button" variant="secondary">Back to project</Button>
</Link>
}
/>
{rows.length === 0 ? (
<EmptyState
title="No activity yet"
description="Memory writes, snippet edits, and share changes will show up here as they happen."
/>
) : (
<Card>
<ol className="divide-y divide-border">
{rows.map((row) => (
<li key={row.id} className="px-4 py-3 flex items-baseline gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-fg">
<strong className="font-medium">{actorLabel(row)}</strong>{" "}
<span className="text-fg-muted">{describeAction(row)}</span>
</div>
{renderPayloadSummary(row)}
</div>
<div className="text-xs text-fg-subtle whitespace-nowrap" title={row.createdAt.toString()}>
{formatRelative(row.createdAt)}
</div>
</li>
))}
</ol>
</Card>
)}
</Container>
);
}
/**
* Human-friendly verb phrase per action. Includes an entity link when
* the entity is still resolvable (memory/snippet id), plain text
* otherwise. Keeps deletes phrased in the past tense so the feed reads
* as a log.
*/
function describeAction(row: ActivityRow): React.ReactNode {
switch (row.action) {
case "memory.write":
return (
<>
wrote{" "}
{row.entityId ? (
<Link href={`/memories/${row.entityId}`} className="no-underline">
a memory
</Link>
) : (
"a memory"
)}
</>
);
case "memory.update": {
const fields = (row.payload?.fields as string[] | undefined) ?? [];
const fieldList = fields.length > 0 ? ` (${fields.join(", ")})` : "";
return (
<>
edited{" "}
{row.entityId ? (
<Link href={`/memories/${row.entityId}`} className="no-underline">
a memory
</Link>
) : (
"a memory"
)}
{fieldList}
</>
);
}
case "memory.delete":
return <>deleted a memory</>;
case "snippet.put":
case "snippet.update": {
const name = (row.payload?.name as string | undefined) ?? null;
const verb = row.action === "snippet.put" ? "saved" : "edited";
return (
<>
{verb} snippet{" "}
{name ? <code className="text-fg">{name}</code> : <em>(unnamed)</em>}
</>
);
}
case "snippet.delete": {
const name = (row.payload?.name as string | undefined) ?? null;
return (
<>
deleted snippet{" "}
{name ? <code className="text-fg">{name}</code> : <em>(unnamed)</em>}
</>
);
}
case "project.share.add": {
const groupName = (row.payload?.groupName as string | undefined) ?? "(unknown group)";
const access = (row.payload?.access as string | undefined) ?? "?";
return (
<>
shared with <strong className="font-medium">{groupName}</strong>{" "}
<Badge tone={access === "rw" ? "success" : "neutral"}>{access}</Badge>
</>
);
}
case "project.share.update": {
const groupName = (row.payload?.groupName as string | undefined) ?? "(unknown group)";
const access = (row.payload?.access as string | undefined) ?? "?";
return (
<>
changed <strong className="font-medium">{groupName}</strong>{"'s "}access to{" "}
<Badge tone={access === "rw" ? "success" : "neutral"}>{access}</Badge>
</>
);
}
case "project.share.remove": {
const groupName = (row.payload?.groupName as string | undefined) ?? "(unknown group)";
return (
<>
stopped sharing with <strong className="font-medium">{groupName}</strong>
</>
);
}
case "project.identify.collision":
return (
<>
project key collided with a shared project of the same name (owned won)
</>
);
default:
return <>{row.action}</>;
}
}
/**
* Optional second line for richer payloads — scope transitions and tag
* changes on memory.update, mainly. Kept terse so the feed scans well.
*/
function renderPayloadSummary(row: ActivityRow): React.ReactNode {
if (row.action !== "memory.update") return null;
const p = row.payload ?? {};
const scope = p.scope as { from: string; to: string } | undefined;
const projectKey = p.projectKey as { from: string | null; to: string | null } | undefined;
if (!scope && !projectKey) return null;
return (
<div className="text-xs text-fg-subtle mt-1">
{scope ? (
<span>
scope: {scope.from} {scope.to}
</span>
) : null}
{scope && projectKey ? <span> · </span> : null}
{projectKey ? (
<span>
project: {projectKey.from ?? "—"} {projectKey.to ?? "—"}
</span>
) : null}
</div>
);
}
/**
* Tiny relative-time formatter — no third-party dep needed for a few
* grain buckets. Anything older than a week falls back to a date.
*/
function formatRelative(d: Date): string {
const now = Date.now();
const t = d.getTime();
const diff = Math.max(0, now - t);
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 7) return `${day}d ago`;
return d.toLocaleDateString();
}
@@ -0,0 +1,395 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import {
groups,
memories,
projects,
projectShares,
users,
} from "@/lib/db/schema";
import {
getProjectAccess,
getUserGroupNames,
readableProjectIds,
} from "@/lib/access";
import {
addProjectShareAction,
removeProjectShareAction,
updateProjectShareAction,
} from "@/lib/share-actions";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
import { Button } from "@/app/_components/ui/button";
import { Input, Label } from "@/app/_components/ui/input";
import { EmptyState } from "@/app/_components/ui/empty-state";
export const dynamic = "force-dynamic";
/**
* Project detail page.
*
* Three personas converge here:
* - Owner viewing their own project: full memory list + share-
* management UI.
* - Member of a group with rw access: same memory list, can edit
* memories, but cannot edit shares.
* - Member with ro access: memory list rendered read-only-ish; no
* "New memory" button.
*
* Authorization is centralised in `lib/access.ts` so this page only
* has to ask "what's my access level" once and branch on the answer.
*/
export default async function ProjectDetailPage({
params,
}: {
params: Promise<{ key: string }>;
}) {
const session = await auth();
const userId = session!.user.id;
const { key: rawKey } = await params;
const key = decodeURIComponent(rawKey);
const groupNames = await getUserGroupNames(userId);
// Resolve the project. Prefer an owned project; otherwise look for a
// shared project with this key the user can read. Mirrors the
// MCP-side project.identify priority.
const ownedRow = await db
.select()
.from(projects)
.where(and(eq(projects.userId, userId), eq(projects.key, key)))
.limit(1);
let project = ownedRow[0];
if (!project) {
if (groupNames.length === 0) notFound();
const readableIds = await readableProjectIds(userId, groupNames);
if (readableIds.length === 0) notFound();
const sharedRow = await db
.select()
.from(projects)
.where(and(eq(projects.key, key), inArray(projects.id, readableIds)))
.limit(1);
if (!sharedRow[0]) notFound();
project = sharedRow[0];
}
const access = await getProjectAccess(userId, groupNames, project.id);
if (access === null) notFound();
const isOwner = access === "owner";
const canWrite = access === "owner" || access === "rw";
// Owner display name for the page header. When the viewer IS the
// owner we just say "Owned by you"; otherwise look up the owner.
let ownerDisplayName: string | null = null;
if (!isOwner) {
const ownerRow = await db
.select({ name: users.name, email: users.email })
.from(users)
.where(eq(users.id, project.userId))
.limit(1);
ownerDisplayName = ownerRow[0]?.name ?? ownerRow[0]?.email ?? "another user";
}
// All shares on this project, regardless of viewer's group memberships
// — the owner needs to see everything; non-owners see the same list
// for situational awareness.
const shareRows = await db
.select({
groupId: groups.id,
groupName: groups.name,
access: projectShares.access,
grantedAt: projectShares.grantedAt,
})
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.where(eq(projectShares.projectId, project.id))
.orderBy(groups.name);
// Memories: visible to owner + members alike — anyone with read
// access on the project sees every memory under it. The query is
// unchanged from the pre-sharing version; project_id is the gate.
const mem = await db
.select({
id: memories.id,
scope: memories.scope,
content: memories.content,
tags: memories.tags,
createdAt: memories.createdAt,
})
.from(memories)
.where(and(eq(memories.projectId, project.id), isNull(memories.deletedAt)))
.orderBy(desc(memories.createdAt))
.limit(100);
// Groups the viewer is a member of — drives the share-add datalist
// for owners (only show groups they could plausibly invite). Returns
// an empty list when the user has no group memberships so the
// datalist is simply absent rather than emitting a broken IN ().
const myGroups =
groupNames.length > 0
? await db
.select({
id: groups.id,
name: groups.name,
displayName: groups.displayName,
})
.from(groups)
.where(inArray(groups.name, groupNames))
.limit(50)
: [];
return (
<Container className="pt-6">
<PageHeader
title={project.displayName ?? project.key}
description={
<span className="flex items-center gap-2 flex-wrap">
<span className="font-mono">{project.key}</span>
<span>·</span>
<span>
{mem.length} memor{mem.length === 1 ? "y" : "ies"}
</span>
<span>·</span>
{isOwner ? (
<Badge tone="success">Owned by you</Badge>
) : (
<span className="text-fg-subtle">Owned by {ownerDisplayName}</span>
)}
{shareRows.length > 0 ? (
<>
<span>·</span>
<Badge tone="accent">
Shared with {shareRows.length} group
{shareRows.length === 1 ? "" : "s"}
</Badge>
</>
) : null}
{!isOwner ? (
<>
<span>·</span>
<Badge tone={access === "rw" ? "success" : "neutral"}>
{access === "rw" ? "read + write" : "read only"}
</Badge>
</>
) : null}
</span>
}
actions={
<>
<Link href="/projects" className="no-underline">
<Button type="button" variant="secondary">All projects</Button>
</Link>
<Link
href={`/projects/${encodeURIComponent(project.key)}/activity`}
className="no-underline"
>
<Button type="button" variant="secondary">Activity</Button>
</Link>
{canWrite ? (
<Link
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
className="no-underline"
>
<Button>New in this project</Button>
</Link>
) : null}
</>
}
/>
<Card className="mb-6">
<CardHeader className="flex items-center justify-between">
<span className="text-sm font-medium">Auto-identify this project</span>
<span className="text-xs text-fg-subtle">.shared-memory-project</span>
</CardHeader>
<CardBody className="space-y-2 text-sm">
<p className="text-fg-muted">
Commit a one-line text file at the repo root so every Claude Code
session opened in this repo automatically targets this project
no per-machine config needed.
</p>
<pre className="!whitespace-pre-wrap text-xs">{`echo "${project.key}" > .shared-memory-project`}</pre>
<p className="text-xs text-fg-subtle">
Commit it. The directive tool descriptions tell Claude to read this
file at session start before falling back to inference or the
<code className="mx-1">X-Project-Key</code>header.
</p>
</CardBody>
</Card>
{shareRows.length > 0 || isOwner ? (
<Card className="mb-6">
<CardHeader className="flex items-center justify-between">
<span className="text-sm font-medium">Sharing</span>
<span className="text-xs text-fg-subtle">
{shareRows.length === 0
? "No groups have access"
: `${shareRows.length} group${shareRows.length === 1 ? "" : "s"}`}
</span>
</CardHeader>
<CardBody className="space-y-3">
{shareRows.length === 0 && !isOwner ? (
<p className="text-sm text-fg-subtle">Only the owner has access.</p>
) : null}
{shareRows.length > 0 ? (
<ul className="divide-y divide-border">
{shareRows.map((s) => (
<li
key={s.groupId}
className="flex items-center gap-3 py-2 text-sm"
>
<Badge tone="accent">{s.groupName}</Badge>
<Badge tone={s.access === "rw" ? "success" : "neutral"}>
{s.access}
</Badge>
<span className="text-xs text-fg-subtle">
since {new Date(s.grantedAt).toLocaleDateString()}
</span>
{isOwner ? (
<div className="ml-auto flex items-center gap-1">
<form action={updateProjectShareAction}>
<input type="hidden" name="projectKey" value={project.key} />
<input type="hidden" name="groupId" value={s.groupId} />
<input
type="hidden"
name="access"
value={s.access === "rw" ? "ro" : "rw"}
/>
<Button
type="submit"
variant="secondary"
size="sm"
title={
s.access === "rw"
? "Downgrade to read-only"
: "Promote to read-write"
}
>
{s.access === "rw" ? "→ ro" : "→ rw"}
</Button>
</form>
<form action={removeProjectShareAction}>
<input type="hidden" name="projectKey" value={project.key} />
<input type="hidden" name="groupId" value={s.groupId} />
<Button type="submit" variant="danger" size="sm">
Remove
</Button>
</form>
</div>
) : null}
</li>
))}
</ul>
) : null}
{isOwner ? (
<form
action={addProjectShareAction}
className="flex flex-wrap items-end gap-2 pt-2 border-t border-border"
>
<input type="hidden" name="projectKey" value={project.key} />
<div className="flex-1 min-w-[200px]">
<Label htmlFor="groupName" hint="must be a group you're a member of">
Group name
</Label>
<Input
id="groupName"
name="groupName"
list="my-group-list"
placeholder="engineering"
required
className="mt-1"
/>
{myGroups.length > 0 ? (
<datalist id="my-group-list">
{myGroups.map((g) => (
<option key={g.id} value={g.name}>
{g.displayName ?? g.name}
</option>
))}
</datalist>
) : null}
</div>
<div>
<Label>Access</Label>
<div className="mt-1 flex items-center gap-3 h-9">
<label className="text-sm flex items-center gap-1">
<input type="radio" name="access" value="ro" defaultChecked />
ro
</label>
<label className="text-sm flex items-center gap-1">
<input type="radio" name="access" value="rw" />
rw
</label>
</div>
</div>
<Button type="submit">Add share</Button>
</form>
) : null}
</CardBody>
</Card>
) : null}
{mem.length === 0 ? (
<EmptyState
title="No memories in this project yet"
description={
canWrite
? "Use the MCP from a Claude Code session, or create one here."
: "Members with write access can add memories from the MCP or the Web UI."
}
action={
canWrite ? (
<Link
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
className="no-underline"
>
<Button>Create the first one</Button>
</Link>
) : null
}
/>
) : (
<ul className="space-y-2">
{mem.map((m) => (
<li key={m.id}>
<Link href={`/memories/${m.id}`} className="block no-underline">
<Card className="hover:border-border-strong transition-colors">
<CardBody className="space-y-2">
<div className="flex items-center gap-2 text-xs text-fg-subtle">
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
{m.scope}
</Badge>
{shareRows.length > 0 ? (
<Badge
tone="accent"
title={`Shared with ${shareRows.map((s) => s.groupName).join(", ")}`}
>
Shared
</Badge>
) : null}
<span>{new Date(m.createdAt).toLocaleString()}</span>
</div>
<p className="text-sm text-fg line-clamp-3">{m.content}</p>
{m.tags.length ? (
<div className="flex gap-1 flex-wrap">
{m.tags.map((t) => (
<Badge key={t}>{t}</Badge>
))}
</div>
) : null}
</CardBody>
</Card>
</Link>
</li>
))}
</ul>
)}
</Container>
);
}
+94
View File
@@ -0,0 +1,94 @@
import Link from "next/link";
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";
import { EmptyState } from "@/app/_components/ui/empty-state";
export const dynamic = "force-dynamic";
export default async function ProjectsPage() {
const session = await auth();
const userId = session!.user.id;
const groupNames = await getUserGroupNames(userId);
// 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,
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 (
<Container className="pt-6">
<PageHeader
title="Projects"
description={`${rows.length} project${rows.length === 1 ? "" : "s"}.`}
/>
{rows.length === 0 ? (
<EmptyState
title="No projects yet"
description="Projects are created automatically the first time you write a project-scoped memory or call project.identify from the MCP."
/>
) : (
<Card>
{rows.map((p, i) => (
<Link
key={p.id}
href={`/projects/${encodeURIComponent(p.key)}`}
className={`block px-4 py-3 hover:bg-surface-2 no-underline ${i > 0 ? "border-t border-border" : ""}`}
>
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<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>
) : null}
</div>
<div className="text-xs text-fg-subtle whitespace-nowrap">
{p.lastActivity
? `last write ${new Date(p.lastActivity).toLocaleDateString()}`
: "empty"}
</div>
</div>
</Link>
))}
</Card>
)}
</Container>
);
}
@@ -0,0 +1,80 @@
import Link from "next/link";
import { eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { groups, userGroups } from "@/lib/db/schema";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card } from "@/app/_components/ui/card";
import { EmptyState } from "@/app/_components/ui/empty-state";
export const dynamic = "force-dynamic";
/**
* Debug page showing the OIDC groups currently associated with the signed-in
* user. The list is rewritten on every sign-in from the IdP's `groups`
* claim (see `lib/auth/sync-groups.ts`), so this view is effectively a
* snapshot of "what your IdP told us about you at last login".
*
* Mainly intended as a sanity check for the upcoming sharing feature —
* if the user expects to see "platform" and doesn't, the IdP probably
* isn't emitting the claim, and the empty state points them at the
* README troubleshooting section.
*/
export default async function GroupsSettingsPage() {
const session = await auth();
const userId = session!.user.id;
const rows = await db
.select({
id: groups.id,
name: groups.name,
oidcIss: groups.oidcIss,
syncedAt: userGroups.syncedAt,
})
.from(userGroups)
.innerJoin(groups, eq(userGroups.groupId, groups.id))
.where(eq(userGroups.userId, userId))
.orderBy(groups.name);
return (
<Container className="pt-6 max-w-3xl">
<PageHeader
title="Groups"
description="OIDC groups your identity provider asserted for you at last sign-in. Used by the upcoming sharing feature to decide which projects you can see."
/>
{rows.length === 0 ? (
<EmptyState
title="No groups yet"
description="Your IdP isn't emitting a `groups` claim on the access token, or you're not a member of any groups. See the troubleshooting section in the project README for how to configure Authentik / EntraID / Keycloak to emit group memberships."
/>
) : (
<Card>
{rows.map((g, i) => (
<div
key={g.id}
className={`px-4 py-3 ${i > 0 ? "border-t border-border" : ""}`}
>
<div className="flex items-baseline gap-3">
<div className="font-mono text-sm text-fg flex-1 truncate">
{g.name}
</div>
<div className="text-xs text-fg-subtle whitespace-nowrap">
synced {new Date(g.syncedAt).toLocaleString()}
</div>
</div>
<div className="text-xs text-fg-subtle font-mono mt-0.5 truncate">
{g.oidcIss}
</div>
</div>
))}
</Card>
)}
<p className="text-xs text-fg-subtle mt-6">
Groups refresh on every sign-in. If something looks stale,{" "}
<Link href="/api/auth/signout">sign out</Link> and sign back in.
</p>
</Container>
);
}
+91
View File
@@ -0,0 +1,91 @@
import Link from "next/link";
import { eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
import { Button } from "@/app/_components/ui/button";
export const dynamic = "force-dynamic";
export default async function SettingsPage() {
const session = await auth();
const userId = session!.user.id;
const userRow = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
const user = userRow[0];
return (
<Container className="pt-6 max-w-3xl">
<PageHeader title="Settings" />
<div className="space-y-6">
<Card>
<CardHeader className="text-sm font-medium text-fg">Profile</CardHeader>
<CardBody className="space-y-2 text-sm">
<Field label="Name" value={user?.name} />
<Field label="Email" value={user?.email} />
<Field label="Internal user id" value={user?.id} mono />
<Field label="OIDC issuer" value={user?.oidcIss} mono />
<Field label="OIDC sub" value={user?.oidcSub} mono />
<Field
label="Joined"
value={user?.createdAt ? new Date(user.createdAt).toLocaleString() : null}
/>
</CardBody>
</Card>
<Card>
<CardHeader className="flex items-center">
<span className="text-sm font-medium text-fg flex-1">CLI tokens</span>
<Link href="/settings/tokens" className="no-underline">
<Button variant="secondary" size="sm">Manage tokens</Button>
</Link>
</CardHeader>
<CardBody className="text-sm text-fg-muted">
Bearer tokens for headless/automated MCP clients. Visit{" "}
<Link href="/settings/tokens">/settings/tokens</Link> to generate
and revoke them.
</CardBody>
</Card>
<Card>
<CardHeader className="flex items-center">
<span className="text-sm font-medium text-fg flex-1">Groups</span>
<Link href="/settings/groups" className="no-underline">
<Button variant="secondary" size="sm">View groups</Button>
</Link>
</CardHeader>
<CardBody className="text-sm text-fg-muted">
OIDC group memberships from your IdP, refreshed at sign-in. Used
by the upcoming sharing feature to scope project visibility.
</CardBody>
</Card>
</div>
</Container>
);
}
function Field({
label,
value,
mono,
}: {
label: string;
value: string | null | undefined;
mono?: boolean;
}) {
return (
<div className="flex items-baseline gap-3">
<span className="text-fg-muted w-36 shrink-0">{label}</span>
<span className={`${mono ? "font-mono text-xs" : "text-sm"} text-fg break-all`}>
{value ?? <span className="text-fg-subtle"></span>}
</span>
</div>
);
}
@@ -0,0 +1,224 @@
import { revalidatePath } from "next/cache";
import { and, asc, desc, eq, isNull } from "drizzle-orm";
import { auth } from "@/auth";
import { env } from "@/lib/env";
import { db } from "@/lib/db/client";
import { cliTokens, projects, users } from "@/lib/db/schema";
import {
mintCliToken,
revokeCliToken,
CLI_TOKEN_TTL_SECONDS,
} from "@/lib/auth/cli-token";
import { ProjectKey } from "@shared-memory/schemas";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
import { EmptyState } from "@/app/_components/ui/empty-state";
import TokensManager, { type CreateTokenState } from "./tokens-manager";
export const dynamic = "force-dynamic";
async function createTokenAction(
_prev: CreateTokenState,
formData: FormData,
): Promise<CreateTokenState> {
"use server";
try {
const session = await auth();
if (!session?.user?.id) {
return { token: null, error: "not authenticated", projectKey: null };
}
const name = String(formData.get("name") ?? "").trim() || `Token ${new Date().toISOString().slice(0, 10)}`;
// Optional pin-to-project. The token JWT itself does NOT need a project
// claim — pinning is purely a UX shortcut so the generated `claude mcp
// add` snippet bakes in `X-Project-Key: <key>` and every call from
// that client lands on the right project by default.
const rawProject = String(formData.get("projectKey") ?? "").trim();
let projectKey: string | null = null;
if (rawProject.length > 0) {
const parsed = ProjectKey.safeParse(rawProject);
if (!parsed.success) {
return {
token: null,
error: `invalid project key: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
projectKey: null,
};
}
// Cross-check the project belongs to this user (defense in depth —
// the dropdown is built from the user's projects, but the form is
// re-submittable so don't trust the value).
const found = await db
.select({ key: projects.key })
.from(projects)
.where(and(eq(projects.userId, session.user.id), eq(projects.key, parsed.data)))
.limit(1);
if (!found[0]) {
return {
token: null,
error: `unknown project '${parsed.data}'`,
projectKey: null,
};
}
projectKey = found[0].key;
}
const userRow = await db
.select({
oidcIss: users.oidcIss,
oidcSub: users.oidcSub,
email: users.email,
name: users.name,
})
.from(users)
.where(eq(users.id, session.user.id))
.limit(1);
const u = userRow[0];
if (!u) return { token: null, error: "user row not found", projectKey: null };
const minted = await mintCliToken(
{
userId: session.user.id,
oidcIss: u.oidcIss,
oidcSub: u.oidcSub,
email: u.email,
name: u.name,
},
{ tokenName: name },
);
revalidatePath("/settings/tokens");
return { token: minted.token, error: null, projectKey };
} catch (e) {
return {
token: null,
error: e instanceof Error ? e.message : "unknown error",
projectKey: null,
};
}
}
async function revokeTokenAction(formData: FormData) {
"use server";
const session = await auth();
if (!session?.user?.id) throw new Error("not authenticated");
const tokenId = String(formData.get("tokenId") ?? "");
await revokeCliToken(session.user.id, tokenId);
revalidatePath("/settings/tokens");
}
export default async function TokensPage() {
const session = await auth();
const userId = session!.user.id;
const [tokens, projectRows] = await Promise.all([
db
.select({
id: cliTokens.id,
name: cliTokens.name,
jti: cliTokens.jti,
createdAt: cliTokens.createdAt,
lastUsedAt: cliTokens.lastUsedAt,
expiresAt: cliTokens.expiresAt,
revokedAt: cliTokens.revokedAt,
})
.from(cliTokens)
.where(eq(cliTokens.userId, userId))
.orderBy(desc(cliTokens.createdAt)),
db
.select({
key: projects.key,
displayName: projects.displayName,
})
.from(projects)
.where(eq(projects.userId, userId))
.orderBy(asc(projects.key)),
]);
const active = tokens.filter((t) => !t.revokedAt && t.expiresAt > new Date());
const inactive = tokens.filter((t) => t.revokedAt || t.expiresAt <= new Date());
const ttlDays = Math.floor(CLI_TOKEN_TTL_SECONDS / 86400);
return (
<Container className="pt-6 max-w-3xl">
<PageHeader
title="CLI tokens"
description={`Long-lived bearer tokens for MCP clients without browser access. ${ttlDays}-day expiry per token.`}
/>
<Card className="mb-6">
<CardHeader className="text-sm font-medium text-fg">Generate a new token</CardHeader>
<CardBody>
<TokensManager
action={createTokenAction}
ttlDays={ttlDays}
projects={projectRows.map((p) => ({
key: p.key,
displayName: p.displayName,
}))}
publicUrl={env().PUBLIC_URL}
/>
</CardBody>
</Card>
<h2 className="text-sm font-medium text-fg-muted mt-8 mb-2">Active tokens</h2>
{active.length === 0 ? (
<EmptyState title="No active tokens" description="Generate one above to connect a headless client." />
) : (
<Card>
{active.map((t, i) => (
<div
key={t.id}
className={`px-4 py-3 flex items-center gap-3 ${i > 0 ? "border-t border-border" : ""}`}
>
<div className="flex-1 min-w-0">
<div className="text-sm text-fg truncate">{t.name}</div>
<div className="text-xs text-fg-subtle">
Created {new Date(t.createdAt).toLocaleDateString()} ·{" "}
{t.lastUsedAt
? `last used ${new Date(t.lastUsedAt).toLocaleString()}`
: "never used"}
{" · "}expires {new Date(t.expiresAt).toLocaleDateString()}
</div>
</div>
<form action={revokeTokenAction}>
<input type="hidden" name="tokenId" value={t.id} />
<button
type="submit"
className="text-xs text-danger hover:underline"
>
Revoke
</button>
</form>
</div>
))}
</Card>
)}
{inactive.length > 0 ? (
<>
<h2 className="text-sm font-medium text-fg-muted mt-8 mb-2">Revoked / expired</h2>
<Card>
{inactive.map((t, i) => (
<div
key={t.id}
className={`px-4 py-3 flex items-center gap-3 ${i > 0 ? "border-t border-border" : ""}`}
>
<div className="flex-1 min-w-0">
<div className="text-sm text-fg-muted truncate">{t.name}</div>
<div className="text-xs text-fg-subtle">
{t.revokedAt
? `Revoked ${new Date(t.revokedAt).toLocaleString()}`
: `Expired ${new Date(t.expiresAt).toLocaleString()}`}
</div>
</div>
<Badge tone="danger">{t.revokedAt ? "revoked" : "expired"}</Badge>
</div>
))}
</Card>
</>
) : null}
</Container>
);
}
@@ -0,0 +1,139 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/app/_components/ui/button";
import { Input, Label } from "@/app/_components/ui/input";
/**
* State returned by the `createTokenAction` server action.
*
* `projectKey` is the project the user chose to pin the token to. It's NOT
* baked into the JWT itself — the token remains identity-only — it just
* lets us bake `--header "X-Project-Key: <key>"` into the generated
* `claude mcp add` snippet so calls from this client default to that
* project without the model having to pass it explicitly.
*/
export interface CreateTokenState {
token: string | null;
error: string | null;
projectKey: string | null;
}
export interface ProjectOption {
key: string;
displayName: string | null;
}
interface Props {
action: (prev: CreateTokenState, formData: FormData) => Promise<CreateTokenState>;
ttlDays: number;
projects: ProjectOption[];
publicUrl: string;
}
const initial: CreateTokenState = { token: null, error: null, projectKey: null };
export default function TokensManager({ action, ttlDays, projects, publicUrl }: Props) {
const [state, formAction, pending] = useActionState(action, initial);
if (state.token) {
return (
<div className="space-y-3">
<div className="text-sm text-success font-medium">
Token generated copy now, you won&apos;t see it again
</div>
<pre
className="!whitespace-pre-wrap !break-all select-all"
style={{ userSelect: "all" }}
>
{state.token}
</pre>
<details className="text-xs text-fg-muted">
<summary className="cursor-pointer">claude mcp add command</summary>
<pre className="mt-2">{buildMcpAddSnippet(state.token, state.projectKey, publicUrl)}</pre>
</details>
<p className="text-xs text-fg-subtle">
Valid for {ttlDays} days. Revoke individually below if it leaks.
{state.projectKey ? (
<>
{" "}This token is pinned to project{" "}
<code className="font-mono">{state.projectKey}</code> via the{" "}
<code className="font-mono">X-Project-Key</code> header in the
snippet above the JWT itself is identity-only.
</>
) : null}
</p>
</div>
);
}
return (
<form action={formAction} className="flex flex-wrap items-end gap-3">
<div className="flex-1 min-w-[200px]">
<Label htmlFor="name" hint="optional">Token name</Label>
<Input
id="name"
name="name"
placeholder="e.g. Laptop, Headless CI, …"
className="mt-1"
autoComplete="off"
/>
</div>
<div className="flex-1 min-w-[200px]">
<Label htmlFor="projectKey" hint="optional">Pin to project</Label>
<ProjectSelect projects={projects} />
</div>
<Button type="submit" disabled={pending}>
{pending ? "Generating…" : "Generate token"}
</Button>
{state.error ? (
<p className="basis-full text-sm text-danger">error: {state.error}</p>
) : null}
</form>
);
}
function ProjectSelect({ projects }: { projects: ProjectOption[] }) {
// Match Input styling — Tailwind v4 classes from `lib/ui/input.tsx`.
const cls =
"mt-1 block w-full h-9 px-3 text-sm rounded-md bg-surface-1 " +
"border border-border text-fg focus:border-accent-400 focus:outline-none " +
"disabled:opacity-50 transition-colors";
if (projects.length === 0) {
return (
<select id="projectKey" name="projectKey" className={cls} disabled>
<option value="">No projects yet</option>
</select>
);
}
return (
<select id="projectKey" name="projectKey" defaultValue="" className={cls}>
<option value="">(none token works across all projects)</option>
{projects.map((p) => (
<option key={p.key} value={p.key}>
{p.displayName && p.displayName !== p.key
? `${p.key}${p.displayName}`
: p.key}
</option>
))}
</select>
);
}
function buildMcpAddSnippet(
token: string,
projectKey: string | null,
publicUrl: string,
): string {
const headerLines = [` --header "Authorization: Bearer ${token}"`];
if (projectKey) {
headerLines.push(` --header "X-Project-Key: ${projectKey}"`);
}
const base = publicUrl.replace(/\/+$/, "");
return [
"claude mcp add --transport http --scope user \\",
...headerLines.map((l) => `${l} \\`),
` shared-memory ${base}/api/mcp`,
].join("\n");
}
@@ -0,0 +1,301 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { and, eq, inArray, isNull, or } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { snippets, projects, users } from "@/lib/db/schema";
import { updateSnippetAction, deleteSnippetAction } from "@/lib/snippet-actions";
import { getSnippet, type SnippetWithProjectKey } from "@/lib/snippets";
import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
import { Input, Textarea, Label } from "@/app/_components/ui/input";
import { Button } from "@/app/_components/ui/button";
import { Badge } from "@/app/_components/ui/badge";
export const dynamic = "force-dynamic";
interface SiblingHit {
scope: "project" | "user";
projectKey: string | null;
}
/**
* When a snippet name exists in more than one scope (e.g. a user-scope
* default plus one or more project-scope variants), we need to either
* disambiguate by query string or, if no hint is given, show a picker.
*
* Visibility widening: with sharing, the user may also see project-
* scope snippets under shared projects. Match rows that the viewer can
* read (own user-scope rows, or project-scope rows in an accessible
* project).
*/
async function findAllMatches(
userId: string,
groupNames: string[],
name: string,
): Promise<SiblingHit[]> {
const accessibleProjectIds = await readableProjectIds(userId, groupNames);
const visibility =
accessibleProjectIds.length > 0
? or(
and(eq(snippets.userId, userId), isNull(snippets.projectId)),
inArray(snippets.projectId, accessibleProjectIds),
)
: and(eq(snippets.userId, userId), isNull(snippets.projectId));
const rows = await db
.select({
scope: snippets.scope,
projectKey: projects.key,
})
.from(snippets)
.leftJoin(projects, eq(snippets.projectId, projects.id))
.where(and(eq(snippets.name, name), isNull(snippets.deletedAt), visibility!));
return rows as SiblingHit[];
}
export default async function SnippetDetailPage({
params,
searchParams,
}: {
params: Promise<{ name: string }>;
searchParams: Promise<{ scope?: string; project?: string; edit?: string }>;
}) {
const session = await auth();
const userId = session!.user.id;
const groupNames = await getUserGroupNames(userId);
const { name: rawName } = await params;
const name = decodeURIComponent(rawName);
const sp = await searchParams;
const scope: "project" | "user" | undefined =
sp.scope === "user" || sp.scope === "project" ? sp.scope : undefined;
const project = sp.project?.trim() || undefined;
const wantsEdit = sp.edit === "1";
const siblings = await findAllMatches(userId, groupNames, name);
if (siblings.length === 0) notFound();
// If multiple matches and the user hasn't disambiguated, show a picker.
if (!scope && siblings.length > 1) {
return (
<Container className="pt-6 max-w-3xl">
<PageHeader
title={name}
description={`This name exists in ${siblings.length} scopes — pick one to view.`}
actions={
<Link href="/snippets" className="no-underline">
<Button type="button" variant="secondary">
Back
</Button>
</Link>
}
/>
<Card>
{siblings.map((s, i) => {
const params = new URLSearchParams({ scope: s.scope });
if (s.scope === "project" && s.projectKey) {
params.set("project", s.projectKey);
}
return (
<Link
key={`${s.scope}-${s.projectKey ?? ""}`}
href={`/snippets/${encodeURIComponent(name)}?${params.toString()}`}
className={`block px-4 py-3 hover:bg-surface-2 no-underline ${i > 0 ? "border-t border-border" : ""}`}
>
<div className="flex items-center gap-3">
<Badge tone={s.scope === "user" ? "accent" : "neutral"}>{s.scope}</Badge>
{s.projectKey ? (
<span className="font-mono text-sm text-fg">{s.projectKey}</span>
) : (
<span className="text-sm text-fg-muted">applies everywhere</span>
)}
</div>
</Link>
);
})}
</Card>
</Container>
);
}
const snippet: SnippetWithProjectKey | null = await getSnippet(userId, {
name,
scope,
projectKey: project,
groupNames,
});
if (!snippet) notFound();
// Authorize: user-scope rows belong solely to their owner; project-
// scope rows require rw on the project (or ownership) to edit.
let canWrite: boolean;
if (snippet.scope === "user") {
canWrite = snippet.userId === userId;
} else if (snippet.projectId) {
const access = await getProjectAccess(userId, groupNames, snippet.projectId);
canWrite = access === "owner" || access === "rw";
} else {
canWrite = false;
}
const isEditing = wantsEdit && canWrite;
// Editor name for "Last edited by ..." footer.
const editorRow = snippet.lastEditedBy
? await db
.select({ name: users.name, email: users.email })
.from(users)
.where(eq(users.id, snippet.lastEditedBy))
.limit(1)
: [];
const editorLabel = editorRow[0]
? editorRow[0].name ?? editorRow[0].email ?? "unknown"
: null;
return (
<Container className="pt-6 max-w-3xl">
<PageHeader
title={isEditing ? `Edit ${snippet.name}` : snippet.name}
description={
<span className="font-mono text-xs text-fg-subtle">
{snippet.scope}
{snippet.projectKey ? ` · ${snippet.projectKey}` : ""}
</span>
}
actions={
<>
<Link href="/snippets" className="no-underline">
<Button type="button" variant="secondary">
Back
</Button>
</Link>
{!isEditing && canWrite ? (
<Link
href={`/snippets/${encodeURIComponent(snippet.name)}?${new URLSearchParams({
scope: snippet.scope,
...(snippet.scope === "project" && snippet.projectKey
? { project: snippet.projectKey }
: {}),
edit: "1",
}).toString()}`}
className="no-underline"
>
<Button>Edit</Button>
</Link>
) : null}
</>
}
/>
<Card className="mb-4">
<CardHeader className="flex items-center gap-2 text-xs text-fg-muted flex-wrap">
<Badge tone={snippet.scope === "user" ? "accent" : "neutral"}>{snippet.scope}</Badge>
{snippet.projectKey ? <span className="font-mono">{snippet.projectKey}</span> : null}
<span>· Created {new Date(snippet.createdAt).toLocaleString()}</span>
{snippet.updatedAt.getTime() !== snippet.createdAt.getTime() ? (
<span>· Updated {new Date(snippet.updatedAt).toLocaleString()}</span>
) : null}
{editorLabel && snippet.lastEditedBy !== snippet.userId ? (
<span className="text-fg-subtle">· Last edited by {editorLabel}</span>
) : null}
</CardHeader>
{isEditing ? (
<CardBody>
<form action={updateSnippetAction} className="space-y-4">
<input type="hidden" name="name" value={snippet.name} />
<input type="hidden" name="scope" value={snippet.scope} />
<input type="hidden" name="version" value={snippet.version} />
{snippet.scope === "project" && snippet.projectKey ? (
<input type="hidden" name="project" value={snippet.projectKey} />
) : null}
<div>
<Label htmlFor="description" hint="Optional">
Description
</Label>
<Input
id="description"
name="description"
defaultValue={snippet.description ?? ""}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="body">Body</Label>
<Textarea
id="body"
name="body"
required
rows={16}
defaultValue={snippet.body}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="tags" hint="comma- or space-separated">
Tags
</Label>
<Input
id="tags"
name="tags"
defaultValue={snippet.tags.join(", ")}
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Link
href={`/snippets/${encodeURIComponent(snippet.name)}?${new URLSearchParams({
scope: snippet.scope,
...(snippet.scope === "project" && snippet.projectKey
? { project: snippet.projectKey }
: {}),
}).toString()}`}
className="no-underline"
>
<Button type="button" variant="secondary">
Cancel
</Button>
</Link>
<Button type="submit">Save changes</Button>
</div>
</form>
</CardBody>
) : (
<CardBody>
{snippet.description ? (
<p className="text-sm text-fg-muted mb-3">{snippet.description}</p>
) : null}
<pre className="whitespace-pre-wrap break-words bg-transparent border-0 p-0 text-sm text-fg leading-relaxed font-mono">
{snippet.body}
</pre>
{snippet.tags.length ? (
<div className="flex gap-1 flex-wrap mt-4">
{snippet.tags.map((t) => (
<Badge key={t}>{t}</Badge>
))}
</div>
) : null}
</CardBody>
)}
</Card>
{!isEditing && canWrite ? (
<form action={deleteSnippetAction} className="flex justify-end">
<input type="hidden" name="name" value={snippet.name} />
<input type="hidden" name="scope" value={snippet.scope} />
<input type="hidden" name="version" value={snippet.version} />
{snippet.scope === "project" && snippet.projectKey ? (
<input type="hidden" name="project" value={snippet.projectKey} />
) : null}
<Button type="submit" variant="danger" size="sm">
Delete snippet
</Button>
</form>
) : null}
</Container>
);
}
+137
View File
@@ -0,0 +1,137 @@
import Link from "next/link";
import { desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { projects } from "@/lib/db/schema";
import { createSnippetAction } from "@/lib/snippet-actions";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody } from "@/app/_components/ui/card";
import { Input, Textarea, Label } from "@/app/_components/ui/input";
import { Button } from "@/app/_components/ui/button";
export const dynamic = "force-dynamic";
export default async function NewSnippetPage({
searchParams,
}: {
searchParams: Promise<{ scope?: string; project?: string; name?: string }>;
}) {
const session = await auth();
const userId = session!.user.id;
const params = await searchParams;
const initialScope = params.scope === "project" ? "project" : "user";
const initialProject = params.project ?? "";
const initialName = params.name ?? "";
const projectList = await db
.select({ key: projects.key, displayName: projects.displayName })
.from(projects)
.where(eq(projects.userId, userId))
.orderBy(desc(projects.updatedAt))
.limit(50);
return (
<Container className="pt-6 max-w-2xl">
<PageHeader
title="New snippet"
description="Name it something stable — that name is the lookup key from now on."
/>
<Card>
<CardBody>
<form action={createSnippetAction} className="space-y-4">
<div>
<Label htmlFor="name" hint="alphanumerics + ._-/">
Name
</Label>
<Input
id="name"
name="name"
required
defaultValue={initialName}
placeholder="pr-description-format"
className="mt-1 font-mono"
/>
</div>
<div>
<Label htmlFor="scope">Scope</Label>
<select
id="scope"
name="scope"
defaultValue={initialScope}
className="mt-1 h-9 px-2 rounded-md bg-surface-1 border border-border text-fg text-sm w-full"
>
<option value="user">User applies everywhere (default)</option>
<option value="project">Project tied to a specific repo</option>
</select>
</div>
<div>
<Label htmlFor="project" hint="Required for project scope">
Project key
</Label>
<Input
id="project"
name="project"
defaultValue={initialProject}
placeholder="repo name, slug, or any stable string"
list="project-list"
className="mt-1"
/>
{projectList.length > 0 ? (
<datalist id="project-list">
{projectList.map((p) => (
<option key={p.key} value={p.key}>
{p.displayName ?? p.key}
</option>
))}
</datalist>
) : null}
</div>
<div>
<Label htmlFor="description" hint="Optional">
Description
</Label>
<Input
id="description"
name="description"
placeholder="When should this template be used?"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="body">Body</Label>
<Textarea
id="body"
name="body"
required
rows={14}
placeholder="The full template, format, or checklist…"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="tags" hint="comma- or space-separated">
Tags
</Label>
<Input id="tags" name="tags" placeholder="format, review, …" className="mt-1" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Link href="/snippets" className="no-underline">
<Button type="button" variant="secondary">
Cancel
</Button>
</Link>
<Button type="submit">Save snippet</Button>
</div>
</form>
</CardBody>
</Card>
</Container>
);
}
+157
View File
@@ -0,0 +1,157 @@
import Link from "next/link";
import { auth } from "@/auth";
import { listSnippets } from "@/lib/snippets";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
import { Button } from "@/app/_components/ui/button";
import { Input } from "@/app/_components/ui/input";
import { EmptyState } from "@/app/_components/ui/empty-state";
export const dynamic = "force-dynamic";
type Scope = "project" | "user";
function detailHref(name: string, scope: Scope, projectKey: string | null): string {
const params = new URLSearchParams({ scope });
if (scope === "project" && projectKey) params.set("project", projectKey);
return `/snippets/${encodeURIComponent(name)}?${params.toString()}`;
}
export default async function SnippetsPage({
searchParams,
}: {
searchParams: Promise<{ scope?: string; project?: string; tag?: string }>;
}) {
const session = await auth();
const userId = session!.user.id;
const params = await searchParams;
const scope: Scope | undefined =
params.scope === "user" || params.scope === "project" ? params.scope : undefined;
const project = params.project?.trim() || undefined;
const tag = params.tag?.trim() || undefined;
const rows = await listSnippets(userId, {
scope,
projectKey: project,
tags: tag ? [tag] : undefined,
limit: 200,
});
return (
<Container className="pt-6">
<PageHeader
title="Snippets"
description={
rows.length === 0
? "Named, reusable templates. Fetched by exact name, never searched."
: `${rows.length} snippet${rows.length === 1 ? "" : "s"}, most recently updated first.`
}
actions={
<Link href="/snippets/new" className="no-underline">
<Button>New snippet</Button>
</Link>
}
/>
<form method="GET" action="/snippets" className="mb-6 flex flex-wrap items-center gap-2">
<FilterSelect
name="scope"
value={scope}
options={["", "project", "user"]}
placeholder="Any scope"
/>
<Input
name="project"
placeholder="Project key…"
defaultValue={project ?? ""}
className="w-44"
/>
<Input name="tag" placeholder="Tag…" defaultValue={tag ?? ""} className="w-32" />
<Button type="submit" variant="secondary">
Apply
</Button>
</form>
{rows.length === 0 ? (
<EmptyState
title="No snippets yet"
description="Create a snippet to save a template, format, or checklist you want to reuse. Snippets are fetched by exact name — pick something stable like 'pr-description-format' or 'commit-msg-rules'."
action={
<Link href="/snippets/new" className="no-underline">
<Button>Create the first one</Button>
</Link>
}
/>
) : (
<ul className="space-y-2">
{rows.map((s) => (
<li key={s.id}>
<Link
href={detailHref(s.name, s.scope, s.projectKey)}
className="block no-underline"
>
<Card className="hover:border-border-strong transition-colors">
<CardBody className="space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm text-fg">{s.name}</span>
<Badge tone={s.scope === "user" ? "accent" : "neutral"}>
{s.scope}
</Badge>
{s.projectKey ? (
<span className="font-mono text-xs text-fg-subtle">
· {s.projectKey}
</span>
) : null}
<span className="ml-auto text-xs text-fg-subtle">
updated {new Date(s.updatedAt).toLocaleString()}
</span>
</div>
{s.description ? (
<p className="text-sm text-fg-muted line-clamp-2">{s.description}</p>
) : (
<p className="text-sm text-fg-subtle line-clamp-2 font-mono">{s.body}</p>
)}
{s.tags.length ? (
<div className="flex gap-1 flex-wrap">
{s.tags.map((t) => (
<Badge key={t}>{t}</Badge>
))}
</div>
) : null}
</CardBody>
</Card>
</Link>
</li>
))}
</ul>
)}
</Container>
);
}
function FilterSelect({
name,
value,
options,
placeholder,
}: {
name: string;
value: string | undefined;
options: string[];
placeholder: string;
}) {
return (
<select
name={name}
defaultValue={value ?? ""}
className="h-9 px-2 rounded-md bg-surface-1 border border-border text-fg text-sm"
>
{options.map((o) => (
<option key={o} value={o}>
{o === "" ? placeholder : o}
</option>
))}
</select>
);
}
@@ -0,0 +1,34 @@
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";
/**
* RFC 9728 — OAuth 2.0 Protected Resource Metadata.
*
* MCP clients discover the authorization server (Authentik) via this
* endpoint after receiving a 401 with `WWW-Authenticate: resource_metadata=...`.
*/
export function GET() {
const resource = env().PUBLIC_URL.replace(/\/$/, "");
// The audience scope MUST be advertised. Authentik only evaluates a scope
// mapping when the client requests that scope by name, and the client only
// learns scope names from this document. Omit it and every access token
// arrives without `aud`, which jwt.ts rejects as "claim invalid: aud".
const audienceScope =
env().OIDC_AUDIENCE_SCOPE ?? `aud-${env().OIDC_AUDIENCE}`;
return NextResponse.json({
resource,
// The MCP application's issuer, which is not necessarily the Web UI's —
// see mcpIssuer(). Advertising the wrong one sends clients to a discovery
// document whose tokens this endpoint will then reject on `iss`.
authorization_servers: [mcpIssuer()],
scopes_supported: ["openid", "profile", "email", audienceScope],
bearer_methods_supported: ["header"],
resource_documentation: `${resource}/`,
});
}
+28
View File
@@ -0,0 +1,28 @@
import type { HTMLAttributes } from "react";
type Tone = "neutral" | "accent" | "success" | "warning" | "danger";
const tones: Record<Tone, string> = {
neutral: "bg-surface-3 text-fg-muted",
accent: "bg-accent-500/15 text-accent-300",
success: "bg-success/15 text-success",
warning: "bg-warning/15 text-warning",
danger: "bg-danger/15 text-danger",
};
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
tone?: Tone;
}
export function Badge({ tone = "neutral", className = "", ...rest }: BadgeProps) {
return (
<span
className={
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm " +
"text-[11px] font-medium leading-none whitespace-nowrap " +
`${tones[tone]} ${className}`
}
{...rest}
/>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { ButtonHTMLAttributes } from "react";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md";
const base =
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium " +
"transition-colors disabled:opacity-50 disabled:cursor-not-allowed " +
"whitespace-nowrap select-none";
const variants: Record<Variant, string> = {
primary:
"bg-accent-500 text-white hover:bg-accent-400 active:bg-accent-600",
secondary:
"bg-surface-2 text-fg border border-border hover:border-border-strong hover:bg-surface-3",
ghost:
"bg-transparent text-fg hover:bg-surface-2",
danger:
"bg-transparent text-danger border border-border hover:bg-danger/10 hover:border-danger/60",
};
const sizes: Record<Size, string> = {
sm: "h-7 px-2.5 text-[13px]",
md: "h-9 px-3.5 text-sm",
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export function Button({
variant = "primary",
size = "md",
className = "",
type = "button",
...rest
}: ButtonProps) {
return (
<button
type={type}
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
{...rest}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { HTMLAttributes } from "react";
export function Card({
className = "",
...rest
}: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`rounded-lg bg-surface-1 border border-border overflow-hidden ${className}`}
{...rest}
/>
);
}
export function CardBody({
className = "",
...rest
}: HTMLAttributes<HTMLDivElement>) {
return <div className={`p-4 ${className}`} {...rest} />;
}
export function CardHeader({
className = "",
...rest
}: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`px-4 py-3 border-b border-border bg-surface-2 ${className}`}
{...rest}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { HTMLAttributes } from "react";
export function Container({
className = "",
...rest
}: HTMLAttributes<HTMLDivElement>) {
return (
<div className={`max-w-5xl mx-auto px-4 sm:px-6 ${className}`} {...rest} />
);
}
export function PageHeader({
title,
description,
actions,
}: {
title: string;
description?: React.ReactNode;
actions?: React.ReactNode;
}) {
return (
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 mb-6">
<div>
<h1 className="text-2xl font-semibold text-fg tracking-tight">{title}</h1>
{description ? (
<p className="text-sm text-fg-muted mt-1">{description}</p>
) : null}
</div>
{actions ? <div className="flex gap-2">{actions}</div> : null}
</div>
);
}
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
export function EmptyState({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="border border-dashed border-border rounded-lg p-8 text-center">
<p className="text-fg font-medium">{title}</p>
{description ? (
<p className="text-sm text-fg-muted mt-1">{description}</p>
) : null}
{action ? <div className="mt-4 flex justify-center">{action}</div> : null}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { InputHTMLAttributes, TextareaHTMLAttributes } 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";
export function Input({
className = "",
...rest
}: InputHTMLAttributes<HTMLInputElement>) {
return <input className={`${field} h-9 px-3 text-sm ${className}`} {...rest} />;
}
export function Textarea({
className = "",
rows = 6,
...rest
}: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
rows={rows}
className={`${field} py-2 px-3 text-sm leading-relaxed font-mono ${className}`}
{...rest}
/>
);
}
export function Label({
htmlFor,
children,
hint,
}: {
htmlFor?: string;
children: React.ReactNode;
hint?: string;
}) {
return (
<label htmlFor={htmlFor} className="block">
<span className="text-sm font-medium text-fg">{children}</span>
{hint ? <span className="ml-2 text-xs text-fg-subtle">{hint}</span> : null}
</label>
);
}
@@ -0,0 +1,2 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { pg } from "@/lib/db/client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Liveness + DB connectivity probe for the docker healthcheck.
* Returns 200 only if Postgres responds within the request timeout.
*/
export async function GET() {
try {
await pg`SELECT 1`;
return NextResponse.json({ status: "ok", db: "up" });
} catch (e) {
return NextResponse.json(
{ status: "degraded", db: "down", error: e instanceof Error ? e.message : "unknown" },
{ status: 503 },
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import { NextResponse } from "next/server";
import { ProjectKey } from "@shared-memory/schemas";
import { authenticateBearer, UnauthorizedError } from "@/lib/auth/jwt";
import { userContextFromClaims } from "@/lib/mcp/context";
import { dispatchMcpMessage } from "@/lib/mcp/server";
import { upsertProject } from "@/lib/projects";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* MCP streamable-HTTP endpoint.
*
* Auth: Bearer token (Authentik-issued JWT). Unauthed requests get 401 with
* a WWW-Authenticate header pointing at our RFC 9728 resource metadata
* so MCP clients can discover the authorization server.
*
* Body: JSON-RPC 2.0 message (request or notification).
*
* Reply: For requests, the JSON-RPC response in the body with
* `Content-Type: application/json`.
* For notifications, HTTP 202 with empty body.
*/
export async function POST(req: Request) {
// ---- auth ----
let claims;
try {
claims = await authenticateBearer(req.headers.get("authorization"));
} catch (e) {
if (e instanceof UnauthorizedError) {
return new NextResponse(JSON.stringify({ error: e.reason }), {
status: 401,
headers: {
"WWW-Authenticate": e.wwwAuthenticate,
"Content-Type": "application/json",
},
});
}
throw e;
}
// ---- parse body ----
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } },
{ status: 400 },
);
}
// ---- optional X-Project-Key header → default project for this request ----
// The header lets a client (e.g. a `claude mcp add` snippet generated from
// /settings/tokens) pin every call to a specific project without having to
// pass `project` on each tool invocation. Tools that take an optional
// `project` arg fall back to this when the caller omits it.
let defaultProjectKey: string | undefined;
const rawProjectKey = req.headers.get("x-project-key");
if (rawProjectKey !== null && rawProjectKey !== "") {
const parsed = ProjectKey.safeParse(rawProjectKey);
if (!parsed.success) {
return NextResponse.json(
{
error: "invalid X-Project-Key",
detail: parsed.error.issues.map((i) => i.message).join("; "),
},
{ status: 400 },
);
}
defaultProjectKey = parsed.data;
}
// ---- resolve user, dispatch ----
const ctx = await userContextFromClaims(claims, { defaultProjectKey });
// Auto-create the header-supplied project if it doesn't exist yet. This
// makes pinning via `X-Project-Key` work transparently — the user doesn't
// have to call `project.identify` first when they paste the generated
// `claude mcp add` snippet from /settings/tokens.
if (defaultProjectKey) {
await upsertProject(ctx.userId, defaultProjectKey);
}
// MCP supports batched requests (array) and single. Handle both.
if (Array.isArray(body)) {
const responses = await Promise.all(body.map((m) => dispatchMcpMessage(m, ctx)));
const filtered = responses.filter((r) => r !== null);
if (filtered.length === 0) {
return new NextResponse(null, { status: 202 });
}
return NextResponse.json(filtered, { status: 200 });
}
const response = await dispatchMcpMessage(body, ctx);
if (response === null) {
// Notification — no body expected.
return new NextResponse(null, { status: 202 });
}
return NextResponse.json(response, { status: 200 });
}
// MCP clients sometimes probe with GET (for SSE). We don't support
// server-initiated events in Phase 1 — return 405 with a discoverable header.
export function GET() {
return new NextResponse(null, {
status: 405,
headers: { Allow: "POST" },
});
}
@@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
interface Props {
value: string;
label: string;
}
export default function CopyButton({ value, label }: Props) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Fallback for old browsers / non-secure contexts: select the next
// <pre> and let the user hit ⌘C themselves.
}
}
return (
<button type="button" onClick={copy} style={{ marginBottom: "0.5rem" }}>
{copied ? "✓ Copied" : label}
</button>
);
}
+136
View File
@@ -0,0 +1,136 @@
import Link from "next/link";
import CopyButton from "./copy-button";
export const dynamic = "force-dynamic";
/**
* /auth/cli-callback OAuth redirect target for clients that can't open a
* loopback port (e.g. Claude Code in a sealed container).
*
* The page is intentionally unauthenticated: the user arrives here as part
* of an in-progress OAuth flow, before any session exists. The code is
* single-use and proof of possession (PKCE on the client side) is still
* required to exchange it. Showing it on this page does NOT grant access
* by itself.
*/
interface SearchParams {
code?: string;
state?: string;
error?: string;
error_description?: string;
iss?: string;
}
export default async function CliCallbackPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const params = await searchParams;
if (params.error) {
return (
<main className="container">
<h1 style={{ color: "#ff6b6b" }}>Sign-in failed</h1>
<p>
<code>{params.error}</code>
{params.error_description ? <> {params.error_description}</> : null}
</p>
<p className="muted">
Switch back to your terminal, cancel the in-progress prompt, and
retry the <code>claude mcp add</code> command. If the error
persists, check that the redirect URI matches what your OIDC
provider has registered.
</p>
<p>
<Link href="/"> home</Link>
</p>
</main>
);
}
if (!params.code) {
return (
<main className="container">
<h1>OAuth callback</h1>
<p className="muted">
This page is the manual-fallback redirect target for the
shared-memory MCP server. It only does something useful in the
middle of an OAuth sign-in flow that couldn&apos;t reach a
loopback callback on your machine.
</p>
<p>
If you&apos;re trying to connect an MCP client, start over from
your terminal with the <code>claude mcp add</code> command shown
in the README.
</p>
<p>
<Link href="/"> home</Link>
</p>
</main>
);
}
const fullUrl = `?code=${encodeURIComponent(params.code)}${
params.state ? `&state=${encodeURIComponent(params.state)}` : ""
}${params.iss ? `&iss=${encodeURIComponent(params.iss)}` : ""}`;
return (
<main className="container">
<h1 style={{ color: "#7ee787" }}>Sign-in complete</h1>
<p>
Switch back to your terminal where Claude Code (or whichever MCP
client) is waiting, and paste one of the values below.
</p>
<h2>Authorization code</h2>
<p className="muted">
Most clients ask for just the <code>code</code>:
</p>
<CopyButton value={params.code} label="Copy code" />
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-all",
userSelect: "all",
}}
>
{params.code}
</pre>
<h2 style={{ marginTop: "2rem" }}>Full callback URL</h2>
<p className="muted">
Some clients ask you to paste the entire URL their loopback timed
out on:
</p>
<CopyButton value={fullUrl} label="Copy URL" />
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-all",
userSelect: "all",
}}
>
{fullUrl}
</pre>
{params.state ? (
<>
<h3 style={{ marginTop: "2rem" }}>State (verification)</h3>
<p className="muted">
Your terminal client may show its expected state; it should
match this value. If it doesn&apos;t, stop and start over
something is wrong with the flow.
</p>
<pre style={{ userSelect: "all" }}>{params.state}</pre>
</>
) : null}
<p className="muted" style={{ marginTop: "2rem" }}>
The code is single-use and expires in a few minutes. If you take
too long, retry the <code>claude mcp add</code> command.
</p>
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
// Legacy URL — moved to /settings/tokens in Phase 3b. Preserve old
// bookmarks and the existing instructions printed by older clients.
export default function ConnectRedirect() {
redirect("/settings/tokens");
}
+96
View File
@@ -0,0 +1,96 @@
@import "tailwindcss";
/* --------------------------------------------------------------------------
* Design tokens.
*
* Dark-first palette (the only theme right now). Light mode can come later
* by extending these tokens.
* -------------------------------------------------------------------------- */
@theme {
/* Brand */
--color-accent-300: oklch(0.79 0.13 250);
--color-accent-400: oklch(0.72 0.16 250);
--color-accent-500: oklch(0.65 0.19 250);
--color-accent-600: oklch(0.55 0.18 250);
/* Surface stack */
--color-bg: #0b0d10;
--color-surface-1: #11151b;
--color-surface-2: #161b22;
--color-surface-3: #1c222b;
/* Foreground */
--color-fg: #e7e9ec;
--color-fg-muted: #9aa3ad;
--color-fg-subtle: #6c7480;
/* Borders */
--color-border: #232a32;
--color-border-strong: #353c46;
/* Semantic */
--color-success: #5fd49d;
--color-danger: #ff6b6b;
--color-warning: #f5c071;
/* Radius */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.625rem;
/* Font */
--font-sans:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
/* --------------------------------------------------------------------------
* Base layer global styling reset (light layer over Tailwind's preflight)
* -------------------------------------------------------------------------- */
html,
body {
background: var(--color-bg);
color: var(--color-fg);
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.55;
min-height: 100%;
}
::selection {
background: color-mix(in srgb, var(--color-accent-500) 35%, transparent);
}
/* Avoid bright white default focus ring when using accent buttons. */
*:focus-visible {
outline: 2px solid var(--color-accent-400);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
a {
color: var(--color-accent-300);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code,
pre {
font-family: var(--font-mono);
}
pre {
background: var(--color-surface-1);
border: 1px solid var(--color-border);
padding: 1rem;
border-radius: var(--radius-md);
overflow-x: auto;
font-size: 13px;
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: "shared-memory",
description: "Shared persistent memory for Claude Code sessions",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
// Legacy URL — Phase 1's debug page. Replaced by /dashboard + /settings.
export default function MeRedirect() {
redirect("/dashboard");
}
+53
View File
@@ -0,0 +1,53 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Button } from "@/app/_components/ui/button";
export const dynamic = "force-dynamic";
export default async function HomePage() {
const session = await auth();
// Signed-in users always go to the app; the landing is for anonymous
// visitors only.
if (session?.user) redirect("/dashboard");
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="max-w-xl w-full text-center space-y-6">
<div className="inline-flex items-center gap-2 text-fg-muted text-sm">
<span className="inline-block size-2 rounded-full bg-accent-400" />
shared-memory
</div>
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-fg">
Shared, persistent memory<br />for every Claude Code session.
</h1>
<p className="text-fg-muted max-w-md mx-auto">
A self-hosted MCP server that lets the Claude Codes on your laptop,
server, and any container share durable memories, scoped per
project or globally.
</p>
<div className="flex justify-center gap-3 pt-2">
<Link href="/api/auth/signin?callbackUrl=/dashboard" className="no-underline">
<Button>Sign in with OIDC</Button>
</Link>
<a
href="https://repo.anhonesthost.net/jknapp/shared-memory"
className="no-underline"
target="_blank"
rel="noreferrer"
>
<Button variant="secondary">Source</Button>
</a>
</div>
<p className="text-xs text-fg-subtle pt-6">
MCP endpoint at <code>/api/mcp</code> · OAuth discovery at{" "}
<code>/.well-known/oauth-protected-resource</code>
</p>
</div>
</main>
);
}
+104
View File
@@ -0,0 +1,104 @@
import NextAuth from "next-auth";
import { env } from "@/lib/env";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
import { syncUserGroupsFromClaim } from "@/lib/auth/sync-groups";
/**
* NextAuth (Auth.js v5) configuration.
*
* Uses a generic OIDC provider so any compliant identity provider works
* Authentik (the example we run in dev), EntraID, Keycloak, Okta, Auth0,
* Zitadel, etc. The provider id is "oidc", which makes the callback URL
* `/api/auth/callback/oidc`. Whichever IdP you're using needs that URL
* registered as a redirect URI on its OAuth client.
*
* We store the user's OIDC `sub` + `iss` on first sign-in, upserting a row
* in `users`. The internal user UUID lives on the JWT/session so
* downstream code never has to re-resolve it.
*/
export const { auth, handlers, signIn, signOut } = NextAuth({
providers: [
{
id: "oidc",
name: "OIDC",
type: "oidc",
issuer: env().OIDC_ISSUER,
clientId: env().OIDC_CLIENT_ID_WEB,
clientSecret: env().OIDC_CLIENT_SECRET_WEB,
},
],
secret: env().NEXTAUTH_SECRET,
session: { strategy: "jwt" },
// No custom `pages.signIn`: Auth.js serves its default provider-picker UI
// at /api/auth/signin. Setting it to that exact path causes a redirect
// loop because Auth.js redirects to the configured page → which is itself.
callbacks: {
async jwt({ token, account, profile }) {
// On first call after sign-in, `account` + `profile` are populated.
if (account && profile) {
const sub = profile.sub;
const iss = (profile.iss as string | undefined) ?? env().OIDC_ISSUER;
if (!sub) throw new Error("OIDC profile missing `sub` claim");
const row = await db
.insert(users)
.values({
oidcSub: sub,
oidcIss: iss,
email: profile.email ?? null,
name: profile.name ?? null,
picture: (profile.picture as string | undefined) ?? null,
})
.onConflictDoUpdate({
target: [users.oidcIss, users.oidcSub],
set: {
email: profile.email ?? null,
name: profile.name ?? null,
picture: (profile.picture as string | undefined) ?? null,
lastSeenAt: new Date(),
},
})
.returning({ id: users.id });
const userId = row[0]?.id;
token.userId = userId;
token.sub = sub;
token.iss = iss;
// Sync group memberships from the OIDC `groups` claim. Missing or
// empty claim is treated as "user is in zero groups" — that path
// wipes the user's existing memberships, which is the conservative
// choice (don't keep stale grants alive if the IdP stopped
// asserting them).
if (userId) {
// `profile.groups` is untyped at the next-auth boundary — coerce.
const claimGroups = (profile as { groups?: unknown }).groups;
await syncUserGroupsFromClaim(userId, iss, claimGroups);
}
}
return token;
},
async session({ session, token }) {
if (token.userId && typeof token.userId === "string") {
session.user = { ...session.user, id: token.userId };
}
return session;
},
},
});
// ---------- module augmentation: typed session.user.id ----------
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
}
export type { Session } from "next-auth";
+12
View File
@@ -0,0 +1,12 @@
import type { Config } from "drizzle-kit";
export default {
schema: "./lib/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://memory:memory@localhost:5432/memory",
},
strict: true,
verbose: true,
} satisfies Config;
+151
View File
@@ -0,0 +1,151 @@
-- Initial migration for shared-memory.
-- Sets up extensions, enum types, tables, generated columns, and indexes
-- required for memory storage + hybrid search (Phase 2 populates the
-- embedding column; FTS works in Phase 1).
-- =============================================================================
-- Extensions
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- trigram index for tag fuzzy match
-- =============================================================================
-- Enums
-- =============================================================================
CREATE TYPE "memory_scope" AS ENUM ('project', 'user');
CREATE TYPE "memory_visibility" AS ENUM ('private', 'shared', 'team');
CREATE TYPE "audit_actor" AS ENUM ('mcp', 'web', 'system');
-- =============================================================================
-- users
-- =============================================================================
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"oidc_sub" text NOT NULL,
"oidc_iss" text NOT NULL,
"email" text,
"name" text,
"picture" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"last_seen_at" timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "users_iss_sub_uq" ON "users" ("oidc_iss", "oidc_sub");
-- =============================================================================
-- projects
-- =============================================================================
CREATE TABLE "projects" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"key" varchar(200) NOT NULL,
"display_name" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "projects_user_key_uq" ON "projects" ("user_id", "key");
-- =============================================================================
-- memories
-- =============================================================================
CREATE TABLE "memories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"project_id" uuid REFERENCES "projects"("id") ON DELETE SET NULL,
"scope" memory_scope NOT NULL DEFAULT 'project',
"visibility" memory_visibility NOT NULL DEFAULT 'private',
"content" text NOT NULL,
"tags" text[] NOT NULL DEFAULT ARRAY[]::text[],
"embedding" vector(384),
"content_tsv" tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce("content", ''))) STORED,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
-- Scope/project_id consistency: project scope requires a project_id,
-- user scope forbids one.
CONSTRAINT "memories_scope_project_chk"
CHECK (
(scope = 'project' AND project_id IS NOT NULL)
OR (scope = 'user' AND project_id IS NULL)
)
);
CREATE INDEX "memories_user_idx" ON "memories" ("user_id");
CREATE INDEX "memories_project_idx" ON "memories" ("project_id");
CREATE INDEX "memories_created_idx" ON "memories" ("created_at" DESC);
-- GIN index for full-text search over the generated tsvector column.
CREATE INDEX "memories_content_tsv_idx" ON "memories" USING GIN ("content_tsv");
-- GIN index on tags for tag-set containment queries (`tags @> ARRAY[...]`).
-- pg_trgm is loaded for future fuzzy text search on `content`, not tags.
CREATE INDEX "memories_tags_idx" ON "memories" USING GIN ("tags");
-- IVFFlat vector index. Lists=100 is a reasonable starting point; tune later
-- once we have real volume. Note: the index requires data to be useful — it's
-- created here so embeddings written in Phase 2 are indexed automatically.
CREATE INDEX "memories_embedding_idx" ON "memories"
USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100);
-- =============================================================================
-- snippets
-- =============================================================================
CREATE TABLE "snippets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"name" varchar(200) NOT NULL,
"body" text NOT NULL,
"description" text,
"tags" text[] NOT NULL DEFAULT ARRAY[]::text[],
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "snippets_user_name_uq" ON "snippets" ("user_id", "name");
CREATE INDEX "snippets_tags_idx" ON "snippets" USING GIN ("tags");
-- =============================================================================
-- audit_log
-- =============================================================================
CREATE TABLE "audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
"actor" audit_actor NOT NULL,
"action" text NOT NULL,
"entity_type" text,
"entity_id" uuid,
"payload" jsonb,
"created_at" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX "audit_user_idx" ON "audit_log" ("user_id");
CREATE INDEX "audit_created_idx" ON "audit_log" ("created_at" DESC);
-- =============================================================================
-- updated_at triggers
-- =============================================================================
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER projects_set_updated_at BEFORE UPDATE ON "projects"
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER memories_set_updated_at BEFORE UPDATE ON "memories"
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER snippets_set_updated_at BEFORE UPDATE ON "snippets"
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+24
View File
@@ -0,0 +1,24 @@
-- cli_tokens: registry of HMAC-signed tokens minted at /connect.
--
-- Each row corresponds to one issued JWT. The token's `jti` claim is the
-- unique identifier — we store the full jti, not a hash, since the jti
-- itself isn't a secret (it's just a UUID; the signing material is
-- CLI_TOKEN_SECRET).
--
-- Soft-delete via revoked_at — never DROP rows; audit value lasts past
-- the JWT's natural expiration.
CREATE TABLE "cli_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"jti" text NOT NULL UNIQUE,
"name" text NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"last_used_at" timestamptz,
"expires_at" timestamptz NOT NULL,
"revoked_at" timestamptz
);
CREATE INDEX "cli_tokens_user_idx" ON "cli_tokens" ("user_id");
CREATE INDEX "cli_tokens_user_active_idx" ON "cli_tokens" ("user_id", "revoked_at")
WHERE "revoked_at" IS NULL;
+39
View File
@@ -0,0 +1,39 @@
-- Snippets gain scope/project mirroring memories.
--
-- Phase 1 created `snippets` as a flat per-user table. To make snippets
-- behave like memories (user-scope = global, project-scope = tied to a
-- repo) we add the same three columns: scope, project_id, deleted_at.
--
-- Uniqueness of `name` is enforced WITHIN a scope:
-- - within (user_id) for user-scope rows
-- - within (user_id, project_id) for project-scope rows
-- Soft-deleted rows are excluded from uniqueness so a name can be reused
-- after deletion.
ALTER TABLE "snippets"
ADD COLUMN "scope" memory_scope NOT NULL DEFAULT 'user',
ADD COLUMN "project_id" uuid REFERENCES "projects"("id") ON DELETE SET NULL,
ADD COLUMN "deleted_at" timestamptz;
-- Scope/project_id consistency mirrors memories_scope_project_chk.
ALTER TABLE "snippets"
ADD CONSTRAINT "snippets_scope_project_chk"
CHECK (
(scope = 'project' AND project_id IS NOT NULL)
OR (scope = 'user' AND project_id IS NULL)
);
-- Drop the old global per-user uniqueness; replace with two partial
-- unique indexes scoped to live (non-deleted) rows.
DROP INDEX IF EXISTS "snippets_user_name_uq";
CREATE UNIQUE INDEX "snippets_user_name_user_scope_uq"
ON "snippets" ("user_id", "name")
WHERE scope = 'user' AND deleted_at IS NULL;
CREATE UNIQUE INDEX "snippets_user_project_name_uq"
ON "snippets" ("user_id", "project_id", "name")
WHERE scope = 'project' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS "snippets_user_idx" ON "snippets" ("user_id");
CREATE INDEX IF NOT EXISTS "snippets_project_idx" ON "snippets" ("project_id");
+63
View File
@@ -0,0 +1,63 @@
-- Groups + per-user group memberships, plus the `memory_access` enum.
--
-- This migration is the substrate for the upcoming group-scoped sharing
-- feature (project_shares). It owns:
--
-- * memory_access enum — reserved for project_shares to reference.
-- * groups table — one row per distinct group seen in any user's
-- OIDC `groups` claim, keyed by (oidc_iss, name)
-- so different IdPs can both have a group called
-- e.g. "platform" without colliding.
-- * user_groups table — current group memberships for each user. Synced
-- on every sign-in: rows are inserted/deleted to
-- mirror the freshly-issued claim, so IdP
-- membership changes propagate at next login.
--
-- We deliberately do NOT add project_shares here — that's Agent B's 0004.
-- Defining the enum in 0003 lets 0004 reference it without sequencing
-- gymnastics.
-- =============================================================================
-- Enums
-- =============================================================================
CREATE TYPE "memory_access" AS ENUM ('ro', 'rw');
-- =============================================================================
-- groups
-- =============================================================================
CREATE TABLE "groups" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- OIDC issuer this group's identity comes from. Pairs with `name` to
-- form the natural key — same group name in two IdPs are distinct rows.
"oidc_iss" text NOT NULL,
-- The group name as it appears in the OIDC `groups` claim.
"name" text NOT NULL,
-- Optional human-friendly label. Most IdPs only emit names so this is
-- typically NULL; reserved for future enrichment.
"display_name" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "groups_iss_name_uq" ON "groups" ("oidc_iss", "name");
CREATE TRIGGER groups_set_updated_at BEFORE UPDATE ON "groups"
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =============================================================================
-- user_groups
-- =============================================================================
CREATE TABLE "user_groups" (
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"group_id" uuid NOT NULL REFERENCES "groups"("id") ON DELETE CASCADE,
-- When this membership was last observed in a sign-in claim. The auth
-- callback rewrites this on every login (insert ... on conflict do
-- update) so it's effectively "last sign-in seen this membership".
"synced_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("user_id", "group_id")
);
CREATE INDEX "user_groups_user_idx" ON "user_groups" ("user_id");
+64
View File
@@ -0,0 +1,64 @@
-- Phase 4c+d+e: project sharing + optimistic-locking version columns.
--
-- Depends on Agent A's `0003_groups.sql`, which introduces:
-- - `groups` table (id, oidc_iss, name, …)
-- - `user_groups` membership table
-- - `memory_access` enum ('ro', 'rw')
--
-- This migration is the sharing layer on top of those foundations plus
-- the co-edit primitives that make multi-user editing safe.
-- =============================================================================
-- project_shares: grants a group access to a project
-- =============================================================================
--
-- One row per (project, group) pair. Access level controls whether
-- members of the group can mutate rows under that project (rw) or only
-- observe them (ro). Owners (projects.user_id = users.id) always retain
-- full control regardless of any project_shares rows.
--
-- granted_by is informational — `SET NULL` on user delete so the share
-- itself outlives the granter's account.
CREATE TABLE "project_shares" (
"project_id" uuid NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE,
"group_id" uuid NOT NULL REFERENCES "groups"("id") ON DELETE CASCADE,
"access" memory_access NOT NULL,
"granted_at" timestamptz NOT NULL DEFAULT now(),
"granted_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
PRIMARY KEY ("project_id", "group_id")
);
-- Lookups go in both directions: "what's shared with group G" (used when
-- resolving a user's accessible projects via their group memberships) and
-- "who has access to project P" (used on the project detail page).
-- The primary key already covers the second; this index covers the first.
CREATE INDEX "project_shares_group_idx" ON "project_shares" ("group_id");
-- =============================================================================
-- memories.version + memories.last_edited_by
-- =============================================================================
--
-- `version` starts at 1 on insert and is bumped by every UPDATE. Edit
-- forms and MCP `memory.update` pass the version they observed; the
-- UPDATE's WHERE clause includes `AND version = $version`, so a stale
-- caller gets 0 rows updated and we surface a "refresh and try again"
-- error rather than clobber a concurrent edit.
--
-- `last_edited_by` records who performed the most recent UPDATE.
ALTER TABLE "memories"
ADD COLUMN "version" integer NOT NULL DEFAULT 1,
ADD COLUMN "last_edited_by" uuid REFERENCES "users"("id") ON DELETE SET NULL;
-- =============================================================================
-- snippets.version + snippets.last_edited_by
-- =============================================================================
--
-- Same shape and rationale as memories. Co-editable snippets live in
-- shared projects; user-scope snippets remain single-author in practice
-- but the columns are uniform across both scopes for simplicity.
ALTER TABLE "snippets"
ADD COLUMN "version" integer NOT NULL DEFAULT 1,
ADD COLUMN "last_edited_by" uuid REFERENCES "users"("id") ON DELETE SET NULL;
+209
View File
@@ -0,0 +1,209 @@
import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
groups,
projects,
projectShares,
userGroups,
} from "@/lib/db/schema";
/**
* Authorization helpers for the project-sharing model.
*
* Access semantics:
* - Owner (projects.user_id = U.id): full read + write.
* - Group share (project_shares.group_id in U.groups):
* access='ro' read only
* access='rw' read + write
*
* Lookups in this module are intentionally cheap and small they only
* resolve project_ids the user can touch. Per-row queries embed those
* ids in their WHERE clauses (or use IN subqueries) so the database still
* does the heavy lifting; we never load all-of-project-X into memory to
* filter in JS.
*
* Why a separate module: callers come from three places
* (`memory-actions`, `snippet-actions`, `mcp/tools`, plus the `lib/`
* search/list helpers), and replicating the same SQL three ways was
* the previous source of inconsistency this phase fixes.
*/
export type ProjectAccess = "owner" | "ro" | "rw";
export interface AccessibleProject {
projectId: string;
access: ProjectAccess;
projectKey: string;
}
/**
* Resolve the set of project ids `userId` can read, with the strongest
* access level for each. Owner > rw > ro. Used by listing/search paths
* that need to widen their WHERE clauses to include shared projects.
*
* Group names are matched case-sensitively against the `groups` table
* the OIDC claim names are the contract. An empty `groupNames` is fine;
* the user just won't see any shared projects.
*/
export async function getAccessibleProjects(
userId: string,
groupNames: string[],
): Promise<AccessibleProject[]> {
const owned = await db
.select({ projectId: projects.id, projectKey: projects.key })
.from(projects)
.where(eq(projects.userId, userId));
const ownedMap = new Map<string, AccessibleProject>(
owned.map((r) => ({
projectId: r.projectId,
access: "owner" as const,
projectKey: r.projectKey,
})).map((r) => [r.projectId, r] as const),
);
if (groupNames.length === 0) {
return [...ownedMap.values()];
}
// Join project_shares → groups → projects so we get the project key
// alongside the access level in a single query.
const shared = await db
.select({
projectId: projectShares.projectId,
access: projectShares.access,
projectKey: projects.key,
})
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.innerJoin(projects, eq(projects.id, projectShares.projectId))
.where(inArray(groups.name, groupNames));
// If two of the user's groups both share the same project at different
// levels, keep the strongest: owner > rw > ro. The DB may emit the same
// project twice (once per group), so we collapse by taking the max.
for (const r of shared) {
const prior = ownedMap.get(r.projectId);
if (prior?.access === "owner" || prior?.access === "rw") continue;
ownedMap.set(r.projectId, {
projectId: r.projectId,
access: r.access as "ro" | "rw",
projectKey: r.projectKey,
});
}
return [...ownedMap.values()];
}
/**
* Resolve project access for a single project_id. Returns null when the
* user has no access at all (deny by default). Owner check is short-
* circuited: we don't query project_shares unless the user isn't owner.
*/
export async function getProjectAccess(
userId: string,
groupNames: string[],
projectId: string,
): Promise<ProjectAccess | null> {
const owned = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.userId, userId)))
.limit(1);
if (owned[0]) return "owner";
if (groupNames.length === 0) return null;
const sharedRows = await db
.select({ access: projectShares.access })
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.where(
and(
eq(projectShares.projectId, projectId),
inArray(groups.name, groupNames),
),
);
if (sharedRows.length === 0) return null;
// If a user is in multiple groups with different levels on the same
// project, pick the strongest.
return sharedRows.some((r) => r.access === "rw") ? "rw" : "ro";
}
/**
* "Can this user read project P?" true for owner, ro, or rw.
*/
export async function canReadProject(
userId: string,
groupNames: string[],
projectId: string,
): Promise<boolean> {
const access = await getProjectAccess(userId, groupNames, projectId);
return access !== null;
}
/**
* "Can this user write to project P?" true for owner or rw share.
*/
export async function canWriteProject(
userId: string,
groupNames: string[],
projectId: string,
): Promise<boolean> {
const access = await getProjectAccess(userId, groupNames, projectId);
return access === "owner" || access === "rw";
}
/**
* Project ids that this user has READ access to (own + any shared). Used
* by candidate-fetch WHERE clauses on listings and search. The empty
* set is encoded explicitly: callers should treat it as "no rows".
*/
export async function readableProjectIds(
userId: string,
groupNames: string[],
): Promise<string[]> {
const all = await getAccessibleProjects(userId, groupNames);
return all.map((p) => p.projectId);
}
/**
* Project ids that this user has WRITE access to (own + rw shares).
*/
export async function writableProjectIds(
userId: string,
groupNames: string[],
): Promise<string[]> {
const all = await getAccessibleProjects(userId, groupNames);
return all.filter((p) => p.access !== "ro").map((p) => p.projectId);
}
/**
* The error message returned to any caller that lost an optimistic-
* locking race. Centralized so the wording stays consistent across MCP
* tools and Server Actions; callers also key off the prefix to surface
* a "Refresh" UI affordance if they care.
*/
export const CONCURRENT_EDIT_ERROR =
"Memory was modified by someone else since you loaded it. Refresh and try again.";
export const CONCURRENT_EDIT_ERROR_SNIPPET =
"Snippet was modified by someone else since you loaded it. Refresh and try again.";
/**
* Fetch the group names this user is currently a member of from the
* `user_groups` table. Used by Web UI Server Actions and pages the
* web session's JWT may carry the same list, but reading from the DB
* means we don't have to coordinate with Agent A's session-callback
* change to consume sharing semantics here. Agent A's sign-in callback
* keeps `user_groups` in sync with the OIDC `groups` claim.
*/
export async function getUserGroupNames(userId: string): Promise<string[]> {
const rows = await db
.select({ name: groups.name })
.from(userGroups)
.innerJoin(groups, eq(groups.id, userGroups.groupId))
.where(eq(userGroups.userId, userId));
return rows.map((r) => r.name);
}
+173
View File
@@ -0,0 +1,173 @@
import { randomUUID } from "node:crypto";
import { SignJWT, jwtVerify, decodeProtectedHeader } from "jose";
import type { JWTPayload } from "jose";
import { and, eq, isNull } from "drizzle-orm";
import { env } from "@/lib/env";
import { db } from "@/lib/db/client";
import { cliTokens } from "@/lib/db/schema";
/**
* CLI tokens HMAC-signed JWTs minted from /settings/tokens (or the
* legacy /connect page) after the user logs into the Web UI via OIDC.
*
* Suitable for pasting into an MCP client's `Authorization` header on
* machines where the OAuth loopback callback isn't reachable.
*
* Trust model: we trust whoever holds CLI_TOKEN_SECRET. Verification is a
* local HMAC check no JWKS round-trip plus an opt-in revocation
* lookup in the `cli_tokens` table.
*
* - Tokens minted by mintCliToken always carry a `jti` claim and have a
* matching row in cli_tokens.
* - Tokens minted by an older version of this server have no `jti`. We
* accept them on signature validity alone until they expire naturally
* (max 30 days post-deploy). Their only revocation knob is rotating
* CLI_TOKEN_SECRET.
*
* To revoke a tracked token immediately, set cli_tokens.revoked_at.
*/
export const CLI_TOKEN_KID = "cli-v1";
export const CLI_TOKEN_ISSUER = "shared-memory:cli";
// Default lifetime for newly minted CLI tokens. Overridable via the
// CLI_TOKEN_TTL_DAYS env var (must be a positive integer number of days);
// anything unset/invalid falls back to this default. Only affects tokens
// minted from now on — already-issued tokens keep their original `exp`.
const DEFAULT_CLI_TOKEN_TTL_DAYS = 90;
function cliTokenTtlSeconds(): number {
const raw = process.env.CLI_TOKEN_TTL_DAYS;
let days = DEFAULT_CLI_TOKEN_TTL_DAYS;
if (raw !== undefined && raw.trim() !== "") {
const parsed = Number(raw);
if (Number.isInteger(parsed) && parsed > 0) {
days = parsed;
}
}
return days * 60 * 60 * 24;
}
export const CLI_TOKEN_TTL_SECONDS = cliTokenTtlSeconds();
function secret(): Uint8Array {
return new TextEncoder().encode(env().CLI_TOKEN_SECRET);
}
export interface CliTokenSubject {
userId: string;
oidcIss: string;
oidcSub: string;
email?: string | null;
name?: string | null;
}
export interface MintCliTokenOptions {
/** Human-readable label shown in the Settings UI. */
tokenName: string;
}
export interface MintCliTokenResult {
token: string;
jti: string;
expiresAt: Date;
}
export async function mintCliToken(
subject: CliTokenSubject,
options: MintCliTokenOptions,
): Promise<MintCliTokenResult> {
const jti = randomUUID();
const expiresAt = new Date(Date.now() + CLI_TOKEN_TTL_SECONDS * 1000);
// Record the issued token first so a crash mid-mint can't leak a usable
// token that isn't in our registry.
await db.insert(cliTokens).values({
userId: subject.userId,
jti,
name: options.tokenName,
expiresAt,
});
const token = await new SignJWT({
oidc_iss: subject.oidcIss,
oidc_sub: subject.oidcSub,
email: subject.email ?? undefined,
name: subject.name ?? undefined,
})
.setProtectedHeader({ alg: "HS256", typ: "JWT", kid: CLI_TOKEN_KID })
.setIssuer(CLI_TOKEN_ISSUER)
.setSubject(subject.oidcSub)
.setAudience(env().OIDC_AUDIENCE)
.setJti(jti)
.setIssuedAt()
.setExpirationTime(`${CLI_TOKEN_TTL_SECONDS}s`)
.sign(secret());
return { token, jti, expiresAt };
}
export interface CliClaims extends JWTPayload {
sub: string;
iss: string;
oidc_iss: string;
oidc_sub: string;
}
export async function verifyCliToken(token: string): Promise<CliClaims> {
const { payload } = await jwtVerify(token, secret(), {
issuer: CLI_TOKEN_ISSUER,
audience: env().OIDC_AUDIENCE,
});
if (typeof payload.oidc_iss !== "string" || typeof payload.oidc_sub !== "string") {
throw new Error("CLI token missing oidc_iss/oidc_sub claims");
}
// If the token carries a jti, enforce the revocation registry. Tokens
// minted before the registry existed have no jti — accept those on
// signature alone until natural expiration.
if (typeof payload.jti === "string") {
const rows = await db
.select({ id: cliTokens.id, revokedAt: cliTokens.revokedAt })
.from(cliTokens)
.where(eq(cliTokens.jti, payload.jti))
.limit(1);
const row = rows[0];
if (!row) {
throw new Error("CLI token not in registry — likely minted by another deployment");
}
if (row.revokedAt) {
throw new Error("CLI token revoked");
}
// Touch last_used_at — best-effort, don't fail the request if this errors.
void db
.update(cliTokens)
.set({ lastUsedAt: new Date() })
.where(eq(cliTokens.id, row.id))
.catch(() => {});
}
return payload as CliClaims;
}
/** Peek at the `kid` header without verifying. Used to pick a verifier. */
export function tokenKid(token: string): string | undefined {
try {
const header = decodeProtectedHeader(token);
return typeof header.kid === "string" ? header.kid : undefined;
} catch {
return undefined;
}
}
/** Revoke a token by id (owned by the given user). */
export async function revokeCliToken(userId: string, tokenId: string): Promise<boolean> {
const result = await db
.update(cliTokens)
.set({ revokedAt: new Date() })
.where(
and(eq(cliTokens.id, tokenId), eq(cliTokens.userId, userId), isNull(cliTokens.revokedAt)),
)
.returning({ id: cliTokens.id });
return result.length > 0;
}
+151
View File
@@ -0,0 +1,151 @@
import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
import type { JWTPayload } from "jose";
import { env } from "@/lib/env";
import { CLI_TOKEN_KID, tokenKid, verifyCliToken } from "./cli-token";
/**
* Authenticates a bearer token presented to the MCP endpoint. Two token
* kinds are accepted, dispatched by the JWT `kid` header:
*
* - Authentik-issued OIDC access tokens (any kid) verified against
* Authentik's JWKS over the network.
* - CLI tokens minted at /connect (kid="cli-v1") verified locally
* with the HMAC CLI_TOKEN_SECRET.
*
* Both resolve to the same `AuthenticatedClaims` shape so downstream code
* (`userContextFromClaims`) doesn't care which path produced them.
*
* This is distinct from the NextAuth session cookie path used by the Web UI.
*/
type GlobalWithJwks = typeof globalThis & {
__sharedMemoryJwks?: ReturnType<typeof createRemoteJWKSet>;
};
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 url = new URL(`${mcpIssuer()}/jwks/`);
g.__sharedMemoryJwks = createRemoteJWKSet(url, {
cacheMaxAge: 10 * 60 * 1000, // 10 min
cooldownDuration: 30 * 1000,
});
return g.__sharedMemoryJwks;
}
export interface AuthenticatedClaims extends JWTPayload {
sub: string;
iss: string;
/**
* Group names from the OIDC `groups` claim. Authentik / Keycloak / properly-
* configured EntraID emit `string[]` here. We coerce non-array / non-string
* entries away and present an empty array if the claim is absent. For CLI
* (HMAC) tokens this is always undefined the consumer (userContextFromClaims)
* falls back to the DB snapshot from the user's last interactive sign-in.
*/
groups?: string[];
}
export class UnauthorizedError extends Error {
constructor(
public readonly reason: string,
public readonly wwwAuthenticate: string,
) {
super(reason);
this.name = "UnauthorizedError";
}
}
/**
* Pull `groups` off a verified OIDC payload as a clean `string[]`. Non-
* string entries are dropped silently. Returns undefined when the claim
* is absent so callers can distinguish "no claim emitted" from "user is
* in zero groups" (`[]`).
*/
function extractGroupsClaim(payload: JWTPayload): string[] | undefined {
const raw = (payload as { groups?: unknown }).groups;
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw)) return [];
const out: string[] = [];
for (const v of raw) {
if (typeof v === "string" && v.trim().length > 0) out.push(v.trim());
}
return out;
}
function buildWwwAuthenticate(error?: string, description?: string): string {
const parts: string[] = [`Bearer realm="OAuth"`];
// RFC 9728 — point clients at our protected-resource metadata so they can
// discover the authorization server.
parts.push(`resource_metadata="${env().PUBLIC_URL.replace(/\/$/, "")}/.well-known/oauth-protected-resource"`);
if (error) parts.push(`error="${error}"`);
if (description) parts.push(`error_description="${description.replace(/"/g, "'")}"`);
return parts.join(", ");
}
export async function authenticateBearer(authHeader: string | null): Promise<AuthenticatedClaims> {
if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
throw new UnauthorizedError("missing bearer token", buildWwwAuthenticate());
}
const token = authHeader.slice("bearer ".length).trim();
if (!token) {
throw new UnauthorizedError("empty bearer token", buildWwwAuthenticate("invalid_token"));
}
// Dispatch by kid: CLI tokens are verified locally, everything else goes
// through Authentik JWKS. We never attempt JWKS verification for CLI
// tokens (or vice versa) so a kid mismatch fails fast.
const isCliToken = tokenKid(token) === CLI_TOKEN_KID;
try {
if (isCliToken) {
const claims = await verifyCliToken(token);
// CLI tokens carry the user's real Authentik identity in oidc_iss /
// oidc_sub. Surface those on the standard claims shape so user
// context resolution is identical to the Authentik path. CLI tokens
// never carry a groups claim — leave `groups` undefined; the user-
// context resolver falls back to the DB snapshot.
return {
...claims,
iss: claims.oidc_iss,
sub: claims.oidc_sub,
} as AuthenticatedClaims;
}
const { payload } = await jwtVerify(token, jwks(), {
issuer: mcpIssuer(),
audience: env().OIDC_AUDIENCE,
});
if (!payload.sub) {
throw new UnauthorizedError(
"token missing sub claim",
buildWwwAuthenticate("invalid_token", "missing sub"),
);
}
return { ...payload, groups: extractGroupsClaim(payload) } as AuthenticatedClaims;
} catch (err) {
if (err instanceof UnauthorizedError) throw err;
const desc =
err instanceof joseErrors.JWTExpired
? "token expired"
: err instanceof joseErrors.JWTInvalid
? "token invalid"
: err instanceof joseErrors.JWTClaimValidationFailed
? `claim invalid: ${err.claim}`
: "verification failed";
throw new UnauthorizedError(desc, buildWwwAuthenticate("invalid_token", desc));
}
}
+93
View File
@@ -0,0 +1,93 @@
import { and, eq, notInArray, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { groups, userGroups } from "@/lib/db/schema";
/**
* Sync a user's group memberships from the OIDC `groups` claim on sign-in.
*
* Claim shape: `string[]`. Authentik emits group *names* directly here;
* Keycloak and Okta likewise (with the right mappers configured). EntraID,
* when correctly configured per README, emits names too but the default
* "groups" optional-claim variant emits object-id GUIDs instead, and if the
* user is in too many groups EntraID switches to a "groups overage"
* indicator (no group list at all). We take the conservative path:
*
* - whatever strings appear in the claim are treated as names verbatim
* and stored as-is. If your IdP emits GUIDs, the UI will show GUIDs;
* fix it at the IdP layer (we don't attempt resolution).
* - if the claim is missing/empty, the user is treated as having zero
* groups and all existing memberships are deleted.
* - groups overage (where EntraID emits `_claim_names.groups` instead of
* `groups`) is not handled in v1 the user appears as having no
* groups. Documented limit; revisit if it bites someone.
*
* The whole operation runs in a single transaction so the membership
* snapshot is atomic (no window where a user partially has new memberships
* and still has stale ones).
*/
export async function syncUserGroupsFromClaim(
userId: string,
oidcIss: string,
rawClaim: unknown,
): Promise<void> {
const names = normalizeGroupsClaim(rawClaim);
await db.transaction(async (tx) => {
if (names.length === 0) {
// Claim missing/empty → user has zero groups now.
await tx.delete(userGroups).where(eq(userGroups.userId, userId));
return;
}
// Upsert each group row keyed by (oidc_iss, name) and collect ids.
// We use a single multi-row insert for the round-trip win; the DB
// resolves duplicates via the unique index.
const inserted = await tx
.insert(groups)
.values(names.map((name) => ({ oidcIss, name })))
.onConflictDoUpdate({
target: [groups.oidcIss, groups.name],
// Touch updated_at so we have a "last seen" signal at the group
// level too; otherwise this would be a do-nothing on conflict.
set: { updatedAt: new Date() },
})
.returning({ id: groups.id, name: groups.name });
const groupIds = inserted.map((g) => g.id);
// Insert (or refresh synced_at on) every current membership.
await tx
.insert(userGroups)
.values(groupIds.map((groupId) => ({ userId, groupId })))
.onConflictDoUpdate({
target: [userGroups.userId, userGroups.groupId],
set: { syncedAt: sql`now()` },
});
// Delete memberships that no longer appear in the claim. We could
// alternatively rely on `synced_at < now()` to find stale rows, but
// an explicit NOT IN is cheaper and clearer.
await tx
.delete(userGroups)
.where(
and(eq(userGroups.userId, userId), notInArray(userGroups.groupId, groupIds)),
);
});
}
/**
* Coerce whatever the IdP put in `profile.groups` into a clean string[]
* of distinct, trimmed, non-empty names. Anything non-string is dropped.
*/
function normalizeGroupsClaim(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const out = new Set<string>();
for (const v of raw) {
if (typeof v !== "string") continue;
const t = v.trim();
if (t.length === 0) continue;
out.add(t);
}
return Array.from(out);
}
+25
View File
@@ -0,0 +1,25 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "@/lib/env";
import * as schema from "./schema";
// Reuse a single connection pool across hot reloads in dev.
type GlobalWithPg = typeof globalThis & {
__sharedMemoryPg?: ReturnType<typeof postgres>;
};
const g = globalThis as GlobalWithPg;
function makePool() {
return postgres(env().DATABASE_URL, {
max: 10,
idle_timeout: 30,
connect_timeout: 10,
prepare: false,
});
}
const sql = g.__sharedMemoryPg ?? makePool();
if (process.env.NODE_ENV !== "production") g.__sharedMemoryPg = sql;
export const db = drizzle(sql, { schema, logger: env().LOG_LEVEL === "debug" });
export { sql as pg, schema };
+285
View File
@@ -0,0 +1,285 @@
import {
pgTable,
pgEnum,
uuid,
text,
timestamp,
jsonb,
uniqueIndex,
index,
primaryKey,
customType,
vector,
varchar,
integer,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
// ---------- custom column types ----------
// Postgres tsvector — generated server-side from `content`, not written by app.
const tsvector = customType<{ data: string; driverData: string }>({
dataType() {
return "tsvector";
},
});
// Text array helper (Drizzle's `.array()` works, but this keeps intent explicit).
const textArray = customType<{ data: string[]; driverData: string }>({
dataType() {
return "text[]";
},
toDriver(value) {
return `{${value.map((v) => `"${v.replace(/"/g, '\\"')}"`).join(",")}}`;
},
});
// ---------- enums ----------
export const memoryScope = pgEnum("memory_scope", ["project", "user"]);
export const memoryVisibility = pgEnum("memory_visibility", ["private", "shared", "team"]);
export const auditActor = pgEnum("audit_actor", ["mcp", "web", "system"]);
// Created by 0003_groups.sql; declared here so the TS layer (notably
// `project_shares`) can reference it as a typed pgEnum.
export const memoryAccess = pgEnum("memory_access", ["ro", "rw"]);
// ---------- tables ----------
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
// OIDC `sub` claim from the IdP — stable identifier for this user.
oidcSub: text("oidc_sub").notNull(),
// OIDC `iss` so we can disambiguate if we ever federate.
oidcIss: text("oidc_iss").notNull(),
email: text("email"),
name: text("name"),
picture: text("picture"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
uniqueIss: uniqueIndex("users_iss_sub_uq").on(t.oidcIss, t.oidcSub),
}),
);
export const projects = pgTable(
"projects",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Caller-supplied stable identifier (e.g. repo name or any string).
key: varchar("key", { length: 200 }).notNull(),
displayName: text("display_name"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
uniqueUserKey: uniqueIndex("projects_user_key_uq").on(t.userId, t.key),
}),
);
export const memories = pgTable(
"memories",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// NULL when scope = 'user' (global to the user across all projects).
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
scope: memoryScope("scope").notNull().default("project"),
visibility: memoryVisibility("visibility").notNull().default("private"),
content: text("content").notNull(),
tags: textArray("tags").notNull().default([]),
// Populated by Phase 2 once the embedder sidecar is online; NULL in Phase 1.
embedding: vector("embedding", { dimensions: 384 }),
// Generated column — see migration SQL for definition.
contentTsv: tsvector("content_tsv"),
// Optimistic-locking counter. Bumped on every successful UPDATE so
// concurrent edits (now possible across shared-project members) can
// detect lost-write situations and surface "refresh and try again".
version: integer("version").notNull().default(1),
// The user whose UPDATE most recently mutated this row. NULL only on
// the very first INSERT (pre-update). FK is `SET NULL` so deleting
// an account doesn't wipe other people's memories.
lastEditedBy: uuid("last_edited_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
},
(t) => ({
userIdx: index("memories_user_idx").on(t.userId),
projectIdx: index("memories_project_idx").on(t.projectId),
createdIdx: index("memories_created_idx").on(t.createdAt),
// Vector index, tsvector index, and trigram index for tags are declared
// in the SQL migration since drizzle-kit doesn't model them.
}),
);
export const snippets = pgTable(
"snippets",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// NULL when scope = 'user' (global to the user). Mirrors `memories`.
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
scope: memoryScope("scope").notNull().default("user"),
name: varchar("name", { length: 200 }).notNull(),
body: text("body").notNull(),
description: text("description"),
tags: textArray("tags").notNull().default([]),
// See `memories.version` / `memories.lastEditedBy` — co-edit primitive
// for snippets in shared projects.
version: integer("version").notNull().default(1),
lastEditedBy: uuid("last_edited_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
},
(t) => ({
userIdx: index("snippets_user_idx").on(t.userId),
projectIdx: index("snippets_project_idx").on(t.projectId),
// Partial unique indexes (one per scope, live rows only) are declared
// in the SQL migration since drizzle-kit doesn't model partial indexes.
}),
);
export const cliTokens = pgTable(
"cli_tokens",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
jti: text("jti").notNull().unique(),
name: text("name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
},
(t) => ({
userIdx: index("cli_tokens_user_idx").on(t.userId),
}),
);
// ---------- groups + sharing ----------
//
// `groups` and `user_groups` come from `0003_groups.sql`; `project_shares`
// comes from `0004_project_shares.sql`. Drizzle declarations here let
// authorization helpers and the share-management UI import everything
// through `@/lib/db/schema`. Column shape MUST stay in lockstep with the
// migrations.
export const groups = pgTable(
"groups",
{
id: uuid("id").primaryKey().defaultRandom(),
// OIDC issuer this group originates from — pairs with `name` so two
// IdPs can both have a "platform" group without collision.
oidcIss: text("oidc_iss").notNull(),
// Group `name` as it appears in the JWT (Authentik / EntraID groups claim).
name: text("name").notNull(),
displayName: text("display_name"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
uniqueIssName: uniqueIndex("groups_iss_name_uq").on(t.oidcIss, t.name),
}),
);
export const userGroups = pgTable(
"user_groups",
{
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
groupId: uuid("group_id")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
// Refreshed on every sign-in that re-observes this membership.
syncedAt: timestamp("synced_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.groupId] }),
userIdx: index("user_groups_user_idx").on(t.userId),
groupIdx: index("user_groups_group_idx").on(t.groupId),
}),
);
// `project_shares` grants a `group` access to a `project`. Each row
// authorizes every user in that group to read (and, when access='rw',
// write) every memory + snippet under that project.
//
// Owners share projects from the Web UI; the MCP layer can resolve
// shared projects via project.identify but cannot grant new shares.
export const projectShares = pgTable(
"project_shares",
{
projectId: uuid("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
groupId: uuid("group_id")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
access: memoryAccess("access").notNull(),
grantedAt: timestamp("granted_at", { withTimezone: true }).notNull().defaultNow(),
// Audit-friendly. `SET NULL` so deleting the granter's account doesn't
// cascade-remove the share.
grantedBy: uuid("granted_by").references(() => users.id, { onDelete: "set null" }),
},
(t) => ({
pk: primaryKey({ columns: [t.projectId, t.groupId] }),
groupIdx: index("project_shares_group_idx").on(t.groupId),
}),
);
export const auditLog = pgTable(
"audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
actor: auditActor("actor").notNull(),
action: text("action").notNull(),
entityType: text("entity_type"),
entityId: uuid("entity_id"),
payload: jsonb("payload"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
userIdx: index("audit_user_idx").on(t.userId),
createdIdx: index("audit_created_idx").on(t.createdAt),
}),
);
// Re-export sql helper so callers can compose raw fragments without a
// second drizzle import.
export { sql };
// ---------- inferred types ----------
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;
export type Memory = typeof memories.$inferSelect;
export type NewMemory = typeof memories.$inferInsert;
export type Snippet = typeof snippets.$inferSelect;
export type NewSnippet = typeof snippets.$inferInsert;
export type CliToken = typeof cliTokens.$inferSelect;
export type NewCliToken = typeof cliTokens.$inferInsert;
export type AuditEntry = typeof auditLog.$inferSelect;
export type NewAuditEntry = typeof auditLog.$inferInsert;
export type Group = typeof groups.$inferSelect;
export type NewGroup = typeof groups.$inferInsert;
export type UserGroup = typeof userGroups.$inferSelect;
export type NewUserGroup = typeof userGroups.$inferInsert;
export type ProjectShare = typeof projectShares.$inferSelect;
export type NewProjectShare = typeof projectShares.$inferInsert;
+65
View File
@@ -0,0 +1,65 @@
import { env } from "@/lib/env";
/**
* Thin HTTP client for the embedder sidecar. Used by memory.write /
* memory.update / memory.search and by the migrator's backfill step.
*
* Calls are blocking on purpose write-path latency is a worthwhile
* trade for "the memory I just wrote is searchable now."
*/
export class EmbedderError extends Error {
constructor(message: string, public readonly status?: number) {
super(message);
this.name = "EmbedderError";
}
}
function url(): string {
const u = env().EMBEDDER_URL;
if (!u) throw new EmbedderError("EMBEDDER_URL is not configured");
return u.replace(/\/$/, "");
}
/** Embed a batch of texts. Returns one vector per input. */
export async function embedTexts(texts: string[]): Promise<number[][]> {
if (texts.length === 0) return [];
const res = await fetch(`${url()}/embed`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ texts }),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new EmbedderError(
`embedder returned ${res.status}: ${detail.slice(0, 200)}`,
res.status,
);
}
const body = (await res.json()) as { vectors: number[][] };
if (!Array.isArray(body.vectors) || body.vectors.length !== texts.length) {
throw new EmbedderError("embedder response shape mismatch");
}
return body.vectors;
}
/** Embed a single text — convenience for one-off calls. */
export async function embedText(text: string): Promise<number[]> {
const [vec] = await embedTexts([text]);
if (!vec) throw new EmbedderError("embedder returned no vector");
return vec;
}
/** Quick check used by the migrator before backfilling. */
export async function embedderReady(): Promise<boolean> {
try {
const res = await fetch(`${url()}/health`);
if (!res.ok) return false;
const body = (await res.json()) as { ready?: boolean };
return body.ready === true;
} catch {
return false;
}
}
+121
View File
@@ -0,0 +1,121 @@
import { z } from "zod";
const Bool = z
.union([z.boolean(), z.enum(["true", "false", "1", "0"])])
.transform((v) => v === true || v === "true" || v === "1");
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
// Public URL the app is reached at (used for OIDC redirects + MCP metadata)
PUBLIC_URL: z.string().url(),
// 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: z.string().url().optional(),
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: z.string().min(1).optional(),
// Database
DATABASE_URL: z.string().url(),
// Embedder sidecar — required in Phase 2 since memory.write embeds inline.
EMBEDDER_URL: z.string().url(),
EMBEDDING_MODEL: z.string().default("Xenova/bge-small-en-v1.5"),
EMBEDDING_DIM: z.coerce.number().int().positive().default(384),
// NextAuth
NEXTAUTH_SECRET: z.string().min(32, "NEXTAUTH_SECRET must be at least 32 chars"),
// Signing key for CLI tokens minted at /connect. Rotate this to invalidate
// every issued CLI token at once.
CLI_TOKEN_SECRET: z.string().min(32, "CLI_TOKEN_SECRET must be at least 32 chars"),
// Behavior flags
ALLOW_INSECURE_HTTP: Bool.optional().default(false),
});
export type Env = z.infer<typeof envSchema>;
function loadEnv(): Env {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues
.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
.join("\n");
throw new Error(`Invalid environment configuration:\n${issues}`);
}
return parsed.data;
}
// During `next build`, Next.js evaluates server modules to collect static
// page data — env vars aren't expected to be present then. Honor a build-only
// bypass so the image can be assembled without baking secrets in.
function isBuildPhase(): boolean {
return (
process.env.SKIP_ENV_VALIDATION === "true" ||
process.env.NEXT_PHASE === "phase-production-build"
);
}
function buildPhaseStub(): Env {
return {
NODE_ENV: "production",
LOG_LEVEL: "info",
PUBLIC_URL: "https://build-phase.invalid",
OIDC_ISSUER: "https://build-phase.invalid",
OIDC_CLIENT_ID_WEB: "build",
OIDC_CLIENT_SECRET_WEB: "build",
OIDC_CLIENT_ID_MCP: "build",
OIDC_AUDIENCE: "build",
DATABASE_URL: "postgres://build:build@build-phase.invalid:5432/build",
EMBEDDER_URL: "http://embedder.invalid:8080",
EMBEDDING_MODEL: "Xenova/bge-small-en-v1.5",
EMBEDDING_DIM: 384,
NEXTAUTH_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
CLI_TOKEN_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
ALLOW_INSECURE_HTTP: false,
};
}
// Lazy singleton so importing this module at build time doesn't crash when
// env vars are absent (e.g. during `next build` without runtime values).
let cached: Env | null = null;
export function env(): Env {
if (cached) return cached;
cached = isBuildPhase() ? buildPhaseStub() : loadEnv();
return cached;
}
// Convenience getter for code paths that only need a single var without
// triggering full validation (rare; prefer `env()`).
export function rawEnv(key: keyof Env): string | undefined {
return process.env[key];
}
+114
View File
@@ -0,0 +1,114 @@
import { db } from "@/lib/db/client";
import { users, groups, userGroups } from "@/lib/db/schema";
import { and, eq } from "drizzle-orm";
import type { AuthenticatedClaims } from "@/lib/auth/jwt";
/**
* Per-request user context for MCP tool handlers.
*
* Resolves (or creates) the internal `users` row from the OIDC claims so
* tools work with stable UUID foreign keys rather than raw `sub` strings.
*
* `groups` and `defaultProjectKey` are populated here from the inbound
* request: groups come from the JWT's `groups` claim (live) with a DB
* fallback for CLI tokens that carry no claim; defaultProjectKey is the
* `X-Project-Key` header (already Zod-validated at the route boundary),
* used as a fallback when a tool call omits `project`.
*/
export interface UserContext {
/** Internal users.id UUID. */
userId: string;
/** OIDC sub claim (stable identifier from the IdP). */
sub: string;
/** OIDC issuer. */
iss: string;
/** Optional profile fields if present in the access token. */
email: string | null;
name: string | null;
/**
* Group *names* the user is a member of. For OIDC bearer tokens these are
* the live values from the verified token's `groups` claim. For CLI tokens
* (which carry no groups claim), this is the DB snapshot from the user's
* last interactive sign-in necessarily stale, but the only signal we
* have without going back to the IdP.
*/
groups: string[];
/**
* Project key supplied via the `X-Project-Key` request header. Tools that
* accept an optional `project` argument use this as a fallback when the
* caller didn't pass one explicitly. Always validated upstream against
* the same Zod schema as the tool argument.
*/
defaultProjectKey?: string;
}
export interface UserContextOverrides {
/** Project key from the X-Project-Key request header (already validated). */
defaultProjectKey?: string;
}
export async function userContextFromClaims(
claims: AuthenticatedClaims,
overrides: UserContextOverrides = {},
): Promise<UserContext> {
const email = (claims.email as string | undefined) ?? null;
const name = (claims.name as string | undefined) ?? null;
const picture = (claims.picture as string | undefined) ?? null;
const row = await db
.insert(users)
.values({
oidcSub: claims.sub,
oidcIss: claims.iss,
email,
name,
picture,
})
.onConflictDoUpdate({
target: [users.oidcIss, users.oidcSub],
set: {
email,
name,
picture,
lastSeenAt: new Date(),
},
})
.returning({ id: users.id });
let userId = row[0]?.id;
if (!userId) {
// Race against another upsert — fall back to a select.
const existing = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.oidcIss, claims.iss), eq(users.oidcSub, claims.sub)))
.limit(1);
if (!existing[0]) throw new Error("user upsert failed and not found on re-read");
userId = existing[0].id;
}
// OIDC bearer tokens carry a `groups` claim (when the IdP is configured to
// emit it). CLI tokens never do — they go through verifyCliToken which
// doesn't set claims.groups. In that case fall back to the DB snapshot
// from the user's last interactive sign-in.
const groupNames = claims.groups ?? (await loadUserGroups(userId));
return {
userId,
sub: claims.sub,
iss: claims.iss,
email,
name,
groups: groupNames,
defaultProjectKey: overrides.defaultProjectKey,
};
}
async function loadUserGroups(userId: string): Promise<string[]> {
const rows = await db
.select({ name: groups.name })
.from(userGroups)
.innerJoin(groups, eq(userGroups.groupId, groups.id))
.where(eq(userGroups.userId, userId));
return rows.map((r) => r.name);
}
+132
View File
@@ -0,0 +1,132 @@
import { tools, toolMap, type ToolResult } from "./tools";
import type { UserContext } from "./context";
/**
* Minimal JSON-RPC 2.0 dispatcher that implements the MCP wire protocol over
* HTTP. We intentionally don't depend on the SDK's `StreamableHTTPServerTransport`
* here because Next.js App Router uses Web `Request`/`Response`, not Node's
* `IncomingMessage`/`ServerResponse`, and a hand-rolled handler is simpler than
* a Node-stream shim. The protocol surface we cover for Phase 1 is:
* - `initialize` handshake
* - `notifications/initialized` ack (no response)
* - `tools/list` enumerate tools
* - `tools/call` invoke a tool
* - `ping` liveness
*
* If we later need server-initiated events (notifications, sampling), we'll
* graduate to SSE responses; for now the protocol works as plain POST/JSON.
*/
const PROTOCOL_VERSION = "2025-06-18";
const SERVER_INFO = {
name: "shared-memory",
version: "0.1.0",
};
type JsonRpcId = string | number | null;
interface JsonRpcRequest {
jsonrpc: "2.0";
id?: JsonRpcId;
method: string;
params?: unknown;
}
interface JsonRpcSuccess {
jsonrpc: "2.0";
id: JsonRpcId;
result: unknown;
}
interface JsonRpcError {
jsonrpc: "2.0";
id: JsonRpcId;
error: { code: number; message: string; data?: unknown };
}
type JsonRpcResponse = JsonRpcSuccess | JsonRpcError;
// JSON-RPC standard codes; MCP also defines server-error codes from -32000.
const RPC = {
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603,
} as const;
function makeError(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcError {
return { jsonrpc: "2.0", id, error: { code, message, ...(data !== undefined && { data }) } };
}
function makeSuccess(id: JsonRpcId, result: unknown): JsonRpcSuccess {
return { jsonrpc: "2.0", id, result };
}
function isNotification(req: JsonRpcRequest): boolean {
return req.id === undefined;
}
export async function dispatchMcpMessage(
message: unknown,
ctx: UserContext,
): Promise<JsonRpcResponse | null> {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return makeError(null, RPC.INVALID_REQUEST, "request must be a JSON object");
}
const req = message as JsonRpcRequest;
if (req.jsonrpc !== "2.0" || typeof req.method !== "string") {
return makeError(req.id ?? null, RPC.INVALID_REQUEST, "invalid jsonrpc envelope");
}
const id = req.id ?? null;
const notification = isNotification(req);
try {
switch (req.method) {
case "initialize":
return makeSuccess(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: SERVER_INFO,
});
case "notifications/initialized":
// No response for notifications.
return null;
case "ping":
return makeSuccess(id, {});
case "tools/list":
return makeSuccess(id, {
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
});
case "tools/call": {
const params = (req.params ?? {}) as { name?: string; arguments?: unknown };
if (!params.name) {
return makeError(id, RPC.INVALID_PARAMS, "tools/call requires `name`");
}
const tool = toolMap[params.name];
if (!tool) {
return makeError(id, RPC.METHOD_NOT_FOUND, `unknown tool: ${params.name}`);
}
const result: ToolResult = await tool.handler(params.arguments ?? {}, ctx);
return makeSuccess(id, result);
}
default:
if (notification) return null; // ignore unknown notifications
return makeError(id, RPC.METHOD_NOT_FOUND, `unknown method: ${req.method}`);
}
} catch (e) {
const message = e instanceof Error ? e.message : "internal error";
return notification ? null : makeError(id, RPC.INTERNAL_ERROR, message);
}
}
File diff suppressed because it is too large Load Diff
+205
View File
@@ -0,0 +1,205 @@
import { and, eq, inArray } from "drizzle-orm";
import { db, pg } from "@/lib/db/client";
import { projects } from "@/lib/db/schema";
import { embedText } from "@/lib/embedder";
import { readableProjectIds } from "@/lib/access";
/**
* Shared search helper. Used by:
* - the MCP `memory.search` tool (returns rich rank data for the model)
* - the Web UI memories page (renders human-readable results)
*
* Performs three candidate fetches in parallel pgvector cosine, FTS
* ts_rank_cd, tag-set overlap then fuses with Reciprocal Rank Fusion
* (k=60). Returns top-N with per-source rank info attached.
*
* Sharing model: a user can see memories they OWN (user_id = U) plus
* project-scope memories under any project that's been shared with one
* of their groups (any access ro is enough to read). The three CTEs
* extend their WHERE clauses accordingly.
*/
export interface SearchFilters {
scope?: "project" | "user";
projectKey?: string;
tags?: string[];
/**
* Group names the requesting user is a member of. Drives shared-
* project visibility. An undefined value is treated as `[]` (no
* shared visibility) pass through `UserContext.groups`.
*/
groupNames?: string[];
/**
* Minimum RRF score a hit must clear. Default `undefined` = no extra
* filter (current behavior every fused result is returned). Set to
* e.g. 0.025 to require at least two rankers to fire at rank 1.
*/
minScore?: number;
}
export interface SearchHit {
id: string;
rank: {
rrfScore: number;
vectorRank: number | null;
ftsRank: number | null;
tagRank: number | null;
};
}
export interface SearchResult {
hits: SearchHit[];
debug: { vec: number; fts: number; tag: number };
}
const CANDIDATES = 50;
const RRF_K = 60;
function toVectorLiteral(v: number[]): string {
return `[${v.join(",")}]`;
}
async function resolveProjectIdForKey(
userId: string,
groupNames: string[],
projectKey: string,
): Promise<string | null> {
// First check owned. Owned wins on key collision (matches
// project.identify's priority).
const owned = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.userId, userId), eq(projects.key, projectKey)))
.limit(1);
if (owned[0]) return owned[0].id;
if (groupNames.length === 0) return null;
// Then any shared project with that key. The user is allowed to read
// it; per-project authorization is enforced by the calling code's IN
// clause against `accessibleIds`.
const accessibleIds = await readableProjectIds(userId, groupNames);
if (accessibleIds.length === 0) return null;
const shared = await db
.select({ id: projects.id })
.from(projects)
.where(
and(
eq(projects.key, projectKey),
inArray(projects.id, accessibleIds),
),
)
.limit(1);
return shared[0]?.id ?? null;
}
export async function searchMemories(
userId: string,
query: string,
filters: SearchFilters = {},
limit = 20,
): Promise<SearchResult> {
const { scope, projectKey, tags, groupNames = [], minScore } = filters;
const projectId = projectKey
? await resolveProjectIdForKey(userId, groupNames, projectKey)
: null;
if (projectKey && !projectId) {
return { hits: [], debug: { vec: 0, fts: 0, tag: 0 } };
}
const queryVec = await embedText(query);
const vecLit = toVectorLiteral(queryVec);
// Build the user-visibility fragment once: rows the caller owns OR
// rows whose project_id is in the set of projects shared with this
// user's groups. When `projectId` is set we've already authorized
// that single project and can drop the fragment.
const accessibleProjectIds = projectId
? null
: await readableProjectIds(userId, groupNames);
// postgres-js's `${array}::uuid[]` interpolates as a Postgres array
// literal automatically. Empty array works: `= ANY('{}')` is false,
// which is the right behaviour for "no projects accessible".
const visibilityFragment = projectId
? pg`AND project_id = ${projectId}`
: pg`AND (user_id = ${userId} OR project_id = ANY(${accessibleProjectIds ?? []}::uuid[]))`;
const vecPromise = pg<{ id: string }[]>`
SELECT id
FROM memories
WHERE deleted_at IS NULL
AND embedding IS NOT NULL
${scope ? pg`AND scope = ${scope}` : pg``}
${visibilityFragment}
ORDER BY embedding <=> ${vecLit}::vector ASC
LIMIT ${CANDIDATES}
`;
const ftsPromise = pg<{ id: string }[]>`
SELECT id
FROM memories, plainto_tsquery('english', ${query}) AS q
WHERE deleted_at IS NULL
AND content_tsv @@ q
${scope ? pg`AND scope = ${scope}` : pg``}
${visibilityFragment}
ORDER BY ts_rank_cd(content_tsv, q) DESC
LIMIT ${CANDIDATES}
`;
const tagPromise =
tags && tags.length > 0
? pg<{ id: string }[]>`
SELECT id
FROM memories
WHERE deleted_at IS NULL
AND tags && ${tags}::text[]
${scope ? pg`AND scope = ${scope}` : pg``}
${visibilityFragment}
ORDER BY cardinality(
ARRAY(SELECT unnest(tags) INTERSECT SELECT unnest(${tags}::text[]))
) DESC
LIMIT ${CANDIDATES}
`
: Promise.resolve([] as { id: string }[]);
const [vec, fts, tag] = await Promise.all([vecPromise, ftsPromise, tagPromise]);
interface Accumulator {
vectorRank: number | null;
ftsRank: number | null;
tagRank: number | null;
rrfScore: number;
}
const scores = new Map<string, Accumulator>();
const accum = (id: string, rank: number, key: "vectorRank" | "ftsRank" | "tagRank") => {
const e =
scores.get(id) ??
({ vectorRank: null, ftsRank: null, tagRank: null, rrfScore: 0 } as Accumulator);
e[key] = rank;
e.rrfScore += 1 / (RRF_K + rank);
scores.set(id, e);
};
vec.forEach((h, i) => accum(h.id, i + 1, "vectorRank"));
fts.forEach((h, i) => accum(h.id, i + 1, "ftsRank"));
tag.forEach((h, i) => accum(h.id, i + 1, "tagRank"));
let entries = [...scores.entries()];
if (typeof minScore === "number" && minScore > 0) {
entries = entries.filter(([, r]) => r.rrfScore >= minScore);
}
const hits = entries
.sort(([, a], [, b]) => b.rrfScore - a.rrfScore)
.slice(0, limit)
.map(([id, rank]) => ({
id,
rank: {
rrfScore: Number(rank.rrfScore.toFixed(6)),
vectorRank: rank.vectorRank,
ftsRank: rank.ftsRank,
tagRank: rank.tagRank,
},
}));
return { hits, debug: { vec: vec.length, fts: fts.length, tag: tag.length } };
}
+385
View File
@@ -0,0 +1,385 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { and, eq, inArray, isNull } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { memories, projects, auditLog } from "@/lib/db/schema";
import { embedText } from "@/lib/embedder";
import { resolveProjectId, upsertProject } from "@/lib/projects";
import {
MemoryWriteInput,
MemoryUpdateInput,
MemoryDeleteInput,
} from "@shared-memory/schemas";
import {
CONCURRENT_EDIT_ERROR,
canWriteProject,
getUserGroupNames,
readableProjectIds,
} from "@/lib/access";
/**
* Server Actions for memory CRUD from the Web UI. Mirrors the MCP tools
* but writes through the same DB layer, so updates and deletes here are
* indistinguishable from those made via Claude Code.
*
* `actor` is "web" in audit_log so we can tell the two paths apart later.
*
* Sharing: project-scope memories may live under projects shared with
* the user's groups. Reads include those projects; writes require the
* user to own the project or have an `rw` share. Cross-user concurrent
* edits use the `version` column for optimistic locking if the stored
* version no longer matches what the form submitted, we surface
* `CONCURRENT_EDIT_ERROR` rather than clobber.
*/
async function requireUserId(): Promise<string> {
const session = await auth();
if (!session?.user?.id) throw new Error("not authenticated");
return session.user.id;
}
function parseTags(raw: FormDataEntryValue | null): string[] {
if (typeof raw !== "string") return [];
return raw
.split(/[,\s]+/)
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
export async function createMemoryAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const payload = {
content: String(formData.get("content") ?? "").trim(),
scope: (formData.get("scope") as "project" | "user") || "project",
project: (formData.get("project") as string | null)?.trim() || undefined,
tags: parseTags(formData.get("tags")),
};
const parsed = MemoryWriteInput.safeParse(payload);
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
let projectId: string | null = null;
if (parsed.data.scope === "project") {
if (!parsed.data.project) throw new Error("scope=project requires `project`");
// Same priority as memory.update's reclassification path: prefer an
// owned project; otherwise check for a shared one we have rw on;
// otherwise auto-upsert as owner.
const owned = await resolveProjectId(userId, parsed.data.project);
if (owned) {
projectId = owned;
} else {
// Restrict the by-key lookup to projects the user can actually
// read. Without this, a different user's project with the same
// key string could be selected (`projects.key` is unique per user,
// not globally), opening a cross-user write hazard.
const readableIds = await readableProjectIds(userId, groupNames);
const sharedRow =
readableIds.length > 0
? await db
.select({ id: projects.id })
.from(projects)
.where(
and(
eq(projects.key, parsed.data.project),
inArray(projects.id, readableIds),
),
)
.limit(1)
: [];
if (sharedRow[0]) {
const allowed = await canWriteProject(userId, groupNames, sharedRow[0].id);
if (!allowed) {
throw new Error(`no write access to project '${parsed.data.project}'`);
}
projectId = sharedRow[0].id;
} else {
projectId = await upsertProject(userId, parsed.data.project);
}
}
}
const embedding = await embedText(parsed.data.content);
const inserted = await db
.insert(memories)
.values({
userId,
projectId,
scope: parsed.data.scope,
content: parsed.data.content,
tags: parsed.data.tags ?? [],
embedding,
lastEditedBy: userId,
})
.returning({ id: memories.id });
await db.insert(auditLog).values({
userId,
actor: "web",
action: "memory.write",
entityType: "memory",
entityId: inserted[0]!.id,
payload: {
scope: parsed.data.scope,
projectKey: parsed.data.project ?? null,
tags: parsed.data.tags ?? [],
},
});
revalidatePath("/memories");
redirect(`/memories/${inserted[0]!.id}`);
}
export async function updateMemoryAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const id = String(formData.get("id") ?? "");
const rawScope = formData.get("scope");
const rawProject = (formData.get("project") as string | null)?.trim() || undefined;
const rawVersion = formData.get("version");
const versionNum =
typeof rawVersion === "string" && rawVersion.length > 0
? Number.parseInt(rawVersion, 10)
: undefined;
const payload = {
id,
content: ((formData.get("content") as string | null) ?? "").trim() || undefined,
tags: parseTags(formData.get("tags")),
scope:
rawScope === "project" || rawScope === "user"
? (rawScope as "project" | "user")
: undefined,
project: rawProject,
version: Number.isFinite(versionNum) ? versionNum : undefined,
};
const parsed = MemoryUpdateInput.safeParse(payload);
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
// Fetch the row regardless of ownership — we may be editing a shared
// memory. Authorization is enforced below against the project, not
// by `user_id`.
const existingRows = await db
.select({
id: memories.id,
content: memories.content,
scope: memories.scope,
projectId: memories.projectId,
projectKey: projects.key,
version: memories.version,
userId: memories.userId,
})
.from(memories)
.leftJoin(projects, eq(memories.projectId, projects.id))
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
const existing = existingRows[0];
if (!existing) throw new Error("not found");
// Authorize write. For user-scope memories, only the owner can edit.
// For project-scope memories, owner OR a group with rw access.
if (existing.scope === "user") {
if (existing.userId !== userId) throw new Error("not found");
} else if (existing.projectId) {
const allowed = await canWriteProject(userId, groupNames, existing.projectId);
if (!allowed) {
throw new Error("you don't have write access to this project");
}
}
const update: Record<string, unknown> = {
updatedAt: new Date(),
lastEditedBy: userId,
version: existing.version + 1,
};
if (parsed.data.tags !== undefined) update.tags = parsed.data.tags;
if (parsed.data.content !== undefined && parsed.data.content !== existing.content) {
update.content = parsed.data.content;
update.embedding = await embedText(parsed.data.content);
}
let scopeChanged = false;
let projectChanged = false;
let newProjectKey: string | null = existing.projectKey ?? null;
if (parsed.data.scope !== undefined) {
if (parsed.data.scope === "user") {
if (existing.scope !== "user") {
update.scope = "user";
scopeChanged = true;
}
if (existing.projectId !== null) {
update.projectId = null;
projectChanged = true;
newProjectKey = null;
}
} else {
// scope === 'project' — schema refine guarantees project is set.
// Moving INTO a project requires write access there. Owners get
// a fresh project upsert; non-owners must target an existing one
// they have rw on.
const projectKey = parsed.data.project!;
let projectId: string;
const existingId = await resolveProjectId(userId, projectKey);
if (existingId) {
projectId = existingId;
} else {
// Restrict the shared-project lookup to projects the user can
// actually read (`projects.key` is unique per user, not globally,
// so an unscoped key match could resolve another user's project).
const readableIds = await readableProjectIds(userId, groupNames);
const sharedRow =
readableIds.length > 0
? await db
.select({ id: projects.id })
.from(projects)
.where(
and(eq(projects.key, projectKey), inArray(projects.id, readableIds)),
)
.limit(1)
: [];
if (sharedRow[0]) {
const allowed = await canWriteProject(userId, groupNames, sharedRow[0].id);
if (!allowed) {
throw new Error(`no write access to project '${projectKey}'`);
}
projectId = sharedRow[0].id;
} else {
// Auto-upsert as owner — user becomes the project owner of a
// brand-new private project.
projectId = await upsertProject(userId, projectKey);
}
}
if (existing.scope !== "project") {
update.scope = "project";
scopeChanged = true;
}
if (existing.projectId !== projectId) {
update.projectId = projectId;
projectChanged = true;
newProjectKey = projectKey;
}
}
}
// Optimistic-locking guard. When `version` is supplied, the UPDATE
// matches on (id, version); a 0-row result means the caller's view
// is stale. When `version` is NOT supplied, we still match on the
// pre-fetched version to keep behaviour deterministic.
const expectedVersion = parsed.data.version ?? existing.version;
const updated = await db
.update(memories)
.set(update)
.where(
and(
eq(memories.id, parsed.data.id),
eq(memories.version, expectedVersion),
),
)
.returning({ id: memories.id });
if (!updated[0]) throw new Error(CONCURRENT_EDIT_ERROR);
const auditFields = Object.keys(update).filter(
(k) => k !== "updatedAt" && k !== "version" && k !== "lastEditedBy",
);
const auditPayload: Record<string, unknown> = { fields: auditFields };
if (scopeChanged || projectChanged) {
auditPayload.scope = {
from: existing.scope,
to: update.scope ?? existing.scope,
};
auditPayload.projectKey = {
from: existing.projectKey ?? null,
to: newProjectKey,
};
}
await db.insert(auditLog).values({
userId,
actor: "web",
action: "memory.update",
entityType: "memory",
entityId: parsed.data.id,
payload: auditPayload,
});
revalidatePath(`/memories/${parsed.data.id}`);
revalidatePath("/memories");
redirect(`/memories/${parsed.data.id}`);
}
export async function deleteMemoryAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const id = String(formData.get("id") ?? "");
const rawVersion = formData.get("version");
const version =
typeof rawVersion === "string" && rawVersion.length > 0
? Number.parseInt(rawVersion, 10)
: undefined;
const parsed = MemoryDeleteInput.safeParse({
id,
version: Number.isFinite(version) ? version : undefined,
});
if (!parsed.success) throw new Error(parsed.error.issues[0]!.message);
// Authorize delete: same rule as update — owner OR rw on the project.
const existing = await db
.select({
id: memories.id,
scope: memories.scope,
projectId: memories.projectId,
userId: memories.userId,
version: memories.version,
})
.from(memories)
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
const row = existing[0];
if (!row) throw new Error("not found");
if (row.scope === "user") {
if (row.userId !== userId) throw new Error("not found");
} else if (row.projectId) {
const allowed = await canWriteProject(userId, groupNames, row.projectId);
if (!allowed) throw new Error("you don't have write access to this project");
}
// CAS on version so a peer's concurrent edit can't be silently overwritten
// by this delete. Form may or may not supply version; fall back to the row
// we just read to keep behaviour deterministic.
const expectedVersion = parsed.data.version ?? row.version;
const updated = await db
.update(memories)
.set({ deletedAt: new Date(), lastEditedBy: userId })
.where(
and(
eq(memories.id, parsed.data.id),
eq(memories.version, expectedVersion),
isNull(memories.deletedAt),
),
)
.returning({ id: memories.id });
if (!updated[0]) throw new Error(CONCURRENT_EDIT_ERROR);
await db.insert(auditLog).values({
userId,
actor: "web",
action: "memory.delete",
entityType: "memory",
entityId: updated[0].id,
});
revalidatePath("/memories");
redirect("/memories");
}
+43
View File
@@ -0,0 +1,43 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { projects } from "@/lib/db/schema";
/**
* Look up a project id by (user, key). Returns null when not found.
* No write side-effects.
*/
export async function resolveProjectId(
userId: string,
key: string,
): Promise<string | null> {
const row = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.userId, userId), eq(projects.key, key)))
.limit(1);
return row[0]?.id ?? null;
}
/**
* Idempotent project creation. Returns the existing row's id when one
* exists, otherwise inserts and returns the new id. Tolerates concurrent
* inserts via ON CONFLICT two simultaneous calls converge on one row.
*/
export async function upsertProject(
userId: string,
key: string,
displayName?: string,
): Promise<string> {
const existing = await resolveProjectId(userId, key);
if (existing) return existing;
const row = await db
.insert(projects)
.values({ userId, key, displayName: displayName ?? null })
.onConflictDoNothing({ target: [projects.userId, projects.key] })
.returning({ id: projects.id });
if (row[0]) return row[0].id;
// ON CONFLICT DO NOTHING returns no rows on conflict — re-read.
const reread = await resolveProjectId(userId, key);
if (!reread) throw new Error("project upsert raced and re-read still empty");
return reread;
}
+256
View File
@@ -0,0 +1,256 @@
"use server";
import { revalidatePath } from "next/cache";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import {
auditLog,
groups,
projects,
projectShares,
userGroups,
} from "@/lib/db/schema";
import { MemoryAccess, ProjectKey } from "@shared-memory/schemas";
/**
* Server Actions for project-sharing controls.
*
* The sharing model:
* - Only the project owner can grant, change, or revoke shares.
* - The granter can only share with groups they themselves belong to.
* This prevents leaking projects to arbitrary group names from the
* OIDC IdP you can only invite people you'd already see in the
* mirror.
* - All three actions audit-log with actor='web' so the timeline of
* access changes survives a future schema change.
*
* Inputs are read from FormData (typical Next.js Server Action surface)
* and validated with zod before any DB writes.
*/
async function requireUserId(): Promise<string> {
const session = await auth();
if (!session?.user?.id) throw new Error("not authenticated");
return session.user.id;
}
/**
* Look up a project this user owns, by key. Returns null if it doesn't
* exist or the caller isn't the owner. Owner-gating happens here rather
* than in every action.
*/
async function resolveOwnedProject(
userId: string,
projectKey: string,
): Promise<{ id: string; key: string } | null> {
const row = await db
.select({ id: projects.id, key: projects.key })
.from(projects)
.where(and(eq(projects.userId, userId), eq(projects.key, projectKey)))
.limit(1);
return row[0] ?? null;
}
/**
* Resolve a group by name AS LONG AS the caller is a member. This is
* the leak-prevention check described above: an owner can't bestow
* access on a group they themselves don't have visibility into.
*/
async function resolveGrantableGroup(
userId: string,
groupName: string,
): Promise<{ id: string; name: string } | null> {
const row = await db
.select({ id: groups.id, name: groups.name })
.from(groups)
.innerJoin(userGroups, eq(userGroups.groupId, groups.id))
.where(and(eq(groups.name, groupName), eq(userGroups.userId, userId)))
.limit(1);
return row[0] ?? null;
}
const AddShareInput = z.object({
projectKey: ProjectKey,
groupName: z.string().min(1).max(200),
access: MemoryAccess,
});
const UpdateShareInput = z.object({
projectKey: ProjectKey,
groupId: z.string().uuid(),
access: MemoryAccess,
});
const RemoveShareInput = z.object({
projectKey: ProjectKey,
groupId: z.string().uuid(),
});
export async function addProjectShareAction(formData: FormData) {
const userId = await requireUserId();
const parsed = AddShareInput.safeParse({
projectKey: String(formData.get("projectKey") ?? "").trim(),
groupName: String(formData.get("groupName") ?? "").trim(),
access: String(formData.get("access") ?? "ro"),
});
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
const project = await resolveOwnedProject(userId, parsed.data.projectKey);
if (!project) throw new Error("project not found or you don't own it");
const group = await resolveGrantableGroup(userId, parsed.data.groupName);
if (!group) {
throw new Error(
`you must be a member of group '${parsed.data.groupName}' to share with it`,
);
}
// Upsert: if a share already exists for (project, group), bump the
// access level. This makes the "Add share" form double as a sanity-
// safe re-grant path if a user accidentally re-adds the same group.
await db
.insert(projectShares)
.values({
projectId: project.id,
groupId: group.id,
access: parsed.data.access,
grantedBy: userId,
})
.onConflictDoUpdate({
target: [projectShares.projectId, projectShares.groupId],
set: {
access: parsed.data.access,
grantedBy: userId,
grantedAt: new Date(),
},
});
await db.insert(auditLog).values({
userId,
actor: "web",
action: "project.share.add",
entityType: "project",
entityId: project.id,
payload: {
projectKey: project.key,
groupName: group.name,
access: parsed.data.access,
},
});
revalidatePath(`/projects/${encodeURIComponent(project.key)}`);
}
export async function updateProjectShareAction(formData: FormData) {
const userId = await requireUserId();
const parsed = UpdateShareInput.safeParse({
projectKey: String(formData.get("projectKey") ?? "").trim(),
groupId: String(formData.get("groupId") ?? "").trim(),
access: String(formData.get("access") ?? "ro"),
});
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
const project = await resolveOwnedProject(userId, parsed.data.projectKey);
if (!project) throw new Error("project not found or you don't own it");
// The owner is allowed to flip any group's access — no membership
// check required (only the add path requires it; ownership is enough
// to twiddle an existing share). The row must exist.
const existing = await db
.select({ groupName: groups.name, access: projectShares.access })
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.where(
and(
eq(projectShares.projectId, project.id),
eq(projectShares.groupId, parsed.data.groupId),
),
)
.limit(1);
if (!existing[0]) throw new Error("share not found");
await db
.update(projectShares)
.set({ access: parsed.data.access, grantedBy: userId, grantedAt: new Date() })
.where(
and(
eq(projectShares.projectId, project.id),
eq(projectShares.groupId, parsed.data.groupId),
),
);
await db.insert(auditLog).values({
userId,
actor: "web",
action: "project.share.update",
entityType: "project",
entityId: project.id,
payload: {
projectKey: project.key,
groupName: existing[0].groupName,
access: { from: existing[0].access, to: parsed.data.access },
},
});
revalidatePath(`/projects/${encodeURIComponent(project.key)}`);
}
export async function removeProjectShareAction(formData: FormData) {
const userId = await requireUserId();
const parsed = RemoveShareInput.safeParse({
projectKey: String(formData.get("projectKey") ?? "").trim(),
groupId: String(formData.get("groupId") ?? "").trim(),
});
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
const project = await resolveOwnedProject(userId, parsed.data.projectKey);
if (!project) throw new Error("project not found or you don't own it");
const existing = await db
.select({ groupName: groups.name, access: projectShares.access })
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.where(
and(
eq(projectShares.projectId, project.id),
eq(projectShares.groupId, parsed.data.groupId),
),
)
.limit(1);
if (!existing[0]) throw new Error("share not found");
await db
.delete(projectShares)
.where(
and(
eq(projectShares.projectId, project.id),
eq(projectShares.groupId, parsed.data.groupId),
),
);
await db.insert(auditLog).values({
userId,
actor: "web",
action: "project.share.remove",
entityType: "project",
entityId: project.id,
payload: {
projectKey: project.key,
groupName: existing[0].groupName,
access: existing[0].access,
},
});
revalidatePath(`/projects/${encodeURIComponent(project.key)}`);
}
+199
View File
@@ -0,0 +1,199 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { auditLog } from "@/lib/db/schema";
import {
SnippetPutInput,
SnippetDeleteInput,
} from "@shared-memory/schemas";
import { putSnippet, softDeleteSnippet } from "@/lib/snippets";
import { getUserGroupNames } from "@/lib/access";
/**
* Server Actions for snippet CRUD from the Web UI. Mirrors the MCP
* tools but writes through the same DB helpers, so the two paths are
* indistinguishable on the storage layer.
*
* `actor` is "web" in audit_log so we can tell the two paths apart later.
*
* Sharing: project-scope snippets under a shared project can be edited
* by any user with rw access via this path; the `putSnippet` helper
* enforces authorization and optimistic-locking concurrency control.
*/
async function requireUserId(): Promise<string> {
const session = await auth();
if (!session?.user?.id) throw new Error("not authenticated");
return session.user.id;
}
function parseTags(raw: FormDataEntryValue | null): string[] {
if (typeof raw !== "string") return [];
return raw
.split(/[,\s]+/)
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
function targetUrl(scope: "project" | "user", name: string, projectKey: string | null): string {
const params = new URLSearchParams({ scope });
if (scope === "project" && projectKey) params.set("project", projectKey);
return `/snippets/${encodeURIComponent(name)}?${params.toString()}`;
}
export async function createSnippetAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const scope = (formData.get("scope") as "project" | "user") || "user";
const projectRaw = (formData.get("project") as string | null)?.trim();
const payload = {
name: String(formData.get("name") ?? "").trim(),
body: String(formData.get("body") ?? ""),
description: ((formData.get("description") as string | null) ?? "").trim() || undefined,
tags: parseTags(formData.get("tags")),
scope,
project: scope === "project" ? projectRaw || undefined : undefined,
};
const parsed = SnippetPutInput.safeParse(payload);
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
const { snippet, inserted } = await putSnippet(userId, {
name: parsed.data.name,
body: parsed.data.body,
description: parsed.data.description,
tags: parsed.data.tags,
scope: parsed.data.scope,
projectKey: parsed.data.project,
groupNames,
});
await db.insert(auditLog).values({
userId,
actor: "web",
action: inserted ? "snippet.put" : "snippet.update",
entityType: "snippet",
entityId: snippet.id,
payload: {
name: snippet.name,
scope: snippet.scope,
projectKey: snippet.projectKey,
tags: snippet.tags,
},
});
revalidatePath("/snippets");
redirect(targetUrl(snippet.scope, snippet.name, snippet.projectKey));
}
export async function updateSnippetAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
// Edits keep the row's identity (scope + name + project unchanged) —
// body/description/tags are what changes. Treat as a put on the same key.
const scope = (formData.get("scope") as "project" | "user") || "user";
const projectRaw = (formData.get("project") as string | null)?.trim();
const rawVersion = formData.get("version");
const versionNum =
typeof rawVersion === "string" && rawVersion.length > 0
? Number.parseInt(rawVersion, 10)
: undefined;
const payload = {
name: String(formData.get("name") ?? "").trim(),
body: String(formData.get("body") ?? ""),
description: ((formData.get("description") as string | null) ?? "").trim() || undefined,
tags: parseTags(formData.get("tags")),
scope,
project: scope === "project" ? projectRaw || undefined : undefined,
version: Number.isFinite(versionNum) ? versionNum : undefined,
};
const parsed = SnippetPutInput.safeParse(payload);
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
const { snippet } = await putSnippet(userId, {
name: parsed.data.name,
body: parsed.data.body,
description: parsed.data.description,
tags: parsed.data.tags,
scope: parsed.data.scope,
projectKey: parsed.data.project,
groupNames,
version: parsed.data.version,
});
await db.insert(auditLog).values({
userId,
actor: "web",
action: "snippet.update",
entityType: "snippet",
entityId: snippet.id,
payload: {
name: snippet.name,
scope: snippet.scope,
projectKey: snippet.projectKey,
},
});
revalidatePath("/snippets");
revalidatePath(`/snippets/${encodeURIComponent(snippet.name)}`);
redirect(targetUrl(snippet.scope, snippet.name, snippet.projectKey));
}
export async function deleteSnippetAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const scope = formData.get("scope") as "project" | "user" | null;
const projectRaw = (formData.get("project") as string | null)?.trim();
const rawVersion = formData.get("version");
const version =
typeof rawVersion === "string" && rawVersion.length > 0
? Number.parseInt(rawVersion, 10)
: undefined;
const payload = {
name: String(formData.get("name") ?? "").trim(),
scope: scope ?? undefined,
project: scope === "project" ? projectRaw || undefined : undefined,
version: Number.isFinite(version) ? version : undefined,
};
const parsed = SnippetDeleteInput.safeParse(payload);
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
const deleted = await softDeleteSnippet(userId, {
name: parsed.data.name,
scope: parsed.data.scope,
projectKey: parsed.data.project,
groupNames,
version: parsed.data.version,
});
if (!deleted) throw new Error("not found");
await db.insert(auditLog).values({
userId,
actor: "web",
action: "snippet.delete",
entityType: "snippet",
entityId: deleted.id,
payload: {
name: parsed.data.name,
scope: deleted.scope,
projectKey: deleted.projectKey,
},
});
revalidatePath("/snippets");
redirect("/snippets");
}
+445
View File
@@ -0,0 +1,445 @@
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";
import {
CONCURRENT_EDIT_ERROR_SNIPPET,
canWriteProject,
readableProjectIds,
} from "@/lib/access";
/**
* Snippet data layer. Shared by the MCP tool handlers and the Web UI
* Server Actions so both paths hit the same uniqueness / scope rules.
*
* Snippets are looked up by EXACT name there is no search. Names are
* unique within a scope:
* - user-scope: unique per (user_id)
* - project-scope: unique per (user_id, project_id)
*
* The same name CAN exist in both a user-scope row and one or more
* project-scope rows for that user; callers disambiguate by passing
* `scope` (+ `project` when project-scoped). When `scope` is omitted on
* a get/delete, we prefer the project match (if `project` was supplied)
* else fall back to the user-scope row.
*
* Sharing extends visibility: for project-scope rows, anyone who has
* read access to the project sees the snippet; rw access is required
* for putSnippet's update path and softDeleteSnippet.
*/
export const CONCURRENT_EDIT_ERROR = CONCURRENT_EDIT_ERROR_SNIPPET;
export interface ResolvedScope {
scope: "project" | "user";
projectId: string | null;
}
async function resolveProjectId(userId: string, key: string): Promise<string | null> {
const row = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.userId, userId), eq(projects.key, key)))
.limit(1);
return row[0]?.id ?? null;
}
/**
* Resolve a project id by key, preferring an owned project, falling
* back to a shared project the user can read. Returns null if the key
* matches nothing visible. Used by snippet lookups (which need to find
* project-scope snippets under shared projects) write authorization
* is enforced separately by the caller via `canWriteProject`.
*/
async function resolveVisibleProjectId(
userId: string,
groupNames: string[],
key: string,
): Promise<string | null> {
const owned = await resolveProjectId(userId, key);
if (owned) return owned;
if (groupNames.length === 0) return null;
const readableIds = await readableProjectIds(userId, groupNames);
if (readableIds.length === 0) return null;
const row = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.key, key), inArray(projects.id, readableIds)))
.limit(1);
return row[0]?.id ?? null;
}
async function upsertProject(userId: string, key: string): Promise<string> {
const existing = await resolveProjectId(userId, key);
if (existing) return existing;
const row = await db
.insert(projects)
.values({ userId, key })
.returning({ id: projects.id });
return row[0]!.id;
}
export interface SnippetWithProjectKey extends Snippet {
projectKey: string | null;
}
/**
* Look up a snippet without enforcing ownership; visibility is restricted
* by the WHERE clause to "owner" or "in a project the user can read".
*
* For user-scope snippets there's no sharing concept — they're personal.
*/
async function findSnippet(
userId: string,
groupNames: string[],
name: string,
scope: "project" | "user",
projectId: string | null,
): Promise<SnippetWithProjectKey | null> {
const where = [
eq(snippets.name, name),
eq(snippets.scope, scope),
isNull(snippets.deletedAt),
];
if (scope === "project") {
if (!projectId) return null;
where.push(eq(snippets.projectId, projectId));
// Project-scope snippet: visibility = owner OR project is readable.
// The caller has already resolved `projectId` via
// `resolveVisibleProjectId`, so we only need to filter to that
// project; any row under it is by definition visible to this user.
} else {
// User-scope snippet: strictly the caller's own row.
where.push(eq(snippets.userId, userId));
where.push(isNull(snippets.projectId));
}
const rows = await db
.select({
id: snippets.id,
userId: snippets.userId,
projectId: snippets.projectId,
scope: snippets.scope,
name: snippets.name,
body: snippets.body,
description: snippets.description,
tags: snippets.tags,
version: snippets.version,
lastEditedBy: snippets.lastEditedBy,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
deletedAt: snippets.deletedAt,
projectKey: projects.key,
})
.from(snippets)
.leftJoin(projects, eq(snippets.projectId, projects.id))
.where(and(...where))
.limit(1);
// groupNames is reserved for future per-group filtering paths; for
// now project-scope visibility is already encoded by `projectId`.
void groupNames;
return (rows[0] as SnippetWithProjectKey | undefined) ?? null;
}
/**
* Look up a single snippet by name. If `scope` is omitted, prefers a
* project match (when `projectKey` is provided) and falls back to the
* user-scope row. Returns null when nothing matches.
*
* `groupNames` widens project visibility to include shared projects.
*/
export async function getSnippet(
userId: string,
args: {
name: string;
scope?: "project" | "user";
projectKey?: string;
groupNames?: string[];
},
): Promise<SnippetWithProjectKey | null> {
const { name, scope, projectKey, groupNames = [] } = args;
if (scope === "project") {
if (!projectKey) return null;
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
if (!pid) return null;
return findSnippet(userId, groupNames, name, "project", pid);
}
if (scope === "user") {
return findSnippet(userId, groupNames, name, "user", null);
}
// Scope unspecified: try project first if a key was given, then user.
if (projectKey) {
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
if (pid) {
const projectHit = await findSnippet(userId, groupNames, name, "project", pid);
if (projectHit) return projectHit;
}
}
return findSnippet(userId, groupNames, name, "user", null);
}
/**
* Upsert a snippet keyed by (scope, project, name). If a live row with
* that key already exists, it's replaced in place preserving its id
* but bumping `version` and recording `last_edited_by`. Returns the
* resulting row plus an `inserted` flag.
*
* Authorization:
* - user-scope: only the calling user can write.
* - project-scope: caller must own the project OR have rw access.
* When the project doesn't yet exist, it's auto-upserted with the
* caller as owner (matching memory-write semantics).
*
* Optimistic locking: pass `version` to require a CAS against the
* current row's version on the update path. A 0-row update surfaces
* `CONCURRENT_EDIT_ERROR_SNIPPET`. Ignored on insert.
*/
export async function putSnippet(
userId: string,
args: {
name: string;
body: string;
description?: string;
tags?: string[];
scope: "project" | "user";
projectKey?: string;
groupNames?: string[];
version?: number;
},
): Promise<{ snippet: SnippetWithProjectKey; inserted: boolean }> {
const { name, body, description, tags, scope, projectKey, groupNames = [], version } = args;
let projectId: string | null = null;
if (scope === "project") {
if (!projectKey) throw new Error("scope=project requires projectKey");
// Prefer owned; if a shared project exists with this key, require
// rw to write through it; otherwise auto-upsert (caller-owned).
const owned = await resolveProjectId(userId, projectKey);
if (owned) {
projectId = owned;
} else {
// Restrict by-key lookup to projects the user can actually read —
// `projects.key` is unique per user, not globally, so an unscoped
// match could resolve another user's project entirely.
const readableIds = await readableProjectIds(userId, groupNames);
const sharedRow =
readableIds.length > 0
? await db
.select({ id: projects.id })
.from(projects)
.where(
and(eq(projects.key, projectKey), inArray(projects.id, readableIds)),
)
.limit(1)
: [];
if (sharedRow[0]) {
const allowed = await canWriteProject(userId, groupNames, sharedRow[0].id);
if (!allowed) {
throw new Error(`no write access to project '${projectKey}'`);
}
projectId = sharedRow[0].id;
} else {
projectId = await upsertProject(userId, projectKey);
}
}
}
const existing = await findSnippet(userId, groupNames, name, scope, projectId);
if (existing) {
const updateValues: Record<string, unknown> = {
body,
tags: tags ?? existing.tags,
updatedAt: new Date(),
version: existing.version + 1,
lastEditedBy: userId,
};
if (description !== undefined) updateValues.description = description;
const expectedVersion = version ?? existing.version;
const updated = await db
.update(snippets)
.set(updateValues)
.where(and(eq(snippets.id, existing.id), eq(snippets.version, expectedVersion)))
.returning({ id: snippets.id });
if (!updated[0]) throw new Error(CONCURRENT_EDIT_ERROR_SNIPPET);
const refreshed = await findSnippet(userId, groupNames, name, scope, projectId);
return { snippet: refreshed!, inserted: false };
}
const inserted = await db
.insert(snippets)
.values({
userId,
projectId,
scope,
name,
body,
description: description ?? null,
tags: tags ?? [],
lastEditedBy: userId,
})
.returning({ id: snippets.id });
const row = await db
.select({
id: snippets.id,
userId: snippets.userId,
projectId: snippets.projectId,
scope: snippets.scope,
name: snippets.name,
body: snippets.body,
description: snippets.description,
tags: snippets.tags,
version: snippets.version,
lastEditedBy: snippets.lastEditedBy,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
deletedAt: snippets.deletedAt,
projectKey: projects.key,
})
.from(snippets)
.leftJoin(projects, eq(snippets.projectId, projects.id))
.where(eq(snippets.id, inserted[0]!.id))
.limit(1);
return { snippet: row[0]! as SnippetWithProjectKey, inserted: true };
}
/**
* List live snippets visible to this user, newest first. Visibility:
* - user-scope rows owned by `userId`
* - project-scope rows under a project the user can read (owner or
* any group share)
*
* Filters mirror memory.list. No pagination cursor yet snippets are
* expected to be relatively low-volume; we cap at the requested limit.
*/
export async function listSnippets(
userId: string,
args: {
scope?: "project" | "user";
projectKey?: string;
tags?: string[];
limit?: number;
groupNames?: string[];
} = {},
): Promise<SnippetWithProjectKey[]> {
const { scope, projectKey, tags, limit = 50, groupNames = [] } = args;
// Visibility: own user-scope rows OR project-scope rows under a
// project the user can read.
const visibleProjectIds = await readableProjectIds(userId, groupNames);
const visibilityClause =
visibleProjectIds.length > 0
? or(
and(eq(snippets.userId, userId), isNull(snippets.projectId)),
inArray(snippets.projectId, visibleProjectIds),
)
: and(eq(snippets.userId, userId), isNull(snippets.projectId));
const where = [visibilityClause!, isNull(snippets.deletedAt)];
if (scope) where.push(eq(snippets.scope, scope));
if (projectKey) {
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
if (!pid) return [];
where.push(eq(snippets.projectId, pid));
}
if (tags && tags.length > 0) {
// 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
.select({
id: snippets.id,
userId: snippets.userId,
projectId: snippets.projectId,
scope: snippets.scope,
name: snippets.name,
body: snippets.body,
description: snippets.description,
tags: snippets.tags,
version: snippets.version,
lastEditedBy: snippets.lastEditedBy,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
deletedAt: snippets.deletedAt,
projectKey: projects.key,
})
.from(snippets)
.leftJoin(projects, eq(snippets.projectId, projects.id))
.where(and(...where))
.orderBy(desc(snippets.updatedAt))
.limit(limit);
return rows as SnippetWithProjectKey[];
}
/**
* Soft-delete a snippet. Returns the deleted row's id, or null if
* nothing matched (already deleted or never existed).
*
* If `scope` is omitted and `projectKey` is provided, deletes the
* project-scope row (if found) falls back to user-scope otherwise.
*
* Authorization mirrors `putSnippet`: project-scope rows require rw on
* the project (or ownership); user-scope rows require ownership.
*/
export async function softDeleteSnippet(
userId: string,
args: {
name: string;
scope?: "project" | "user";
projectKey?: string;
groupNames?: string[];
version?: number;
},
): Promise<{ id: string; scope: "project" | "user"; projectKey: string | null } | null> {
const { groupNames = [], version } = args;
const target = await getSnippet(userId, args);
if (!target) return null;
// Authorize the delete. For user-scope, only the owner can delete;
// `getSnippet` already filters to the user's own user-scope row, but
// we double-check defensively in case the same name exists across
// scopes and the caller passed scope=undefined.
if (target.scope === "user") {
if (target.userId !== userId) return null;
} else if (target.projectId) {
const allowed = await canWriteProject(userId, groupNames, target.projectId);
if (!allowed) throw new Error("you don't have write access to this project");
}
// CAS on version so a peer's concurrent edit can't be silently dropped
// by this delete. Caller-supplied version wins; else we use the version
// we just read in `getSnippet` for in-handler consistency.
const expectedVersion = version ?? target.version;
const updated = await db
.update(snippets)
.set({ deletedAt: new Date(), lastEditedBy: userId })
.where(and(eq(snippets.id, target.id), eq(snippets.version, expectedVersion)))
.returning({ id: snippets.id });
if (!updated[0]) {
throw new Error(CONCURRENT_EDIT_ERROR_SNIPPET);
}
return {
id: target.id,
scope: target.scope,
projectKey: target.projectKey,
};
}
// Helpers re-exported so callers that need the project-id resolution
// don't have to duplicate the lookup logic.
export { resolveProjectId, upsertProject };
+20
View File
@@ -0,0 +1,20 @@
import type { NextConfig } from "next";
const config: NextConfig = {
output: "standalone",
reactStrictMode: true,
poweredByHeader: false,
serverExternalPackages: ["postgres"],
async headers() {
return [
{
source: "/api/mcp/:path*",
headers: [
{ key: "Cache-Control", value: "no-store" },
],
},
];
},
};
export default config;
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@shared-memory/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start --port 3000",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx ./scripts/migrate.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"@shared-memory/schemas": "workspace:*",
"drizzle-orm": "^0.36.4",
"jose": "^5.9.6",
"next": "^15.1.0",
"next-auth": "5.0.0-beta.25",
"postgres": "^3.4.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@tailwindcss/postcss": "^4.0.0",
"drizzle-kit": "^0.30.1",
"esbuild": "^0.24.2",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
View File
+160
View File
@@ -0,0 +1,160 @@
/**
* Run pending SQL migrations from ./drizzle in lexical filename order.
*
* Lightweight runner drizzle-kit's TS migrator doesn't handle the raw SQL
* features we need (pgvector, generated columns), so we manage migration
* state ourselves in `_migrations` and apply files as plain SQL.
*/
import { existsSync } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import postgres from "postgres";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Resolve the migrations directory. Try, in order:
// 1. $MIGRATIONS_DIR env override (explicit deploy-time control)
// 2. `<script>/drizzle` — production: migrate.mjs sits alongside drizzle/
// 3. `<script>/../drizzle` — dev: scripts/migrate.ts has drizzle/ one up
function findMigrationsDir(): string {
if (process.env.MIGRATIONS_DIR) return process.env.MIGRATIONS_DIR;
const sibling = join(__dirname, "drizzle");
if (existsSync(sibling)) return sibling;
const parent = join(__dirname, "..", "drizzle");
if (existsSync(parent)) return parent;
throw new Error(
`Couldn't locate migrations directory (tried ${sibling}, ${parent}). ` +
`Set MIGRATIONS_DIR to override.`,
);
}
const MIGRATIONS_DIR = findMigrationsDir();
async function main() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL is not set");
process.exit(1);
}
const sql = postgres(url, { max: 1, prepare: false });
try {
await sql`
CREATE TABLE IF NOT EXISTS "_migrations" (
"id" serial PRIMARY KEY,
"name" text NOT NULL UNIQUE,
"applied_at" timestamptz NOT NULL DEFAULT now()
)
`;
const files = (await readdir(MIGRATIONS_DIR))
.filter((f) => f.endsWith(".sql"))
.sort();
const applied = new Set(
(await sql<{ name: string }[]>`SELECT name FROM "_migrations"`).map((r) => r.name),
);
for (const file of files) {
if (applied.has(file)) {
console.log(`${file} (already applied)`);
continue;
}
const body = await readFile(join(MIGRATIONS_DIR, file), "utf8");
console.log(`${file} (applying)`);
await sql.begin(async (tx) => {
await tx.unsafe(body);
await tx`INSERT INTO "_migrations" (name) VALUES (${file})`;
});
console.log(`${file}`);
}
console.log("Migrations complete.");
if (process.env.EMBEDDER_URL) {
await backfillEmbeddings(sql);
} else {
console.log("EMBEDDER_URL not set — skipping embedding backfill.");
}
} finally {
await sql.end({ timeout: 5 });
}
}
/**
* Backfill embedding column for any memory written before embeddings were
* online. Idempotent: only touches rows where embedding IS NULL. Runs on
* every migrator boot, so deploying Phase 2 or recovering from an
* embedder outage that left fresh rows unembedded needs no manual step.
*/
async function backfillEmbeddings(sql: ReturnType<typeof postgres>) {
const embedderUrl = process.env.EMBEDDER_URL!.replace(/\/$/, "");
const BATCH = 32;
// Wait for the embedder to report ready — its first boot has to download
// and load the model, which can take 3060s on a cold container.
const waitDeadline = Date.now() + 180_000;
for (;;) {
try {
const res = await fetch(`${embedderUrl}/health`);
if (res.ok) {
const body = (await res.json()) as { ready?: boolean };
if (body.ready) break;
}
} catch {
/* embedder not up yet */
}
if (Date.now() > waitDeadline) {
throw new Error("embedder did not become ready within 180s");
}
await new Promise((r) => setTimeout(r, 2000));
}
let total = 0;
for (;;) {
const rows = await sql<{ id: string; content: string }[]>`
SELECT id, content FROM memories
WHERE embedding IS NULL AND deleted_at IS NULL
ORDER BY created_at
LIMIT ${BATCH}
`;
if (rows.length === 0) break;
const res = await fetch(`${embedderUrl}/embed`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ texts: rows.map((r) => r.content) }),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`embedder error ${res.status}: ${detail.slice(0, 200)}`);
}
const { vectors } = (await res.json()) as { vectors: number[][] };
await sql.begin(async (tx) => {
for (let i = 0; i < rows.length; i++) {
const id = rows[i]!.id;
const vec = vectors[i];
if (!vec) continue;
const literal = `[${vec.join(",")}]`;
await tx`UPDATE memories SET embedding = ${literal}::vector WHERE id = ${id}`;
}
});
total += rows.length;
console.log(` embedded ${rows.length} memories (total: ${total})`);
}
if (total === 0) {
console.log("Embedding backfill: nothing to do.");
} else {
console.log(`Embedding backfill complete: ${total} memories embedded.`);
}
}
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "ES2022"],
"jsx": "preserve",
"allowJs": true,
"incremental": true,
"noEmit": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules", ".next"]
}
+61
View File
@@ -0,0 +1,61 @@
# =============================================================================
# shared-memory — external Postgres override.
#
# Use this override when you want to point the app at a managed Postgres
# (AWS RDS, GCP Cloud SQL, Azure Database for PostgreSQL, your own VM, ...)
# instead of the bundled `db` container.
#
# Invocation (always together with the base file):
#
# docker compose -f docker-compose.yml -f docker-compose.external-db.yml up -d
#
# The caller MUST set `DATABASE_URL` explicitly in `.env` so that `migrator`
# and `app` know where to connect. The `POSTGRES_*` variables are not used
# in this mode (the bundled `db` service is disabled below). Example:
#
# DATABASE_URL=postgres://memory:STRONG_PASSWORD@your-rds.region.rds.amazonaws.com:5432/memory?sslmode=require
#
# The DB user needs privileges to `CREATE EXTENSION` for pgvector, pg_trgm,
# and pgcrypto on first run — on RDS that means the `rds_superuser` role, or
# pre-create the extensions yourself. See README "External Postgres".
#
# REQUIRES DOCKER COMPOSE >= 2.24.0 (Docker Desktop >= 4.25, or Compose plugin
# 2.24.0+). The `!override` YAML tag on the depends_on blocks below is what
# fully replaces — rather than merges — the base file's `depends_on: db`
# entries. On older Compose the tag is silently ignored, the `db` dependency
# survives the merge, and startup fails with "depends on undefined service
# db". Check with: docker compose version
# =============================================================================
services:
db:
# Park the bundled DB on a profile that nothing ever enables. Compose
# only starts services whose profile list is empty OR matches a
# `--profile` flag on the command line. "never" is not a magic name —
# it's just a label we promise not to pass, so the service stays down.
profiles: ["never"]
migrator:
# Docker compose merges `depends_on` by key — listing `embedder` here
# alone would keep the base file's `db` entry and break with
# "depends on undefined service db". The `!override` tag (compose 2.24+)
# replaces the whole block instead of merging.
depends_on: !override
embedder:
condition: service_healthy
environment:
# The base file hardcodes DATABASE_URL to point at the bundled `db`
# service. Override it to pass through whatever the operator set in
# `.env` (e.g. an RDS endpoint with sslmode=require).
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL not set in .env (required with external-db override)}
app:
# Same merge caveat as above — fully replace the block, keep embedder
# and migrator deps.
depends_on: !override
embedder:
condition: service_healthy
migrator:
condition: service_completed_successfully
environment:
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL not set in .env (required with external-db override)}
+179
View File
@@ -0,0 +1,179 @@
# =============================================================================
# shared-memory — compose stack.
#
# Two supported deployment modes:
#
# 1. Behind an external reverse proxy (DEFAULT)
# The `app` service exposes ${APP_PORT:-3000} on the host. Point your
# proxy (HAProxy, nginx, Traefik, Cloudflare Tunnel, etc.) at it. The
# app trusts X-Forwarded-Proto / X-Forwarded-Host headers so callbacks
# and MCP discovery URLs use PUBLIC_URL correctly.
#
# docker compose up -d
#
# 2. Built-in TLS via Caddy (opt-in profile)
# Adds a Caddy reverse proxy on host ports 80/443 with automatic
# Let's Encrypt certificates for $APP_HOSTNAME. Use this on a VM that
# doesn't already sit behind a proxy.
#
# docker compose --profile tls up -d
#
# All runtime config lives in .env (never committed). See .env.example.
# =============================================================================
name: shared-memory
services:
db:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER not set in .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD not set in .env}
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB not set in .env}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
networks:
- internal
# Embedding sidecar — loads bge-small-en-v1.5 once and serves /embed.
# First boot downloads the model (~30 MB) into a named volume so future
# boots are warm.
embedder:
image: ${EMBEDDER_IMAGE_REF:-shared-memory-embedder:local}
build:
context: .
dockerfile: apps/embedder/Dockerfile
restart: unless-stopped
environment:
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-Xenova/bge-small-en-v1.5}
EMBEDDING_DIM: ${EMBEDDING_DIM:-384}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- embedder_models:/data/models
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8080/health | grep -q '\"ready\":true' || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 180s
networks:
- internal
# One-shot migration runner + embedding backfill. Exits 0 when both are
# up-to-date; `app` waits on its successful completion before starting.
migrator:
image: ${IMAGE_REF:-shared-memory-web:local}
build:
context: .
dockerfile: apps/web/Dockerfile
restart: "no"
depends_on:
db:
condition: service_healthy
embedder:
condition: service_healthy
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
EMBEDDER_URL: ${EMBEDDER_URL:-http://embedder:8080}
command: ["node", "apps/web/migrate.mjs"]
networks:
- internal
app:
image: ${IMAGE_REF:-shared-memory-web:local}
build:
context: .
dockerfile: apps/web/Dockerfile
restart: unless-stopped
depends_on:
db:
condition: service_healthy
embedder:
condition: service_healthy
migrator:
condition: service_completed_successfully
environment:
NODE_ENV: production
LOG_LEVEL: ${LOG_LEVEL:-info}
PUBLIC_URL: ${PUBLIC_URL:?PUBLIC_URL not set in .env}
# Auth.js v5 needs to know its public URL when behind a reverse proxy.
AUTH_URL: ${PUBLIC_URL}
AUTH_TRUST_HOST: "true"
OIDC_ISSUER: ${OIDC_ISSUER:?OIDC_ISSUER not set in .env}
OIDC_CLIENT_ID_WEB: ${OIDC_CLIENT_ID_WEB:?required}
OIDC_CLIENT_SECRET_WEB: ${OIDC_CLIENT_SECRET_WEB:?required}
OIDC_CLIENT_ID_MCP: ${OIDC_CLIENT_ID_MCP:?required}
OIDC_AUDIENCE: ${OIDC_AUDIENCE:?required}
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
EMBEDDER_URL: ${EMBEDDER_URL:-http://embedder:8080}
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-Xenova/bge-small-en-v1.5}
EMBEDDING_DIM: ${EMBEDDING_DIM:-384}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?required}
CLI_TOKEN_SECRET: ${CLI_TOKEN_SECRET:?required}
ports:
# Exposed to the host so an external reverse proxy (HAProxy, nginx,
# etc.) can reach the app. When using the `tls` profile, Caddy also
# proxies via the internal network — leaving this exposed is harmless
# 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:
# 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
start_period: 15s
networks:
- internal
- web
# Opt-in TLS terminator. Skipped unless `--profile tls` is passed.
# External-proxy deployments (HAProxy, nginx, Cloudflare Tunnel, etc.)
# leave this off and proxy directly to host:${APP_PORT}.
caddy:
image: caddy:2-alpine
profiles: ["tls"]
restart: unless-stopped
depends_on:
app:
condition: service_healthy
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
APP_HOSTNAME: ${APP_HOSTNAME:-localhost}
ACME_EMAIL: ${ACME_EMAIL:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- web
volumes:
db_data:
caddy_data:
caddy_config:
embedder_models:
networks:
internal:
driver: bridge
web:
driver: bridge
+19
View File
@@ -0,0 +1,19 @@
{
"name": "shared-memory",
"version": "0.1.0",
"private": true,
"description": "Self-hosted MCP server providing shared persistent memory across Claude Code sessions, authed via Authentik OIDC",
"license": "MIT",
"packageManager": "pnpm@9.12.3",
"engines": {
"node": ">=20.11.0"
},
"scripts": {
"build": "pnpm -r build",
"dev": "pnpm --filter @shared-memory/web dev",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck",
"db:generate": "pnpm --filter @shared-memory/web db:generate",
"db:migrate": "pnpm --filter @shared-memory/web db:migrate"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@shared-memory/schemas",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "echo 'no build step; consumed as TS source'"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}
+198
View File
@@ -0,0 +1,198 @@
import { z } from "zod";
export const MemoryScope = z.enum(["project", "user"]);
export type MemoryScope = z.infer<typeof MemoryScope>;
export const MemoryVisibility = z.enum(["private", "shared", "team"]);
export type MemoryVisibility = z.infer<typeof MemoryVisibility>;
// Access level a group has on a shared project. Mirrors the Postgres
// `memory_access` enum defined by Agent A's groups migration.
export const MemoryAccess = z.enum(["ro", "rw"]);
export type MemoryAccess = z.infer<typeof MemoryAccess>;
export const ProjectKey = z
.string()
.min(1)
.max(200)
.regex(/^[a-zA-Z0-9._\-/]+$/, "project key may only contain alphanumerics, ._-/");
export type ProjectKey = z.infer<typeof ProjectKey>;
export const MemoryContent = z.string().min(1).max(64_000);
export const Tags = z
.array(z.string().min(1).max(64).regex(/^[a-zA-Z0-9._\-]+$/, "tag must be alphanumeric ._-"))
.max(32)
.default([]);
export const MemoryWriteInput = z.object({
content: MemoryContent,
project: ProjectKey.optional(),
tags: Tags.optional(),
scope: MemoryScope.default("project"),
});
export type MemoryWriteInput = z.infer<typeof MemoryWriteInput>;
export const MemoryListInput = z.object({
project: ProjectKey.optional(),
scope: MemoryScope.optional(),
tags: z.array(z.string()).optional(),
limit: z.number().int().min(1).max(200).default(50),
cursor: z.string().optional(),
});
export type MemoryListInput = z.infer<typeof MemoryListInput>;
export const MemoryIdInput = z.object({
id: z.string().uuid(),
});
export type MemoryIdInput = z.infer<typeof MemoryIdInput>;
// memory.delete may CAS on `version` to avoid clobbering a concurrent edit
// (shared projects allow co-edit, so the version a caller observed at
// load time can race a peer's update).
export const MemoryDeleteInput = z.object({
id: z.string().uuid(),
version: z.number().int().nonnegative().optional(),
});
export type MemoryDeleteInput = z.infer<typeof MemoryDeleteInput>;
export const MemoryUpdateInput = z.object({
id: z.string().uuid(),
content: MemoryContent.optional(),
tags: Tags.optional(),
scope: MemoryScope.optional(),
project: ProjectKey.optional(),
// Optimistic-locking token returned by memory.get / memory.list. When
// present, the UPDATE matches on (id, version); a 0-row result means
// someone else edited this memory since you read it.
version: z.number().int().nonnegative().optional(),
})
.refine(
(v) =>
v.content !== undefined ||
v.tags !== undefined ||
v.scope !== undefined ||
v.project !== undefined,
{ message: "memory.update requires content, tags, scope, or project" },
)
.refine(
(v) => v.scope !== "project" || (v.project !== undefined && v.project !== ""),
{ message: "scope='project' requires a non-empty project key" },
)
.refine((v) => v.scope !== "user" || v.project === undefined, {
message: "scope='user' cannot have a project key",
});
export type MemoryUpdateInput = z.infer<typeof MemoryUpdateInput>;
export const MemorySearchInput = z.object({
query: z.string().min(1).max(2000),
project: ProjectKey.optional(),
scope: MemoryScope.optional(),
tags: z.array(z.string()).optional(),
limit: z.number().int().min(1).max(50).default(10),
/**
* Minimum Reciprocal Rank Fusion score a result must clear to be
* returned. Useful for stricter "high-confidence only" filtering set
* higher than 1/(60+1)0.0164 to exclude single-ranker-rank-1 matches
* (semantic-only hits with no FTS/tag corroboration), or to ~0.03 to
* require at least two rankers to fire at rank 1. Omit / set 0 for the
* unfiltered default.
*/
minScore: z.number().min(0).max(1).optional(),
});
export type MemorySearchInput = z.infer<typeof MemorySearchInput>;
export const ProjectIdentifyInput = z.object({
key: ProjectKey,
display_name: z.string().min(1).max(200).optional(),
/**
* How the caller resolved this project key. The server uses this to
* decide whether to include a `setupHint` in the response suggesting
* the user commit a `.shared-memory-project` file:
* - 'file' already from .shared-memory-project; no hint needed
* - 'explicit' user named the project in-conversation; hint shown
* - 'header' X-Project-Key fallback; hint shown
* - 'inferred' guessed from repo/cwd; hint shown
*
* Omitting the field is treated as 'inferred'.
*/
source: z.enum(["file", "explicit", "header", "inferred"]).optional(),
});
export type ProjectIdentifyInput = z.infer<typeof ProjectIdentifyInput>;
// =============================================================================
// Snippets
//
// Snippets are named, exactly-reproducible artifacts (templates, formats,
// checklists). Unlike memories, they're fetched by EXACT name — never
// searched. They mirror the memory scope/project model so the same key
// can have a global default plus per-repo variants.
// =============================================================================
export const SnippetName = z
.string()
.min(1)
.max(200)
.regex(
/^[a-zA-Z0-9._\-/]+$/,
"snippet name may only contain alphanumerics, ._-/",
);
export type SnippetName = z.infer<typeof SnippetName>;
export const SnippetBody = z.string().min(1).max(64_000);
export const SnippetDescription = z.string().max(2_000);
// Shared scope/project consistency: project-scope requires `project`,
// user-scope forbids it. Matches the DB CHECK constraint and the same
// refinement used implicitly for memories at the handler level.
const scopeProjectRefinement = {
check: (v: { scope?: "project" | "user"; project?: string }) => {
if (v.scope === "project") return Boolean(v.project);
if (v.scope === "user") return v.project === undefined;
return true;
},
message: "scope='project' requires `project`; scope='user' forbids `project`",
};
export const SnippetPutInput = z
.object({
name: SnippetName,
body: SnippetBody,
description: SnippetDescription.optional(),
tags: Tags.optional(),
scope: MemoryScope.default("user"),
project: ProjectKey.optional(),
// Optimistic-locking token used on the update path (when a row with
// this name+scope+project already exists). Ignored on first put.
version: z.number().int().nonnegative().optional(),
})
.refine(scopeProjectRefinement.check, { message: scopeProjectRefinement.message });
export type SnippetPutInput = z.infer<typeof SnippetPutInput>;
export const SnippetGetInput = z
.object({
name: SnippetName,
scope: MemoryScope.optional(),
project: ProjectKey.optional(),
})
.refine(scopeProjectRefinement.check, { message: scopeProjectRefinement.message });
export type SnippetGetInput = z.infer<typeof SnippetGetInput>;
export const SnippetListInput = z.object({
project: ProjectKey.optional(),
scope: MemoryScope.optional(),
tags: z.array(z.string()).optional(),
limit: z.number().int().min(1).max(200).default(50),
});
export type SnippetListInput = z.infer<typeof SnippetListInput>;
export const SnippetDeleteInput = z
.object({
name: SnippetName,
scope: MemoryScope.optional(),
project: ProjectKey.optional(),
// Optional CAS for co-edit safety on shared snippets.
version: z.number().int().nonnegative().optional(),
})
.refine(scopeProjectRefinement.check, { message: scopeProjectRefinement.message });
export type SnippetDeleteInput = z.infer<typeof SnippetDeleteInput>;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"noEmit": true
},
"include": ["src/**/*.ts"]
}
+3 -3
View File
@@ -2,9 +2,9 @@
"$schema": "https://anthropic.com/claude-code/plugin.schema.json", "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
"name": "shared-memory", "name": "shared-memory",
"version": "0.1.0", "version": "0.1.0",
"description": "Shared persistent memory and snippet library for Claude Code sessions, backed by memory.dnspegasus.net and authenticated with Authentik OIDC.", "description": "Shared persistent memory and snippet library for Claude Code sessions, backed by your own shared-memory server and authenticated with OIDC.",
"author": { "author": {
"name": "jknapp" "name": "CyberCove Labs"
}, },
"homepage": "https://memory.dnspegasus.net" "homepage": "https://repo.anhonesthost.net/cybercove-labs/shared-memory"
} }
+2 -2
View File
@@ -2,9 +2,9 @@
"mcpServers": { "mcpServers": {
"shared-memory": { "shared-memory": {
"type": "http", "type": "http",
"url": "https://memory.dnspegasus.net/api/mcp", "url": "https://memory.example.com/api/mcp",
"oauth": { "oauth": {
"clientId": "5rkRS3rJhn3Ci9swWkxYMIrZ9OggsjOGy3cIOhYY", "clientId": "<OIDC_CLIENT_ID_MCP>",
"callbackPort": 33418 "callbackPort": 33418
} }
} }
+6500
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"
+298
View File
@@ -0,0 +1,298 @@
# shared-memory — Terraform module (AWS Fargate)
Deploys [shared-memory](../README.md) to AWS Fargate ECS behind an ALB.
Brings up the `app` (Next.js web + MCP endpoint), the `embedder` sidecar
(Xenova bge-small on CPU, EFS-backed model cache), and a one-shot
`migrator` task definition. Targets an externally-managed RDS Postgres
instance and an existing OIDC identity provider — neither is the module's
job.
---
## What you provide before running
The module deliberately stops short of creating shared infrastructure
that's usually account-wide and not specific to this app. You bring:
### 1. A VPC with public + private subnets
At least two of each across two AZs. Public subnets host the internet-facing
ALB; private subnets host the Fargate tasks and EFS mount targets. The
private subnets need outbound internet access (NAT gateway or VPC endpoints
for ECR / Secrets Manager / CloudWatch / Hugging Face) so tasks can pull
images, decrypt secrets, and on first cold start download the embedding
model.
### 2. An RDS Postgres instance
Postgres **≥ 15.5** with `pgvector`, `pg_trgm`, and `pgcrypto`. RDS makes
all three available on modern versions; you may need to add them to
`rds.allowed_extensions` in the parameter group, but the migrator runs
`CREATE EXTENSION IF NOT EXISTS …` itself.
Connectivity gotcha: the RDS security group is owned by you. After
`terraform apply` you must add an inbound rule on it allowing 5432 from
the module's task security groups. Use the outputs:
```
app_security_group_id # app needs RDS for runtime queries
migrator_security_group_id # migrator needs RDS for DDL on apply
```
The embedder does **not** talk to Postgres.
### 3. An ACM certificate
In the **same region** as the ALB (ACM certs are regional). Cover the
public hostname you'll use for `domain_name`. DNS validation is the
easiest route; AWS docs walk through it.
### 4. ECR repositories with pushed images
The module references `var.app_image` and `var.embedder_image` by URI —
it doesn't build, doesn't push, doesn't create the repos. Two repos
typically:
```
shared-memory-web # built from apps/web/Dockerfile
shared-memory-embedder # built from apps/embedder/Dockerfile
```
Build from the repo root and tag with whatever version scheme you prefer
(git SHA, semver, etc.). The app and embedder images use unrelated runtime
stacks (Node alpine vs Node slim) — keep them as separate repos.
### 5. OIDC clients
Two clients in your IdP (Authentik, EntraID, Keycloak, …) — one
confidential for the Web UI, one public/PKCE for the MCP endpoint. See the
[main README](../README.md#oidc-provider-setup) for the Authentik walkthrough.
The redirect URI you register on the Web UI client is
`https://${domain_name}/api/auth/callback/oidc`, so plan the domain name
*before* configuring the IdP.
---
## Quick start
```bash
cd terraform/examples/basic
# 1. Edit main.tf — replace vpc-…, subnet-…, ARN placeholders, image URIs.
$EDITOR main.tf
# 2. Create terraform.tfvars with the sensitive values (0600 perms!).
umask 077
cat > terraform.tfvars <<EOF
database_url = "postgres://memory:CHANGEME@my-rds-host.us-east-1.rds.amazonaws.com:5432/memory"
oidc_client_id_web = "abc123…"
oidc_client_secret_web = "secretvalue"
oidc_client_id_mcp = "def456…"
nextauth_secret = "$(openssl rand -base64 32)"
cli_token_secret = "$(openssl rand -base64 32)"
EOF
chmod 600 terraform.tfvars
# 3. Apply.
terraform init
terraform plan -out plan.out
terraform apply plan.out
```
`terraform apply` creates the ECS cluster, both services, the ALB, EFS,
Secrets Manager entries, log groups, security groups, and the migrator
task definition. It does **not** run migrations — the migrator is a
one-shot task you trigger separately. See the next section.
After apply, expect the **embedder** to take 60180 seconds on first
boot to download the bge-small model to EFS. Subsequent restarts are
warm because EFS keeps the cache.
---
## Post-apply: run the migrator and verify
The migrator creates schema, applies SQL migrations from
`apps/web/drizzle/`, and (if any rows already exist) backfills embeddings.
It must run **before** the app is useful, but the module ships it as a
task definition with no service so you can run it explicitly.
### Run it
```bash
CLUSTER=$(terraform output -raw ecs_cluster_name)
FAMILY=$(terraform output -raw migrator_task_definition_family)
SG=$(terraform output -raw migrator_security_group_id)
SUBNETS=$(terraform output -json private_subnet_ids | jq -r 'join(",")')
aws ecs run-task \
--cluster "$CLUSTER" \
--task-definition "$FAMILY" \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SG],assignPublicIp=DISABLED}"
```
The task exits 0 on success and a non-zero exit on failure. Watch it:
```bash
aws ecs list-tasks --cluster "$CLUSTER" --family "$FAMILY"
aws ecs describe-tasks --cluster "$CLUSTER" --tasks <task-id>
```
### Read its logs
```bash
LOG_GROUP=$(terraform output -raw migrator_log_group_name)
aws logs tail "$LOG_GROUP" --follow
```
A healthy run prints `Migrations complete.` and (if you have prior data)
`Embedding backfill complete: N memories embedded.`.
You should re-run the migrator after **every** deploy that ships a new
SQL migration file. It's idempotent — already-applied migrations are
skipped via the `_migrations` ledger table.
### Verify the app is up
```bash
ALB=$(terraform output -raw alb_dns_name)
curl -fsS "https://$ALB/api/health" # — once DNS / cert is wired up
```
(If DNS isn't wired yet, you can `curl --resolve memory.example.com:443:<ALB-IP>`
to test against the cert without touching DNS.)
---
## Updating images
Push a new tag to ECR, then re-apply with the new tag:
```bash
terraform apply -var 'app_image=…/shared-memory-web:v0.5.1'
```
ECS performs a rolling deploy: `deployment_minimum_healthy_percent = 50`
and `deployment_maximum_percent = 200` mean it stands up new tasks before
draining old ones. If the new tasks fail their ALB health check the old
ones stay.
If the new image ships a SQL migration, **run the migrator again first**
(or right after; the SQL is backwards-compatible in this codebase), then
roll the app.
The embedder side is rarer to update — the image hardly changes. When it
does, EFS keeps the existing model cache so the new revision is warm
immediately.
---
## DNS setup
The ALB has a generated DNS name (`…elb.amazonaws.com`); you point your
real hostname at it with an A-alias record.
If your DNS lives in Route53:
```hcl
resource "aws_route53_record" "app" {
zone_id = "Z0123456789ABCDEFG" # your hosted zone
name = "memory.example.com"
type = "A"
alias {
name = module.shared_memory.alb_dns_name
zone_id = module.shared_memory.alb_zone_id
evaluate_target_health = true
}
}
```
If your DNS is elsewhere (Cloudflare, NS1, …), a CNAME from
`memory.example.com``<alb_dns_name>` works equivalently, modulo apex
limitations.
Once DNS propagates, the OIDC callback URL you registered earlier
(`https://memory.example.com/api/auth/callback/oidc`) will start working
and you can sign in.
---
## Security note
Several inputs (`database_url`, `nextauth_secret`, `cli_token_secret`,
`oidc_client_secret_web`) are sensitive. The module marks them as such so
they're scrubbed from CLI output, but they still:
- Pass through `terraform plan` and `terraform apply`
- Land in `terraform.tfstate`
- Round-trip through Secrets Manager versions
Hardening checklist:
- Put values in `terraform.tfvars` (not committed) with `chmod 600`.
- Use a remote state backend with encryption (S3 + KMS) and tight IAM
on the bucket. Local state in a shared repo is the failure mode.
- Consider an external secret manager (1Password, Doppler, Vault) and
feeding values via `-var-file` from a `terraform-data` shim. The
module accepts plain strings — keep the indirection outside.
- Rotate `nextauth_secret` and `cli_token_secret` periodically. Both can
change with no DB migration; in-flight sessions and unexpired CLI
tokens will be invalidated.
The module's Secrets Manager entries are scoped under
`${name_prefix}/<ENV_VAR_NAME>` and the task execution role has
`secretsmanager:GetSecretValue` on those ARNs only — no wildcard.
---
## What the module creates
| Resource | Purpose |
|---|---|
| `aws_ecs_cluster` | Fargate cluster, Service Connect default namespace |
| `aws_ecs_service.app` | Web/MCP service behind ALB |
| `aws_ecs_service.embedder` | Internal sidecar service |
| `aws_ecs_task_definition.{app,embedder,migrator}` | Task defs |
| `aws_lb` + listener + target group | Public ALB, HTTPS + redirect |
| `aws_efs_file_system` + access point + mount targets | Embedder model cache |
| `aws_secretsmanager_secret.*` (4) | DATABASE_URL, NEXTAUTH_SECRET, CLI_TOKEN_SECRET, OIDC_CLIENT_SECRET_WEB |
| `aws_cloudwatch_log_group.*` (4) | app, embedder, migrator, service-connect |
| `aws_security_group.{alb,app,embedder,migrator,efs}` | Tier security groups |
| `aws_iam_role.{execution,app_task,embedder_task,migrator_task}` | Execution + per-service task roles |
| `aws_service_discovery_http_namespace` | Service Connect namespace `${name_prefix}.internal` |
## What the module does NOT create
- VPC, subnets, NAT, route tables — you own these
- RDS instance, parameter group, subnet group — you own
- ACM certificate or its DNS validation records — you own
- ECR repositories or the image build pipeline — you own
- OIDC clients — you own
- Route53 records — you own (see [DNS setup](#dns-setup))
- WAF, Shield, CloudFront — out of scope
## Module inputs
See [`variables.tf`](variables.tf) for the full list with descriptions
and defaults.
## Module outputs
See [`outputs.tf`](outputs.tf). The ones you'll use:
- `alb_dns_name`, `alb_zone_id` — for the Route53 alias
- `ecs_cluster_name`, `migrator_task_definition_family`,
`private_subnet_ids_for_run_task`, `migrator_security_group_id`
to assemble the `aws ecs run-task` call
- `app_security_group_id` / `migrator_security_group_id` — to whitelist
on your RDS SG
- `app_log_group_name`, `embedder_log_group_name`, `migrator_log_group_name`
for `aws logs tail`
## Worked example
See [`examples/basic/`](examples/basic/).
+85
View File
@@ -0,0 +1,85 @@
# -----------------------------------------------------------------------------
# Application Load Balancer.
#
# * Internet-facing, in the public subnets
# * HTTP listener on :80 returns a 301 to https://${domain}${path}
# * HTTPS listener on :443 terminates TLS with the user's ACM cert and
# forwards to the app target group on 3000
#
# Target type is `ip` because Fargate tasks register their ENI IPs directly,
# not via an EC2 instance.
# -----------------------------------------------------------------------------
resource "aws_lb" "this" {
name = "${var.name_prefix}-alb"
load_balancer_type = "application"
internal = false
subnets = var.public_subnet_ids
security_groups = [aws_security_group.alb.id]
# Keep HTTP/2 on (default) so MCP streaming works smoothly. drop_invalid
# headers protects against header smuggling against the upstream.
drop_invalid_header_fields = true
tags = merge(local.tags, { Name = "${var.name_prefix}-alb" })
}
resource "aws_lb_target_group" "app" {
name = "${var.name_prefix}-app"
port = local.app_port
protocol = "HTTP"
target_type = "ip"
vpc_id = var.vpc_id
deregistration_delay = 30
health_check {
enabled = true
path = "/api/health"
port = "traffic-port"
protocol = "HTTP"
matcher = "200"
interval = 15
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 3
}
tags = local.tags
}
# Port 80 301 redirect to HTTPS.
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
protocol = "HTTPS"
port = "443"
status_code = "HTTP_301"
}
}
tags = local.tags
}
# Port 443 app target group. TLS terminates at the ALB; the app speaks
# plain HTTP behind it. PUBLIC_URL teaches Auth.js and the MCP route that
# the public origin is HTTPS regardless.
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.this.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.acm_certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
tags = local.tags
}
+370
View File
@@ -0,0 +1,370 @@
# -----------------------------------------------------------------------------
# ECS cluster + services + task definitions.
#
# Service Connect (introduced in 2022) handles appembedder discovery: both
# services join the same namespace, the embedder advertises itself as
# `embedder` on port 8080, and the app talks to `http://embedder:8080` like
# it does in docker-compose. No Route53 records, no Cloud Map manual
# wiring, no sidecar plumbing in the app image.
#
# The migrator runs as a task definition with no service operators invoke
# it via `aws ecs run-task` after a fresh deploy (see README).
# -----------------------------------------------------------------------------
# ---- Cluster + Service Connect namespace ----
resource "aws_service_discovery_http_namespace" "this" {
name = local.service_connect_namespace
description = "Service Connect namespace for ${var.name_prefix}"
tags = local.tags
}
resource "aws_ecs_cluster" "this" {
name = var.name_prefix
service_connect_defaults {
namespace = aws_service_discovery_http_namespace.this.arn
}
setting {
name = "containerInsights"
value = "enabled"
}
tags = local.tags
}
resource "aws_ecs_cluster_capacity_providers" "this" {
cluster_name = aws_ecs_cluster.this.name
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
default_capacity_provider_strategy {
capacity_provider = "FARGATE"
weight = 1
base = 1
}
}
# ---- Shared env block (non-secret) for app + migrator ----
locals {
app_environment = [
{ name = "NODE_ENV", value = "production" },
{ name = "LOG_LEVEL", value = var.log_level },
{ name = "PUBLIC_URL", value = local.public_url },
{ name = "AUTH_URL", value = local.public_url },
{ name = "AUTH_TRUST_HOST", value = "true" },
{ name = "OIDC_ISSUER", value = var.oidc_issuer },
{ name = "OIDC_CLIENT_ID_WEB", value = var.oidc_client_id_web },
{ name = "OIDC_CLIENT_ID_MCP", value = var.oidc_client_id_mcp },
{ name = "OIDC_AUDIENCE", value = var.oidc_audience },
{ name = "EMBEDDER_URL", value = "http://embedder:${local.embedder_port}" },
{ name = "EMBEDDING_MODEL", value = var.embedding_model },
{ name = "EMBEDDING_DIM", value = tostring(var.embedding_dim) },
{ name = "NEXT_TELEMETRY_DISABLED", value = "1" },
]
# `secrets` block format that ECS expects: name = env-var name, valueFrom
# = secret ARN. ECS resolves these to env vars at task start.
app_secrets = [
{ name = "DATABASE_URL", valueFrom = aws_secretsmanager_secret.database_url.arn },
{ name = "NEXTAUTH_SECRET", valueFrom = aws_secretsmanager_secret.nextauth_secret.arn },
{ name = "CLI_TOKEN_SECRET", valueFrom = aws_secretsmanager_secret.cli_token_secret.arn },
{ name = "OIDC_CLIENT_SECRET_WEB", valueFrom = aws_secretsmanager_secret.oidc_client_secret_web.arn },
]
embedder_environment = [
# awsvpc network mode gives every task its own ENI bind to 0.0.0.0
# explicitly so Service Connect reaches the embedder on the task's
# ENI address. Default Node servers often bind 127.0.0.1, which
# would silently make every appembedder call time out.
{ name = "HOST", value = "0.0.0.0" },
{ name = "PORT", value = tostring(local.embedder_port) },
{ name = "LOG_LEVEL", value = var.log_level },
{ name = "EMBEDDING_MODEL", value = var.embedding_model },
{ name = "EMBEDDING_DIM", value = tostring(var.embedding_dim) },
{ name = "MODEL_CACHE_DIR", value = "/data/models" },
]
# Migrator needs only the DB + embedder URL. EMBEDDER_URL is what triggers
# the post-migration backfill loop in scripts/migrate.ts.
migrator_environment = [
{ name = "NODE_ENV", value = "production" },
{ name = "LOG_LEVEL", value = var.log_level },
{ name = "EMBEDDER_URL", value = "http://embedder:${local.embedder_port}" },
]
migrator_secrets = [
{ name = "DATABASE_URL", valueFrom = aws_secretsmanager_secret.database_url.arn },
]
}
# ---- App task definition ----
resource "aws_ecs_task_definition" "app" {
family = "${var.name_prefix}-app"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.app_cpu
memory = var.app_memory
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.app_task.arn
container_definitions = jsonencode([
{
name = "app"
image = var.app_image
essential = true
portMappings = [
{
name = "app"
containerPort = local.app_port
hostPort = local.app_port
protocol = "tcp"
appProtocol = "http"
},
]
environment = local.app_environment
secrets = local.app_secrets
# Mirrors the Dockerfile healthcheck keeps individual tasks honest
# even before ALB health checks notice a problem.
healthCheck = {
command = ["CMD-SHELL", "wget -q -O /dev/null http://localhost:${local.app_port}/api/health || exit 1"]
interval = 15
timeout = 5
retries = 5
startPeriod = 30
}
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.app.name
awslogs-region = data.aws_region.current.name
awslogs-stream-prefix = "app"
}
}
},
])
tags = local.tags
}
# ---- Embedder task definition ----
resource "aws_ecs_task_definition" "embedder" {
family = "${var.name_prefix}-embedder"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.embedder_cpu
memory = var.embedder_memory
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.embedder_task.arn
# EFS-backed volume for the model cache.
volume {
name = "models"
efs_volume_configuration {
file_system_id = aws_efs_file_system.embedder_models.id
transit_encryption = "ENABLED"
authorization_config {
access_point_id = aws_efs_access_point.embedder_models.id
iam = "DISABLED"
}
}
}
container_definitions = jsonencode([
{
name = "embedder"
image = var.embedder_image
essential = true
portMappings = [
{
name = "embedder"
containerPort = local.embedder_port
hostPort = local.embedder_port
protocol = "tcp"
appProtocol = "http"
},
]
environment = local.embedder_environment
mountPoints = [
{
sourceVolume = "models"
containerPath = "/data/models"
readOnly = false
},
]
# 180s start period mirrors the Dockerfile first boot has to load
# (and on a cold EFS, download) the model.
healthCheck = {
command = ["CMD-SHELL", "wget -q -O - http://127.0.0.1:${local.embedder_port}/health | grep -q '\"ready\":true' || exit 1"]
interval = 15
timeout = 5
retries = 8
startPeriod = 180
}
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.embedder.name
awslogs-region = data.aws_region.current.name
awslogs-stream-prefix = "embedder"
}
}
},
])
tags = local.tags
}
# ---- Migrator task definition (no service one-shot via `aws ecs run-task`) ----
resource "aws_ecs_task_definition" "migrator" {
family = "${var.name_prefix}-migrator"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.migrator_cpu
memory = var.migrator_memory
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.migrator_task.arn
container_definitions = jsonencode([
{
name = "migrator"
image = var.app_image # same web image runs migrate.mjs instead of server.js
essential = true
# Override the image's CMD to run the bundled migrator. Mirrors the
# docker-compose migrator service.
command = ["node", "apps/web/migrate.mjs"]
environment = local.migrator_environment
secrets = local.migrator_secrets
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.migrator.name
awslogs-region = data.aws_region.current.name
awslogs-stream-prefix = "migrator"
}
}
},
])
tags = local.tags
}
# ---- Services ----
# Embedder is created first because the app's Service Connect client config
# references the namespace, not the embedder service ARN but starting the
# embedder first lets the app pass its DNS health probes immediately on first
# deploy.
resource "aws_ecs_service" "embedder" {
name = "${var.name_prefix}-embedder"
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.embedder.arn
desired_count = var.embedder_desired_count
launch_type = "FARGATE"
enable_execute_command = var.enable_execute_command
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.embedder.id]
assign_public_ip = false
}
service_connect_configuration {
enabled = true
namespace = aws_service_discovery_http_namespace.this.arn
# The app reaches this via `embedder:8080`. portName matches the
# portMappings entry in the task def; discoveryName is the DNS label.
service {
port_name = "embedder"
discovery_name = "embedder"
client_alias {
port = local.embedder_port
dns_name = "embedder"
}
}
log_configuration {
log_driver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.service_connect.name
awslogs-region = data.aws_region.current.name
awslogs-stream-prefix = "embedder-sc"
}
}
}
# Cold-start tolerance: the model load can take ~180s, so don't let ECS
# mark the task unhealthy from its perspective during that window.
health_check_grace_period_seconds = 240
deployment_minimum_healthy_percent = 50
deployment_maximum_percent = 200
tags = local.tags
}
resource "aws_ecs_service" "app" {
name = "${var.name_prefix}-app"
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = var.app_desired_count
launch_type = "FARGATE"
enable_execute_command = var.enable_execute_command
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.app.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = "app"
container_port = local.app_port
}
service_connect_configuration {
enabled = true
namespace = aws_service_discovery_http_namespace.this.arn
log_configuration {
log_driver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.service_connect.name
awslogs-region = data.aws_region.current.name
awslogs-stream-prefix = "app-sc"
}
}
}
health_check_grace_period_seconds = 60
deployment_minimum_healthy_percent = 50
deployment_maximum_percent = 200
# The HTTPS listener must exist before the service tries to attach to the
# target group otherwise the first apply races.
depends_on = [aws_lb_listener.https]
tags = local.tags
}
+57
View File
@@ -0,0 +1,57 @@
# -----------------------------------------------------------------------------
# EFS for the embedder model cache.
#
# Without persistent storage, every cold-start embedder task re-downloads
# the ~30 MB bge-small model from Hugging Face slow and rate-limit-risky.
# EFS lets us share a warm cache across replicas and across restarts.
#
# The access point pins ownership to UID/GID 1001, matching the
# `node-embedder` user baked into apps/embedder/Dockerfile, so files written
# through the access point are owned correctly.
# -----------------------------------------------------------------------------
resource "aws_efs_file_system" "embedder_models" {
creation_token = "${var.name_prefix}-embedder-models"
encrypted = true
# General Purpose performance mode + bursting throughput is plenty for a
# ~30 MB read-mostly cache. Don't pay for provisioned throughput.
performance_mode = "generalPurpose"
throughput_mode = "bursting"
tags = merge(local.tags, { Name = "${var.name_prefix}-embedder-models" })
}
# One mount target per private subnet so any AZ the embedder lands in can
# reach the file system.
resource "aws_efs_mount_target" "embedder_models" {
for_each = toset(var.private_subnet_ids)
file_system_id = aws_efs_file_system.embedder_models.id
subnet_id = each.value
security_groups = [aws_security_group.efs.id]
}
# Access point gives the embedder task a chrooted view of the file system,
# with files always owned by uid/gid 1001 regardless of which task wrote
# them. Matches the `node-embedder` user in the Dockerfile.
resource "aws_efs_access_point" "embedder_models" {
file_system_id = aws_efs_file_system.embedder_models.id
posix_user {
uid = 1001
gid = 1001
}
root_directory {
path = "/models"
creation_info {
owner_uid = 1001
owner_gid = 1001
permissions = "0755"
}
}
tags = merge(local.tags, { Name = "${var.name_prefix}-embedder-models" })
}
+63
View File
@@ -0,0 +1,63 @@
# Basic example — shared-memory on AWS Fargate
Minimal invocation of `../../`. Fill in your real IDs and run.
## Prereqs
Before you `terraform apply`, you need (see the [module README](../../README.md)
for the long version):
- A VPC with two public + two private subnets
- An RDS Postgres ≥ 15.5 instance with `pgvector`, `pg_trgm`, `pgcrypto`
available (or creatable by the migrator on first run)
- An ACM certificate in the same region as the ALB, covering `domain_name`
- ECR repos populated with images for `apps/web` and `apps/embedder`
- OIDC clients registered (web confidential + MCP public/PKCE)
## Configure
1. Open `main.tf` and replace the placeholder `vpc-…` / `subnet-…` /
`arn:aws:acm:…` / image URIs with your real values.
2. Create `terraform.tfvars` with the sensitive inputs and chmod it:
```bash
umask 077
cat > terraform.tfvars <<EOF
database_url = "postgres://memory:CHANGEME@my-rds-host.us-east-1.rds.amazonaws.com:5432/memory"
oidc_client_id_web = "abc123…"
oidc_client_secret_web = "secretvalue"
oidc_client_id_mcp = "def456…"
nextauth_secret = "$(openssl rand -base64 32)"
cli_token_secret = "$(openssl rand -base64 32)"
EOF
chmod 600 terraform.tfvars
```
## Apply
```bash
terraform init
terraform plan -out plan.out
terraform apply plan.out
```
## Post-apply
Open the [module README](../../README.md#post-apply) for the migrator
`aws ecs run-task` invocation and the DNS setup.
The shortcut, using outputs from this directory:
```bash
CLUSTER=$(terraform output -raw ecs_cluster_name)
FAMILY=$(terraform output -raw migrator_task_definition_family)
SG=$(terraform output -raw migrator_security_group_id)
SUBNETS=$(terraform output -json private_subnet_ids | jq -r 'join(",")')
aws ecs run-task \
--cluster "$CLUSTER" \
--task-definition "$FAMILY" \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SG],assignPublicIp=DISABLED}"
```
+100
View File
@@ -0,0 +1,100 @@
# -----------------------------------------------------------------------------
# Worked example for the shared-memory Terraform module.
#
# This config does NOT create the VPC, RDS, ACM cert, ECR repos, or OIDC
# clients see ../../README.md for the prerequisite checklist. Replace the
# placeholders below with the actual IDs from your environment.
# -----------------------------------------------------------------------------
terraform {
required_version = "~> 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
# Region inherits from AWS_REGION / AWS_PROFILE / shared-config. Set it
# here only if you want to pin it explicitly.
# region = "us-east-1"
}
module "shared_memory" {
source = "../../"
# ---- Identity / wiring ----
name_prefix = "shared-memory-prod"
vpc_id = "vpc-0123456789abcdef0"
public_subnet_ids = ["subnet-aaa", "subnet-bbb"]
private_subnet_ids = ["subnet-ccc", "subnet-ddd"]
# ---- TLS / DNS ----
acm_certificate_arn = "arn:aws:acm:us-east-1:123456789012:certificate/<uuid>"
domain_name = "memory.example.com"
# ---- Images (push your own, then reference here) ----
app_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/shared-memory-web:v0.5.0"
embedder_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/shared-memory-embedder:v0.5.0"
# ---- Database (external RDS) ----
# Format: postgres://USER:PASSWORD@HOST:5432/DBNAME
# Real-world: pull from `aws_secretsmanager_secret_version` or `random_password`,
# don't hardcode.
database_url = var.database_url
# ---- OIDC ----
oidc_issuer = "https://auth.example.com/application/o/shared-memory/"
oidc_client_id_web = var.oidc_client_id_web
oidc_client_secret_web = var.oidc_client_secret_web
oidc_client_id_mcp = var.oidc_client_id_mcp
oidc_audience = "shared-memory"
# ---- App-level secrets ----
# Generate with: openssl rand -base64 32
nextauth_secret = var.nextauth_secret
cli_token_secret = var.cli_token_secret
# ---- Sizing (defaults are fine for small deployments) ----
app_desired_count = 1
embedder_desired_count = 1
tags = {
environment = "prod"
project = "shared-memory"
}
}
# ---- Sensitive inputs surfaced as vars so they live in terraform.tfvars
# with 0600 perms (not in this file). See ../../README.md "Security note".
variable "database_url" {
type = string
sensitive = true
}
variable "oidc_client_id_web" {
type = string
}
variable "oidc_client_secret_web" {
type = string
sensitive = true
}
variable "oidc_client_id_mcp" {
type = string
}
variable "nextauth_secret" {
type = string
sensitive = true
}
variable "cli_token_secret" {
type = string
sensitive = true
}
+55
View File
@@ -0,0 +1,55 @@
# Surface the module outputs so `terraform output` from this directory
# gives the operator everything they need without diving into the module.
output "alb_dns_name" {
description = "Point your Route53 record (alias) at this."
value = module.shared_memory.alb_dns_name
}
output "alb_zone_id" {
description = "Used as alias.zone_id on aws_route53_record."
value = module.shared_memory.alb_zone_id
}
output "ecs_cluster_name" {
description = "Pass to `aws ecs run-task --cluster`."
value = module.shared_memory.ecs_cluster_name
}
output "migrator_task_definition_family" {
description = "Pass to `aws ecs run-task --task-definition`."
value = module.shared_memory.migrator_task_definition_family
}
output "migrator_security_group_id" {
description = "Whitelist on RDS SG (inbound 5432)."
value = module.shared_memory.migrator_security_group_id
}
output "app_security_group_id" {
description = "Whitelist on RDS SG (inbound 5432)."
value = module.shared_memory.app_security_group_id
}
output "private_subnet_ids" {
description = "Echoed from input — handy for `aws ecs run-task --network-configuration`."
value = module.shared_memory.private_subnet_ids_for_run_task
}
output "app_log_group_name" {
value = module.shared_memory.app_log_group_name
}
output "embedder_log_group_name" {
value = module.shared_memory.embedder_log_group_name
}
output "migrator_log_group_name" {
value = module.shared_memory.migrator_log_group_name
}
output "secret_arns" {
description = "Visibility into where the module stored its secrets."
value = module.shared_memory.secret_arns
sensitive = true
}
+104
View File
@@ -0,0 +1,104 @@
# -----------------------------------------------------------------------------
# IAM. Two role kinds:
#
# * Task execution role used by the ECS agent itself to pull images,
# fetch secrets, and write logs. Shared across all three task defs.
# * Task role assumed by the running container. We give every service
# its own (even if empty today) so future per-service permissions (S3,
# SES, etc.) can be granted without widening blast radius.
# -----------------------------------------------------------------------------
# ---- Task execution role ----
data "aws_iam_policy_document" "ecs_tasks_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "execution" {
name = "${var.name_prefix}-ecs-execution"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
tags = local.tags
}
# AWS-managed policy: pull from ECR, write to CloudWatch.
resource "aws_iam_role_policy_attachment" "execution_default" {
role = aws_iam_role.execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# Allow the execution role to decrypt the specific secrets this module owns.
# Scoped to the module's secret ARNs only no wildcard against the account.
data "aws_iam_policy_document" "execution_secrets" {
statement {
sid = "ReadModuleSecrets"
actions = ["secretsmanager:GetSecretValue"]
resources = values(local.secret_arns)
}
}
resource "aws_iam_role_policy" "execution_secrets" {
name = "${var.name_prefix}-execution-secrets"
role = aws_iam_role.execution.id
policy = data.aws_iam_policy_document.execution_secrets.json
}
# ---- Task roles (one per service; empty by default but ready to be widened) ----
resource "aws_iam_role" "app_task" {
name = "${var.name_prefix}-app-task"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
tags = local.tags
}
resource "aws_iam_role" "embedder_task" {
name = "${var.name_prefix}-embedder-task"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
tags = local.tags
}
resource "aws_iam_role" "migrator_task" {
name = "${var.name_prefix}-migrator-task"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
tags = local.tags
}
# ---- ECS Execute Command (opt-in via var.enable_execute_command) ----
#
# When the operator flips this on for incident debugging, the task role
# needs the SSM messages permissions for the channel to open. We attach
# the policy conditionally to both app and embedder task roles the
# migrator is short-lived and doesn't get exec.
data "aws_iam_policy_document" "exec_command" {
count = var.enable_execute_command ? 1 : 0
statement {
sid = "AllowECSExecuteCommand"
actions = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
]
resources = ["*"]
}
}
resource "aws_iam_role_policy" "app_exec_command" {
count = var.enable_execute_command ? 1 : 0
name = "${var.name_prefix}-app-exec-command"
role = aws_iam_role.app_task.id
policy = data.aws_iam_policy_document.exec_command[0].json
}
resource "aws_iam_role_policy" "embedder_exec_command" {
count = var.enable_execute_command ? 1 : 0
name = "${var.name_prefix}-embedder-exec-command"
role = aws_iam_role.embedder_task.id
policy = data.aws_iam_policy_document.exec_command[0].json
}
+30
View File
@@ -0,0 +1,30 @@
# -----------------------------------------------------------------------------
# CloudWatch log groups one per service. The ECS task definitions reference
# these via `awslogs-group`. Retention is configurable via var.log_retention_days.
# -----------------------------------------------------------------------------
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${var.name_prefix}/app"
retention_in_days = var.log_retention_days
tags = local.tags
}
resource "aws_cloudwatch_log_group" "embedder" {
name = "/ecs/${var.name_prefix}/embedder"
retention_in_days = var.log_retention_days
tags = local.tags
}
resource "aws_cloudwatch_log_group" "migrator" {
name = "/ecs/${var.name_prefix}/migrator"
retention_in_days = var.log_retention_days
tags = local.tags
}
# Service Connect proxy (Envoy) logs go here. ECS writes these automatically
# when the service has service_connect_configuration with log_configuration.
resource "aws_cloudwatch_log_group" "service_connect" {
name = "/ecs/${var.name_prefix}/service-connect"
retention_in_days = var.log_retention_days
tags = local.tags
}

Some files were not shown because too many files have changed in this diff Show More