feat: Phase 3b — proper Web UI for memories, projects, settings
Replaces the debug /me + /connect pages with a real authed app shell.
Pages
- / — anonymous landing; redirects to /dashboard once signed in
- /dashboard — recent memories + top projects, quick "new memory" action
- /memories — searchable list with hybrid (vector+FTS+tags) scoring;
per-result rank breakdown shown inline
- /memories/[id] — view + inline edit toggle + delete
- /memories/new — create form with project autocomplete
- /projects — list with memory counts and last-activity
- /projects/[key] — that project's memories
- /settings — read-only Authentik profile + link to tokens
- /settings/tokens — list / create / revoke CLI tokens
Old URLs preserved as redirects:
- /me → /dashboard
- /connect → /settings/tokens
Stack additions
- Tailwind v4 with CSS-first @theme tokens (dark only for now)
- App shell in app/(authed)/ — auth guard + top nav with global search box
- Lightweight UI primitives in app/_components/ui/ (Button, Input, Card,
Badge, EmptyState, Container, PageHeader)
- Search logic extracted from MCP tool into lib/memories.ts so Web UI and
MCP both call the same RRF code path
- Memory CRUD via Server Actions in lib/memory-actions.ts; audit_log
rows are tagged actor='web' to distinguish from MCP writes
Per-token revoke
- New cli_tokens table (id, user_id, jti unique, name, created_at,
last_used_at, expires_at, revoked_at) — migration 0001_cli_tokens.sql
- mintCliToken now records jti + name; verifyCliToken enforces revocation
for tracked tokens. Legacy tokens minted before this change (no jti)
are accepted on signature alone until they expire naturally.
- /settings/tokens lists active + revoked tokens with one-click revoke
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,30 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { SignJWT, jwtVerify, decodeProtectedHeader } from "jose";
|
||||
import type { JWTPayload } from "jose";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { env } from "@/lib/env";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { cliTokens } from "@/lib/db/schema";
|
||||
|
||||
/**
|
||||
* "CLI tokens" are HMAC-signed JWTs minted on demand from the /connect page
|
||||
* after the user logs into the Web UI via Authentik. They're suitable for
|
||||
* pasting into an MCP client's Authorization header on machines where the
|
||||
* OAuth loopback callback isn't reachable (containers, headless setups).
|
||||
* CLI tokens — HMAC-signed JWTs minted from /settings/tokens (or the
|
||||
* legacy /connect page) after the user logs into the Web UI via OIDC.
|
||||
*
|
||||
* Suitable for pasting into an MCP client's `Authorization` header on
|
||||
* machines where the OAuth loopback callback isn't reachable.
|
||||
*
|
||||
* Trust model: we trust whoever holds CLI_TOKEN_SECRET. Verification is a
|
||||
* local HMAC check — no JWKS roundtrip. To revoke ALL outstanding CLI
|
||||
* tokens, rotate CLI_TOKEN_SECRET.
|
||||
* local HMAC check — no JWKS round-trip — plus an opt-in revocation
|
||||
* lookup in the `cli_tokens` table.
|
||||
*
|
||||
* The payload carries the user's real Authentik identity in `iss` + `sub`
|
||||
* so the same `users` row resolution path works for both token kinds.
|
||||
* - Tokens minted by mintCliToken always carry a `jti` claim and have a
|
||||
* matching row in cli_tokens.
|
||||
* - Tokens minted by an older version of this server have no `jti`. We
|
||||
* accept them on signature validity alone until they expire naturally
|
||||
* (max 30 days post-deploy). Their only revocation knob is rotating
|
||||
* CLI_TOKEN_SECRET.
|
||||
*
|
||||
* Dispatch from the standard Authentik verifier is by the `kid` header:
|
||||
* CLI tokens set `kid: "cli-v1"`, Authentik tokens carry whatever key id
|
||||
* the JWKS published.
|
||||
* To revoke a tracked token immediately, set cli_tokens.revoked_at.
|
||||
*/
|
||||
|
||||
export const CLI_TOKEN_KID = "cli-v1";
|
||||
@@ -29,14 +36,41 @@ function secret(): Uint8Array {
|
||||
}
|
||||
|
||||
export interface CliTokenSubject {
|
||||
userId: string;
|
||||
oidcIss: string;
|
||||
oidcSub: string;
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export async function mintCliToken(subject: CliTokenSubject): Promise<string> {
|
||||
return await new SignJWT({
|
||||
export interface MintCliTokenOptions {
|
||||
/** Human-readable label shown in the Settings UI. */
|
||||
tokenName: string;
|
||||
}
|
||||
|
||||
export interface MintCliTokenResult {
|
||||
token: string;
|
||||
jti: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export async function mintCliToken(
|
||||
subject: CliTokenSubject,
|
||||
options: MintCliTokenOptions,
|
||||
): Promise<MintCliTokenResult> {
|
||||
const jti = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + CLI_TOKEN_TTL_SECONDS * 1000);
|
||||
|
||||
// Record the issued token first so a crash mid-mint can't leak a usable
|
||||
// token that isn't in our registry.
|
||||
await db.insert(cliTokens).values({
|
||||
userId: subject.userId,
|
||||
jti,
|
||||
name: options.tokenName,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const token = await new SignJWT({
|
||||
oidc_iss: subject.oidcIss,
|
||||
oidc_sub: subject.oidcSub,
|
||||
email: subject.email ?? undefined,
|
||||
@@ -46,9 +80,12 @@ export async function mintCliToken(subject: CliTokenSubject): Promise<string> {
|
||||
.setIssuer(CLI_TOKEN_ISSUER)
|
||||
.setSubject(subject.oidcSub)
|
||||
.setAudience(env().OIDC_AUDIENCE)
|
||||
.setJti(jti)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${CLI_TOKEN_TTL_SECONDS}s`)
|
||||
.sign(secret());
|
||||
|
||||
return { token, jti, expiresAt };
|
||||
}
|
||||
|
||||
export interface CliClaims extends JWTPayload {
|
||||
@@ -66,6 +103,31 @@ export async function verifyCliToken(token: string): Promise<CliClaims> {
|
||||
if (typeof payload.oidc_iss !== "string" || typeof payload.oidc_sub !== "string") {
|
||||
throw new Error("CLI token missing oidc_iss/oidc_sub claims");
|
||||
}
|
||||
|
||||
// If the token carries a jti, enforce the revocation registry. Tokens
|
||||
// minted before the registry existed have no jti — accept those on
|
||||
// signature alone until natural expiration.
|
||||
if (typeof payload.jti === "string") {
|
||||
const rows = await db
|
||||
.select({ id: cliTokens.id, revokedAt: cliTokens.revokedAt })
|
||||
.from(cliTokens)
|
||||
.where(eq(cliTokens.jti, payload.jti))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
throw new Error("CLI token not in registry — likely minted by another deployment");
|
||||
}
|
||||
if (row.revokedAt) {
|
||||
throw new Error("CLI token revoked");
|
||||
}
|
||||
// Touch last_used_at — best-effort, don't fail the request if this errors.
|
||||
void db
|
||||
.update(cliTokens)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(cliTokens.id, row.id))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return payload as CliClaims;
|
||||
}
|
||||
|
||||
@@ -78,3 +140,15 @@ export function tokenKid(token: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke a token by id (owned by the given user). */
|
||||
export async function revokeCliToken(userId: string, tokenId: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.update(cliTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(
|
||||
and(eq(cliTokens.id, tokenId), eq(cliTokens.userId, userId), isNull(cliTokens.revokedAt)),
|
||||
)
|
||||
.returning({ id: cliTokens.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,25 @@ export const snippets = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const cliTokens = pgTable(
|
||||
"cli_tokens",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
jti: text("jti").notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("cli_tokens_user_idx").on(t.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const auditLog = pgTable(
|
||||
"audit_log",
|
||||
{
|
||||
@@ -158,5 +177,7 @@ export type Memory = typeof memories.$inferSelect;
|
||||
export type NewMemory = typeof memories.$inferInsert;
|
||||
export type Snippet = typeof snippets.$inferSelect;
|
||||
export type NewSnippet = typeof snippets.$inferInsert;
|
||||
export type CliToken = typeof cliTokens.$inferSelect;
|
||||
export type NewCliToken = typeof cliTokens.$inferInsert;
|
||||
export type AuditEntry = typeof auditLog.$inferSelect;
|
||||
export type NewAuditEntry = typeof auditLog.$inferInsert;
|
||||
|
||||
+16
-108
@@ -1,5 +1,5 @@
|
||||
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { db, pg } from "@/lib/db/client";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { memories, projects, auditLog } from "@/lib/db/schema";
|
||||
import {
|
||||
MemoryIdInput,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ProjectIdentifyInput,
|
||||
} from "@shared-memory/schemas";
|
||||
import { embedText } from "@/lib/embedder";
|
||||
import { searchMemories } from "@/lib/memories";
|
||||
import type { UserContext } from "./context";
|
||||
|
||||
/**
|
||||
@@ -62,11 +63,6 @@ async function resolveProjectId(
|
||||
return row[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** pgvector accepts vectors as text literals like "[0.1,0.2,...]". */
|
||||
function toVectorLiteral(v: number[]): string {
|
||||
return `[${v.join(",")}]`;
|
||||
}
|
||||
|
||||
// ---------- tools ----------
|
||||
|
||||
const projectIdentify: ToolDef = {
|
||||
@@ -368,13 +364,6 @@ const memoryUpdate: ToolDef = {
|
||||
},
|
||||
};
|
||||
|
||||
interface RankAccumulator {
|
||||
vectorRank?: number;
|
||||
ftsRank?: number;
|
||||
tagRank?: number;
|
||||
rrfScore: number;
|
||||
}
|
||||
|
||||
const memorySearch: ToolDef = {
|
||||
name: "memory.search",
|
||||
description:
|
||||
@@ -399,87 +388,21 @@ const memorySearch: ToolDef = {
|
||||
? await resolveProjectId(ctx, parsed.data.project)
|
||||
: null;
|
||||
if (parsed.data.project && !projectId) {
|
||||
return ok({ items: [], _ranks: {} }, "0 results (unknown project)");
|
||||
return ok({ items: [], debug: { vec: 0, fts: 0, tag: 0 } }, "0 results (unknown project)");
|
||||
}
|
||||
|
||||
const queryVec = await embedText(query);
|
||||
const vecLit = toVectorLiteral(queryVec);
|
||||
const CANDIDATES = 50;
|
||||
const RRF_K = 60;
|
||||
const result = await searchMemories(
|
||||
ctx.userId,
|
||||
query,
|
||||
{ scope, projectKey: parsed.data.project, tags },
|
||||
limit,
|
||||
);
|
||||
|
||||
// Run the three candidate-fetch queries in parallel. The filter is
|
||||
// expressed via pg's tagged-template binding so values are safely
|
||||
// interpolated.
|
||||
const userId = ctx.userId;
|
||||
|
||||
const vecPromise = pg<{ id: string }[]>`
|
||||
SELECT id
|
||||
FROM memories
|
||||
WHERE user_id = ${userId}
|
||||
AND deleted_at IS NULL
|
||||
AND embedding IS NOT NULL
|
||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
||||
ORDER BY embedding <=> ${vecLit}::vector ASC
|
||||
LIMIT ${CANDIDATES}
|
||||
`;
|
||||
|
||||
const ftsPromise = pg<{ id: string }[]>`
|
||||
SELECT id
|
||||
FROM memories, plainto_tsquery('english', ${query}) AS q
|
||||
WHERE user_id = ${userId}
|
||||
AND deleted_at IS NULL
|
||||
AND content_tsv @@ q
|
||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
||||
ORDER BY ts_rank_cd(content_tsv, q) DESC
|
||||
LIMIT ${CANDIDATES}
|
||||
`;
|
||||
|
||||
const tagPromise =
|
||||
tags && tags.length > 0
|
||||
? pg<{ id: string }[]>`
|
||||
SELECT id
|
||||
FROM memories
|
||||
WHERE user_id = ${userId}
|
||||
AND deleted_at IS NULL
|
||||
AND tags && ${tags}::text[]
|
||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
||||
ORDER BY cardinality(
|
||||
ARRAY(SELECT unnest(tags) INTERSECT SELECT unnest(${tags}::text[]))
|
||||
) DESC
|
||||
LIMIT ${CANDIDATES}
|
||||
`
|
||||
: Promise.resolve([] as { id: string }[]);
|
||||
|
||||
const [vecHits, ftsHits, tagHits] = await Promise.all([
|
||||
vecPromise,
|
||||
ftsPromise,
|
||||
tagPromise,
|
||||
]);
|
||||
|
||||
// Fuse via RRF: score(d) = Σ_r 1/(k + rank_r(d))
|
||||
const scores = new Map<string, RankAccumulator>();
|
||||
const accum = (id: string, rank: number, key: "vectorRank" | "ftsRank" | "tagRank") => {
|
||||
const e = scores.get(id) ?? { rrfScore: 0 };
|
||||
e[key] = rank;
|
||||
e.rrfScore += 1 / (RRF_K + rank);
|
||||
scores.set(id, e);
|
||||
};
|
||||
vecHits.forEach((h, i) => accum(h.id, i + 1, "vectorRank"));
|
||||
ftsHits.forEach((h, i) => accum(h.id, i + 1, "ftsRank"));
|
||||
tagHits.forEach((h, i) => accum(h.id, i + 1, "tagRank"));
|
||||
|
||||
if (scores.size === 0) {
|
||||
return ok({ items: [], debug: { vec: 0, fts: 0, tag: 0 } }, "0 results");
|
||||
if (result.hits.length === 0) {
|
||||
return ok({ items: [], debug: result.debug }, "0 results");
|
||||
}
|
||||
|
||||
const sorted = [...scores.entries()]
|
||||
.sort(([, a], [, b]) => b.rrfScore - a.rrfScore)
|
||||
.slice(0, limit);
|
||||
const topIds = sorted.map(([id]) => id);
|
||||
|
||||
const topIds = result.hits.map((h) => h.id);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: memories.id,
|
||||
@@ -494,33 +417,18 @@ const memorySearch: ToolDef = {
|
||||
.where(inArray(memories.id, topIds));
|
||||
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
const items = sorted.flatMap(([id, rank]) => {
|
||||
const row = byId.get(id);
|
||||
const items = result.hits.flatMap((hit) => {
|
||||
const row = byId.get(hit.id);
|
||||
if (!row) return [];
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
_rank: {
|
||||
rrfScore: Number(rank.rrfScore.toFixed(6)),
|
||||
vectorRank: rank.vectorRank ?? null,
|
||||
ftsRank: rank.ftsRank ?? null,
|
||||
tagRank: rank.tagRank ?? null,
|
||||
},
|
||||
_rank: hit.rank,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return ok(
|
||||
{
|
||||
items,
|
||||
debug: {
|
||||
vec: vecHits.length,
|
||||
fts: ftsHits.length,
|
||||
tag: tagHits.length,
|
||||
},
|
||||
},
|
||||
`${items.length} result(s)`,
|
||||
);
|
||||
return ok({ items, debug: result.debug }, `${items.length} result(s)`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db, pg } from "@/lib/db/client";
|
||||
import { projects } from "@/lib/db/schema";
|
||||
import { embedText } from "@/lib/embedder";
|
||||
|
||||
/**
|
||||
* Shared search helper. Used by:
|
||||
* - the MCP `memory.search` tool (returns rich rank data for the model)
|
||||
* - the Web UI memories page (renders human-readable results)
|
||||
*
|
||||
* Performs three candidate fetches in parallel — pgvector cosine, FTS
|
||||
* ts_rank_cd, tag-set overlap — then fuses with Reciprocal Rank Fusion
|
||||
* (k=60). Returns top-N with per-source rank info attached.
|
||||
*/
|
||||
|
||||
export interface SearchFilters {
|
||||
scope?: "project" | "user";
|
||||
projectKey?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
id: string;
|
||||
rank: {
|
||||
rrfScore: number;
|
||||
vectorRank: number | null;
|
||||
ftsRank: number | null;
|
||||
tagRank: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
hits: SearchHit[];
|
||||
debug: { vec: number; fts: number; tag: number };
|
||||
}
|
||||
|
||||
const CANDIDATES = 50;
|
||||
const RRF_K = 60;
|
||||
|
||||
function toVectorLiteral(v: number[]): string {
|
||||
return `[${v.join(",")}]`;
|
||||
}
|
||||
|
||||
async function resolveProjectId(
|
||||
userId: string,
|
||||
projectKey?: string,
|
||||
): Promise<string | null> {
|
||||
if (!projectKey) return null;
|
||||
const row = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.userId, userId), eq(projects.key, projectKey)))
|
||||
.limit(1);
|
||||
return row[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export async function searchMemories(
|
||||
userId: string,
|
||||
query: string,
|
||||
filters: SearchFilters = {},
|
||||
limit = 20,
|
||||
): Promise<SearchResult> {
|
||||
const { scope, projectKey, tags } = filters;
|
||||
const projectId = projectKey ? await resolveProjectId(userId, projectKey) : null;
|
||||
if (projectKey && !projectId) {
|
||||
return { hits: [], debug: { vec: 0, fts: 0, tag: 0 } };
|
||||
}
|
||||
|
||||
const queryVec = await embedText(query);
|
||||
const vecLit = toVectorLiteral(queryVec);
|
||||
|
||||
const vecPromise = pg<{ id: string }[]>`
|
||||
SELECT id
|
||||
FROM memories
|
||||
WHERE user_id = ${userId}
|
||||
AND deleted_at IS NULL
|
||||
AND embedding IS NOT NULL
|
||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
||||
ORDER BY embedding <=> ${vecLit}::vector ASC
|
||||
LIMIT ${CANDIDATES}
|
||||
`;
|
||||
|
||||
const ftsPromise = pg<{ id: string }[]>`
|
||||
SELECT id
|
||||
FROM memories, plainto_tsquery('english', ${query}) AS q
|
||||
WHERE user_id = ${userId}
|
||||
AND deleted_at IS NULL
|
||||
AND content_tsv @@ q
|
||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
||||
ORDER BY ts_rank_cd(content_tsv, q) DESC
|
||||
LIMIT ${CANDIDATES}
|
||||
`;
|
||||
|
||||
const tagPromise =
|
||||
tags && tags.length > 0
|
||||
? pg<{ id: string }[]>`
|
||||
SELECT id
|
||||
FROM memories
|
||||
WHERE user_id = ${userId}
|
||||
AND deleted_at IS NULL
|
||||
AND tags && ${tags}::text[]
|
||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
||||
ORDER BY cardinality(
|
||||
ARRAY(SELECT unnest(tags) INTERSECT SELECT unnest(${tags}::text[]))
|
||||
) DESC
|
||||
LIMIT ${CANDIDATES}
|
||||
`
|
||||
: Promise.resolve([] as { id: string }[]);
|
||||
|
||||
const [vec, fts, tag] = await Promise.all([vecPromise, ftsPromise, tagPromise]);
|
||||
|
||||
interface Accumulator {
|
||||
vectorRank: number | null;
|
||||
ftsRank: number | null;
|
||||
tagRank: number | null;
|
||||
rrfScore: number;
|
||||
}
|
||||
const scores = new Map<string, Accumulator>();
|
||||
const accum = (id: string, rank: number, key: "vectorRank" | "ftsRank" | "tagRank") => {
|
||||
const e =
|
||||
scores.get(id) ??
|
||||
({ vectorRank: null, ftsRank: null, tagRank: null, rrfScore: 0 } as Accumulator);
|
||||
e[key] = rank;
|
||||
e.rrfScore += 1 / (RRF_K + rank);
|
||||
scores.set(id, e);
|
||||
};
|
||||
vec.forEach((h, i) => accum(h.id, i + 1, "vectorRank"));
|
||||
fts.forEach((h, i) => accum(h.id, i + 1, "ftsRank"));
|
||||
tag.forEach((h, i) => accum(h.id, i + 1, "tagRank"));
|
||||
|
||||
const hits = [...scores.entries()]
|
||||
.sort(([, a], [, b]) => b.rrfScore - a.rrfScore)
|
||||
.slice(0, limit)
|
||||
.map(([id, rank]) => ({
|
||||
id,
|
||||
rank: {
|
||||
rrfScore: Number(rank.rrfScore.toFixed(6)),
|
||||
vectorRank: rank.vectorRank,
|
||||
ftsRank: rank.ftsRank,
|
||||
tagRank: rank.tagRank,
|
||||
},
|
||||
}));
|
||||
|
||||
return { hits, debug: { vec: vec.length, fts: fts.length, tag: tag.length } };
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { memories, projects, auditLog } from "@/lib/db/schema";
|
||||
import { embedText } from "@/lib/embedder";
|
||||
import {
|
||||
MemoryWriteInput,
|
||||
MemoryUpdateInput,
|
||||
MemoryIdInput,
|
||||
} from "@shared-memory/schemas";
|
||||
|
||||
/**
|
||||
* Server Actions for memory CRUD from the Web UI. Mirrors the MCP tools
|
||||
* but writes through the same DB layer, so updates and deletes here are
|
||||
* indistinguishable from those made via Claude Code.
|
||||
*
|
||||
* `actor` is "web" in audit_log so we can tell the two paths apart later.
|
||||
*/
|
||||
|
||||
async function requireUserId(): Promise<string> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("not authenticated");
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
async function resolveProjectId(userId: string, key: string): Promise<string | null> {
|
||||
const row = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.userId, userId), eq(projects.key, key)))
|
||||
.limit(1);
|
||||
return row[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async function upsertProject(
|
||||
userId: string,
|
||||
key: string,
|
||||
displayName?: string,
|
||||
): Promise<string> {
|
||||
const existing = await resolveProjectId(userId, key);
|
||||
if (existing) return existing;
|
||||
const row = await db
|
||||
.insert(projects)
|
||||
.values({ userId, key, displayName: displayName ?? null })
|
||||
.returning({ id: projects.id });
|
||||
return row[0]!.id;
|
||||
}
|
||||
|
||||
function parseTags(raw: FormDataEntryValue | null): string[] {
|
||||
if (typeof raw !== "string") return [];
|
||||
return raw
|
||||
.split(/[,\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
export async function createMemoryAction(formData: FormData) {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const payload = {
|
||||
content: String(formData.get("content") ?? "").trim(),
|
||||
scope: (formData.get("scope") as "project" | "user") || "project",
|
||||
project: (formData.get("project") as string | null)?.trim() || undefined,
|
||||
tags: parseTags(formData.get("tags")),
|
||||
};
|
||||
const parsed = MemoryWriteInput.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
|
||||
}
|
||||
|
||||
let projectId: string | null = null;
|
||||
if (parsed.data.scope === "project") {
|
||||
if (!parsed.data.project) throw new Error("scope=project requires `project`");
|
||||
projectId = await upsertProject(userId, parsed.data.project);
|
||||
}
|
||||
|
||||
const embedding = await embedText(parsed.data.content);
|
||||
|
||||
const inserted = await db
|
||||
.insert(memories)
|
||||
.values({
|
||||
userId,
|
||||
projectId,
|
||||
scope: parsed.data.scope,
|
||||
content: parsed.data.content,
|
||||
tags: parsed.data.tags ?? [],
|
||||
embedding,
|
||||
})
|
||||
.returning({ id: memories.id });
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
userId,
|
||||
actor: "web",
|
||||
action: "memory.write",
|
||||
entityType: "memory",
|
||||
entityId: inserted[0]!.id,
|
||||
payload: {
|
||||
scope: parsed.data.scope,
|
||||
projectKey: parsed.data.project ?? null,
|
||||
tags: parsed.data.tags ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/memories");
|
||||
redirect(`/memories/${inserted[0]!.id}`);
|
||||
}
|
||||
|
||||
export async function updateMemoryAction(formData: FormData) {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const id = String(formData.get("id") ?? "");
|
||||
const payload = {
|
||||
id,
|
||||
content: ((formData.get("content") as string | null) ?? "").trim() || undefined,
|
||||
tags: parseTags(formData.get("tags")),
|
||||
};
|
||||
const parsed = MemoryUpdateInput.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ id: memories.id, content: memories.content })
|
||||
.from(memories)
|
||||
.where(
|
||||
and(eq(memories.id, parsed.data.id), eq(memories.userId, userId), isNull(memories.deletedAt)),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new Error("not found");
|
||||
|
||||
const update: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (parsed.data.tags !== undefined) update.tags = parsed.data.tags;
|
||||
if (parsed.data.content !== undefined && parsed.data.content !== existing[0].content) {
|
||||
update.content = parsed.data.content;
|
||||
update.embedding = await embedText(parsed.data.content);
|
||||
}
|
||||
|
||||
await db.update(memories).set(update).where(eq(memories.id, parsed.data.id));
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
userId,
|
||||
actor: "web",
|
||||
action: "memory.update",
|
||||
entityType: "memory",
|
||||
entityId: parsed.data.id,
|
||||
payload: { fields: Object.keys(update).filter((k) => k !== "updatedAt") },
|
||||
});
|
||||
|
||||
revalidatePath(`/memories/${parsed.data.id}`);
|
||||
revalidatePath("/memories");
|
||||
redirect(`/memories/${parsed.data.id}`);
|
||||
}
|
||||
|
||||
export async function deleteMemoryAction(formData: FormData) {
|
||||
const userId = await requireUserId();
|
||||
const id = String(formData.get("id") ?? "");
|
||||
const parsed = MemoryIdInput.safeParse({ id });
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0]!.message);
|
||||
|
||||
const updated = await db
|
||||
.update(memories)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(
|
||||
and(eq(memories.id, parsed.data.id), eq(memories.userId, userId), isNull(memories.deletedAt)),
|
||||
)
|
||||
.returning({ id: memories.id });
|
||||
|
||||
if (!updated[0]) throw new Error("not found");
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
userId,
|
||||
actor: "web",
|
||||
action: "memory.delete",
|
||||
entityType: "memory",
|
||||
entityId: updated[0].id,
|
||||
});
|
||||
|
||||
revalidatePath("/memories");
|
||||
redirect("/memories");
|
||||
}
|
||||
Reference in New Issue
Block a user