Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fa0ab58da |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "triple-c",
|
||||
"description": "shared-memory plugin for the triple-c container (OAuth callback on the mapped port 5693).",
|
||||
"owner": {
|
||||
"name": "jknapp",
|
||||
"url": "https://repo.anhonesthost.net/jknapp/shared-memory"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "shared-memory",
|
||||
"source": "./plugin",
|
||||
"description": "Shared persistent memory and snippet library for Claude Code sessions, backed by memory.dnspegasus.net and authenticated with Authentik OIDC."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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
|
||||
@@ -1,84 +0,0 @@
|
||||
# =============================================================================
|
||||
# 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/
|
||||
OIDC_CLIENT_ID_WEB=replace-me
|
||||
OIDC_CLIENT_SECRET_WEB=replace-me
|
||||
OIDC_CLIENT_ID_MCP=replace-me
|
||||
OIDC_AUDIENCE=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
@@ -1,45 +0,0 @@
|
||||
# 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/
|
||||
@@ -1,5 +0,0 @@
|
||||
link-workspace-packages=true
|
||||
prefer-workspace-packages=true
|
||||
auto-install-peers=true
|
||||
shamefully-hoist=false
|
||||
strict-peer-dependencies=false
|
||||
@@ -1 +0,0 @@
|
||||
shared-memory
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
}
|
||||
@@ -1,549 +1,14 @@
|
||||
# shared-memory
|
||||
# instance/triple-c
|
||||
|
||||
A self-hosted MCP server that gives Claude Code sessions a **shared, persistent
|
||||
memory** plus a **reusable snippet library**, behind your own OIDC login.
|
||||
Includes a Web UI for reviewing, editing, and deleting what's been stored.
|
||||
Orphan branch — plugin manifests only, for the `triple-c` container.
|
||||
|
||||
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 Code │──MCP──▶│ shared-memory app │◀──OIDC─│ Your OIDC IdP │
|
||||
│ (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)
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
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.
|
||||
Identical to `instance/dnspegasus` except **`callbackPort` is 5693** instead of
|
||||
33418. The container can only receive an OAuth loopback callback on a port that
|
||||
is mapped through from its host, and 5693 is the one that is.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
claude plugin marketplace add "https://repo.anhonesthost.net/cybercove-labs/shared-memory.git#instance/triple-c"
|
||||
claude plugin install shared-memory@triple-c
|
||||
```
|
||||
|
||||
The app listens on `${APP_PORT:-3000}` on the host. Point your proxy there.
|
||||
See **HAProxy example** below.
|
||||
|
||||
### Mode B — Built-in TLS via Caddy
|
||||
|
||||
The host directly faces the internet on ports 80/443 and you want
|
||||
auto-managed Let's Encrypt certs.
|
||||
|
||||
```bash
|
||||
docker compose --profile tls up -d
|
||||
```
|
||||
|
||||
Caddy reads `APP_HOSTNAME` and `ACME_EMAIL` from `.env` and proxies to the
|
||||
app on the internal Docker network.
|
||||
|
||||
### Mode C — AWS Fargate (Terraform)
|
||||
|
||||
For deployments where docker-compose on a VM isn't a fit (multi-AZ HA,
|
||||
managed RDS, no host to babysit), the [`terraform/`](terraform/) directory
|
||||
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`. |
|
||||
| `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` with expression:
|
||||
```python
|
||||
return {"aud": "shared-memory"}
|
||||
```
|
||||
2. On the MCP provider, add this scope mapping under **Scopes** and tick it
|
||||
so it's emitted for the default scope.
|
||||
|
||||
> If you skip this, the MCP route will return 401 with
|
||||
> `error_description="claim invalid: aud"`. Check `docker compose logs app`
|
||||
> for the exact failure.
|
||||
|
||||
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
|
||||
|
||||
Two paths, in order of preference:
|
||||
|
||||
### A. OAuth flow (recommended — picks up your IdP credentials)
|
||||
|
||||
```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.
|
||||
|
||||
### B. 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`.
|
||||
|
||||
### C. 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.
|
||||
Never merge `main` into this branch — see `instance/dnspegasus`'s README.
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"noEmit": false,
|
||||
"declaration": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { and, desc, eq, isNull, inArray, or, sql } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { memories, projects, projectShares } from "@/lib/db/schema";
|
||||
import { searchMemories } from "@/lib/memories";
|
||||
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
|
||||
import { 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,
|
||||
groupNames: string[],
|
||||
scope?: Scope,
|
||||
project?: string,
|
||||
): Promise<MemoryRow[]> {
|
||||
// Visibility: own rows OR rows in an accessible project.
|
||||
const accessibleIds = await readableProjectIds(userId, groupNames);
|
||||
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 — match the project key against any project the
|
||||
// user can read (owned or shared). When the key matches none of
|
||||
// those, return empty.
|
||||
filters.push(
|
||||
sql`${memories.projectId} IN (
|
||||
SELECT id FROM ${projects}
|
||||
WHERE ${projects.key} = ${project}
|
||||
AND (${projects.userId} = ${userId}
|
||||
OR ${projects.id} = ANY(${accessibleIds}::uuid[]))
|
||||
)`,
|
||||
);
|
||||
}
|
||||
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;
|
||||
|
||||
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, groupNames, 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" />
|
||||
<Input
|
||||
name="project"
|
||||
placeholder="Project key…"
|
||||
defaultValue={project ?? ""}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { memories, projects } from "@/lib/db/schema";
|
||||
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 rows = 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(eq(projects.userId, userId))
|
||||
.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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
"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'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");
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
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(/\/$/, "");
|
||||
return NextResponse.json({
|
||||
resource,
|
||||
authorization_servers: [env().OIDC_ISSUER],
|
||||
scopes_supported: ["openid", "profile", "email"],
|
||||
bearer_methods_supported: ["header"],
|
||||
resource_documentation: `${resource}/`,
|
||||
});
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import { handlers } from "@/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -1,21 +0,0 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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" },
|
||||
});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
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't reach a
|
||||
loopback callback on your machine.
|
||||
</p>
|
||||
<p>
|
||||
If you'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'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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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";
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
@@ -1,151 +0,0 @@
|
||||
-- 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();
|
||||
@@ -1,24 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,39 +0,0 @@
|
||||
-- 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");
|
||||
@@ -1,63 +0,0 @@
|
||||
-- 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");
|
||||
@@ -1,64 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,209 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
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;
|
||||
|
||||
function jwks() {
|
||||
if (g.__sharedMemoryJwks) return g.__sharedMemoryJwks;
|
||||
// Authentik discovery is at `${issuer}/.well-known/openid-configuration`;
|
||||
// the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`.
|
||||
// Authentik canonically serves `${issuer}/jwks/`.
|
||||
const issuer = env().OIDC_ISSUER.replace(/\/$/, "");
|
||||
const url = new URL(`${issuer}/jwks/`);
|
||||
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: env().OIDC_ISSUER,
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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 };
|
||||
@@ -1,285 +0,0 @@
|
||||
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;
|
||||
@@ -1,65 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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(),
|
||||
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),
|
||||
|
||||
// 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];
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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
@@ -1,205 +0,0 @@
|
||||
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 } };
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
"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");
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
"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)}`);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
"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");
|
||||
}
|
||||
@@ -1,439 +0,0 @@
|
||||
import { and, desc, eq, inArray, isNull, or, sql } 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) {
|
||||
where.push(sql`${snippets.tags} @> ${tags}::text[]`);
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* 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 30–60s 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);
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
# =============================================================================
|
||||
# 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)}
|
||||
@@ -1,175 +0,0 @@
|
||||
# =============================================================================
|
||||
# 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:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost: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
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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>;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
||||
"name": "shared-memory",
|
||||
"version": "0.1.0",
|
||||
"description": "Shared persistent memory and snippet library for Claude Code sessions, backed by memory.dnspegasus.net and authenticated with Authentik OIDC.",
|
||||
"author": {
|
||||
"name": "jknapp"
|
||||
},
|
||||
"homepage": "https://memory.dnspegasus.net"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shared-memory": {
|
||||
"type": "http",
|
||||
"url": "https://memory.dnspegasus.net/api/mcp",
|
||||
"oauth": {
|
||||
"clientId": "5rkRS3rJhn3Ci9swWkxYMIrZ9OggsjOGy3cIOhYY",
|
||||
"callbackPort": 5693
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-6500
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
@@ -1,298 +0,0 @@
|
||||
# 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 60–180 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/).
|
||||
@@ -1,85 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# ECS cluster + services + task definitions.
|
||||
#
|
||||
# Service Connect (introduced in 2022) handles app→embedder 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 app→embedder 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
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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" })
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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}"
|
||||
```
|
||||
@@ -1,100 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# shared-memory — AWS Fargate deployment module
|
||||
#
|
||||
# Deploys the three runtime components (app, embedder, migrator) as ECS
|
||||
# tasks behind an internet-facing ALB. The user is responsible for the VPC,
|
||||
# RDS Postgres, ACM cert, ECR images, and OIDC clients (see README).
|
||||
#
|
||||
# Region is inherited from the configured AWS provider — do not hardcode.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
data "aws_region" "current" {}
|
||||
data "aws_caller_identity" "current" {}
|
||||
|
||||
locals {
|
||||
# Merged tag set applied to every resource in the module. Callers can pin
|
||||
# cost-allocation tags / environment markers via var.tags.
|
||||
tags = merge(
|
||||
{
|
||||
"managed-by" = "terraform"
|
||||
"module" = "shared-memory"
|
||||
},
|
||||
var.tags,
|
||||
)
|
||||
|
||||
# Public URL is the canonical external origin — feeds PUBLIC_URL, AUTH_URL,
|
||||
# and OIDC redirect URIs alike.
|
||||
public_url = "https://${var.domain_name}"
|
||||
|
||||
# Service Connect namespace name. One per module instance so multiple
|
||||
# deployments (e.g. staging + prod in one cluster) don't collide.
|
||||
service_connect_namespace = "${var.name_prefix}.internal"
|
||||
|
||||
# Port constants — keep these aligned with the Dockerfiles.
|
||||
app_port = 3000
|
||||
embedder_port = 8080
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Security groups. One per logical tier; rules are kept tight on inbound and
|
||||
# permissive on egress (Fargate needs to reach ECR, Secrets Manager, and
|
||||
# CloudWatch — locking egress requires VPC endpoints, which the user owns).
|
||||
#
|
||||
# Note: the RDS security group is NOT created here. The user must add an
|
||||
# inbound rule on their RDS SG allowing 5432 from the embedder/app task SGs
|
||||
# (see outputs `app_security_group_id` / `embedder_security_group_id`).
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# ALB — internet-facing, terminates TLS, accepts 80 (redirect) and 443.
|
||||
resource "aws_security_group" "alb" {
|
||||
name = "${var.name_prefix}-alb"
|
||||
description = "shared-memory ALB: HTTPS in from internet, app out"
|
||||
vpc_id = var.vpc_id
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-alb" })
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
|
||||
security_group_id = aws_security_group.alb.id
|
||||
description = "HTTP (redirected to HTTPS)"
|
||||
ip_protocol = "tcp"
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
|
||||
security_group_id = aws_security_group.alb.id
|
||||
description = "HTTPS from the internet"
|
||||
ip_protocol = "tcp"
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_egress_rule" "alb_all" {
|
||||
security_group_id = aws_security_group.alb.id
|
||||
description = "ALB to app tasks (and anywhere — narrowed by destination SG)"
|
||||
ip_protocol = "-1"
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
# App tasks — accept 3000 only from the ALB SG.
|
||||
resource "aws_security_group" "app" {
|
||||
name = "${var.name_prefix}-app"
|
||||
description = "shared-memory app tasks: 3000 in from ALB only"
|
||||
vpc_id = var.vpc_id
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-app" })
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
|
||||
security_group_id = aws_security_group.app.id
|
||||
description = "App port from ALB"
|
||||
ip_protocol = "tcp"
|
||||
from_port = local.app_port
|
||||
to_port = local.app_port
|
||||
referenced_security_group_id = aws_security_group.alb.id
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_egress_rule" "app_all" {
|
||||
security_group_id = aws_security_group.app.id
|
||||
description = "Egress to embedder, RDS, ECR, Secrets Manager, CloudWatch, OIDC IdP"
|
||||
ip_protocol = "-1"
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
# Embedder tasks — accept 8080 only from app SG.
|
||||
resource "aws_security_group" "embedder" {
|
||||
name = "${var.name_prefix}-embedder"
|
||||
description = "shared-memory embedder tasks: 8080 in from app only"
|
||||
vpc_id = var.vpc_id
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-embedder" })
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "embedder_from_app" {
|
||||
security_group_id = aws_security_group.embedder.id
|
||||
description = "Embedder port from app tasks"
|
||||
ip_protocol = "tcp"
|
||||
from_port = local.embedder_port
|
||||
to_port = local.embedder_port
|
||||
referenced_security_group_id = aws_security_group.app.id
|
||||
}
|
||||
|
||||
# The migrator runs the embedding backfill against the embedder, so it
|
||||
# needs the same path as the app does.
|
||||
resource "aws_vpc_security_group_ingress_rule" "embedder_from_migrator" {
|
||||
security_group_id = aws_security_group.embedder.id
|
||||
description = "Embedder port from migrator one-shot task"
|
||||
ip_protocol = "tcp"
|
||||
from_port = local.embedder_port
|
||||
to_port = local.embedder_port
|
||||
referenced_security_group_id = aws_security_group.migrator.id
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_egress_rule" "embedder_all" {
|
||||
security_group_id = aws_security_group.embedder.id
|
||||
description = "Egress to Hugging Face (model download), ECR, Secrets Manager, CloudWatch"
|
||||
ip_protocol = "-1"
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
# Migrator one-shot task — gets its own SG so RDS allow-lists are clearer.
|
||||
resource "aws_security_group" "migrator" {
|
||||
name = "${var.name_prefix}-migrator"
|
||||
description = "shared-memory migrator one-shot task (no inbound)"
|
||||
vpc_id = var.vpc_id
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-migrator" })
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_egress_rule" "migrator_all" {
|
||||
security_group_id = aws_security_group.migrator.id
|
||||
description = "Egress to RDS, embedder, ECR, Secrets Manager, CloudWatch"
|
||||
ip_protocol = "-1"
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
# EFS mount targets — accept NFS only from embedder SG (the only mounter).
|
||||
resource "aws_security_group" "efs" {
|
||||
name = "${var.name_prefix}-efs"
|
||||
description = "shared-memory EFS: 2049/tcp in from embedder tasks"
|
||||
vpc_id = var.vpc_id
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-efs" })
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "efs_from_embedder" {
|
||||
security_group_id = aws_security_group.efs.id
|
||||
description = "NFS from embedder tasks"
|
||||
ip_protocol = "tcp"
|
||||
from_port = 2049
|
||||
to_port = 2049
|
||||
referenced_security_group_id = aws_security_group.embedder.id
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Outputs.
|
||||
#
|
||||
# Designed to give the operator everything they need to:
|
||||
# * point DNS at the ALB
|
||||
# * run the migrator one-shot
|
||||
# * extend the RDS security group with task ingress
|
||||
# * tail logs
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
output "alb_dns_name" {
|
||||
description = "ALB DNS name. Create a Route53 alias record pointing var.domain_name at this."
|
||||
value = aws_lb.this.dns_name
|
||||
}
|
||||
|
||||
output "alb_zone_id" {
|
||||
description = "ALB hosted zone ID, used as `alias.zone_id` on aws_route53_record."
|
||||
value = aws_lb.this.zone_id
|
||||
}
|
||||
|
||||
output "ecs_cluster_arn" {
|
||||
description = "ECS cluster ARN."
|
||||
value = aws_ecs_cluster.this.arn
|
||||
}
|
||||
|
||||
output "ecs_cluster_name" {
|
||||
description = "ECS cluster name. Pass to `aws ecs run-task --cluster`."
|
||||
value = aws_ecs_cluster.this.name
|
||||
}
|
||||
|
||||
output "app_service_name" {
|
||||
description = "App ECS service name."
|
||||
value = aws_ecs_service.app.name
|
||||
}
|
||||
|
||||
output "embedder_service_name" {
|
||||
description = "Embedder ECS service name."
|
||||
value = aws_ecs_service.embedder.name
|
||||
}
|
||||
|
||||
output "migrator_task_definition_arn" {
|
||||
description = "Migrator task definition ARN. Use with `aws ecs run-task --task-definition`."
|
||||
value = aws_ecs_task_definition.migrator.arn
|
||||
}
|
||||
|
||||
output "migrator_task_definition_family" {
|
||||
description = "Migrator task definition family — accepts the latest revision automatically when passed to `aws ecs run-task`."
|
||||
value = aws_ecs_task_definition.migrator.family
|
||||
}
|
||||
|
||||
output "app_log_group_name" {
|
||||
description = "CloudWatch log group for the app service."
|
||||
value = aws_cloudwatch_log_group.app.name
|
||||
}
|
||||
|
||||
output "embedder_log_group_name" {
|
||||
description = "CloudWatch log group for the embedder service."
|
||||
value = aws_cloudwatch_log_group.embedder.name
|
||||
}
|
||||
|
||||
output "migrator_log_group_name" {
|
||||
description = "CloudWatch log group for the migrator one-shot task."
|
||||
value = aws_cloudwatch_log_group.migrator.name
|
||||
}
|
||||
|
||||
output "app_security_group_id" {
|
||||
description = "Security group attached to app tasks. Add this as a source on your RDS SG inbound rule for port 5432."
|
||||
value = aws_security_group.app.id
|
||||
}
|
||||
|
||||
output "embedder_security_group_id" {
|
||||
description = "Security group attached to embedder tasks. Embedder doesn't hit RDS today, but expose for symmetry."
|
||||
value = aws_security_group.embedder.id
|
||||
}
|
||||
|
||||
output "migrator_security_group_id" {
|
||||
description = "Security group attached to the migrator one-shot. Must be allowed inbound on your RDS SG (5432) — this is what runs SQL migrations."
|
||||
value = aws_security_group.migrator.id
|
||||
}
|
||||
|
||||
output "private_subnet_ids_for_run_task" {
|
||||
description = "Echo of var.private_subnet_ids so `aws ecs run-task --network-configuration` can be assembled without re-typing them."
|
||||
value = var.private_subnet_ids
|
||||
}
|
||||
|
||||
output "secret_arns" {
|
||||
description = "Map of env-var name to Secrets Manager ARN. For visibility only — do not re-feed back into the module. Marked sensitive so the secret names don't print in `terraform apply` stdout or CI logs."
|
||||
value = local.secret_arns
|
||||
sensitive = true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user