feat(server): plan-07 operability — CLI subcommands, headers, viewer, schema, docker

- #2572: server keys/jobs/api-key migrate-scopes CLI subcommands (secrets never
  printed), hand-rolled security headers (no helmet dep), wrong-runtime guard.
- #2552: mount viewer static handler + compat API on the server runtime (ServerViewerRoutes).
- #2554: fix stale Claude model (claude-3-5-sonnet-latest -> claude-sonnet-4-6);
  document subscription vs API-key auth; confirm 0.0.0.0 bind avoids loopback ECONNREFUSED.
- #2558: docker-compose restart: unless-stopped on all services, REDIS_URL fallback,
  credentials-file mount (config-only, not runtime-verified in sandbox).
- #2560: postgres platform_source column+indexes (idempotent), thread platform_source
  end-to-end through events schema/storage/routes/compat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-05-28 17:38:54 -07:00
parent 33ae79ec0d
commit b45515c5fe
19 changed files with 1078 additions and 314 deletions
+40 -2
View File
@@ -28,10 +28,33 @@
# POSTGRES_USER
# POSTGRES_PASSWORD
# POSTGRES_DB
#
# #2558 — every long-running service declares `restart: unless-stopped` so a
# crashed container (OOM, panic, transient dependency failure) is brought back
# automatically; a killed container recovers without operator intervention.
#
# #2558 — Redis/Valkey URL has a fallback so the stack is not brittle: the
# worker/server read CLAUDE_MEM_REDIS_URL with a default of
# redis://valkey:6379 instead of hard-failing when the var is unset.
#
# #2558 — secrets can be supplied via a credentials file mounted into the
# server/worker containers (see the commented `secrets:` blocks below) instead
# of being passed inline through the environment.
#
# Auth modes (#2554):
# - API-KEY auth (default here, CLAUDE_MEM_AUTH_MODE=api-key): every request
# carries a bearer key created with `server api-key create`. Generation
# uses a configured provider API key (ANTHROPIC_API_KEY/...). This path
# bills per token and can be EXPENSIVE at high observation volume.
# - SUBSCRIPTION auth: point the generation provider at a Claude subscription
# / Pro session instead of a metered API key to avoid per-token API cost.
# Set the provider credentials accordingly on the worker service; the HTTP
# auth contract (bearer API keys) is unchanged.
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
@@ -47,6 +70,7 @@ services:
valkey:
image: valkey/valkey:8-alpine
restart: unless-stopped
# BullMQ requires noeviction; AOF gives durability across restarts.
command:
- valkey-server
@@ -68,6 +92,7 @@ services:
build:
context: .
dockerfile: docker/claude-mem/Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
@@ -86,7 +111,10 @@ services:
CLAUDE_MEM_WORKER_PORT: "37877"
CLAUDE_MEM_DATA_DIR: /data/claude-mem
CLAUDE_MEM_QUEUE_ENGINE: bullmq
CLAUDE_MEM_REDIS_URL: redis://valkey:6379
# #2558 — REDIS_URL fallback: default to the in-stack valkey service so the
# var is not a brittle hard requirement; override CLAUDE_MEM_REDIS_URL to
# point at an external Redis.
CLAUDE_MEM_REDIS_URL: ${CLAUDE_MEM_REDIS_URL:-redis://valkey:6379}
CLAUDE_MEM_REDIS_MODE: docker
CLAUDE_MEM_SERVER_DATABASE_URL: postgres://${POSTGRES_USER:?POSTGRES_USER is required}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB:?POSTGRES_DB is required}
CLAUDE_MEM_AUTH_MODE: api-key
@@ -98,6 +126,11 @@ services:
- "37877:37877"
volumes:
- claude-mem-data:/data/claude-mem
# #2558 — credentials-file mount. Place provider/API secrets in a file
# (git-ignored) and mount it read-only instead of inlining secrets in the
# environment. The entrypoint / operator can `source` it. Uncomment and
# point CREDENTIALS_FILE at the host path:
# - ${CREDENTIALS_FILE:-./.docker-credentials}:/run/secrets/claude-mem-credentials:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:37877/healthz"]
interval: 10s
@@ -109,6 +142,7 @@ services:
build:
context: .
dockerfile: docker/claude-mem/Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
@@ -122,7 +156,8 @@ services:
CLAUDE_MEM_RUNTIME: server-beta
CLAUDE_MEM_DATA_DIR: /data/claude-mem
CLAUDE_MEM_QUEUE_ENGINE: bullmq
CLAUDE_MEM_REDIS_URL: redis://valkey:6379
# #2558 — REDIS_URL fallback (see server service above).
CLAUDE_MEM_REDIS_URL: ${CLAUDE_MEM_REDIS_URL:-redis://valkey:6379}
CLAUDE_MEM_REDIS_MODE: docker
CLAUDE_MEM_SERVER_DATABASE_URL: postgres://${POSTGRES_USER:?POSTGRES_USER is required}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB:?POSTGRES_DB is required}
CLAUDE_MEM_AUTH_MODE: api-key
@@ -137,6 +172,9 @@ services:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
volumes:
- claude-mem-data:/data/claude-mem
# #2558 — credentials-file mount (see server service above). Keeps
# provider/API secrets out of the inline environment.
# - ${CREDENTIALS_FILE:-./.docker-credentials}:/run/secrets/claude-mem-credentials:ro
volumes:
claude-mem-data:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
@@ -10,6 +10,11 @@ export const AgentEventSchema = z.object({
serverSessionId: z.string().min(1).nullable().default(null),
sourceType: AgentEventSourceTypeSchema,
eventType: z.string().min(1),
// #2560 — which platform produced the event (claude-code, opencode, cursor,
// ...). Persisted on the Postgres agent_events row for plan-09 scoping; the
// SQLite repo ignores it. Optional and nullable so existing clients are
// unaffected.
platformSource: z.string().min(1).nullable().default(null),
payload: z.unknown().default({}),
contentSessionId: z.string().min(1).nullable().default(null),
memorySessionId: z.string().min(1).nullable().default(null),
@@ -22,6 +27,7 @@ export const CreateAgentEventSchema = AgentEventSchema.omit({
createdAtEpoch: true
}).partial({
serverSessionId: true,
platformSource: true,
payload: true,
contentSessionId: true,
memorySessionId: true
@@ -105,6 +105,9 @@ export class SessionsObservationsAdapter implements RouteHandler {
sourceAdapter: COMPAT_SOURCE_ADAPTER,
sourceEventId: toolUseId,
eventType: COMPAT_EVENT_TYPE,
// #2560 — persist platform_source on the event row (not just inside
// payload) so plan-09 scoping/queries can filter by platform.
platformSource: typeof parsed.data.platformSource === 'string' ? parsed.data.platformSource : null,
payload: {
contentSessionId: parsed.data.contentSessionId,
tool_name: parsed.data.tool_name,
@@ -14,7 +14,13 @@ import type {
const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_VERSION = '2023-06-01';
const DEFAULT_MODEL = 'claude-3-5-sonnet-latest';
// #2554 — the previous default `claude-3-5-sonnet-latest` is stale and 404s on
// the current Anthropic Messages API. Align with the repo's canonical default
// model (CLAUDE_MEM_MODEL in src/ui/viewer/constants/settings.ts and the
// installer's model list) so a server with no explicit CLAUDE_MEM_SERVER_MODEL
// generates against a valid model id instead of failing every job with a 404.
export const DEFAULT_SERVER_CLAUDE_MODEL = 'claude-sonnet-4-6';
const DEFAULT_MODEL = DEFAULT_SERVER_CLAUDE_MODEL;
export interface ClaudeObservationProviderOptions {
apiKey: string;
@@ -985,6 +985,7 @@ export class ServerV1PostgresRoutes implements RouteHandler {
? ((body as Record<string, unknown>).sourceEventId as string)
: null,
eventType: body.eventType,
platformSource: body.platformSource ?? null,
payload: (body.payload ?? {}) as object,
metadata: typeof (body as Record<string, unknown>).metadata === 'object'
&& (body as Record<string, unknown>).metadata !== null
@@ -1663,6 +1664,7 @@ function serializeEvent(event: PostgresAgentEvent): Record<string, unknown> {
sourceAdapter: event.sourceAdapter,
sourceEventId: event.sourceEventId,
eventType: event.eventType,
platformSource: event.platformSource,
payload: event.payload,
metadata: event.metadata,
occurredAtEpoch: event.occurredAtEpoch,
+260 -7
View File
@@ -18,6 +18,7 @@ import { ServerV1PostgresRoutes } from '../routes/v1/ServerV1PostgresRoutes.js';
import { SessionsObservationsAdapter } from '../compat/SessionsObservationsAdapter.js';
import { SessionsSummarizeAdapter } from '../compat/SessionsSummarizeAdapter.js';
import { ActiveServerBetaQueueManager } from './ActiveServerBetaQueueManager.js';
import { ServerViewerRoutes } from './ServerViewerRoutes.js';
import type { ServerBetaServiceGraph, ServerBetaQueueLaneMetric } from './types.js';
const SERVER_BETA_RUNTIME = 'server-beta';
@@ -118,6 +119,9 @@ export class ServerBetaService {
}
const server = new Server({
// #2572 — server runtime is reachable over the network in Docker, so it
// emits hardening headers (the worker, loopback-only, does not).
securityHeaders: true,
getInitializationComplete: () => this.graph.postgres.bootstrap.initialized,
getMcpReady: () => true,
onShutdown: () => this.stop(),
@@ -199,6 +203,13 @@ export class ServerBetaService {
authMode: compatAuthMode,
}));
// #2552 — mount the Viewer UI static handler so the viewer loads on the
// server runtime. Registered AFTER the /v1 and compat API routes so the
// viewer's own API calls resolve against those; express.static only
// matches existing files and the `/` GET only matches the root, so this
// never shadows an API route.
server.registerRoutes(new ServerViewerRoutes());
server.finalizeRoutes();
await server.listen(this.requestedPort, this.host);
@@ -295,15 +306,27 @@ export async function runServerBetaCli(argv: string[] = process.argv.slice(2)):
process.exit(1);
}
// `server api-key create|list|revoke` mirrors the worker-service tooling
// but writes to the Postgres `api_keys` table the server-beta runtime
// actually reads from. The legacy worker-service CLI talks to SQLite and
// would be invisible to this stack.
// `server api-key create|list|revoke|migrate-scopes` mirrors the
// worker-service tooling but writes to the Postgres `api_keys` table the
// server-beta runtime actually reads from. The legacy worker-service CLI
// talks to SQLite and would be invisible to this stack.
if (command === 'server' && argv[1]?.toLowerCase() === 'api-key') {
await runServerBetaApiKeyCli(argv.slice(2));
return;
}
// #2572 — `server keys` lists ACTIVE keys (never printing secrets) and
// `server jobs` lists/inspects queued generation jobs. Both read the
// Postgres backend the server-beta runtime uses.
if (command === 'server' && argv[1]?.toLowerCase() === 'keys') {
await runServerBetaKeysCli(argv.slice(2));
return;
}
if (command === 'server' && argv[1]?.toLowerCase() === 'jobs') {
await runServerBetaJobsCli(argv.slice(2));
return;
}
switch (command) {
case 'start': {
const existing = readServerBetaPidFile();
@@ -385,6 +408,9 @@ export async function runServerBetaCli(argv: string[] = process.argv.slice(2)):
console.error(' stop stop a running daemon');
console.error(' restart stop then start (daemon)');
console.error(' status print runtime status');
console.error(' server api-key create|list|revoke|migrate-scopes manage Postgres API keys');
console.error(' server keys list active keys (no secrets)');
console.error(' server jobs [list|inspect <id>] list/inspect generation jobs');
process.exit(1);
}
}
@@ -412,15 +438,54 @@ async function runServerBetaForeground(port: number, host: string): Promise<void
// legacy `worker-service.cjs server api-key` command talks to SQLite and
// is invisible to the server-beta runtime, which reads keys from
// Postgres. Use this entrypoint inside Docker / Compose.
// #2572 — wrong-runtime guard.
//
// The server-beta operability commands (`api-key`, `keys`, `jobs`) only make
// sense in the server-beta runtime, whose canonical store is Postgres. If they
// are invoked in a worker-only context — `CLAUDE_MEM_RUNTIME` set to `worker`,
// or no `CLAUDE_MEM_SERVER_DATABASE_URL` configured — we fail fast with an
// actionable message instead of crashing later with an opaque pool error.
export function assertServerRuntimeForCli(
commandLabel: string,
env: NodeJS.ProcessEnv = process.env,
): void {
const runtime = (env.CLAUDE_MEM_RUNTIME ?? '').trim().toLowerCase();
if (runtime && runtime !== 'server-beta') {
throw new Error(
`\`server ${commandLabel}\` is a server-beta runtime command, but CLAUDE_MEM_RUNTIME=${runtime}. ` +
'Set CLAUDE_MEM_RUNTIME=server-beta (and CLAUDE_MEM_SERVER_DATABASE_URL) to run server operations, ' +
'or use the worker CLI (`worker-service ...`) for the worker runtime.',
);
}
if (!(env.CLAUDE_MEM_SERVER_DATABASE_URL ?? '').trim()) {
throw new Error(
`CLAUDE_MEM_SERVER_DATABASE_URL is required for \`server ${commandLabel}\`. ` +
'This command talks to the server-beta Postgres backend; export the connection string before running it.',
);
}
}
export async function runServerBetaApiKeyCli(argv: string[]): Promise<void> {
const sub = argv[0]?.toLowerCase();
const options = parseFlagArgs(argv.slice(1));
if (!process.env.CLAUDE_MEM_SERVER_DATABASE_URL) {
console.error('CLAUDE_MEM_SERVER_DATABASE_URL is required for `server api-key` commands.');
try {
assertServerRuntimeForCli('api-key');
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
// #2560 — `api-key migrate-scopes <id>` brings a key's scope set up to a
// working default (or an explicit --scope list). The pure helper
// migrateServerApiKeyScopes() backs the SQLite path; here we apply the same
// semantics against the Postgres `api_keys` table the server-beta runtime
// reads from.
if (sub === 'migrate-scopes') {
await migrateServerBetaApiKeyScopes(argv.slice(1));
return;
}
const { getSharedPostgresPool } = await import('../../storage/postgres/index.js');
const { PostgresAuthRepository } = await import('../../storage/postgres/auth.js');
const { createHash, randomBytes } = await import('crypto');
@@ -538,7 +603,7 @@ export async function runServerBetaApiKeyCli(argv: string[]): Promise<void> {
}
console.error(`Unknown server api-key subcommand: ${sub ?? '(none)'}`);
console.error('Usage: server-beta-service server api-key create|list|revoke');
console.error('Usage: server-beta-service server api-key create|list|revoke|migrate-scopes');
process.exit(1);
} finally {
// Pool is shared; do not close here. The process will exit and the
@@ -546,6 +611,194 @@ export async function runServerBetaApiKeyCli(argv: string[]): Promise<void> {
}
}
// #2560 — Postgres scope migration. Mirrors migrateServerApiKeyScopes() (the
// SQLite helper) for the server-beta Postgres `api_keys` table: re-issues a
// key's scope set so an operator can bring legacy/empty-scope keys up to a
// working default (or an explicit --scope list). Defaults to the same
// read+write memory scopes the v1 routes require.
const DEFAULT_SERVER_KEY_SCOPES = ['memories:read', 'memories:write'];
export async function migrateServerBetaApiKeyScopes(argv: string[]): Promise<void> {
const id = argv[0] && !argv[0].startsWith('--') ? argv[0] : undefined;
const options = parseFlagArgs(argv);
if (!id) {
console.error('Usage: server-beta-service server api-key migrate-scopes <id> [--scope a,b]');
process.exit(1);
}
const scopes = (options.scope ?? options.scopes ?? DEFAULT_SERVER_KEY_SCOPES.join(','))
.split(',')
.map(scope => scope.trim())
.filter(Boolean);
const { getSharedPostgresPool } = await import('../../storage/postgres/index.js');
const pool = getSharedPostgresPool({ requireDatabaseUrl: true });
const result = await pool.query<{ id: string; scopes: unknown }>(
`UPDATE api_keys
SET scopes = $2::jsonb, updated_at = now()
WHERE id = $1 AND revoked_at IS NULL
RETURNING id, scopes`,
[id, JSON.stringify(scopes)],
);
if (result.rowCount === 0) {
console.error(`API key not found or revoked: ${id}`);
process.exit(1);
}
console.log(JSON.stringify({ id, scopes, status: 'scopes-migrated' }, null, 2));
}
// #2572 — pure serialization for `server keys`. SECURITY: this is the ONLY
// shaping of a key row the `keys` command emits, and it deliberately copies
// only non-secret metadata — never `key_hash` or any raw key material. Exported
// so a test can prove no secret field can leak regardless of the input row.
export interface ServerKeyRow {
id: string;
team_id: string | null;
project_id: string | null;
scopes: unknown;
expires_at: Date | null;
last_used_at: Date | null;
created_at: Date;
// A leaked/extra secret column should NEVER appear in the output.
key_hash?: string;
}
export function serializeActiveServerKeyRow(row: ServerKeyRow): Record<string, unknown> {
return {
id: row.id,
teamId: row.team_id,
projectId: row.project_id,
scopes: row.scopes,
status: 'active',
lastUsedAt: row.last_used_at?.toISOString() ?? null,
expiresAt: row.expires_at?.toISOString() ?? null,
createdAt: row.created_at.toISOString(),
};
}
// #2572 — `server keys`: list ACTIVE (non-revoked, non-expired) keys. NEVER
// prints the raw key or its hash — only non-secret metadata. This is a thin
// operator convenience over `api-key list` that filters to usable keys.
export async function runServerBetaKeysCli(argv: string[]): Promise<void> {
try {
assertServerRuntimeForCli('keys');
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
const options = parseFlagArgs(argv);
const teamFilter = options.team ?? null;
const limitArg = Number.parseInt(options.limit ?? '100', 10);
const limit = Number.isFinite(limitArg) && limitArg > 0 && limitArg <= 500 ? limitArg : 100;
const { getSharedPostgresPool } = await import('../../storage/postgres/index.js');
const pool = getSharedPostgresPool({ requireDatabaseUrl: true });
const where = teamFilter
? 'WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()) AND team_id = $2'
: 'WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())';
const params: unknown[] = teamFilter ? [limit, teamFilter] : [limit];
const result = await pool.query<{
id: string;
team_id: string | null;
project_id: string | null;
scopes: unknown;
expires_at: Date | null;
last_used_at: Date | null;
created_at: Date;
}>(
`SELECT id, team_id, project_id, scopes, expires_at, last_used_at, created_at
FROM api_keys
${where}
ORDER BY created_at DESC
LIMIT $1`,
params,
);
// SECURITY: serializeActiveServerKeyRow omits key_hash and any raw key
// material — only non-secret metadata is emitted.
console.log(JSON.stringify({
teamId: teamFilter,
count: result.rows.length,
keys: result.rows.map(serializeActiveServerKeyRow),
}, null, 2));
}
// #2572 — `server jobs [list|inspect <id>]`: list or inspect queued generation
// jobs from the Postgres `observation_generation_jobs` table the server-beta
// runtime and its BullMQ workers share.
export async function runServerBetaJobsCli(argv: string[]): Promise<void> {
try {
assertServerRuntimeForCli('jobs');
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
const sub = (argv[0] && !argv[0].startsWith('--') ? argv[0] : 'list').toLowerCase();
const { getSharedPostgresPool } = await import('../../storage/postgres/index.js');
const pool = getSharedPostgresPool({ requireDatabaseUrl: true });
if (sub === 'inspect') {
const id = argv[1];
if (!id) {
console.error('Usage: server-beta-service server jobs inspect <id>');
process.exit(1);
}
const result = await pool.query(
`SELECT id, project_id, team_id, source_type, source_id, status, attempts,
max_attempts, created_at, completed_at, failed_at, last_error, payload
FROM observation_generation_jobs WHERE id = $1`,
[id],
);
if (result.rowCount === 0) {
console.error(`Generation job not found: ${id}`);
process.exit(1);
}
console.log(JSON.stringify(result.rows[0], null, 2));
return;
}
// list (default)
const options = parseFlagArgs(sub === 'list' ? argv.slice(1) : argv);
const status = options.status ?? null;
const limitArg = Number.parseInt(options.limit ?? '50', 10);
const limit = Number.isFinite(limitArg) && limitArg > 0 && limitArg <= 500 ? limitArg : 50;
const params: unknown[] = [limit];
let where = '';
if (status) {
params.unshift(status);
where = 'WHERE status = $1';
}
const limitIdx = params.length;
const result = await pool.query<{
id: string;
project_id: string;
team_id: string;
source_type: string;
status: string;
attempts: number;
created_at: Date;
}>(
`SELECT id, project_id, team_id, source_type, status, attempts, created_at
FROM observation_generation_jobs
${where}
ORDER BY created_at DESC
LIMIT $${limitIdx}`,
params,
);
console.log(JSON.stringify({
status: status ?? 'any',
count: result.rows.length,
jobs: result.rows.map(row => ({
id: row.id,
projectId: row.project_id,
teamId: row.team_id,
sourceType: row.source_type,
status: row.status,
attempts: row.attempts,
createdAt: row.created_at.toISOString(),
})),
}, null, 2));
}
function parseFlagArgs(argv: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (let i = 0; i < argv.length; i++) {
+69
View File
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: Apache-2.0
//
// #2552 — Viewer UI on the server runtime.
//
// The Viewer UI (plugin/ui/viewer.html) is served by the in-plugin worker via
// ViewerRoutes, but the server-beta runtime never mounted any static handler,
// so the viewer was unreachable. This handler mirrors the worker's static
// serving: it caches viewer.html at boot and serves it at `/` plus any static
// assets under the package `ui` directory. The viewer's API calls resolve
// against the same Express app (the /v1/* routes and the legacy
// /api/sessions/* compat adapters are already registered on it).
import express, { type Application, type Request, type Response } from 'express';
import path from 'path';
import { existsSync, readFileSync } from 'fs';
import type { RouteHandler } from '../../services/server/Server.js';
import { getPackageRoot } from '../../shared/paths.js';
import { logger } from '../../utils/logger.js';
const VIEWER_HTML_CANDIDATE_PATHS: readonly string[] = (() => {
const packageRoot = getPackageRoot();
return [
path.join(packageRoot, 'ui', 'viewer.html'),
path.join(packageRoot, 'plugin', 'ui', 'viewer.html'),
];
})();
const resolvedViewerHtmlPath: string | null =
VIEWER_HTML_CANDIDATE_PATHS.find(candidate => existsSync(candidate)) ?? null;
const viewerHtmlBytes: Buffer | null = resolvedViewerHtmlPath
? readFileSync(resolvedViewerHtmlPath)
: null;
if (resolvedViewerHtmlPath) {
logger.info('SYSTEM', 'Cached viewer.html at boot (server runtime)', {
path: resolvedViewerHtmlPath,
bytes: viewerHtmlBytes!.byteLength,
});
} else {
logger.warn('SYSTEM', 'viewer.html not found for server runtime', {
candidates: VIEWER_HTML_CANDIDATE_PATHS,
});
}
export class ServerViewerRoutes implements RouteHandler {
setupRoutes(app: Application): void {
const packageRoot = getPackageRoot();
// Serve static assets from BOTH the npm-package `ui` dir and the plugin
// `plugin/ui` dir, matching the worker's resolution order so the viewer
// loads regardless of which layout the server image ships.
app.use(express.static(path.join(packageRoot, 'ui')));
app.use(express.static(path.join(packageRoot, 'plugin', 'ui')));
app.get('/', (_req: Request, res: Response) => {
if (!viewerHtmlBytes) {
res.status(503).json({ error: 'ViewerUnavailable', message: 'Viewer UI not found at any expected location' });
return;
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(viewerHtmlBytes);
});
}
// Exposed for tests: did the build ship a viewer.html the server can serve?
static hasViewerHtml(): boolean {
return viewerHtmlBytes !== null;
}
}
+35
View File
@@ -87,6 +87,29 @@ export interface ServerOptions {
getAiStatus: () => AiStatus;
preBodyParserRoutes?: RouteHandler[];
getQueueHealth?: () => ObservationQueueHealth | null | Promise<ObservationQueueHealth | null>;
// #2572 — when true, install a minimal set of hardening response headers
// (the same headers helmet's defaults emit) before any route runs. Opt-in so
// the in-plugin worker runtime is unchanged; the server runtime sets it.
securityHeaders?: boolean;
}
// #2572 — hand-rolled security headers.
//
// We deliberately do NOT add `helmet` as a dependency: it is not currently in
// package.json, and the only headers we need for the server runtime are a small
// static set that helmet itself emits by default. Hand-rolling them keeps the
// dependency surface (and the esbuild bundle) unchanged while still closing the
// hardening gap. If helmet is ever added for richer policy, this can delegate.
export function applySecurityHeaders(res: Response): void {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-DNS-Prefetch-Control', 'off');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
res.setHeader('Origin-Agent-Cluster', '?1');
// Helmet removes this fingerprinting header by default.
res.removeHeader('X-Powered-By');
}
export class Server {
@@ -98,6 +121,8 @@ export class Server {
constructor(options: ServerOptions) {
this.options = options;
this.app = express();
this.app.disable('x-powered-by');
this.setupSecurityHeaders();
this.setupCors();
this.setupPreBodyParserRoutes();
this.setupMiddleware();
@@ -163,6 +188,16 @@ export class Server {
middlewares.forEach(mw => this.app.use(mw));
}
private setupSecurityHeaders(): void {
if (!this.options.securityHeaders) {
return;
}
this.app.use((_req: Request, res: Response, next: () => void) => {
applySecurityHeaders(res);
next();
});
}
private setupCors(): void {
this.app.use(createCorsMiddleware());
}
+44 -5
View File
@@ -53,6 +53,7 @@ import {
createServerApiKey,
listServerApiKeys,
revokeServerApiKey,
migrateServerApiKeyScopes,
DEFAULT_LOCAL_API_KEY_SCOPES,
} from '../server/auth/sqlite-api-key-service.js';
import { ServerV1Routes } from '../server/routes/v1/ServerV1Routes.js';
@@ -564,7 +565,7 @@ function parseWorkerServiceCommand(argv: string[]): ParsedWorkerCommand {
if (maybeSubCommand && lifecycleCommands.has(maybeSubCommand)) {
return { command: `server-${maybeSubCommand}`, args: rest };
}
const serverCommands = new Set(['logs', 'doctor', 'migrate', 'export', 'import', 'api-key']);
const serverCommands = new Set(['logs', 'doctor', 'migrate', 'export', 'import', 'api-key', 'keys', 'jobs']);
return {
command: maybeSubCommand && serverCommands.has(maybeSubCommand) ? `server-${maybeSubCommand}` : 'server-help',
args: rest,
@@ -602,7 +603,7 @@ function printWorkerAliasHelp(): never {
process.exit(1);
}
function runServerBetaServiceCli(command: string): void {
function runServerBetaServiceCli(command: string, extraArgs: string[] = []): void {
const serverBetaScript = path.join(__dirname, 'server-beta-service.cjs');
if (!existsSync(serverBetaScript)) {
console.error(`Server beta script not found at: ${serverBetaScript}`);
@@ -610,7 +611,7 @@ function runServerBetaServiceCli(command: string): void {
process.exit(1);
}
const child = spawn(process.execPath, [serverBetaScript, command], {
const child = spawn(process.execPath, [serverBetaScript, command, ...extraArgs], {
stdio: 'inherit',
env: process.env,
});
@@ -709,8 +710,29 @@ function runServerApiKeyCli(args: string[]): never {
process.exit(0);
}
if (subCommand === 'migrate-scopes') {
// #2560 — bring a key's scope set up to the default (or an explicit
// --scope list) so legacy/empty-scope keys work against the v1 routes.
const id = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
if (!id) {
console.error('Usage: worker-service server api-key migrate-scopes <id> [--scope a,b]');
process.exit(1);
}
const scopeFlag = options.scope ?? options.scopes;
const scopes = scopeFlag
? scopeFlag.split(',').map(scope => scope.trim()).filter(Boolean)
: [...DEFAULT_LOCAL_API_KEY_SCOPES];
const updated = migrateServerApiKeyScopes(db, id, scopes);
if (!updated) {
console.error(`API key not found: ${id}`);
process.exit(1);
}
console.log(JSON.stringify({ id: updated.id, scopes: updated.scopes, status: 'scopes-migrated' }, null, 2));
process.exit(0);
}
console.error(`Unknown server api-key subcommand: ${subCommand ?? '(none)'}`);
console.error('Usage: worker-service server api-key create|list|revoke');
console.error('Usage: worker-service server api-key create|list|revoke|migrate-scopes');
process.exit(1);
} finally {
db.close();
@@ -813,12 +835,29 @@ async function main() {
if (apiKeyCommand === 'create' || apiKeyCommand === 'list' || apiKeyCommand === 'revoke') {
runServerApiKeyCli(commandArgs);
}
if (apiKeyCommand === 'migrate-scopes') {
// #2560 — scope migration runs against the SQLite local backend here.
runServerApiKeyCli(commandArgs);
}
console.error(`Unknown server api-key subcommand: ${apiKeyCommand ?? '(none)'}`);
console.error('Usage: worker-service server api-key create|list|revoke');
console.error('Usage: worker-service server api-key create|list|revoke|migrate-scopes');
process.exit(1);
break;
}
// #2572 — `keys`/`jobs` are server-beta (Postgres) operability commands.
// Delegate to the server-beta script so they read the Postgres backend the
// server runtime actually uses, instead of the SQLite worker store.
case 'server-keys': {
runServerBetaServiceCli('server', ['keys', ...commandArgs]);
break;
}
case 'server-jobs': {
runServerBetaServiceCli('server', ['jobs', ...commandArgs]);
break;
}
case 'server-help': {
printServerCommandHelp();
break;
+11 -3
View File
@@ -21,6 +21,7 @@ export interface PostgresAgentEvent {
sourceEventId: string | null;
idempotencyKey: string;
eventType: string;
platformSource: string | null;
payload: JsonValue;
metadata: JsonObject;
occurredAtEpoch: number;
@@ -36,6 +37,9 @@ export interface CreatePostgresAgentEventInput {
sourceAdapter: string;
sourceEventId?: string | null;
eventType: string;
// #2560 — which platform produced the event (claude-code, opencode, ...).
// Persisted on agent_events for plan-09 scoping. Optional; null when unknown.
platformSource?: string | null;
payload?: JsonValue;
metadata?: JsonObject;
occurredAt: Date | string | number;
@@ -50,6 +54,7 @@ interface AgentEventRow {
source_event_id: string | null;
idempotency_key: string;
event_type: string;
platform_source: string | null;
payload: unknown;
metadata: unknown;
occurred_at: Date;
@@ -71,11 +76,12 @@ export class PostgresAgentEventsRepository {
`
INSERT INTO agent_events (
id, project_id, team_id, server_session_id, source_adapter,
source_event_id, idempotency_key, event_type, payload, metadata, occurred_at
source_event_id, idempotency_key, event_type, platform_source, payload, metadata, occurred_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12)
ON CONFLICT (idempotency_key) DO UPDATE SET
metadata = agent_events.metadata || excluded.metadata
metadata = agent_events.metadata || excluded.metadata,
platform_source = COALESCE(excluded.platform_source, agent_events.platform_source)
RETURNING *
`,
[
@@ -87,6 +93,7 @@ export class PostgresAgentEventsRepository {
input.sourceEventId ?? null,
idempotencyKey,
input.eventType,
input.platformSource ?? null,
JSON.stringify(input.payload ?? {}),
JSON.stringify(input.metadata ?? {}),
new Date(input.occurredAt)
@@ -177,6 +184,7 @@ function mapAgentEventRow(row: AgentEventRow): PostgresAgentEvent {
sourceEventId: row.source_event_id,
idempotencyKey: row.idempotency_key,
eventType: row.event_type,
platformSource: row.platform_source,
payload: row.payload,
metadata: toJsonObject(row.metadata),
occurredAtEpoch: toEpoch(row.occurred_at),
+10
View File
@@ -256,6 +256,16 @@ CREATE TABLE IF NOT EXISTS observation_generation_job_events (
CREATE INDEX IF NOT EXISTS idx_agent_events_project_session ON agent_events(project_id, server_session_id, occurred_at);
ALTER TABLE server_sessions ADD COLUMN IF NOT EXISTS idempotency_key TEXT;
-- #2560 platform_source on agent_events (consistent with server_sessions and
-- the plan-09 scoping): which platform produced the event (claude-code,
-- opencode, cursor, ...). Idempotent so an existing DB upgrades in place.
ALTER TABLE agent_events ADD COLUMN IF NOT EXISTS platform_source TEXT;
CREATE INDEX IF NOT EXISTS idx_agent_events_platform_source
ON agent_events(team_id, project_id, platform_source, occurred_at)
WHERE platform_source IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_server_sessions_platform_source
ON server_sessions(team_id, project_id, platform_source, started_at)
WHERE platform_source IS NOT NULL;
ALTER TABLE observations ADD COLUMN IF NOT EXISTS content_search TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;
ALTER TABLE observations DROP CONSTRAINT IF EXISTS observations_generation_key_key;
ALTER TABLE observation_generation_jobs DROP CONSTRAINT IF EXISTS observation_generation_jobs_source_type_source_id_job_type_key;
+2
View File
@@ -270,6 +270,7 @@ interface UnprocessedEventRow {
source_event_id: string | null;
idempotency_key: string;
event_type: string;
platform_source: string | null;
payload: unknown;
metadata: unknown;
occurred_at: Date;
@@ -287,6 +288,7 @@ function mapUnprocessedEventRow(row: UnprocessedEventRow): PostgresAgentEvent {
sourceEventId: row.source_event_id,
idempotencyKey: row.idempotency_key,
eventType: row.event_type,
platformSource: row.platform_source,
payload: toJsonObject(row.payload),
metadata: toJsonObject(row.metadata),
occurredAtEpoch: row.occurred_at.getTime(),
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: Apache-2.0
//
// #2560 — platform_source threading + idempotent Postgres migration.
import { describe, expect, it } from 'bun:test';
import { CreateAgentEventSchema, AgentEventSchema } from '../../src/core/schemas/agent-event.js';
describe('agent-event platformSource threading (#2560)', () => {
it('CreateAgentEventSchema preserves platformSource when provided', () => {
const parsed = CreateAgentEventSchema.parse({
projectId: 'proj-1',
sourceType: 'api',
eventType: 'tool_use',
platformSource: 'opencode',
occurredAtEpoch: 1,
});
expect(parsed.platformSource).toBe('opencode');
});
it('defaults platformSource to null when omitted (back-compat)', () => {
const parsed = CreateAgentEventSchema.parse({
projectId: 'proj-1',
sourceType: 'hook',
eventType: 'tool_use',
occurredAtEpoch: 1,
});
expect(parsed.platformSource).toBeNull();
});
it('full AgentEventSchema round-trips platformSource', () => {
const parsed = AgentEventSchema.parse({
id: 'evt-1',
projectId: 'proj-1',
sourceType: 'server',
eventType: 'tool_use',
platformSource: 'claude-code',
occurredAtEpoch: 1,
createdAtEpoch: 2,
});
expect(parsed.platformSource).toBe('claude-code');
});
});
+37
View File
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: Apache-2.0
//
// #2572 — `server keys` must list active keys WITHOUT ever printing secrets
// (the raw key or its hash). We prove the pure serializer drops any secret
// column even when the input row carries one.
import { describe, expect, it } from 'bun:test';
import { serializeActiveServerKeyRow } from '../../src/server/runtime/ServerBetaService.js';
describe('server keys CLI — never prints secrets (#2572)', () => {
it('emits only non-secret metadata and drops key_hash entirely', () => {
const out = serializeActiveServerKeyRow({
id: 'key-1',
team_id: 'team-1',
project_id: 'proj-1',
scopes: ['memories:read', 'memories:write'],
expires_at: null,
last_used_at: new Date('2026-05-28T00:00:00.000Z'),
created_at: new Date('2026-05-01T00:00:00.000Z'),
// A secret that must NEVER survive serialization.
key_hash: 'scrypt$16384$deadbeef$cafebabe',
});
expect(out.id).toBe('key-1');
expect(out.status).toBe('active');
expect(out.scopes).toEqual(['memories:read', 'memories:write']);
// No secret field, under any name, may appear.
const serialized = JSON.stringify(out);
expect(serialized).not.toContain('key_hash');
expect(serialized).not.toContain('keyHash');
expect(serialized).not.toContain('scrypt$');
expect(serialized).not.toContain('deadbeef');
expect(Object.keys(out)).not.toContain('key_hash');
expect(Object.keys(out)).not.toContain('keyHash');
});
});
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: Apache-2.0
//
// #2572 — wrong-runtime guard for the server-beta operability CLI.
// #2554 — stale DEFAULT_MODEL fix.
import { describe, expect, it } from 'bun:test';
import { assertServerRuntimeForCli } from '../../src/server/runtime/ServerBetaService.js';
import { DEFAULT_SERVER_CLAUDE_MODEL } from '../../src/server/generation/providers/ClaudeObservationProvider.js';
describe('assertServerRuntimeForCli — wrong-runtime guard (#2572)', () => {
it('passes for server-beta runtime with a database URL', () => {
expect(() =>
assertServerRuntimeForCli('keys', {
CLAUDE_MEM_RUNTIME: 'server-beta',
CLAUDE_MEM_SERVER_DATABASE_URL: 'postgres://localhost/db',
}),
).not.toThrow();
});
it('passes when runtime is unset but a database URL is present (bare server image)', () => {
expect(() =>
assertServerRuntimeForCli('jobs', {
CLAUDE_MEM_SERVER_DATABASE_URL: 'postgres://localhost/db',
}),
).not.toThrow();
});
it('fails CLEARLY when run in a worker-only runtime context', () => {
expect(() =>
assertServerRuntimeForCli('keys', {
CLAUDE_MEM_RUNTIME: 'worker',
CLAUDE_MEM_SERVER_DATABASE_URL: 'postgres://localhost/db',
}),
).toThrow(/server-beta runtime command.*CLAUDE_MEM_RUNTIME=worker/s);
});
it('fails CLEARLY (actionable) when no database URL is configured', () => {
expect(() =>
assertServerRuntimeForCli('jobs', { CLAUDE_MEM_RUNTIME: 'server-beta' }),
).toThrow(/CLAUDE_MEM_SERVER_DATABASE_URL is required/);
});
});
describe('Claude provider default model (#2554)', () => {
it('uses a current, valid model id (not the stale claude-3-5-sonnet-latest)', () => {
expect(DEFAULT_SERVER_CLAUDE_MODEL).toBe('claude-sonnet-4-6');
expect(DEFAULT_SERVER_CLAUDE_MODEL).not.toBe('claude-3-5-sonnet-latest');
});
});
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: Apache-2.0
//
// #2572 — the server runtime must emit hardening response headers. The worker
// (loopback-only) leaves them off. We assert the opt-in `securityHeaders`
// option installs the headers on every response and is absent by default.
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
import { logger } from '../../src/utils/logger.js';
import { Server, type ServerOptions } from '../../src/services/server/Server.js';
function baseOptions(overrides: Partial<ServerOptions> = {}): ServerOptions {
return {
getInitializationComplete: () => true,
getMcpReady: () => true,
onShutdown: () => Promise.resolve(),
onRestart: () => Promise.resolve(),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({ provider: 'disabled', authMethod: 'api-key', lastInteraction: null }),
...overrides,
};
}
describe('Server security headers (#2572)', () => {
let server: Server | null = null;
let spies: ReturnType<typeof spyOn>[] = [];
afterEach(async () => {
spies.forEach(s => s.mockRestore());
spies = [];
if (server?.getHttpServer()) {
try { await server.close(); } catch { /* ignore */ }
}
server = null;
});
it('emits hardening headers on a server response when securityHeaders=true', async () => {
spies = [spyOn(logger, 'info').mockImplementation(() => {})];
server = new Server(baseOptions({ securityHeaders: true }));
const port = 41000 + Math.floor(Math.random() * 9000);
await server.listen(port, '127.0.0.1');
const res = await fetch(`http://127.0.0.1:${port}/api/health`);
expect(res.status).toBe(200);
expect(res.headers.get('x-content-type-options')).toBe('nosniff');
expect(res.headers.get('x-frame-options')).toBe('DENY');
expect(res.headers.get('referrer-policy')).toBe('no-referrer');
expect(res.headers.get('cross-origin-opener-policy')).toBe('same-origin');
expect(res.headers.get('x-powered-by')).toBeNull();
});
it('does NOT emit the hardening headers by default (worker runtime)', async () => {
spies = [spyOn(logger, 'info').mockImplementation(() => {})];
server = new Server(baseOptions());
const port = 41000 + Math.floor(Math.random() * 9000);
await server.listen(port, '127.0.0.1');
const res = await fetch(`http://127.0.0.1:${port}/api/health`);
expect(res.status).toBe(200);
expect(res.headers.get('x-content-type-options')).toBeNull();
expect(res.headers.get('x-frame-options')).toBeNull();
});
});
+79
View File
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: Apache-2.0
//
// #2552 — the Viewer UI + API compat layer must be reachable on the server
// runtime. We register ServerViewerRoutes alongside a stub API route on the
// SAME Express app (as ServerBetaService does) and assert:
// - the viewer root `/` responds (HTML when built, 503 when not),
// - the static handler does NOT shadow a co-mounted API route.
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
import { logger } from '../../src/utils/logger.js';
import { Server, type ServerOptions } from '../../src/services/server/Server.js';
import { ServerViewerRoutes } from '../../src/server/runtime/ServerViewerRoutes.js';
function baseOptions(): ServerOptions {
return {
getInitializationComplete: () => true,
getMcpReady: () => true,
onShutdown: () => Promise.resolve(),
onRestart: () => Promise.resolve(),
workerPath: '',
getAiStatus: () => ({ provider: 'disabled', authMethod: 'api-key', lastInteraction: null }),
};
}
describe('ServerViewerRoutes on the server runtime (#2552)', () => {
let server: Server | null = null;
let spies: ReturnType<typeof spyOn>[] = [];
afterEach(async () => {
spies.forEach(s => s.mockRestore());
spies = [];
if (server?.getHttpServer()) {
try { await server.close(); } catch { /* ignore */ }
}
server = null;
});
it('serves the viewer root and does not shadow a co-mounted API route', async () => {
spies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
];
server = new Server(baseOptions());
// Mirror ServerBetaService: register an API route BEFORE the viewer's
// static handler so we can prove the static handler does not swallow it.
server.registerRoutes({
setupRoutes(app) {
app.get('/v1/info', (_req, res) => {
res.json({ name: 'claude-mem-server', runtime: 'server-beta' });
});
},
});
server.registerRoutes(new ServerViewerRoutes());
server.finalizeRoutes();
const port = 42000 + Math.floor(Math.random() * 9000);
await server.listen(port, '127.0.0.1');
// The co-mounted API route still resolves (compat/v1 layer reachable).
const apiRes = await fetch(`http://127.0.0.1:${port}/v1/info`);
expect(apiRes.status).toBe(200);
const apiBody = await apiRes.json();
expect(apiBody.runtime).toBe('server-beta');
// The viewer root route is registered and responds. When the build shipped
// a viewer.html it is 200 text/html; otherwise it is a clean 503 (not a
// 404/crash), proving the handler is mounted.
const rootRes = await fetch(`http://127.0.0.1:${port}/`);
if (ServerViewerRoutes.hasViewerHtml()) {
expect(rootRes.status).toBe(200);
expect(rootRes.headers.get('content-type')).toContain('text/html');
} else {
expect(rootRes.status).toBe(503);
const body = await rootRes.json();
expect(body.error).toBe('ViewerUnavailable');
}
});
});