Files
shared-memory/apps/web/drizzle/0000_init.sql
T
shadowdaoandClaude Opus 4.7 077d0a0825 feat: Phase 1 — Authentik auth, MCP endpoint, persistent memory
End-to-end Phase 1 of shared-memory: a logged-in Authentik user can sign
into the Web UI (/me debug page), and an MCP client with an Authentik-
issued bearer token can call memory.write / memory.list / memory.get /
memory.delete plus project.identify against /api/mcp.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:04:11 -07:00

152 lines
6.2 KiB
PL/PgSQL

-- 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();