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,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();
|
||||
Reference in New Issue
Block a user