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