mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-09-20 04:23:02 +08:00
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:
+40
-2
@@ -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
+173
-173
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user