feat: Phase 1 — Authentik auth, MCP endpoint, persistent memory
End-to-end Phase 1 of shared-memory: a logged-in Authentik user can sign into the Web UI (/me debug page), and an MCP client with an Authentik- issued bearer token can call memory.write / memory.list / memory.get / memory.delete plus project.identify against /api/mcp. Stack: - Next.js 15 (App Router) + React 19 + TypeScript, pnpm workspaces - Drizzle ORM + Postgres 16 + pgvector + pg_trgm - Auth.js v5 with Authentik provider (Web UI) - jose + Authentik JWKS for MCP bearer-token validation - JSON-RPC 2.0 dispatcher implementing the MCP wire protocol over plain HTTP POST (hand-rolled to fit Next.js App Router; switches to SSE in a later phase if server-initiated events are needed) - bge-small embeddings sidecar deferred to Phase 2; the schema already reserves the vector(384) column + IVFFlat index, FTS via a STORED tsvector column, and the visibility enum (private/shared/team) so cross-user memory sharing can be added without a future migration Deployment supports two modes (set in .env, never committed): - Behind an external reverse proxy (HAProxy / nginx / Cloudflare Tunnel / Traefik) — DEFAULT; the app exposes APP_PORT on the host with X-Forwarded-* trusted, no in-container TLS - Built-in TLS via Caddy — opt-in with `docker compose --profile tls up` Discovery endpoint at /.well-known/oauth-protected-resource (RFC 9728) points MCP clients at the Authentik authorization server after a 401. README walks through both Authentik providers (Web UI + MCP resource server), the audience scope mapping, redirect URIs, and includes a worked HAProxy config snippet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# VCS
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Local env — must NEVER end up in image layers
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
**/node_modules
|
||||||
|
**/.next
|
||||||
|
**/dist
|
||||||
|
**/build
|
||||||
|
**/.turbo
|
||||||
|
**/coverage
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
+28
-8
@@ -5,14 +5,30 @@
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Public URL the app is reached at.
|
# Public URL the app is reached at.
|
||||||
# Used for OIDC redirect URIs, MCP discovery metadata, and the Caddy site name.
|
# Used for OIDC redirect URIs, MCP discovery metadata, and Auth.js callbacks.
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
PUBLIC_URL=https://memory.example.com
|
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
|
# Authentik OIDC
|
||||||
# Create two Applications in Authentik (one for the Web UI, one for the MCP
|
# Create two Applications in Authentik (one for the Web UI, one for the MCP
|
||||||
# resource server). See README.md for the exact provider settings.
|
# resource server). See README.md for exact provider settings.
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
OIDC_ISSUER=https://auth.example.com/application/o/shared-memory/
|
OIDC_ISSUER=https://auth.example.com/application/o/shared-memory/
|
||||||
OIDC_CLIENT_ID_WEB=replace-me
|
OIDC_CLIENT_ID_WEB=replace-me
|
||||||
@@ -21,18 +37,20 @@ OIDC_CLIENT_ID_MCP=replace-me
|
|||||||
OIDC_AUDIENCE=shared-memory
|
OIDC_AUDIENCE=shared-memory
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Database (Postgres 16 + pgvector)
|
# Database (Postgres 16 + pgvector — pgvector/pgvector:pg16 image)
|
||||||
# Default values match the docker-compose `db` service.
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
POSTGRES_USER=memory
|
POSTGRES_USER=memory
|
||||||
POSTGRES_PASSWORD=replace-me-with-a-strong-password
|
POSTGRES_PASSWORD=replace-me-with-a-strong-password
|
||||||
POSTGRES_DB=memory
|
POSTGRES_DB=memory
|
||||||
DATABASE_URL=postgres://memory:replace-me-with-a-strong-password@db:5432/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
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Embedder sidecar (added in Phase 2)
|
# Embedder sidecar (added in Phase 2; leave EMBEDDER_URL empty in Phase 1)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
EMBEDDER_URL=http://embedder:8080
|
EMBEDDER_URL=
|
||||||
EMBEDDING_MODEL=Xenova/bge-small-en-v1.5
|
EMBEDDING_MODEL=Xenova/bge-small-en-v1.5
|
||||||
EMBEDDING_DIM=384
|
EMBEDDING_DIM=384
|
||||||
|
|
||||||
@@ -45,4 +63,6 @@ NEXTAUTH_SECRET=replace-me-with-32-bytes-of-random
|
|||||||
# App
|
# App
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
NODE_ENV=production
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
link-workspace-packages=true
|
||||||
|
prefer-workspace-packages=true
|
||||||
|
auto-install-peers=true
|
||||||
|
shamefully-hoist=false
|
||||||
|
strict-peer-dependencies=false
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Caddy config for shared-memory.
|
||||||
|
#
|
||||||
|
# Hostname and ACME email come from environment variables set by docker-compose
|
||||||
|
# (which loads them from .env). For local development without TLS, override
|
||||||
|
# this file or set APP_HOSTNAME=localhost and use a docker-compose override.
|
||||||
|
|
||||||
|
{
|
||||||
|
email {$ACME_EMAIL}
|
||||||
|
# Uncomment to use the Let's Encrypt staging directory while testing:
|
||||||
|
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
}
|
||||||
|
|
||||||
|
{$APP_HOSTNAME} {
|
||||||
|
encode gzip zstd
|
||||||
|
|
||||||
|
# Trust X-Forwarded-* from this proxy. Auth.js + Next.js use these to
|
||||||
|
# construct callback URLs that match PUBLIC_URL.
|
||||||
|
header {
|
||||||
|
# Tell upstream we terminated TLS.
|
||||||
|
# (`reverse_proxy` already sets X-Forwarded-* by default.)
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy app:3000 {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# shared-memory
|
||||||
|
|
||||||
|
A self-hosted MCP server that gives Claude Code sessions a **shared, persistent
|
||||||
|
memory** plus a **reusable snippet library**, behind your own Authentik OIDC
|
||||||
|
login. Includes a Web UI for reviewing, editing, and deleting what's been
|
||||||
|
stored.
|
||||||
|
|
||||||
|
> **Status:** Phase 1 — core memory path end-to-end (write / list / get /
|
||||||
|
> delete), Authentik-authed Web UI, MCP endpoint with Authentik JWT validation.
|
||||||
|
> Semantic search and the rich Web UI land in Phase 2 / Phase 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌───────────────────────┐ ┌──────────────┐
|
||||||
|
│ Claude Code │──MCP──▶│ shared-memory app │◀──OIDC─│ Authentik │
|
||||||
|
│ (many sessions)│ HTTP │ Next.js + MCP route │ └──────────────┘
|
||||||
|
└─────────────────┘ │ + Web UI │ ▲
|
||||||
|
└───────────┬───────────┘ │
|
||||||
|
│ │
|
||||||
|
┌──────▼──────┐ user logs in
|
||||||
|
│ Postgres 16 │ via web browser
|
||||||
|
│ + pgvector │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The same container serves both the MCP endpoint (under `/api/mcp`) and the
|
||||||
|
Web UI. Users authenticate via your Authentik instance — pre-registered
|
||||||
|
confidential clients, not dynamic client registration. Identity is keyed on
|
||||||
|
the OIDC `sub` claim so memories are scoped per user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A host with **Docker** and **Docker Compose v2** installed.
|
||||||
|
- A **self-hosted Authentik instance** you administer.
|
||||||
|
- A **public DNS record** for the chosen hostname pointing at your reverse
|
||||||
|
proxy (HAProxy, nginx, Cloudflare Tunnel, …) or at this host directly.
|
||||||
|
- A Postgres-friendly disk for the `db_data` volume.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment modes
|
||||||
|
|
||||||
|
Pick one based on how you handle TLS:
|
||||||
|
|
||||||
|
### Mode A — Behind an external reverse proxy (DEFAULT)
|
||||||
|
|
||||||
|
You already have HAProxy / nginx / Traefik / Cloudflare Tunnel terminating
|
||||||
|
TLS for your domain. The app exposes a plain HTTP port to the host; your
|
||||||
|
proxy forwards traffic to it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 "Authentik 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 Authentik**. 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.dnspegasus.net`. 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 | Authentik's OIDC issuer URL for **this app**. Looks like `https://auth.example.com/application/o/shared-memory/`. |
|
||||||
|
| `OIDC_CLIENT_ID_WEB` | both | Client ID of the Web-UI Authentik provider. |
|
||||||
|
| `OIDC_CLIENT_SECRET_WEB` | both | Client secret of the Web-UI Authentik provider. |
|
||||||
|
| `OIDC_CLIENT_ID_MCP` | both | Client ID of the MCP resource-server Authentik provider. |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentik setup
|
||||||
|
|
||||||
|
You need **two** Authentik OAuth2/OpenID Connect providers + applications:
|
||||||
|
one for the Web UI (browser logins), one for the MCP resource server (the
|
||||||
|
audience Claude Code's access tokens are minted for). Reusing one provider
|
||||||
|
for both works, but the dual-provider setup keeps audiences cleanly separated
|
||||||
|
and is what the rest of this doc assumes.
|
||||||
|
|
||||||
|
### 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.dnspegasus.net/api/auth/callback/authentik
|
||||||
|
```
|
||||||
|
(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.dnspegasus.net/`
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
In a future Phase, we'll publish a one-line Claude Code config snippet. For
|
||||||
|
Phase 1, follow the [MCP authorization flow][mcp-auth]:
|
||||||
|
|
||||||
|
1. Add the MCP server to Claude Code's config, pointing at
|
||||||
|
`https://memory.dnspegasus.net/api/mcp`.
|
||||||
|
2. On first connection, the server returns 401 with `WWW-Authenticate`
|
||||||
|
pointing at `/.well-known/oauth-protected-resource`.
|
||||||
|
3. Claude Code reads the protected-resource metadata, follows the link to
|
||||||
|
Authentik's discovery doc, and runs the OAuth 2.1 PKCE flow.
|
||||||
|
4. You'll be prompted in your browser to authenticate with Authentik.
|
||||||
|
5. Claude Code stores the access token and uses it on subsequent requests.
|
||||||
|
|
||||||
|
If Authentik refuses the redirect URI Claude Code attempts to use, copy the
|
||||||
|
URI from the error and add it under the MCP provider's **Redirect URIs**.
|
||||||
|
|
||||||
|
[mcp-auth]: https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.dnspegasus.net.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.dnspegasus.net
|
||||||
|
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.dnspegasus.net/api/auth/callback/authentik`.
|
||||||
|
|
||||||
|
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 Authentik, 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
|
||||||
|
pnpm db:migrate
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The Authentik provider you use locally must accept
|
||||||
|
`http://localhost:3000/api/auth/callback/authentik` as a redirect URI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **`401 claim invalid: aud`** from `/api/mcp` — your MCP provider isn't
|
||||||
|
emitting `aud`. See **Setting the `aud` claim** above.
|
||||||
|
- **Auth.js callback fails with `OAUTH_CALLBACK_ERROR`** — your `PUBLIC_URL`
|
||||||
|
doesn't match the redirect URI Authentik 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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 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 . .
|
||||||
|
|
||||||
|
# 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 apps/web/scripts/migrate.ts \
|
||||||
|
--bundle --platform=node --target=node20 --format=esm \
|
||||||
|
--outfile=apps/web/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"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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}/`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { pg } from "@/lib/db/client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liveness + DB connectivity probe for the docker healthcheck.
|
||||||
|
* Returns 200 only if Postgres responds within the request timeout.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await pg`SELECT 1`;
|
||||||
|
return NextResponse.json({ status: "ok", db: "up" });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: "degraded", db: "down", error: e instanceof Error ? e.message : "unknown" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authenticateBearer, UnauthorizedError } from "@/lib/auth/jwt";
|
||||||
|
import { userContextFromClaims } from "@/lib/mcp/context";
|
||||||
|
import { dispatchMcpMessage } from "@/lib/mcp/server";
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- resolve user, dispatch ----
|
||||||
|
const ctx = await userContextFromClaims(claims);
|
||||||
|
|
||||||
|
// MCP supports batched requests (array) and single. Handle both.
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
const responses = await Promise.all(body.map((m) => dispatchMcpMessage(m, ctx)));
|
||||||
|
const filtered = responses.filter((r) => r !== null);
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return new NextResponse(null, { status: 202 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(filtered, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await dispatchMcpMessage(body, ctx);
|
||||||
|
if (response === null) {
|
||||||
|
// Notification — no body expected.
|
||||||
|
return new NextResponse(null, { status: 202 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(response, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP clients sometimes probe with GET (for SSE). We don't support
|
||||||
|
// server-initiated events in Phase 1 — return 405 with a discoverable header.
|
||||||
|
export function GET() {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 405,
|
||||||
|
headers: { Allow: "POST" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #0b0d10;
|
||||||
|
--fg: #e7e9ec;
|
||||||
|
--muted: #8a9099;
|
||||||
|
--accent: #6ea8fe;
|
||||||
|
--surface: #14181d;
|
||||||
|
--border: #232a31;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "shared-memory",
|
||||||
|
description: "Shared persistent memory for Claude Code sessions",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth, signOut } from "@/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function MePage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/api/auth/signin");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container">
|
||||||
|
<h1>Signed in</h1>
|
||||||
|
<p className="muted">
|
||||||
|
Debug view — confirms the Authentik round-trip and the OIDC claims we
|
||||||
|
received.
|
||||||
|
</p>
|
||||||
|
<h2>Session</h2>
|
||||||
|
<pre>{JSON.stringify(session, null, 2)}</pre>
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
"use server";
|
||||||
|
await signOut({ redirectTo: "/" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container">
|
||||||
|
<h1>shared-memory</h1>
|
||||||
|
<p className="muted">
|
||||||
|
Self-hosted MCP server providing shared persistent memory across Claude Code sessions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{session?.user ? (
|
||||||
|
<p>
|
||||||
|
Signed in as <strong>{session.user.email ?? session.user.name ?? session.user.id}</strong>{" "}
|
||||||
|
— <Link href="/me">view session</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
<Link href="/api/auth/signin">Sign in with Authentik</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<hr style={{ borderColor: "var(--border)", margin: "2rem 0" }} />
|
||||||
|
<h2>MCP endpoint</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Connect a Claude Code session to <code>/api/mcp</code> with a bearer token
|
||||||
|
issued by Authentik for this resource. See the README for setup steps.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Authentik from "next-auth/providers/authentik";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { users } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NextAuth (Auth.js v5) configuration.
|
||||||
|
*
|
||||||
|
* Authentik is the OIDC issuer. 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: [
|
||||||
|
Authentik({
|
||||||
|
clientId: env().OIDC_CLIENT_ID_WEB,
|
||||||
|
clientSecret: env().OIDC_CLIENT_SECRET_WEB,
|
||||||
|
issuer: env().OIDC_ISSUER,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
secret: env().NEXTAUTH_SECRET,
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
// Sign-in page is the default Auth.js form; can be customized later.
|
||||||
|
pages: { signIn: "/api/auth/signin" },
|
||||||
|
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 });
|
||||||
|
|
||||||
|
token.userId = row[0]?.id;
|
||||||
|
token.sub = sub;
|
||||||
|
token.iss = iss;
|
||||||
|
}
|
||||||
|
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";
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Config } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: "./lib/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL ?? "postgres://memory:memory@localhost:5432/memory",
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
verbose: true,
|
||||||
|
} satisfies Config;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
-- Initial migration for shared-memory.
|
||||||
|
-- Sets up extensions, enum types, tables, generated columns, and indexes
|
||||||
|
-- required for memory storage + hybrid search (Phase 2 populates the
|
||||||
|
-- embedding column; FTS works in Phase 1).
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Extensions
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- trigram index for tag fuzzy match
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Enums
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TYPE "memory_scope" AS ENUM ('project', 'user');
|
||||||
|
CREATE TYPE "memory_visibility" AS ENUM ('private', 'shared', 'team');
|
||||||
|
CREATE TYPE "audit_actor" AS ENUM ('mcp', 'web', 'system');
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- users
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"oidc_sub" text NOT NULL,
|
||||||
|
"oidc_iss" text NOT NULL,
|
||||||
|
"email" text,
|
||||||
|
"name" text,
|
||||||
|
"picture" text,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"last_seen_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "users_iss_sub_uq" ON "users" ("oidc_iss", "oidc_sub");
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- projects
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE "projects" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||||
|
"key" varchar(200) NOT NULL,
|
||||||
|
"display_name" text,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "projects_user_key_uq" ON "projects" ("user_id", "key");
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- memories
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE "memories" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||||
|
"project_id" uuid REFERENCES "projects"("id") ON DELETE SET NULL,
|
||||||
|
"scope" memory_scope NOT NULL DEFAULT 'project',
|
||||||
|
"visibility" memory_visibility NOT NULL DEFAULT 'private',
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"tags" text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||||
|
"embedding" vector(384),
|
||||||
|
"content_tsv" tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce("content", ''))) STORED,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
-- Scope/project_id consistency: project scope requires a project_id,
|
||||||
|
-- user scope forbids one.
|
||||||
|
CONSTRAINT "memories_scope_project_chk"
|
||||||
|
CHECK (
|
||||||
|
(scope = 'project' AND project_id IS NOT NULL)
|
||||||
|
OR (scope = 'user' AND project_id IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "memories_user_idx" ON "memories" ("user_id");
|
||||||
|
CREATE INDEX "memories_project_idx" ON "memories" ("project_id");
|
||||||
|
CREATE INDEX "memories_created_idx" ON "memories" ("created_at" DESC);
|
||||||
|
|
||||||
|
-- GIN index for full-text search over the generated tsvector column.
|
||||||
|
CREATE INDEX "memories_content_tsv_idx" ON "memories" USING GIN ("content_tsv");
|
||||||
|
|
||||||
|
-- GIN index on tags for exact-tag filtering, and trigram index for fuzzy matching.
|
||||||
|
CREATE INDEX "memories_tags_idx" ON "memories" USING GIN ("tags");
|
||||||
|
CREATE INDEX "memories_tags_trgm_idx" ON "memories" USING GIN ("tags" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- 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();
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
|
||||||
|
import type { JWTPayload } from "jose";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a bearer token issued by Authentik against the configured
|
||||||
|
* OIDC issuer. Verifies signature (via JWKS), issuer, audience, and expiry.
|
||||||
|
*
|
||||||
|
* Used by the MCP endpoint to authenticate incoming Claude Code requests.
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly reason: string,
|
||||||
|
public readonly wwwAuthenticate: string,
|
||||||
|
) {
|
||||||
|
super(reason);
|
||||||
|
this.name = "UnauthorizedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
// Reuse a single connection pool across hot reloads in dev.
|
||||||
|
type GlobalWithPg = typeof globalThis & {
|
||||||
|
__sharedMemoryPg?: ReturnType<typeof postgres>;
|
||||||
|
};
|
||||||
|
const g = globalThis as GlobalWithPg;
|
||||||
|
|
||||||
|
function makePool() {
|
||||||
|
return postgres(env().DATABASE_URL, {
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 30,
|
||||||
|
connect_timeout: 10,
|
||||||
|
prepare: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = g.__sharedMemoryPg ?? makePool();
|
||||||
|
if (process.env.NODE_ENV !== "production") g.__sharedMemoryPg = sql;
|
||||||
|
|
||||||
|
export const db = drizzle(sql, { schema, logger: env().LOG_LEVEL === "debug" });
|
||||||
|
export { sql as pg, schema };
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
pgEnum,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
uniqueIndex,
|
||||||
|
index,
|
||||||
|
customType,
|
||||||
|
vector,
|
||||||
|
varchar,
|
||||||
|
} 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"]);
|
||||||
|
|
||||||
|
// ---------- tables ----------
|
||||||
|
|
||||||
|
export const users = pgTable(
|
||||||
|
"users",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
// OIDC `sub` claim from Authentik — 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"),
|
||||||
|
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" }),
|
||||||
|
name: varchar("name", { length: 200 }).notNull(),
|
||||||
|
body: text("body").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
tags: textArray("tags").notNull().default([]),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniqueUserName: uniqueIndex("snippets_user_name_uq").on(t.userId, t.name),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 AuditEntry = typeof auditLog.$inferSelect;
|
||||||
|
export type NewAuditEntry = typeof auditLog.$inferInsert;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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 (used in Phase 2; present-but-empty allowed in Phase 1)
|
||||||
|
EMBEDDER_URL: z.string().url().optional(),
|
||||||
|
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"),
|
||||||
|
|
||||||
|
// 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: undefined,
|
||||||
|
EMBEDDING_MODEL: "Xenova/bge-small-en-v1.5",
|
||||||
|
EMBEDDING_DIM: 384,
|
||||||
|
NEXTAUTH_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];
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { users } 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 Authentik OIDC
|
||||||
|
* claims so tools work with stable UUID foreign keys rather than raw `sub`
|
||||||
|
* strings.
|
||||||
|
*/
|
||||||
|
export interface UserContext {
|
||||||
|
/** Internal users.id UUID. */
|
||||||
|
userId: string;
|
||||||
|
/** OIDC sub claim (stable identifier from Authentik). */
|
||||||
|
sub: string;
|
||||||
|
/** OIDC issuer. */
|
||||||
|
iss: string;
|
||||||
|
/** Optional profile fields if present in the access token. */
|
||||||
|
email: string | null;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userContextFromClaims(claims: AuthenticatedClaims): 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 });
|
||||||
|
|
||||||
|
const 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");
|
||||||
|
return { userId: existing[0].id, sub: claims.sub, iss: claims.iss, email, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId, sub: claims.sub, iss: claims.iss, email, name };
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { tools, toolMap, type ToolResult } from "./tools";
|
||||||
|
import type { UserContext } from "./context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal JSON-RPC 2.0 dispatcher that implements the MCP wire protocol over
|
||||||
|
* HTTP. We intentionally don't depend on the SDK's `StreamableHTTPServerTransport`
|
||||||
|
* here because Next.js App Router uses Web `Request`/`Response`, not Node's
|
||||||
|
* `IncomingMessage`/`ServerResponse`, and a hand-rolled handler is simpler than
|
||||||
|
* a Node-stream shim. The protocol surface we cover for Phase 1 is:
|
||||||
|
* - `initialize` — handshake
|
||||||
|
* - `notifications/initialized` — ack (no response)
|
||||||
|
* - `tools/list` — enumerate tools
|
||||||
|
* - `tools/call` — invoke a tool
|
||||||
|
* - `ping` — liveness
|
||||||
|
*
|
||||||
|
* If we later need server-initiated events (notifications, sampling), we'll
|
||||||
|
* graduate to SSE responses; for now the protocol works as plain POST/JSON.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PROTOCOL_VERSION = "2025-06-18";
|
||||||
|
const SERVER_INFO = {
|
||||||
|
name: "shared-memory",
|
||||||
|
version: "0.1.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonRpcId = string | number | null;
|
||||||
|
|
||||||
|
interface JsonRpcRequest {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
id?: JsonRpcId;
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonRpcSuccess {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
id: JsonRpcId;
|
||||||
|
result: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonRpcError {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
id: JsonRpcId;
|
||||||
|
error: { code: number; message: string; data?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonRpcResponse = JsonRpcSuccess | JsonRpcError;
|
||||||
|
|
||||||
|
// JSON-RPC standard codes; MCP also defines server-error codes from -32000.
|
||||||
|
const RPC = {
|
||||||
|
PARSE_ERROR: -32700,
|
||||||
|
INVALID_REQUEST: -32600,
|
||||||
|
METHOD_NOT_FOUND: -32601,
|
||||||
|
INVALID_PARAMS: -32602,
|
||||||
|
INTERNAL_ERROR: -32603,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function makeError(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcError {
|
||||||
|
return { jsonrpc: "2.0", id, error: { code, message, ...(data !== undefined && { data }) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSuccess(id: JsonRpcId, result: unknown): JsonRpcSuccess {
|
||||||
|
return { jsonrpc: "2.0", id, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotification(req: JsonRpcRequest): boolean {
|
||||||
|
return req.id === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchMcpMessage(
|
||||||
|
message: unknown,
|
||||||
|
ctx: UserContext,
|
||||||
|
): Promise<JsonRpcResponse | null> {
|
||||||
|
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||||
|
return makeError(null, RPC.INVALID_REQUEST, "request must be a JSON object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = message as JsonRpcRequest;
|
||||||
|
if (req.jsonrpc !== "2.0" || typeof req.method !== "string") {
|
||||||
|
return makeError(req.id ?? null, RPC.INVALID_REQUEST, "invalid jsonrpc envelope");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = req.id ?? null;
|
||||||
|
const notification = isNotification(req);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (req.method) {
|
||||||
|
case "initialize":
|
||||||
|
return makeSuccess(id, {
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
capabilities: { tools: { listChanged: false } },
|
||||||
|
serverInfo: SERVER_INFO,
|
||||||
|
});
|
||||||
|
|
||||||
|
case "notifications/initialized":
|
||||||
|
// No response for notifications.
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "ping":
|
||||||
|
return makeSuccess(id, {});
|
||||||
|
|
||||||
|
case "tools/list":
|
||||||
|
return makeSuccess(id, {
|
||||||
|
tools: tools.map((t) => ({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
inputSchema: t.inputSchema,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
case "tools/call": {
|
||||||
|
const params = (req.params ?? {}) as { name?: string; arguments?: unknown };
|
||||||
|
if (!params.name) {
|
||||||
|
return makeError(id, RPC.INVALID_PARAMS, "tools/call requires `name`");
|
||||||
|
}
|
||||||
|
const tool = toolMap[params.name];
|
||||||
|
if (!tool) {
|
||||||
|
return makeError(id, RPC.METHOD_NOT_FOUND, `unknown tool: ${params.name}`);
|
||||||
|
}
|
||||||
|
const result: ToolResult = await tool.handler(params.arguments ?? {}, ctx);
|
||||||
|
return makeSuccess(id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (notification) return null; // ignore unknown notifications
|
||||||
|
return makeError(id, RPC.METHOD_NOT_FOUND, `unknown method: ${req.method}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "internal error";
|
||||||
|
return notification ? null : makeError(id, RPC.INTERNAL_ERROR, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { memories, projects, auditLog } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
MemoryIdInput,
|
||||||
|
MemoryListInput,
|
||||||
|
MemoryWriteInput,
|
||||||
|
ProjectIdentifyInput,
|
||||||
|
} from "@shared-memory/schemas";
|
||||||
|
import type { UserContext } from "./context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP tool definitions for v1 (Phase 1). Each tool has:
|
||||||
|
* - name: dotted identifier exposed to clients
|
||||||
|
* - description: shown to the model
|
||||||
|
* - inputSchema: JSON Schema for the arguments object
|
||||||
|
* - handler: async function that runs the tool
|
||||||
|
*
|
||||||
|
* Search (memory.search) and snippets come in later phases.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ToolResult {
|
||||||
|
content: Array<{ type: "text"; text: string }>;
|
||||||
|
isError?: boolean;
|
||||||
|
structuredContent?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolDef {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
handler: (args: unknown, ctx: UserContext) => Promise<ToolResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
function ok(structured: unknown, summary: string): ToolResult {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: summary }],
|
||||||
|
structuredContent: structured,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function err(message: string): ToolResult {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `error: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveProjectId(
|
||||||
|
ctx: UserContext,
|
||||||
|
projectKey: string | undefined,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!projectKey) return null;
|
||||||
|
const row = await db
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(and(eq(projects.userId, ctx.userId), eq(projects.key, projectKey)))
|
||||||
|
.limit(1);
|
||||||
|
return row[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- tools ----------
|
||||||
|
|
||||||
|
const projectIdentify: ToolDef = {
|
||||||
|
name: "project.identify",
|
||||||
|
description:
|
||||||
|
"Register or look up a project for this user by its stable key. Returns the project's internal ID and display name. Call once per session before writing project-scoped memories.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Stable project identifier. Recommended: repo name, repo URL, or any string the caller can reproduce across sessions.",
|
||||||
|
},
|
||||||
|
display_name: {
|
||||||
|
type: "string",
|
||||||
|
description: "Human-readable name shown in the Web UI. Optional.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["key"],
|
||||||
|
},
|
||||||
|
async handler(args, ctx) {
|
||||||
|
const parsed = ProjectIdentifyInput.safeParse(args);
|
||||||
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
|
const row = await db
|
||||||
|
.insert(projects)
|
||||||
|
.values({
|
||||||
|
userId: ctx.userId,
|
||||||
|
key: parsed.data.key,
|
||||||
|
displayName: parsed.data.display_name ?? null,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [projects.userId, projects.key],
|
||||||
|
set: {
|
||||||
|
displayName: parsed.data.display_name ?? sql`${projects.displayName}`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: projects.id,
|
||||||
|
key: projects.key,
|
||||||
|
displayName: projects.displayName,
|
||||||
|
createdAt: projects.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const p = row[0]!;
|
||||||
|
return ok(p, `project ${p.key} (${p.id})`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const memoryWrite: ToolDef = {
|
||||||
|
name: "memory.write",
|
||||||
|
description:
|
||||||
|
"Persist a memory for this user. With scope='project' (default), the memory is attached to the named project. With scope='user', it's a user-global memory shared across all projects.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
content: { type: "string", description: "Memory content (1–64,000 chars)." },
|
||||||
|
project: {
|
||||||
|
type: "string",
|
||||||
|
description: "Project key (required when scope='project').",
|
||||||
|
},
|
||||||
|
scope: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["project", "user"],
|
||||||
|
description: "Scope of the memory. Defaults to 'project'.",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Optional tags for filtering/grouping.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["content"],
|
||||||
|
},
|
||||||
|
async handler(args, ctx) {
|
||||||
|
const parsed = MemoryWriteInput.safeParse(args);
|
||||||
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
|
const scope = parsed.data.scope;
|
||||||
|
let projectId: string | null = null;
|
||||||
|
if (scope === "project") {
|
||||||
|
if (!parsed.data.project) return err("scope=project requires `project` key");
|
||||||
|
projectId = await resolveProjectId(ctx, parsed.data.project);
|
||||||
|
if (!projectId) {
|
||||||
|
return err(`unknown project '${parsed.data.project}'; call project.identify first`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await db
|
||||||
|
.insert(memories)
|
||||||
|
.values({
|
||||||
|
userId: ctx.userId,
|
||||||
|
projectId,
|
||||||
|
scope,
|
||||||
|
content: parsed.data.content,
|
||||||
|
tags: parsed.data.tags ?? [],
|
||||||
|
})
|
||||||
|
.returning({ id: memories.id, createdAt: memories.createdAt });
|
||||||
|
|
||||||
|
const m = inserted[0]!;
|
||||||
|
await db.insert(auditLog).values({
|
||||||
|
userId: ctx.userId,
|
||||||
|
actor: "mcp",
|
||||||
|
action: "memory.write",
|
||||||
|
entityType: "memory",
|
||||||
|
entityId: m.id,
|
||||||
|
payload: { scope, projectKey: parsed.data.project ?? null, tags: parsed.data.tags ?? [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok({ id: m.id, createdAt: m.createdAt }, `wrote memory ${m.id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const memoryList: ToolDef = {
|
||||||
|
name: "memory.list",
|
||||||
|
description:
|
||||||
|
"List memories for this user, most recent first. Filter by project key and/or scope. Phase 2 will add memory.search for semantic + full-text lookup.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
project: { type: "string", description: "Filter by project key." },
|
||||||
|
scope: { type: "string", enum: ["project", "user"], description: "Filter by scope." },
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Require all of these tags.",
|
||||||
|
},
|
||||||
|
limit: { type: "integer", minimum: 1, maximum: 200, default: 50 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(args, ctx) {
|
||||||
|
const parsed = MemoryListInput.safeParse(args);
|
||||||
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
|
const where = [eq(memories.userId, ctx.userId), isNull(memories.deletedAt)];
|
||||||
|
|
||||||
|
if (parsed.data.scope) where.push(eq(memories.scope, parsed.data.scope));
|
||||||
|
|
||||||
|
if (parsed.data.project) {
|
||||||
|
const projectId = await resolveProjectId(ctx, parsed.data.project);
|
||||||
|
if (!projectId) return ok({ items: [], next_cursor: null }, "0 results");
|
||||||
|
where.push(eq(memories.projectId, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.tags && parsed.data.tags.length > 0) {
|
||||||
|
where.push(sql`${memories.tags} @> ${parsed.data.tags}::text[]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: memories.id,
|
||||||
|
scope: memories.scope,
|
||||||
|
projectId: memories.projectId,
|
||||||
|
content: memories.content,
|
||||||
|
tags: memories.tags,
|
||||||
|
createdAt: memories.createdAt,
|
||||||
|
updatedAt: memories.updatedAt,
|
||||||
|
})
|
||||||
|
.from(memories)
|
||||||
|
.where(and(...where))
|
||||||
|
.orderBy(desc(memories.createdAt))
|
||||||
|
.limit(parsed.data.limit);
|
||||||
|
|
||||||
|
return ok({ items: rows, next_cursor: null }, `${rows.length} result(s)`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const memoryGet: ToolDef = {
|
||||||
|
name: "memory.get",
|
||||||
|
description: "Fetch a single memory by its UUID.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { id: { type: "string", format: "uuid" } },
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
async handler(args, ctx) {
|
||||||
|
const parsed = MemoryIdInput.safeParse(args);
|
||||||
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
|
const row = await db
|
||||||
|
.select()
|
||||||
|
.from(memories)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(memories.id, parsed.data.id),
|
||||||
|
eq(memories.userId, ctx.userId),
|
||||||
|
isNull(memories.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row[0]) return err("not found");
|
||||||
|
return ok(row[0], `memory ${row[0].id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const memoryDelete: ToolDef = {
|
||||||
|
name: "memory.delete",
|
||||||
|
description: "Soft-delete a memory (sets deleted_at; preserved for audit).",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { id: { type: "string", format: "uuid" } },
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
async handler(args, ctx) {
|
||||||
|
const parsed = MemoryIdInput.safeParse(args);
|
||||||
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
|
const updated = await db
|
||||||
|
.update(memories)
|
||||||
|
.set({ deletedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(memories.id, parsed.data.id),
|
||||||
|
eq(memories.userId, ctx.userId),
|
||||||
|
isNull(memories.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: memories.id });
|
||||||
|
|
||||||
|
if (!updated[0]) return err("not found");
|
||||||
|
|
||||||
|
await db.insert(auditLog).values({
|
||||||
|
userId: ctx.userId,
|
||||||
|
actor: "mcp",
|
||||||
|
action: "memory.delete",
|
||||||
|
entityType: "memory",
|
||||||
|
entityId: updated[0].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok({ id: updated[0].id, deleted: true }, `deleted memory ${updated[0].id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tools: ToolDef[] = [
|
||||||
|
projectIdentify,
|
||||||
|
memoryWrite,
|
||||||
|
memoryList,
|
||||||
|
memoryGet,
|
||||||
|
memoryDelete,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const toolMap: Record<string, ToolDef> = Object.fromEntries(
|
||||||
|
tools.map((t) => [t.name, t]),
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const config: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
reactStrictMode: true,
|
||||||
|
poweredByHeader: false,
|
||||||
|
serverExternalPackages: ["postgres"],
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/mcp/:path*",
|
||||||
|
headers: [
|
||||||
|
{ key: "Cache-Control", value: "no-store" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"drizzle-kit": "^0.30.1",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-config-next": "^15.1.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 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 { 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));
|
||||||
|
const MIGRATIONS_DIR = join(__dirname, "..", "drizzle");
|
||||||
|
|
||||||
|
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.");
|
||||||
|
} finally {
|
||||||
|
await sql.end({ timeout: 5 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Migration failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||||
|
"jsx": "preserve",
|
||||||
|
"allowJs": true,
|
||||||
|
"incremental": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules", ".next"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# One-shot migration runner. Exits 0 when migrations 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
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
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
|
||||||
|
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:-}
|
||||||
|
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-Xenova/bge-small-en-v1.5}
|
||||||
|
EMBEDDING_DIM: ${EMBEDDING_DIM:-384}
|
||||||
|
|
||||||
|
NEXTAUTH_SECRET: ${NEXTAUTH_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:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
web:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "shared-memory",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Self-hosted MCP server providing shared persistent memory across Claude Code sessions, authed via Authentik OIDC",
|
||||||
|
"license": "MIT",
|
||||||
|
"packageManager": "pnpm@9.12.3",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.11.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm -r build",
|
||||||
|
"dev": "pnpm --filter @shared-memory/web dev",
|
||||||
|
"lint": "pnpm -r lint",
|
||||||
|
"typecheck": "pnpm -r typecheck",
|
||||||
|
"db:generate": "pnpm --filter @shared-memory/web db:generate",
|
||||||
|
"db:migrate": "pnpm --filter @shared-memory/web db:migrate"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@shared-memory/schemas",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build": "echo 'no build step; consumed as TS source'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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>;
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
export const ProjectIdentifyInput = z.object({
|
||||||
|
key: ProjectKey,
|
||||||
|
display_name: z.string().min(1).max(200).optional(),
|
||||||
|
});
|
||||||
|
export type ProjectIdentifyInput = z.infer<typeof ProjectIdentifyInput>;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Generated
+4994
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", ".next", "build"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user