From e1ea7164c4b6706732e142a6790af00a4ffbbc58 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Wed, 7 Oct 2026 15:56:30 -0500 Subject: [PATCH 01/13] `@simulacrum/server` process reaper (#381) * server reaper and always watch /stop * launchGraph option support * coderabbit comments * reaper does a tree kill * remove pids * foreground matches background running with tracked commands * split info pid from tracked pid, split req timeout from startup timeout * bump test fixture delay check * reaper remove with release * daemon vs exec statuses * test race * remove test sleep * bit more exec tweaking * status on regardless of pid * use /stop first graph shutdown, then escalate * use service graph set port with none supplied * fmt --- .../simulacrum-server-launchGraph-option.md | 5 + .changes/simulacrum-server-reaper.md | 5 + .changes/simulacrum-server-stop-flag.md | 5 + packages/server/README.md | 53 +- packages/server/package.json | 7 + packages/server/src/cli.ts | 345 +++++++++++-- packages/server/src/control-plane.ts | 486 ++++++++++++++++++ packages/server/src/control-service.ts | 200 ------- packages/server/src/launch-metadata.ts | 33 ++ packages/server/src/reaper.ts | 198 +++++++ packages/server/src/run-reaper.ts | 99 ++++ packages/server/src/select-services.ts | 108 ++++ packages/server/src/service-graph-context.ts | 4 + packages/server/src/service-graph.ts | 246 ++++----- packages/server/src/service-status.ts | 111 ++++ packages/server/src/simulation.ts | 14 +- packages/server/test/cli-background.test.ts | 402 ++++++++++++++- packages/server/test/data-service.test.ts | 282 +++++++++- .../server/test/fixtures/background-graph.ts | 10 +- .../server/test/fixtures/init-data-sim.ts | 12 +- .../test/fixtures/multi-process-graph.ts | 34 ++ packages/server/test/fixtures/reaper-graph.ts | 21 + packages/server/test/reaper.test.ts | 218 ++++++++ packages/server/test/service-status.test.ts | 47 ++ packages/server/tsdown.config.ts | 2 +- pnpm-lock.yaml | 6 + 26 files changed, 2527 insertions(+), 426 deletions(-) create mode 100644 .changes/simulacrum-server-launchGraph-option.md create mode 100644 .changes/simulacrum-server-reaper.md create mode 100644 .changes/simulacrum-server-stop-flag.md create mode 100644 packages/server/src/control-plane.ts delete mode 100644 packages/server/src/control-service.ts create mode 100644 packages/server/src/launch-metadata.ts create mode 100644 packages/server/src/reaper.ts create mode 100644 packages/server/src/run-reaper.ts create mode 100644 packages/server/src/select-services.ts create mode 100644 packages/server/src/service-graph-context.ts create mode 100644 packages/server/src/service-status.ts create mode 100644 packages/server/test/fixtures/multi-process-graph.ts create mode 100644 packages/server/test/fixtures/reaper-graph.ts create mode 100644 packages/server/test/reaper.test.ts create mode 100644 packages/server/test/service-status.test.ts diff --git a/.changes/simulacrum-server-launchGraph-option.md b/.changes/simulacrum-server-launchGraph-option.md new file mode 100644 index 00000000..caa24f32 --- /dev/null +++ b/.changes/simulacrum-server-launchGraph-option.md @@ -0,0 +1,5 @@ +--- +"@simulacrum/server": minor:enhance +--- + +Add `launchGraph` option in `simulationCLI` to allow a user to "wrap" the service graph process. For example, a team using only linux could opt to wrap the graph in `unshare` for more direct kernel protections from zombie processes. diff --git a/.changes/simulacrum-server-reaper.md b/.changes/simulacrum-server-reaper.md new file mode 100644 index 00000000..b2c0b671 --- /dev/null +++ b/.changes/simulacrum-server-reaper.md @@ -0,0 +1,5 @@ +--- +"@simulacrum/server": minor:enhance +--- + +Add a new reaper process. We hand it the pids of processes when they start and when they shutdown. If the main service graph gets hard killed and the reaper see it lost the parent, the reaper will kill all (now) zombied processes and shut itself down. This is primarily in place to prevent bad shutdown sequences leaving zombie processes. diff --git a/.changes/simulacrum-server-stop-flag.md b/.changes/simulacrum-server-stop-flag.md new file mode 100644 index 00000000..7da54916 --- /dev/null +++ b/.changes/simulacrum-server-stop-flag.md @@ -0,0 +1,5 @@ +--- +"@simulacrum/server": patch:bug +--- + +Allow the /stop endpoint even if the service graph is not backgrounded. Allows one terminal to start but another to stop (or to use the idea of backgrounding through another function such as in tmux, etc.). diff --git a/packages/server/README.md b/packages/server/README.md index 850cac6f..f633231b 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -211,6 +211,8 @@ Creates a runner for a graph of services, simulators, and supporting processes. ###### Returns - `ServiceGraphRunner` - a runner operation factory that starts the graph when invoked +- `options`: + - `simulationCLI` uses service graph `options.controlPort` when no `--control-port` override is supplied. Call the runner inside an `effection` scope to start the graph: @@ -240,7 +242,7 @@ const graph = yield * runner(["api"], { controlPort: 4310 }); File watching: pass `options.watch = true` and `options.watchDebounce` to enable watching and restart propagation across dependents. This is enabled through the CLI helper. Control port: pass `options.controlPort` or `runner(..., { controlPort })` when you want the runtime service to bind to a stable port for background/recall workflows. Exclude services: pass `runner(undefined, { exclude: ["worker"] })` or combine it with a subset to skip named services and automatically prune dependents that no longer have their startup requirements. -The CLI uses `43034` as the default control port for `--background` and `--stop` when you do not provide `--control-port`. +The CLI uses the graph's configured `controlPort` when `--control-port` is omitted; otherwise it defaults to `43034`. An explicit CLI `--control-port` overrides the graph configuration. Each item in the `ServicesMap` passed as the first argument to `useServiceGraph` is a `ServiceDefinition`. @@ -260,7 +262,7 @@ type ServiceDefinition = { - In most cases, pass `useSimulation(args)` or `useService(args)`. - Each service must provide an `operation: Operation` or another long-lived `effection` operation that resolves when the service is ready. -- The operation may also return service metadata such as `{ port: number }` or `{ port: number; pid: number }` to surface runtime information in the graph's `status` map. +- The operation may also return service metadata such as `{ port: number }` or `{ port: number; pid: number }` to surface runtime information in the graph's `status` map. We also track all PIDs launched through `@effectionx/process` so the graph can track their lifecycle and clean them up in failure situations. - If you are defining your own custom operation, use `try { ... yield* suspend(); } finally { ... }` inside an `effection` operation or `resource()` to run cleanup logic when the service stops. #### Test rigs @@ -502,8 +504,9 @@ The `options.wellnessCheck` object supports: #### simulationCLI(serviceGraph) -- `simulationCLI` wraps the runner in a small CLI loop and provides convenience flags: `--services`, `--watch`, `--watch-debounce`, `--background`, `--stop`, and `--control-port`. +- `simulationCLI` wraps the runner in a small CLI loop and provides convenience flags: `--services`, `--watch`, `--watch-debounce`, `--startup-timeout`, `--background`, `--stop`, and `--control-port`. - Use the CLI helper for local development workflows where you want to run your graph directly from a file (see `service-graph.ts` examples above). +- Foreground and background CLI modes launch a managed child process to provide robustness against various external process kills or failures. Foreground inherits terminal stdio and requests graceful shutdown through the control plane on Ctrl+C; `--background` detaches the child. `useServiceTestRig` / `createServiceTestRig` run the service graph in-process allowing the test runner to manage the service lifecycle. ```bash # foreground @@ -522,9 +525,47 @@ node ./service-graph.ts --stop --control-port 4310 node ./service-graph.ts --stop ``` -- `--background` starts the graph in a detached managed child process and waits until the runtime service responds on the requested control port. -- `--stop` sends `POST /stop` to the runtime service on the requested control port. -- `--control-port` defaults to `43034` for both `--background` and `--stop`. +- Foreground inherits stdio to print it to your terminal; Ctrl+C requests `POST /stop` and waits for graceful shutdown before signaling the managed child as a fallback. `--background` runs a detached process and waits until all selected services report ready. +- `--stop` sends `POST /stop` to the runtime service on the requested control port. It works for both foreground and background graphs. +- `--control-port` overrides the graph's configured `controlPort`; when neither is set, the service graph defaults to `43034`. +- The `--background` mode polls the control plane's HTTP `GET /ready` endpoint internally. Each request waits up to 30 seconds, and the client retries after a 504 until its total startup wait expires. +- `--startup-timeout` sets the total background CLI wait in milliseconds and defaults to `120000`. `simulationCLI` also accepts `startupTimeout` in its options. If it expires, the CLI requests graceful graph shutdown, waits for the shared grace period, then escalates if needed before reporting the timeout. + +The control plane's `/health` endpoint reports whether its HTTP server is running. `/status` returns an immediate service-state snapshot, including the command and arguments for services that launch a process. When a tracked process exits, its service reports `lastExit` with the observed exit code and/or signal; graph-requested restarts may also include `requestedSignal`. The `simulacrum` service also reports the graph's `launcher` command; this can differ from its `command` when a wrapper such as `unshare` or `tini` is used, see below. + +##### Custom graph launcher + +Pass an optional second argument to `simulationCLI` to customize how the managed graph process is launched. Its `launchGraph` hook receives the launch request and a default launcher it can delegate to. For example, a team using only Linux could wrap the graph in a PID namespace and use `tini` as its init process: + +```ts +import { simulationCLI } from "@simulacrum/server"; + +simulationCLI(services, { + launchGraph(request, launch) { + return launch({ + ...request, + command: "unshare", + args: [ + "--user", + "--map-root-user", + "--pid", + "--fork", + "--mount-proc", + "--kill-child", + "tini", + "-g", + "--", + request.command, + ...request.args, + ], + }); + }, +}); +``` + +This example requires `tini` to be on `PATH` and unprivileged user namespaces to be enabled. If user namespaces are disabled, use an appropriately privileged setup instead. + +The same hook is then used in foreground and background modes. The request includes the mode, stdio policy, and detachment setting. The default launcher uses Node's child-process API in both modes. Custom launchers can delegate with a modified request or implement their own spawning behavior; foreground launchers should preserve stdio and forward SIGINT for graceful shutdown. ## Development diff --git a/packages/server/package.json b/packages/server/package.json index 27810090..fe543658 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -42,6 +42,10 @@ "development": "./src/index.ts", "default": "./dist/index.mjs" }, + "./run-reaper": { + "development": "./src/run-reaper.ts", + "default": "./dist/run-reaper.mjs" + }, "./run-simulation-child": { "development": "./src/run-simulation-child.ts", "default": "./dist/run-simulation-child.mjs" @@ -54,6 +58,7 @@ }, "exports": { ".": "./dist/index.mjs", + "./run-reaper": "./dist/run-reaper.mjs", "./run-simulation-child": "./dist/run-simulation-child.mjs", "./package.json": "./package.json" } @@ -69,10 +74,12 @@ }, "dependencies": { "@effectionx/context-api": "^0.6.0", + "@effectionx/node": "^0.2.4", "@effectionx/process": "^0.8.1", "@effectionx/stream-helpers": "^0.8.2", "@effectionx/timebox": "^0.4.3", "chokidar": "^5.0.0", + "ctrlc-windows": "^2.2.0", "effection": "catalog:", "picomatch": "^4.0.4" }, diff --git a/packages/server/src/cli.ts b/packages/server/src/cli.ts index a447c531..b56f1de3 100644 --- a/packages/server/src/cli.ts +++ b/packages/server/src/cli.ts @@ -1,6 +1,20 @@ import { parseArgs } from "node:util"; -import { main, sleep, suspend, until, withResolvers } from "effection"; -import { spawn as spawnProcess } from "node:child_process"; +import { + main, + call, + ensure, + race, + sleep, + until, + useAbortSignal, + withResolvers, + type Operation, + type Subscription, +} from "effection"; +import { timebox } from "@effectionx/timebox"; +import { spawn as spawnProcess, type ChildProcess } from "node:child_process"; +import { on } from "@effectionx/node/events"; +import { ctrlc } from "ctrlc-windows"; import { useAttributes } from "./logging.ts"; import type { ServiceDefinition, @@ -9,8 +23,37 @@ import type { ServiceGraphStatus, } from "./service-graph.ts"; import { Debugging, logger } from "./logging.ts"; +import { GRAPH_LAUNCHER_ENV, type ProcessCommand } from "./launch-metadata.ts"; +import { DEFAULT_PROCESS_SHUTDOWN_GRACE } from "./reaper.ts"; export const DEFAULT_CONTROL_PORT = 43034; +export const DEFAULT_STARTUP_TIMEOUT = 120_000; + +export type GraphLaunchRequest = { + command: string; + args: string[]; + mode: "foreground" | "background"; + cwd: string; + env: NodeJS.ProcessEnv; + detached: boolean; + stdio: "inherit" | "ignore"; +}; + +export type GraphLaunchHook = ( + request: GraphLaunchRequest, + launchDefault: (request: GraphLaunchRequest) => ChildProcess, +) => ChildProcess; + +export type SimulationCLIOptions = { + launchGraph?: GraphLaunchHook; + startupTimeout?: number; +}; + +function* nextEvent(subscription: Subscription): Operation { + const next = yield* subscription.next(); + if (next.done) throw new Error("event stream closed unexpectedly"); + return next.value; +} function parseServiceList(value: string | undefined): string[] | undefined { if (!value) { @@ -35,36 +78,231 @@ function parseControlPort(value: string | undefined): number | undefined { return port; } -async function launchBackgroundProcess() { - const childArgs = process.argv.slice(1).filter((arg) => arg !== "--background"); - childArgs.push("--managed-child"); +function parseStartupTimeout(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + + const timeout = Number(value); + if (!Number.isSafeInteger(timeout) || timeout < 0) { + throw new Error(`invalid startup timeout '${value}'`); + } + return timeout; +} + +function launchGraphProcess( + mode: GraphLaunchRequest["mode"], + launchGraph?: GraphLaunchHook, + controlPort = DEFAULT_CONTROL_PORT, +): ChildProcess { + const args = process.argv.slice(1); + const childArgs: string[] = []; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--background") continue; + if (arg === "--startup-timeout") { + index++; + continue; + } + if (arg.startsWith("--startup-timeout=")) continue; + childArgs.push(arg); + } + childArgs.splice(1, 0, "start"); + if (!childArgs.some((arg) => arg === "--control-port" || arg.startsWith("--control-port="))) { + childArgs.push("--control-port", String(controlPort)); + } - const child = spawnProcess(process.execPath, [...process.execArgv, ...childArgs], { - detached: true, - stdio: "ignore", + const request: GraphLaunchRequest = { + command: process.execPath, + args: [...process.execArgv, ...childArgs], + mode, + cwd: process.cwd(), env: process.env, + detached: mode === "background", + stdio: mode === "background" ? "ignore" : "inherit", + }; + + const launchDefault = (next: GraphLaunchRequest) => { + const env: NodeJS.ProcessEnv = { + ...next.env, + [GRAPH_LAUNCHER_ENV]: JSON.stringify({ + executable: next.command, + arguments: next.args, + } satisfies ProcessCommand), + }; + + return spawnProcess(next.command, next.args, { + cwd: next.cwd, + env, + detached: next.detached, + stdio: next.stdio, + }); + }; + const child = launchGraph ? launchGraph(request, launchDefault) : launchDefault(request); + if (mode === "background") child.unref(); + return child; +} + +function* waitForControlService(controlPort: number, startupTimeout: number) { + const result = yield* timebox(startupTimeout, function* () { + const signal = yield* useAbortSignal(); + while (true) { + let response: Response; + try { + response = yield* until(fetch(`http://127.0.0.1:${controlPort}/ready`, { signal })); + } catch { + if (signal.aborted) return; + yield* sleep(25); + continue; + } + + if (response.status === 504) { + yield* until(response.text()); + continue; + } + if (!response.ok) { + const reason = yield* until(response.text()); + throw new Error(`background graph on port ${controlPort} did not become ready: ${reason}`); + } + return; + } }); - child.unref(); + if (result.timeout) { + throw new Error( + `background graph on port ${controlPort} did not become ready within ${startupTimeout}ms`, + ); + } } -function* waitForControlService(controlPort: number) { - const deadline = Date.now() + 5000; +function signalManagedChild(child: ChildProcess, signal: NodeJS.Signals, processGroup: boolean) { + if (child.pid === undefined) return; - while (Date.now() < deadline) { - try { - const response = yield* until(fetch(`http://127.0.0.1:${controlPort}/health`)); - if (response.ok) { + if (process.platform === "win32") { + if (signal === "SIGINT") { + try { + ctrlc(child.pid); return; + } catch { + // Fall back to ChildProcess signaling if console Ctrl+C is unavailable. } - } catch (ignore) { - // keep polling until the child is ready or we time out } + child.kill(signal); + return; + } + + if (processGroup) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // Fall back to the direct child if it does not own a separate group. + } + } + child.kill(signal); +} + +function* requestGraphStop(controlPort: number): Operation { + try { + const response = yield* until( + fetch(`http://127.0.0.1:${controlPort}/stop`, { + method: "POST", + signal: AbortSignal.timeout(1_000), + }), + ); + return response.status === 202; + } catch { + return false; + } +} + +function* waitForChildExit( + child: ChildProcess, + exits: Subscription<[number | null, NodeJS.Signals | null], never>, + timeout: number, +): Operation { + if (child.exitCode !== null || child.signalCode !== null) return true; + return yield* race([ + call(function* () { + yield* nextEvent(exits); + return true; + }), + call(function* () { + yield* sleep(timeout); + return false; + }), + ]); +} + +function* stopManagedChild(child: ChildProcess, controlPort: number, processGroup: boolean) { + if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return; + + const exits = yield* on<[number | null, NodeJS.Signals | null]>(child, "exit"); + const stopAccepted = yield* requestGraphStop(controlPort); + if (stopAccepted && (yield* waitForChildExit(child, exits, DEFAULT_PROCESS_SHUTDOWN_GRACE))) { + return; + } + + signalManagedChild(child, process.platform === "win32" ? "SIGINT" : "SIGTERM", processGroup); + if (yield* waitForChildExit(child, exits, DEFAULT_PROCESS_SHUTDOWN_GRACE)) return; - yield* sleep(25); + if (child.exitCode === null && child.signalCode === null) { + signalManagedChild(child, "SIGKILL", processGroup); + if (child.exitCode === null && child.signalCode === null) yield* nextEvent(exits); } +} + +function* waitForBackgroundGraph(controlPort: number, child: ChildProcess, startupTimeout: number) { + const exits = yield* on<[number | null, NodeJS.Signals | null]>(child, "exit"); + const errors = yield* on<[Error]>(child, "error"); + let ready = false; + + yield* ensure(function* () { + if (ready) return; + try { + yield* stopManagedChild(child, controlPort, true); + } catch { + // Preserve the startup failure while making a best-effort shutdown. + } + }); - throw new Error(`timed out waiting for background graph on port ${controlPort}`); + yield* race([ + waitForControlService(controlPort, startupTimeout), + call(function* () { + const [code, signal] = yield* nextEvent(exits); + throw new Error( + `managed graph exited before becoming ready (code ${code}, signal ${signal})`, + ); + }), + call(function* () { + const [error] = yield* nextEvent(errors); + throw error; + }), + ]); + ready = true; +} + +function* waitForForegroundGraph(child: ChildProcess, controlPort: number) { + const exits = yield* on<[number | null, NodeJS.Signals | null]>(child, "exit"); + const errors = yield* on<[Error]>(child, "error"); + + yield* ensure(function* () { + yield* stopManagedChild(child, controlPort, false); + }); + + const outcome = yield* race([ + call(function* () { + const [code, signal] = yield* nextEvent(exits); + return { type: "exit" as const, code, signal }; + }), + call(function* () { + const [error] = yield* nextEvent(errors); + return { type: "error" as const, error }; + }), + ]); + + if (outcome.type === "error") throw outcome.error; + if (outcome.code !== 0) { + throw new Error(`foreground graph exited (code ${outcome.code}, signal ${outcome.signal})`); + } } function* hasControlService(controlPort: number) { @@ -112,9 +350,10 @@ function* throwIfGraphAlreadyRunning(controlPort: number) { */ export function* simulationCLIOp>>( serviceGraph: ServiceGraphRunner, + options: SimulationCLIOptions = {}, ) { try { - const { values } = parseArgs({ + const { values, positionals } = parseArgs({ options: { services: { type: "string", short: "s" }, "exclude-services": { type: "string" }, @@ -122,13 +361,13 @@ export function* simulationCLIOp) + ? Object.entries(json.services) .map( ([name, info]) => - ` ${name}: ${info.port ? `port ${info.port}` : ""}${info.pid ? `; pid ${info.pid}` : ""}`, + ` ${name}: ${info.state ?? "unknown"}${info.port ? `; port ${info.port}` : ""}${info.pid ? `; pid ${info.pid}` : ""}${info.command ? `; command ${JSON.stringify(info.command)}` : ""}${info.launcher ? `; launcher ${JSON.stringify(info.launcher)}` : ""}${info.lastExit ? `; last exit ${JSON.stringify(info.lastExit)}` : ""}`, ) .join("\n") : "no service info available" @@ -247,36 +492,35 @@ export function* simulationCLIOp("wait for a stop request from the runtime service"); - - yield* serviceGraph(subset as unknown as Array, { - ...runOptions, - requestStop: () => stopRequested.resolve(), - }); - - yield* stopRequested.operation; - return; + if (values.background) { + throw new Error("use --background without the 'start' subcommand"); } - yield* throwIfGraphAlreadyRunning(requestedControlPort ?? DEFAULT_CONTROL_PORT); + yield* throwIfGraphAlreadyRunning(controlPort); // Start the graph and fetch the provided info // subset is a string array from CLI; cast to service key array for strict runner - yield* serviceGraph(subset as unknown as Array, runOptions); + const stopRequested = withResolvers("wait for a stop request from the runtime service"); + yield* serviceGraph(subset as unknown as Array, { + ...runOptions, + requestStop: () => stopRequested.resolve(), + }); - yield* suspend(); + yield* stopRequested.operation; } finally { yield* logger.debug("simulationCLI finally"); } @@ -291,9 +535,10 @@ export function* simulationCLIOp>>( serviceGraph: ServiceGraphRunner, + options: SimulationCLIOptions = {}, ) { try { - return await main(() => simulationCLIOp(serviceGraph)); + return await main(() => simulationCLIOp(serviceGraph, options)); } catch (err) { process.exitCode = 1; console.error("simulationCLI error:", err instanceof Error ? err.stack : err); diff --git a/packages/server/src/control-plane.ts b/packages/server/src/control-plane.ts new file mode 100644 index 00000000..d8ab721d --- /dev/null +++ b/packages/server/src/control-plane.ts @@ -0,0 +1,486 @@ +import { + action, + call, + ensure, + race, + resource, + sleep, + spawn, + type Operation, + type Subscription, + type WithResolvers, + useScope, + withResolvers, +} from "effection"; +import type { ExecOptions, Process } from "@effectionx/process"; +import type { ExitStatus } from "@effectionx/process"; +import { on } from "@effectionx/node/events"; +import { useAttributes } from "./logging.ts"; +import { createServer } from "node:http"; +import { logger } from "./logging.ts"; +import type { Server, ServerResponse } from "node:http"; +import { type ProcessReaperClient, useProcessReaper } from "./reaper.ts"; +import { + ServiceStatusRecord, + type ServiceInfo, + type ServiceInfoUpdate, + type ServiceState, +} from "./service-status.ts"; +import { GraphLauncher } from "./service-graph-context.ts"; + +export type { ServiceInfo, ServiceState } from "./service-status.ts"; +export type ControlPlaneServiceInfo = ServiceInfo; + +export type ControlPlaneOptions = { + data?: Record; + port?: number | undefined; + services?: readonly string[]; + requestStop?: (() => void) | undefined; + requestRestart?: ((service?: string) => void) | undefined; +}; + +export type ControlPlane = { + port: number; + getServiceStatus: (name: string) => ServiceStatusRecord | undefined; + getServiceInfo: (name: string) => ServiceInfo | undefined; + setServiceInfo: (name: string, info: ServiceInfoUpdate) => void; + clearServiceInfo: (name: string, state?: ServiceState) => void; + trackProcess: ( + name: string, + command: string, + options: ExecOptions, + process: Operation, + failOnExit: boolean, + ) => Operation; +}; + +function* nextEvent(subscription: Subscription): Operation { + const next = yield* subscription.next(); + if (next.done) throw new Error("event stream closed unexpectedly"); + return next.value; +} + +function* listen(server: Server, port: number | undefined): Operation { + const listening = yield* on<[]>(server, "listening"); + const errors = yield* on<[Error]>(server, "error"); + server.listen(port ?? 0, "127.0.0.1"); + + yield* race([ + nextEvent(listening), + call(function* () { + const [error] = yield* nextEvent(errors); + throw error; + }), + ]); +} + +function* close(server: Server): Operation { + yield* action((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + return () => {}; + }, "close control plane"); +} + +type ControlPlaneServerOptions = { + data: Record; + port: number | undefined; + services: Map; + requestStop?: (() => void) | undefined; + requestRestart?: ((service?: string) => void) | undefined; +}; + +type TrackedProcess = { + failOnExit: boolean; + release(): void; +}; + +const READINESS_REQUEST_TIMEOUT = 30_000; + +function useControlPlaneServer( + options: ControlPlaneServerOptions, +): Operation<{ port: number; notifyServiceChange: () => void }> { + return resource(function* (provide) { + const scope = yield* useScope(); + let port = 0; + const readinessWaiters = new Map< + ServerResponse, + { done: WithResolvers; onClose: () => void } + >(); + + function respondToReadyWaiter( + res: ServerResponse, + statusCode: number, + body: Record, + ) { + const waiter = readinessWaiters.get(res); + if (!waiter) return; + readinessWaiters.delete(res); + res.off("close", waiter.onClose); + waiter.done.resolve(); + if (res.destroyed) return; + const response = JSON.stringify(body); + res.writeHead(statusCode, { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(response)), + }); + res.end(response); + } + + function currentReadiness(): { statusCode: number; body: Record } | undefined { + const failed = Array.from(options.services.entries()).find( + ([, status]) => status.state === "failed" || status.state === "stopping", + ); + if (failed) { + return { + statusCode: 503, + body: { ready: false, service: failed[0], state: failed[1].state }, + }; + } + if (Array.from(options.services.values()).every((status) => status.state === "ready")) { + return { statusCode: 200, body: { ready: true, port } }; + } + return undefined; + } + + function notifyServiceChange() { + const readiness = currentReadiness(); + if (!readiness) return; + for (const res of readinessWaiters.keys()) { + respondToReadyWaiter(res, readiness.statusCode, readiness.body); + } + } + + function waitForReadiness(res: ServerResponse) { + const readiness = currentReadiness(); + if (readiness) { + const body = JSON.stringify(readiness.body); + res.writeHead(readiness.statusCode, { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(body)), + }); + res.end(body); + return; + } + + const onClose = () => { + const waiter = readinessWaiters.get(res); + if (waiter) waiter.done.resolve(); + readinessWaiters.delete(res); + }; + const waiter = { done: withResolvers(), onClose }; + readinessWaiters.set(res, waiter); + res.once("close", onClose); + + const task = scope.run(function* () { + const timedOut = yield* race([ + call(function* () { + yield* sleep(READINESS_REQUEST_TIMEOUT); + return true; + }), + call(function* () { + yield* waiter.done.operation; + return false; + }), + ]); + if (timedOut) { + respondToReadyWaiter(res, 504, { ready: false, reason: "readiness timed out" }); + } + }); + void task.catch((error) => console.error("control plane readiness wait failed:", error)); + } + + const server = createServer((req, res) => { + try { + const url = new URL(req.url ?? "", `http://127.0.0.1`); + const pathname = url.pathname; + + if (req.method === "GET" && (pathname === "/data" || pathname === "/")) { + const body = JSON.stringify(options.data); + res.writeHead(200, { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(body)), + }); + res.end(body); + return; + } + + if (req.method === "GET" && pathname.startsWith("/data/")) { + const key = decodeURIComponent(pathname.replace(/^\/data\//, "")); + if (!key) { + res.writeHead(400); + res.end(); + return; + } + + const value = options.data[key]; + if (value === undefined) { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("not found"); + return; + } + + const body = JSON.stringify(value); + res.writeHead(200, { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(body)), + }); + res.end(body); + return; + } + + if (req.method === "GET" && pathname === "/health") { + const body = JSON.stringify({ ok: true, port }); + res.writeHead(200, { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(body)), + }); + res.end(body); + return; + } + + if (req.method === "GET" && pathname === "/ready") { + waitForReadiness(res); + return; + } + + if (req.method === "GET" && pathname === "/status") { + const services = Object.fromEntries( + Array.from(options.services, ([name, status]) => [name, status.snapshot()]), + ); + const body = JSON.stringify({ + cwd: process.cwd(), + services, + }); + res.writeHead(200, { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(body)), + }); + res.end(body); + return; + } + + if (req.method === "POST" && pathname === "/stop") { + if (!options.requestStop) { + res.writeHead(501, { "content-type": "text/plain" }); + res.end("stop not configured"); + return; + } + + const body = JSON.stringify({ ok: true, stopping: true }); + res.writeHead(202, { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(body)), + }); + res.end(body); + queueMicrotask(() => options.requestStop?.()); + return; + } + + if (req.method === "POST" && pathname === "/restart") { + if (!options.requestRestart) { + res.writeHead(501, { "content-type": "text/plain" }); + res.end("restart not configured"); + return; + } + + const service = url.searchParams.get("service") ?? undefined; + try { + options.requestRestart(service); + } catch (error) { + res.writeHead(400, { "content-type": "text/plain" }); + res.end(String(error)); + return; + } + + const body = JSON.stringify({ ok: true, restarting: true, service }); + res.writeHead(202, { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(body)), + }); + res.end(body); + return; + } + + res.writeHead(404, { "content-type": "text/plain" }); + res.end("not found"); + } catch (error) { + res.writeHead(500, { "content-type": "text/plain" }); + res.end(String(error)); + } + }); + + yield* listen(server, options.port); + yield* ensure(function* () { + for (const res of readinessWaiters.keys()) { + respondToReadyWaiter(res, 503, { ready: false, reason: "control plane is stopping" }); + } + if (server.listening) yield* close(server); + yield* logger.debug(`control plane stopped on port ${port}`); + }); + + const address = server.address(); + port = typeof address === "object" && address !== null && "port" in address ? address.port : 0; + yield* logger.debug(`control plane started on port ${port}`); + yield* useAttributes({ name: "controlPlane", port }); + yield* provide({ port, notifyServiceChange }); + }); +} + +/** + * Start the local HTTP control plane and its process registry. + * + * This is intended for local testing and to supply a small amount of + * configuration or initialization data to child simulations via the + * "simulacrum" gateway. The operation yields its control-plane API once listening. + * + * @param options - Arbitrary JSON-serializable data to serve at `/data`, plus control settings + * @returns an operation that provides the control-plane API + */ +export function useControlPlane(options: ControlPlaneOptions = {}): Operation { + return resource(function* (provide) { + const data = options.data ?? {}; + const reaper: ProcessReaperClient = yield* useProcessReaper(); + const launcher = yield* GraphLauncher.get(); + const services = new Map( + (options.services ?? []).map((name) => [name, new ServiceStatusRecord()]), + ); + const trackedProcesses = new Map(); + services.set( + "simulacrum", + new ServiceStatusRecord({ + pid: process.pid, + command: { + executable: process.execPath, + arguments: [...process.execArgv, ...process.argv.slice(1)], + }, + launcher, + }), + ); + yield* useAttributes({ name: "controlPlane", keys: Object.keys(data).join(", ") }); + // start up the http metadata and data service + const { port, notifyServiceChange } = yield* useControlPlaneServer({ + data, + port: options.port, + services, + requestStop: options.requestStop, + requestRestart: options.requestRestart, + }); + + function setServiceInfo(name: string, info: ServiceInfoUpdate) { + const current = services.get(name) ?? new ServiceStatusRecord(); + current.update(info); + services.set(name, current); + notifyServiceChange(); + } + + function clearServiceInfo(name: string, state?: ServiceState) { + const previous = services.get(name) ?? new ServiceStatusRecord(); + previous.clear(state); + services.set(name, previous); + notifyServiceChange(); + } + + // daemon calls exec and we track both, so we need to ensure we only track each PID once. + // If we encounter a PID that is already being tracked, we only "upgrade" it and do not + // register it again. Ideally we only get daemon, but this protects against someone using + // exec directly and handling the status messages on a confusing manner. + function watchProcess(pid: number, failOnExit: boolean) { + const existing = trackedProcesses.get(pid); + if (existing) { + existing.failOnExit ||= failOnExit; + return undefined; + } + + let active = true; + const tracking: TrackedProcess = { + failOnExit, + release() { + if (!active) return; + active = false; + if (trackedProcesses.get(pid) !== tracking) return; + trackedProcesses.delete(pid); + reaper.remove(pid); + }, + }; + trackedProcesses.set(pid, tracking); + reaper.add(pid); + return tracking; + } + + function* trackProcess( + name: string, + command: string, + options: ExecOptions, + operation: Operation, + failOnExit: boolean, + ): Operation { + let release: ((state?: ServiceState, exit?: ExitStatus) => void) | undefined; + + // This outer ensure runs after the wrapped process resource cleans up. + yield* ensure(() => { + const current = services.get(name); + release?.(failOnExit && current?.state !== "stopping" ? "failed" : undefined); + }); + + const process = yield* operation; + const tracking = watchProcess(process.pid, failOnExit); + if (!tracking) return process; + setServiceInfo(name, { + pid: process.pid, + command: { + executable: command, + arguments: options.arguments ?? [], + ...(options.shell === undefined ? {} : { shell: options.shell }), + }, + }); + let active = true; + release = (state?: ServiceState, exit?: ExitStatus) => { + if (!active) return; + active = false; + tracking.release(); + const current = services.get(name); + const requestedSignal = current?.snapshot().requestedSignal; + if (current?.pid === process.pid) { + clearServiceInfo(name, state); + } else if (state !== undefined) { + setServiceInfo(name, { state }); + } + if (exit) { + setServiceInfo(name, { + lastExit: { + ...(requestedSignal ? { requestedSignal } : {}), + ...(exit.code == null ? {} : { code: exit.code }), + ...(exit.signal == null ? {} : { signal: exit.signal }), + }, + requestedSignal: undefined, + }); + } + }; + yield* spawn(function* () { + const exit = yield* process.join(); + const current = services.get(name); + const failed = tracking.failOnExit && current?.state !== "stopping"; + release(failed ? "failed" : undefined, exit); + }); + + return process; + } + + setServiceInfo("simulacrum", { port, state: "ready" }); + + yield* provide({ + port, + getServiceStatus: (name) => services.get(name), + getServiceInfo: (name) => services.get(name)?.snapshot(), + setServiceInfo, + clearServiceInfo, + trackProcess, + }); + }); +} diff --git a/packages/server/src/control-service.ts b/packages/server/src/control-service.ts deleted file mode 100644 index 5f39f187..00000000 --- a/packages/server/src/control-service.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { resource, type Operation, withResolvers } from "effection"; -import { useAttributes } from "./logging.ts"; -import { createServer } from "node:http"; -import { logger } from "./logging.ts"; -import type { Server } from "node:http"; - -export type DataServiceOptions = { - data?: Record; - port?: number | undefined; - getStatus?: () => unknown; - requestStop?: (() => void) | undefined; - requestRestart?: ((service?: string) => void) | undefined; -}; - -function* listen(server: Server, port: number | undefined): Operation { - const ready = withResolvers("wait for data service to start listening"); - - const onError = (error: Error) => { - server.off("listening", onListening); - ready.reject(error); - }; - const onListening = () => { - server.off("error", onError); - ready.resolve(); - }; - - server.once("error", onError); - server.once("listening", onListening); - server.listen(port ?? 0, "127.0.0.1"); - - try { - yield* ready.operation; - } finally { - server.off("error", onError); - server.off("listening", onListening); - } -} - -function* close(server: Server): Operation { - const closed = withResolvers("wait for data service to stop listening"); - - server.close((error) => { - if (error) { - closed.reject(error); - } else { - closed.resolve(); - } - }); - - yield* closed.operation; -} - -/** - * Start a simple local HTTP data service that serves a user-provided object. - * - * This is intended for local testing and to supply a small amount of - * configuration or initialization data to child simulations via the - * "simulacrum" gateway. The operation yields an object with `{ port }` once - * listening. - * - * @param options - Arbitrary JSON-serializable data to serve at `/data`, plus optional control settings - * @returns an operation that provides `{ port: number }` when ready - */ -export function startDataService(options: DataServiceOptions = {}): Operation<{ port: number }> { - return resource(function* (provide) { - const data = options.data ?? {}; - let port = 0; - yield* useAttributes({ - name: "dataService", - keys: Object.keys(data).join(", "), - }); - const server = createServer((req, res) => { - try { - const url = new URL(req.url ?? "", `http://127.0.0.1`); - const pathname = url.pathname; - - // GET /data -> whole object - if (req.method === "GET" && (pathname === "/data" || pathname === "/")) { - const body = JSON.stringify(data || {}); - res.writeHead(200, { - "content-type": "application/json", - "content-length": String(Buffer.byteLength(body)), - }); - res.end(body); - return; - } - - // GET /data/ -> value or 404 - if (req.method === "GET" && pathname.startsWith("/data/")) { - const key = decodeURIComponent(pathname.replace(/^\/data\//, "")); - if (!key) { - res.writeHead(400); - res.end(); - return; - } - - const value = (data as Record | undefined)?.[key]; - if (value === undefined) { - res.writeHead(404, { "content-type": "text/plain" }); - res.end("not found"); - return; - } - - const body = JSON.stringify(value); - res.writeHead(200, { - "content-type": "application/json", - "content-length": String(Buffer.byteLength(body)), - }); - res.end(body); - return; - } - - if (req.method === "GET" && pathname === "/health") { - const body = JSON.stringify({ ok: true, port }); - res.writeHead(200, { - "content-type": "application/json", - "content-length": String(Buffer.byteLength(body)), - }); - res.end(body); - return; - } - - if (req.method === "GET" && pathname === "/status") { - const body = JSON.stringify(options.getStatus?.() ?? {}); - res.writeHead(200, { - "content-type": "application/json", - "content-length": String(Buffer.byteLength(body)), - }); - res.end(body); - return; - } - - if (req.method === "POST" && pathname === "/stop") { - if (!options.requestStop) { - res.writeHead(501, { "content-type": "text/plain" }); - res.end("stop not configured"); - return; - } - - const body = JSON.stringify({ ok: true, stopping: true }); - res.writeHead(202, { - "content-type": "application/json", - "content-length": String(Buffer.byteLength(body)), - }); - res.end(body); - queueMicrotask(() => options.requestStop?.()); - return; - } - - if (req.method === "POST" && pathname === "/restart") { - if (!options.requestRestart) { - res.writeHead(501, { "content-type": "text/plain" }); - res.end("restart not configured"); - return; - } - - const service = url.searchParams.get("service") ?? undefined; - try { - options.requestRestart(service); - } catch (error) { - res.writeHead(400, { "content-type": "text/plain" }); - res.end(String(error)); - return; - } - - const body = JSON.stringify({ ok: true, restarting: true, service }); - res.writeHead(202, { - "content-type": "application/json", - "content-length": String(Buffer.byteLength(body)), - }); - res.end(body); - return; - } - - // unknown endpoint - res.writeHead(404, { "content-type": "text/plain" }); - res.end("not found"); - } catch (err) { - res.writeHead(500, { "content-type": "text/plain" }); - res.end(String(err)); - } - }); - - // listen on ephemeral port bound to localhost - yield* listen(server, options.port); - - const address = server.address(); - port = typeof address === "object" && address !== null && "port" in address ? address.port : 0; - - yield* logger.debug(`data service started on port ${port}`); - yield* useAttributes({ name: "dataService", port }); - - try { - yield* provide({ port }); - } finally { - yield* close(server); - yield* logger.debug(`data service stopped on port ${port}`); - } - }); -} diff --git a/packages/server/src/launch-metadata.ts b/packages/server/src/launch-metadata.ts new file mode 100644 index 00000000..8cf6e1a3 --- /dev/null +++ b/packages/server/src/launch-metadata.ts @@ -0,0 +1,33 @@ +export const GRAPH_LAUNCHER_ENV = "SIMULACRUM_GRAPH_LAUNCHER"; + +export type ProcessCommand = { + executable: string; + arguments: string[]; +}; + +export function consumeGraphLauncher(): ProcessCommand | undefined { + // we don't have a clean way to pass this as a value through the child_process + // so we serialize it as a JSON string in an environment variable. + // It only adds information and won't break anything if it is missing. + const raw = process.env[GRAPH_LAUNCHER_ENV]; + delete process.env[GRAPH_LAUNCHER_ENV]; + if (!raw) return undefined; + + try { + const value: unknown = JSON.parse(raw); + if ( + typeof value === "object" && + value !== null && + "executable" in value && + typeof value.executable === "string" && + "arguments" in value && + Array.isArray(value.arguments) && + value.arguments.every((argument) => typeof argument === "string") + ) { + return { executable: value.executable, arguments: value.arguments }; + } + } catch { + // Ignore malformed launcher metadata and keep status available. + } + return undefined; +} diff --git a/packages/server/src/reaper.ts b/packages/server/src/reaper.ts new file mode 100644 index 00000000..2c82c280 --- /dev/null +++ b/packages/server/src/reaper.ts @@ -0,0 +1,198 @@ +import { fork, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { once } from "@effectionx/node/events"; +import { + call, + createQueue, + ensure, + race, + resource, + sleep, + spawn, + withResolvers, + type Operation, +} from "effection"; + +export const DEFAULT_PROCESS_SHUTDOWN_GRACE = 10_000; +const REAPER_SHUTDOWN_TIMEOUT = 1000; + +export type ProcessReaperClient = { + add(pid: number): void; + remove(pid: number): void; +}; + +type ReaperMessage = + | { type: "ready" } + | { type: "watch"; pid: number } + | { type: "unwatch"; pid: number } + | { type: "shutdown" }; + +type ReaperWorker = { + child: ChildProcess; + ready: boolean; +}; + +const reaperPathTs = fileURLToPath(new URL("./run-reaper.ts", import.meta.url)); +const reaperPathJs = fileURLToPath(new URL("./run-reaper.mjs", import.meta.url)); +const reaperPath = existsSync(reaperPathTs) ? reaperPathTs : reaperPathJs; + +function send(child: ChildProcess, message: ReaperMessage): void { + if (!child.connected) return; + try { + child.send(message); + } catch { + // The exit handler will reconnect and replay the complete watch set. + } +} + +/** + * Start the detached process which owns the graph's PID watch list. + * + * The reaper remains alive when this process is hard-killed. A normal resource + * teardown sends an explicit shutdown message, which prevents it from + * interpreting the resulting IPC disconnect as a crash. + */ +export function useProcessReaper( + killDelay = DEFAULT_PROCESS_SHUTDOWN_GRACE, +): Operation { + return resource(function* (provide) { + const watched = new Set(); + let worker: ReaperWorker | undefined; + let stopping = false; + const scheduleRestart = () => { + if (!stopping) restartQueue.add(undefined); + }; + const restartQueue = createQueue(); + + function* startWorker(): Operation { + let child: ChildProcess; + try { + child = fork(reaperPath, [String(killDelay)], { + cwd: process.cwd(), + detached: true, + execArgv: process.execArgv.filter( + (arg) => arg !== "--test" && !arg.startsWith("--test-"), + ), + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)); + } + + const current: ReaperWorker = { child, ready: false }; + worker = current; + child.unref(); + const ready = withResolvers("wait for process reaper to become ready"); + + child.on("message", (message: ReaperMessage) => { + if (message.type !== "ready" || worker !== current) return; + current.ready = true; + for (const pid of watched) send(child, { type: "watch", pid }); + ready.resolve(); + }); + + child.once("error", (error) => { + if (worker !== current) return; + worker = undefined; + ready.reject(error); + if (!stopping) scheduleRestart(); + if (child.connected) child.disconnect(); + if (child.exitCode === null && child.signalCode === null) child.kill(); + }); + + child.once("exit", () => { + if (worker !== current) return; + worker = undefined; + if (!current.ready) { + ready.reject(new Error("process reaper exited before becoming ready")); + } + if (!stopping) scheduleRestart(); + }); + try { + yield* ready.operation; + } catch (error) { + if (worker === current) worker = undefined; + if (child.connected) child.disconnect(); + if (child.exitCode === null && child.signalCode === null) child.kill(); + throw error; + } + } + + function* ensureWorkerReady(): Operation { + while (!stopping && !worker?.ready) { + try { + yield* startWorker(); + } catch { + yield* sleep(25); + } + } + } + + yield* ensure(function* () { + stopping = true; + const current = worker; + worker = undefined; + if (!current?.child.connected) return; + if (current.child.exitCode !== null || current.child.signalCode !== null) return; + + const exited = once<[number | null, NodeJS.Signals | null]>(current.child, "exit"); + try { + current.child.send({ type: "shutdown" }, () => { + if (current.child.connected) current.child.disconnect(); + }); + } catch { + current.child.disconnect(); + } + const stopped = yield* race([ + call(function* () { + yield* exited; + return true; + }), + call(function* () { + yield* sleep(REAPER_SHUTDOWN_TIMEOUT); + return false; + }), + ]); + + if (!stopped) { + if (current.child.exitCode === null && current.child.signalCode === null) { + current.child.kill("SIGKILL"); + } + yield* race([ + call(function* () { + yield* exited; + }), + call(function* () { + yield* sleep(REAPER_SHUTDOWN_TIMEOUT); + }), + ]); + } + }); + + yield* ensureWorkerReady(); + + yield* spawn(function* () { + while (true) { + const next = yield* restartQueue.next(); + if (next.done) return; + yield* sleep(25); + if (!stopping && !worker) yield* ensureWorkerReady(); + } + }); + + const client: ProcessReaperClient = { + add(pid) { + if (!Number.isInteger(pid) || pid <= 0) return; + watched.add(pid); + if (worker?.ready) send(worker.child, { type: "watch", pid }); + }, + remove(pid) { + watched.delete(pid); + if (worker?.ready) send(worker.child, { type: "unwatch", pid }); + }, + }; + + yield* provide(client); + }); +} diff --git a/packages/server/src/run-reaper.ts b/packages/server/src/run-reaper.ts new file mode 100644 index 00000000..9246c362 --- /dev/null +++ b/packages/server/src/run-reaper.ts @@ -0,0 +1,99 @@ +import { setTimeout as delay } from "node:timers"; +import { spawnSync } from "node:child_process"; +import { ctrlc } from "ctrlc-windows"; + +type ReaperMessage = + | { type: "watch"; pid: number } + | { type: "unwatch"; pid: number } + | { type: "shutdown" }; + +const killDelay = Number(process.argv[2]) || 10_000; +const watched = new Set(); +let released = false; +let reapTimer: NodeJS.Timeout | undefined; + +function signalAll(signal: NodeJS.Signals): void { + for (const pid of watched) { + try { + // as we don't use @effectionx/process because it doesn't support detaching + // and leaving a process running, we had to use child_process directly to start + // and manage their termination manually. + if (process.platform === "win32") { + if (signal === "SIGKILL") { + spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + } else { + ctrlc(pid); + } + } else { + // @effectionx/process starts POSIX processes in their own process group. + process.kill(-pid, signal); + } + } catch (error) { + if (process.platform !== "win32" && (error as NodeJS.ErrnoException).code === "ESRCH") { + watched.delete(pid); + } + // The process may have exited between registration and reaping. + } + } +} + +function anyWatchedProcessGroupAlive(): boolean { + if (process.platform === "win32") return watched.size > 0; + + for (const pid of watched) { + try { + process.kill(-pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") { + watched.delete(pid); + } else { + return true; + } + } + } + return false; +} + +function reap(): void { + if (released || reapTimer) return; + signalAll("SIGTERM"); + const deadline = Date.now() + killDelay; + const check = () => { + if (watched.size === 0) { + process.exit(0); + } else if (Date.now() >= deadline) { + // Do not probe PGID liveness here; a completed group ID could be reused. + signalAll("SIGKILL"); + process.exit(0); + } else if (!anyWatchedProcessGroupAlive()) { + process.exit(0); + } else { + reapTimer = delay(check, Math.min(100, deadline - Date.now())); + } + }; + check(); +} + +process.on("message", (message: ReaperMessage) => { + if (message.type === "watch") { + if (!released) watched.add(message.pid); + } else if (message.type === "unwatch") { + watched.delete(message.pid); + } else if (message.type === "shutdown") { + released = true; + watched.clear(); + if (process.connected) process.disconnect(); + } +}); + +process.on("disconnect", reap); + +if (!process.send) { + process.exit(1); +} else { + process.send({ type: "ready" }); +} diff --git a/packages/server/src/select-services.ts b/packages/server/src/select-services.ts new file mode 100644 index 00000000..7798f3bf --- /dev/null +++ b/packages/server/src/select-services.ts @@ -0,0 +1,108 @@ +type ServiceWithDependencies = { + dependsOn?: { startup: readonly string[] }; +}; + +export type ServiceSelection = { + services: S; + requested: Set | undefined; + excluded: Set; + selected: Set; + filtered: boolean; +}; + +export function selectServices>( + services: S, + subset?: Array, + exclude?: string[], +): ServiceSelection { + const nodes = Object.keys(services); + const visiting = new Set(); + const visited = new Set(); + + // Reject startup dependency cycles before applying any filters. + function visit(name: string) { + if (visited.has(name)) return; + if (visiting.has(name)) throw new Error("Cycle detected in services"); + visiting.add(name); + for (const dependency of services[name].dependsOn?.startup ?? []) { + if (dependency in services) visit(dependency); + } + visiting.delete(name); + visited.add(name); + } + + for (const name of nodes) { + visit(name); + } + + // Normalize user filters and validate excluded names. + const requested = subset + ? new Set(subset.map((name) => String(name).trim()).filter(Boolean)) + : undefined; + const excluded = new Set((exclude ?? []).map((name) => name.trim()).filter(Boolean)); + + for (const name of excluded) { + if (!(name in services)) throw new Error(`Excluded service '${name}' not found`); + } + + const filtered = requested !== undefined || excluded.size > 0; + if (!filtered) { + return { + services, + requested, + excluded, + selected: new Set(nodes), + filtered, + }; + } + + // Start with requested services (or all services) and include startup dependencies. + const selected = requested ? new Set() : new Set(nodes); + + function include(name: string) { + if (selected.has(name)) return; + if (!(name in services)) throw new Error(`Requested service '${name}' not found`); + selected.add(name); + for (const dependency of services[name].dependsOn?.startup ?? []) { + include(dependency); + } + } + + for (const name of requested ?? []) { + include(name); + } + + // Apply exclusions, then remove services whose startup dependencies are missing. + for (const name of excluded) { + selected.delete(name); + } + + let pruned = true; + while (pruned) { + pruned = false; + for (const name of Array.from(selected)) { + const dependencies = services[name].dependsOn?.startup ?? []; + if (dependencies.some((dependency) => !selected.has(dependency))) { + selected.delete(name); + pruned = true; + } + } + } + + // A requested service cannot silently disappear during exclusion pruning. + for (const name of requested ?? []) { + if (!selected.has(name)) { + throw new Error( + `Requested service '${name}' cannot be started because it or one of its startup dependencies is excluded`, + ); + } + } + + // Build the typed service map for the selected graph. + const picked: Partial = {}; + for (const name of selected) { + picked[name as keyof S] = services[name as keyof S]; + } + + return { services: picked as S, requested, excluded, selected, filtered }; +} diff --git a/packages/server/src/service-graph-context.ts b/packages/server/src/service-graph-context.ts new file mode 100644 index 00000000..80f0f82d --- /dev/null +++ b/packages/server/src/service-graph-context.ts @@ -0,0 +1,4 @@ +import { createContext } from "effection"; +import type { ProcessCommand } from "./launch-metadata.ts"; + +export const GraphLauncher = createContext("GraphLauncher"); diff --git a/packages/server/src/service-graph.ts b/packages/server/src/service-graph.ts index 42656b6d..24d406b5 100644 --- a/packages/server/src/service-graph.ts +++ b/packages/server/src/service-graph.ts @@ -1,19 +1,24 @@ import { type Operation, type Stream, - type WithResolvers, resource, spawn, withResolvers, each, createContext, } from "effection"; +import { ProcessApi } from "@effectionx/process"; import { useAttributes } from "./logging.ts"; import { type ServiceUpdate, useWatcher } from "./watch.ts"; import { logger } from "./logging.ts"; -import { startDataService } from "./control-service.ts"; +import { useControlPlane } from "./control-plane.ts"; import { getOperationMetadata } from "./operation-metadata.ts"; +import type { ServiceInfo, ServiceStatusRecord } from "./service-status.ts"; +export type { ServiceInfo } from "./service-status.ts"; +import { selectServices } from "./select-services.ts"; +import { consumeGraphLauncher } from "./launch-metadata.ts"; +import { GraphLauncher } from "./service-graph-context.ts"; /** * Context key for the Simulacrum gateway listening port. @@ -50,17 +55,10 @@ export type ServiceGraphStatus = { services: Record; }; -export type ServiceInfo = { - port?: number | undefined; - pid?: number | undefined; -}; - -export type ServiceStatus = { - startup: WithResolvers; - running: WithResolvers; - port?: number | undefined; - pid?: number | undefined; -}; +export type ServiceStatus = Pick< + ServiceStatusRecord, + "startup" | "running" | "state" | "port" | "pid" | "command" | "launcher" | "lastExit" +>; export type ServiceGraphRunOptions = { watch?: boolean; @@ -71,10 +69,19 @@ export type ServiceGraphRunOptions = { requestRestart?: ((service?: string) => void) | undefined; }; -export type ServiceGraphRunner = ( +export type ServiceGraphOptions = { + globalData?: Record; + watch?: boolean; + watchDebounce?: number; + controlPort?: number; +}; + +export type ServiceGraphRunner = (( subset?: Array, runOptions?: ServiceGraphRunOptions, -) => Operation>; +) => Operation>) & { + options?: ServiceGraphOptions | undefined; +}; export type ServiceGraphFor> = R extends ServiceGraphRunner ? ServiceGraph : never; @@ -89,20 +96,20 @@ export type ServiceGraphFor> = * callers. * * @param services - a map of service names to definitions - * @param options - optional configuration: `{ globalData?, watch?, watchDebounce? }` + * @param options - optional graph configuration such as `globalData`, watching, and `controlPort` * @returns a runner function returning the graph operation */ export function useServiceGraph( services: S, - options?: { - globalData?: Record; - watch?: boolean; - watchDebounce?: number; - controlPort?: number; - }, + options?: ServiceGraphOptions, ): ServiceGraphRunner { - return (subset?: Array, runOptions?: ServiceGraphRunOptions) => { + const runner: ServiceGraphRunner = ( + subset?: Array, + runOptions?: ServiceGraphRunOptions, + ) => { return resource>(function* (provide) { + yield* GraphLauncher.set(consumeGraphLauncher()); + const effectiveRunOptions = { watch: runOptions?.watch ?? options?.watch, watchDebounce: runOptions?.watchDebounce ?? options?.watchDebounce, @@ -111,124 +118,27 @@ export function useServiceGraph( requestStop: runOptions?.requestStop, }; - // detect cycles in the dependency graph - const nodes = Object.keys(services); // label the root of the service graph operation yield* useAttributes({ name: "serviceGraph", - totalServices: String(nodes.length), + totalServices: String(Object.keys(services).length), }); - const temp = new Set(); - const perm = new Set(); - - function visit(n: string) { - if (perm.has(n)) return; - if (temp.has(n)) throw new Error("Cycle detected in services"); - temp.add(n); - const def = services[n]; - const deps: readonly string[] = def.dependsOn?.startup ?? []; - for (const d of deps) { - if (!(d in services)) continue; - visit(d); - } - temp.delete(n); - perm.add(n); - } - - for (const n of nodes) { - visit(n); - } - - let effectiveServices = services; // {} as typeof services; - const requested = subset - ? new Set(subset.map((s) => String(s).trim()).filter((s) => s.length > 0)) - : undefined; - const excluded = new Set( - (effectiveRunOptions.exclude ?? []) - .map((s) => String(s).trim()) - .filter((s) => s.length > 0), - ); - - for (const name of excluded) { - if (!(name in services)) { - throw new Error(`Excluded service '${name}' not found`); - } - } - - if (requested || excluded.size > 0) { - const selected = requested ? new Set() : new Set(Object.keys(services)); - - function include(name: string) { - if (selected.has(name)) return; - if (!(name in services)) throw new Error(`Requested service '${name}' not found`); - selected.add(name); - for (const dep of services[name].dependsOn?.startup ?? []) { - include(String(dep)); - } - } - - for (const name of requested ?? []) { - include(name); - } - - for (const name of excluded) { - selected.delete(name); - } - - let pruned = true; - while (pruned) { - pruned = false; - for (const name of Array.from(selected)) { - const deps = services[name].dependsOn?.startup ?? []; - if (deps.some((dep) => !selected.has(String(dep)))) { - selected.delete(name); - pruned = true; - } - } - } - - for (const name of requested ?? []) { - if (!selected.has(name)) { - throw new Error( - `Requested service '${name}' cannot be started because it or one of its startup dependencies is excluded`, - ); - } - } - - const picked: Partial = {}; - for (const name of selected) { - picked[name as keyof typeof services] = services[name as keyof typeof services]; - } - effectiveServices = picked as typeof services; - + const selection = selectServices(services, subset, effectiveRunOptions.exclude); + const effectiveServices = selection.services; + if (selection.filtered) { yield* useAttributes({ name: "serviceGraph", - requestedServices: Array.from(requested ?? []).join(", "), - excludedServices: Array.from(excluded).join(", "), - includedServices: Array.from(selected).join(", "), + requestedServices: Array.from(selection.requested ?? []).join(", "), + excludedServices: Array.from(selection.excluded).join(", "), + includedServices: Array.from(selection.selected).join(", "), }); yield* logger.stdout( - `simulation starting with services: ${Array.from(selected).join(", ")}`, + `simulation starting with services: ${Array.from(selection.selected).join(", ")}`, ); } const status = new Map(); - function serializeStatus(): ServiceGraphStatus { - return { - cwd: process.cwd(), - services: Object.fromEntries( - Array.from(status.entries()).map(([name, service]) => [ - name, - { - port: service.port, - pid: service.pid, - }, - ]), - ), - }; - } - let restartRequested = withResolvers("wait for a restart request"); function requestRestart(service?: string) { if (service !== undefined && !(service in effectiveServices)) { @@ -240,28 +150,26 @@ export function useServiceGraph( current.resolve(service); } - const dataServiceProvided = yield* startDataService({ + const controlPlane = yield* useControlPlane({ data: options?.globalData ?? {}, port: effectiveRunOptions.controlPort, - getStatus: serializeStatus, + services: Object.keys(effectiveServices), requestStop: effectiveRunOptions.requestStop, requestRestart, }); yield* useAttributes({ name: "serviceGraph", - dataServicePort: String(dataServiceProvided.port), + dataServicePort: String(controlPlane.port), }); - status.set("simulacrum", { - startup: withResolvers(), - running: withResolvers(), - port: dataServiceProvided.port, - }); - status.get("simulacrum")?.startup.resolve(); + const controlServiceStatus = controlPlane.getServiceStatus("simulacrum"); + if (!controlServiceStatus) throw new Error("missing simulacrum service status"); + status.set("simulacrum", controlServiceStatus); + controlServiceStatus.startup.resolve(); // set the SimulacrumEndpoint in this operation scope so children started // in this graph can access the port via context - yield* SimulacrumEndpoint.set(dataServiceProvided.port); + yield* SimulacrumEndpoint.set(controlPlane.port); // start up a watcher only when the CLI or caller explicitly asks for it // or when at least one of the services has a `watch` configuration. by @@ -283,10 +191,9 @@ export function useServiceGraph( for (const name of Object.keys(effectiveServices)) { const def = effectiveServices[name]; - status.set(name, { - startup: withResolvers(), - running: withResolvers(), - }); + const serviceStatus = controlPlane.getServiceStatus(name); + if (!serviceStatus) throw new Error(`missing status for service '${name}'`); + status.set(name, serviceStatus); if (def.watch && watcher) { watcher.add(name, def.watch); } @@ -310,14 +217,12 @@ export function useServiceGraph( // log so it is clear in the inspector output when a restart is triggered yield* logger.stdout(`restarting service ${service}`); + controlPlane.setServiceInfo(service, { state: "stopping" }); + controlPlane.setServiceInfo(service, { requestedSignal: "SIGTERM" }); // refresh the startup resolver task.startup = withResolvers(); - // remove any recorded port/pid for the service; it will be re-registered when it starts again - delete task.port; - delete task.pid; - - // signal the running operation to stop so it can clean up + // Let the operation clean up before clearing its control-plane record. task.running.resolve(); } @@ -378,6 +283,7 @@ export function useServiceGraph( status: `restarted ${++restartCount} times`, }); yield* waitDeps(service, restartCount); + controlPlane.setServiceInfo(service, { state: "starting" }); const def = effectiveServices[service]; const task = status.get(service); @@ -389,6 +295,29 @@ export function useServiceGraph( // run the service in a scoped child operation so it can be cleanly // cancelled when a file change triggers a restart const serviceTask = yield* spawn(function* () { + yield* ProcessApi.around({ + *exec(args, next) { + const [command, options] = args; + return yield* controlPlane.trackProcess( + service, + command, + options, + next(...args), + false, + ); + }, + *daemon(args, next) { + const [command, options] = args; + return yield* controlPlane.trackProcess( + service, + command, + options, + next(...args), + true, + ); + }, + }); + // capture any returned listening info (e.g., from useSimulation) const maybeProvided = yield* def.operation; if (maybeProvided && typeof maybeProvided === "object") { @@ -397,22 +326,36 @@ export function useServiceGraph( name: `service ${service}`, port: String(maybeProvided.port), }); - task.port = maybeProvided.port; + controlPlane.setServiceInfo(service, { port: maybeProvided.port }); } if ("pid" in maybeProvided && typeof maybeProvided.pid === "number") { - task.pid = maybeProvided.pid; yield* useAttributes({ name: `service ${service}`, pid: String(maybeProvided.pid), }); + controlPlane.setServiceInfo(service, { pid: maybeProvided.pid }); + } + if ("command" in maybeProvided) { + controlPlane.setServiceInfo(service, { + command: maybeProvided.command, + }); } } + if (controlPlane.getServiceInfo(service)?.state !== "failed") { + controlPlane.setServiceInfo(service, { state: "ready" }); + } task.startup.resolve(); // wait until the watcher asks for this service to be restarted yield* task.running.operation; }); - yield* serviceTask; + try { + yield* serviceTask; + } catch (error) { + controlPlane.setServiceInfo(service, { state: "failed" }); + throw error; + } + controlPlane.clearServiceInfo(service, "waiting"); } } @@ -447,4 +390,7 @@ export function useServiceGraph( } }); }; + + runner.options = options; + return runner; } diff --git a/packages/server/src/service-status.ts b/packages/server/src/service-status.ts new file mode 100644 index 00000000..6b5defb6 --- /dev/null +++ b/packages/server/src/service-status.ts @@ -0,0 +1,111 @@ +import type { ProcessCommand } from "./launch-metadata.ts"; +import { withResolvers, type WithResolvers } from "effection"; + +export type ServiceState = "waiting" | "starting" | "ready" | "stopping" | "failed"; + +export type ServiceExit = { + requestedSignal?: string; + code?: number; + signal?: string; +}; + +export type ServiceInfo = { + state: ServiceState; + port?: number | undefined; + // PID is relative to the process namespace in which the graph runs. + pid?: number | undefined; + command?: { executable: string; arguments: string[]; shell?: boolean | string } | undefined; + launcher?: ProcessCommand | undefined; + requestedSignal?: string | undefined; + lastExit?: ServiceExit | undefined; +}; + +export type ServiceInfoUpdate = Omit, "command"> & { + command?: unknown; +}; + +function isProcessCommand(value: unknown): value is NonNullable { + return ( + typeof value === "object" && + value !== null && + "executable" in value && + typeof value.executable === "string" && + "arguments" in value && + Array.isArray(value.arguments) && + value.arguments.every((argument) => typeof argument === "string") && + (!("shell" in value) || typeof value.shell === "boolean" || typeof value.shell === "string") + ); +} + +export class ServiceStatusRecord { + #info: ServiceInfo; + startup: WithResolvers = withResolvers(); + running: WithResolvers = withResolvers(); + + constructor(info: Partial = {}) { + this.#info = { state: "waiting", ...info }; + } + + get state(): ServiceState { + return this.#info.state; + } + + get pid(): number | undefined { + return this.#info.pid; + } + + get port(): number | undefined { + return this.#info.port; + } + + get command(): ServiceInfo["command"] { + return this.#info.command; + } + + get launcher(): ProcessCommand | undefined { + return this.#info.launcher; + } + + get lastExit(): ServiceInfo["lastExit"] { + return this.#info.lastExit; + } + + update(info: ServiceInfoUpdate): void { + if ("pid" in info && info.pid === undefined && this.#info.pid !== undefined) { + delete this.#info.pid; + delete this.#info.command; + } else if (info.pid !== undefined) { + this.#info.pid = info.pid; + } + + if (isProcessCommand(info.command)) { + this.#info.command = { + executable: info.command.executable, + arguments: [...info.command.arguments], + ...(info.command.shell === undefined ? {} : { shell: info.command.shell }), + }; + } + if (info.launcher !== undefined) this.#info.launcher = info.launcher; + if (info.requestedSignal !== undefined) this.#info.requestedSignal = info.requestedSignal; + if ("requestedSignal" in info && info.requestedSignal === undefined) { + delete this.#info.requestedSignal; + } + if (info.lastExit !== undefined) this.#info.lastExit = info.lastExit; + if (info.port !== undefined) this.#info.port = info.port; + if (info.state !== undefined) { + this.#info.state = info.state; + if (info.state === "starting") delete this.#info.requestedSignal; + } + } + + clear(state?: ServiceState): void { + delete this.#info.port; + delete this.#info.pid; + delete this.#info.command; + if (state !== undefined) this.#info.state = state; + } + + snapshot(): ServiceInfo { + return { ...this.#info }; + } +} diff --git a/packages/server/src/simulation.ts b/packages/server/src/simulation.ts index 97a17f12..3413b230 100644 --- a/packages/server/src/simulation.ts +++ b/packages/server/src/simulation.ts @@ -80,7 +80,11 @@ export function useSimulationChildProcess( options?: { nodeArgs?: string[] }, ) { return withOperationMetadata( - resource<{ port: number; pid: number }>(function* (provide) { + resource<{ + port: number; + pid: number; + command: { executable: string; arguments: string[] }; + }>(function* (provide) { yield* useAttributes({ name: `useSimulation ${name}`, module: modulePath, @@ -168,7 +172,11 @@ export function useSimulationChildProcess( yield* logger.debug(`${name} simulation: port ${port} pid ${pid}`); try { - yield* provide({ port, pid }); + yield* provide({ + port, + pid, + command: { executable: "node", arguments: [...(options?.nodeArgs ?? []), ...args] }, + }); } finally { yield* logger.debug(`${name} simulation: closed on port ${port}`); } @@ -205,7 +213,7 @@ export function useSimulation( name: string, modulePath: string, options?: { subprocess?: true; nodeArgs?: string[] }, -): Operation<{ port: number; pid: number }>; +): Operation<{ port: number; pid: number; command: { executable: string; arguments: string[] } }>; export function useSimulation>( name: string, factoryOrModulePath: ((initData?: unknown) => FoundationSimulator) | string, diff --git a/packages/server/test/cli-background.test.ts b/packages/server/test/cli-background.test.ts index f48814f7..6db5c132 100644 --- a/packages/server/test/cli-background.test.ts +++ b/packages/server/test/cli-background.test.ts @@ -4,9 +4,15 @@ import { createServer } from "node:net"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { run } from "effection"; +import { ctrlc } from "ctrlc-windows"; import { DEFAULT_CONTROL_PORT } from "../src/cli.ts"; import { waitForFetchClosed } from "./utils.ts"; +type StatusPayload = { + pid?: number; + services?: Record; +}; + async function getAvailablePort(): Promise { return await new Promise((resolve, reject) => { const server = createServer(); @@ -57,6 +63,66 @@ async function ensureDefaultControlPortAvailable() { } } +async function waitForHealth(controlPort: number) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${controlPort}/health`); + if (response.ok) return; + } catch (ignore) { + // keep polling until the graph is ready or the deadline expires + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`timed out waiting for graph on port ${controlPort}`); +} + +async function waitForPidExit(pid: number) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`timed out waiting for process ${pid} to exit`); +} + +async function waitForStatus(controlPort: number, ready: (status: StatusPayload) => boolean) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const response = await fetch(`http://127.0.0.1:${controlPort}/status`); + const status = (await response.json()) as StatusPayload; + if (ready(status)) return status; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`timed out waiting for graph status on port ${controlPort}`); +} + +function waitForTrackedChildPids(child: ReturnType): Promise<[number, number]> { + return new Promise((resolve, reject) => { + let output = ""; + const onData = (chunk: Buffer) => { + output += chunk.toString(); + const match = output.match(/tracked-child-pids:(\d+),(\d+)/); + if (match) { + child.stdout?.off("data", onData); + child.off("exit", onExit); + resolve([Number(match[1]), Number(match[2])]); + } + }; + const onExit = () => { + child.stdout?.off("data", onData); + reject(new Error(`graph exited before reporting child PIDs: ${output}`)); + }; + child.stdout?.on("data", onData); + child.once("exit", onExit); + }); +} + it("can background a graph and stop it through the CLI using the control port", async () => { const controlPort = await getAvailablePort(); const fixture = fileURLToPath(new URL("./fixtures/background-graph.ts", import.meta.url)); @@ -72,6 +138,7 @@ it("can background a graph and stop it through the CLI using the control port", const startResult = await waitForExit(background); assert.strictEqual(startResult.code, 0, startResult.stderr || startResult.stdout); + assert.match(startResult.stdout, /custom launcher: background/); const healthRes = await fetch(`http://127.0.0.1:${controlPort}/health`); assert.strictEqual(healthRes.status, 200); @@ -81,17 +148,29 @@ it("can background a graph and stop it through the CLI using the control port", assert.strictEqual(dataRes.status, 200); assert.deepStrictEqual(await dataRes.json(), true); - const statusRes = await fetch(`http://127.0.0.1:${controlPort}/status`); - assert.strictEqual(statusRes.status, 200); - const statusJson = (await statusRes.json()) as { + const statusJson = (await waitForStatus( + controlPort, + (status) => typeof status.services?.simulacrum?.port === "number", + )) as { cwd: string; - services: Record; + services: Record< + string, + { + state?: string; + port?: number; + pid?: number; + launcher?: { executable: string; arguments: string[] }; + } + >; }; assert.strictEqual( statusJson.cwd.replace(/\/$/, ""), fileURLToPath(new URL("..", import.meta.url)).replace(/\/$/, ""), ); assert.strictEqual(statusJson.services.simulacrum?.port, controlPort); + assert.strictEqual(statusJson.services.simulacrum?.state, "ready"); + assert.strictEqual(statusJson.services.simulacrum?.launcher?.executable, process.execPath); + assert.ok(statusJson.services.simulacrum?.launcher?.arguments.includes("start")); const stop = spawn(process.execPath, [fixture, "--stop", "--control-port", String(controlPort)], { cwd: fileURLToPath(new URL("..", import.meta.url)), @@ -106,6 +185,273 @@ it("can background a graph and stop it through the CLI using the control port", }); }); +it("does not report a background graph ready until its services are ready", async () => { + const controlPort = await getAvailablePort(); + const fixture = fileURLToPath(new URL("./fixtures/reaper-graph.ts", import.meta.url)); + const background = spawn( + process.execPath, + [fixture, "--background", "--control-port", String(controlPort)], + { + cwd: fileURLToPath(new URL("..", import.meta.url)), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + const result = await waitForExit(background); + assert.strictEqual(result.code, 0, result.stderr || result.stdout); + + const status = await fetch(`http://127.0.0.1:${controlPort}/status`); + const json = (await status.json()) as { + services: Record; + }; + assert.strictEqual(json.services.child?.state, "ready"); + assert.strictEqual(typeof json.services.child?.port, "number"); + + const response = await fetch(`http://127.0.0.1:${controlPort}/stop`, { method: "POST" }); + assert.strictEqual(response.status, 202); + await run(function* () { + yield* waitForFetchClosed(`http://127.0.0.1:${controlPort}/health`, 5000); + }); +}); + +it("gracefully stops a background graph when the startup wait expires", async () => { + const controlPort = await getAvailablePort(); + const fixture = fileURLToPath(new URL("./fixtures/reaper-graph.ts", import.meta.url)); + const background = spawn( + process.execPath, + [fixture, "--background", "--control-port", String(controlPort), "--startup-timeout", "2000"], + { + cwd: fileURLToPath(new URL("..", import.meta.url)), + env: { ...process.env, SIMULACRUM_TEST_STARTUP_DELAY_MS: "5000" }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let childPid: number | undefined; + let graphPid: number | undefined; + let graphStopped = false; + try { + await waitForHealth(controlPort); + const initialStatus = await waitForStatus( + controlPort, + (value) => typeof value.services?.child?.pid === "number", + ); + childPid = initialStatus.services?.child?.pid; + graphPid = initialStatus.services?.simulacrum?.pid; + assert.ok(typeof graphPid === "number"); + assert.ok(typeof childPid === "number"); + + const result = await waitForExit(background); + assert.strictEqual(result.code, 1); + assert.match(result.stderr, /did not become ready/); + await waitForPidExit(childPid); + await run(function* () { + yield* waitForFetchClosed(`http://127.0.0.1:${controlPort}/health`, 5000); + }); + graphStopped = true; + } finally { + if (background.exitCode === null) background.kill("SIGKILL"); + if (!graphStopped && graphPid !== undefined) { + try { + process.kill(graphPid, "SIGKILL"); + } catch { + // The graph may already have exited. + } + } + if (childPid !== undefined) { + try { + process.kill(childPid, "SIGKILL"); + } catch { + // The graph may already have reaped the child. + } + } + } +}); + +it("can stop a foreground graph through its control port", async () => { + const controlPort = await getAvailablePort(); + const fixture = fileURLToPath(new URL("./fixtures/background-graph.ts", import.meta.url)); + const graph = spawn(process.execPath, [fixture, "--control-port", String(controlPort)], { + cwd: fileURLToPath(new URL("..", import.meta.url)), + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + await waitForHealth(controlPort); + const response = await fetch(`http://127.0.0.1:${controlPort}/stop`, { method: "POST" }); + assert.strictEqual(response.status, 202); + const result = await waitForExit(graph); + assert.strictEqual(result.code, 0, result.stderr || result.stdout); + assert.match(result.stdout, /custom launcher: foreground/); + } finally { + if (!graph.killed) graph.kill("SIGKILL"); + } +}); + +it("forwards foreground launcher termination to the managed graph", async () => { + const controlPort = await getAvailablePort(); + const fixture = fileURLToPath(new URL("./fixtures/background-graph.ts", import.meta.url)); + const graph = spawn(process.execPath, [fixture, "--control-port", String(controlPort)], { + cwd: fileURLToPath(new URL("..", import.meta.url)), + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + await waitForHealth(controlPort); + const status = await waitForStatus( + controlPort, + (value) => typeof value.services?.simulacrum?.pid === "number", + ); + const managedPid = status.services?.simulacrum?.pid; + assert.ok(typeof managedPid === "number"); + + const graphExit = waitForExit(graph); + graph.kill("SIGTERM"); + const result = await graphExit; + assert.strictEqual(result.code, 143, result.stderr || result.stdout); + await waitForPidExit(managedPid); + await run(function* () { + yield* waitForFetchClosed(`http://127.0.0.1:${controlPort}/health`, 5000); + }); + } finally { + if (graph.exitCode === null) graph.kill("SIGKILL"); + } +}); + +it("gracefully shuts down a foreground graph on Ctrl+C", async () => { + const controlPort = await getAvailablePort(); + const fixture = fileURLToPath(new URL("./fixtures/reaper-graph.ts", import.meta.url)); + const graph = spawn(process.execPath, [fixture, "--control-port", String(controlPort)], { + cwd: fileURLToPath(new URL("..", import.meta.url)), + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + await waitForHealth(controlPort); + const status = await waitForStatus( + controlPort, + (value) => typeof value.services?.child?.pid === "number", + ); + const childPid = status.services?.child?.pid; + assert.ok(typeof childPid === "number"); + + const graphExit = waitForExit(graph); + if (process.platform === "win32") { + assert.ok(typeof graph.pid === "number"); + ctrlc(graph.pid); + } else { + graph.kill("SIGINT"); + } + const result = await graphExit; + assert.strictEqual(result.code, 130, result.stderr || result.stdout); + await waitForPidExit(childPid); + await run(function* () { + yield* waitForFetchClosed(`http://127.0.0.1:${controlPort}/health`, 5000); + }); + } finally { + if (graph.exitCode === null) graph.kill("SIGKILL"); + } +}); + +it("starts the graph directly with the start subcommand", async () => { + const controlPort = await getAvailablePort(); + const fixture = fileURLToPath(new URL("./fixtures/background-graph.ts", import.meta.url)); + const graph = spawn(process.execPath, [fixture, "start", "--control-port", String(controlPort)], { + cwd: fileURLToPath(new URL("..", import.meta.url)), + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + await waitForHealth(controlPort); + const status = await waitForStatus( + controlPort, + (payload) => payload.services?.simulacrum?.state === "ready", + ); + assert.strictEqual(status.services?.simulacrum?.pid, graph.pid); + + const response = await fetch(`http://127.0.0.1:${controlPort}/stop`, { method: "POST" }); + assert.strictEqual(response.status, 202); + const result = await waitForExit(graph); + assert.strictEqual(result.code, 0, result.stderr || result.stdout); + } finally { + if (graph.exitCode === null) graph.kill("SIGKILL"); + } +}); + +it("reaps child simulators when a graph is hard-killed", async () => { + const controlPort = await getAvailablePort(); + const fixture = fileURLToPath(new URL("./fixtures/reaper-graph.ts", import.meta.url)); + const graph = spawn(process.execPath, [fixture, "--control-port", String(controlPort)], { + cwd: fileURLToPath(new URL("..", import.meta.url)), + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + await waitForHealth(controlPort); + const json = (await waitForStatus( + controlPort, + (status) => + typeof status.services?.child?.pid === "number" && + typeof status.services?.child?.port !== "number", + )) as { + services: Record; + }; + const rootPid = json.services.simulacrum?.pid; + const pid = json.services.child?.pid; + assert.ok(typeof rootPid === "number"); + assert.ok(typeof pid === "number"); + assert.strictEqual(json.services.child?.state, "starting"); + + const graphExit = waitForExit(graph); + process.kill(rootPid, "SIGKILL"); + await waitForPidExit(rootPid); + await waitForPidExit(pid); + await graphExit; + } finally { + if (graph.exitCode === null) graph.kill("SIGKILL"); + } +}); + +it("keeps reaper watches for multiple processes in one service", async () => { + const controlPort = await getAvailablePort(); + const fixture = fileURLToPath(new URL("./fixtures/multi-process-graph.ts", import.meta.url)); + const reported = spawn(process.execPath, ["-e", "setInterval(() => {}, 10000)"], { + detached: true, + stdio: "ignore", + }); + assert.ok(typeof reported.pid === "number"); + const graph = spawn(process.execPath, [fixture, "start", "--control-port", String(controlPort)], { + cwd: fileURLToPath(new URL("..", import.meta.url)), + env: { ...process.env, SIMULACRUM_TEST_REPORTED_PID: String(reported.pid) }, + stdio: ["ignore", "pipe", "pipe"], + }); + const trackedPids = waitForTrackedChildPids(graph); + + try { + await waitForHealth(controlPort); + const [firstPid, secondPid] = await trackedPids; + const status = await waitForStatus( + controlPort, + (value) => + value.services?.worker?.pid === secondPid && value.services?.reported?.pid === reported.pid, + ); + assert.strictEqual(status.services?.worker?.pid, secondPid); + assert.strictEqual(status.services?.reported?.pid, reported.pid); + + const graphExit = waitForExit(graph); + graph.kill("SIGKILL"); + await graphExit; + await waitForPidExit(firstPid); + await waitForPidExit(secondPid); + assert.doesNotThrow(() => process.kill(reported.pid!, 0)); + } finally { + if (graph.exitCode === null) graph.kill("SIGKILL"); + if (reported.exitCode === null && reported.signalCode === null) { + reported.kill("SIGKILL"); + } + } +}); + it("defaults background and stop commands to the default control port", async () => { await ensureDefaultControlPortAvailable(); @@ -136,6 +482,54 @@ it("defaults background and stop commands to the default control port", async () }); }); +it("uses the graph-configured control port when no CLI port is supplied", async () => { + const controlPort = await getAvailablePort(); + const fixture = fileURLToPath(new URL("./fixtures/background-graph.ts", import.meta.url)); + const packageCwd = fileURLToPath(new URL("..", import.meta.url)); + const env = { + ...process.env, + SIMULACRUM_TEST_GRAPH_CONTROL_PORT: String(controlPort), + }; + let stopped = false; + + try { + const background = spawn(process.execPath, [fixture, "--background"], { + cwd: packageCwd, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + const startResult = await waitForExit(background); + assert.strictEqual(startResult.code, 0, startResult.stderr || startResult.stdout); + await waitForHealth(controlPort); + + const stop = spawn(process.execPath, [fixture, "--stop"], { + cwd: packageCwd, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + const stopResult = await waitForExit(stop); + assert.strictEqual(stopResult.code, 0, stopResult.stderr || stopResult.stdout); + stopped = true; + + await run(function* () { + yield* waitForFetchClosed(`http://127.0.0.1:${controlPort}/health`, 5000); + }); + } finally { + if (!stopped) { + try { + const stop = spawn(process.execPath, [fixture, "--stop"], { + cwd: packageCwd, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + await waitForExit(stop); + } catch { + // The graph may not have reached startup before the test failed. + } + } + } +}); + it("errors before starting a foreground graph when a background graph is already running on the default control port", async () => { await ensureDefaultControlPortAvailable(); diff --git a/packages/server/test/data-service.test.ts b/packages/server/test/data-service.test.ts index 0d77ccb2..b8d9ca3f 100644 --- a/packages/server/test/data-service.test.ts +++ b/packages/server/test/data-service.test.ts @@ -1,11 +1,12 @@ import { it } from "node:test"; import assert from "node:assert"; import { createServer } from "node:net"; -import { run, until } from "effection"; +import { resource, run, spawn, suspend, until } from "effection"; +import { daemon, exec } from "@effectionx/process"; import { useServiceGraph } from "../src/service-graph.ts"; import { useSimulation } from "../src/simulation.ts"; -import { waitFor } from "./utils.ts"; -import { createFoundationSimulationServer } from "@simulacrum/foundation-simulator"; +import { useService } from "../src/service.ts"; +import { waitFor, waitForOperation } from "./utils.ts"; async function getAvailablePort(): Promise { return await new Promise((resolve, reject) => { @@ -47,6 +48,93 @@ it("starts data service and serves configured data", async () => { }); }); +it("fails readiness when a daemon exits during service startup", async () => { + const controlPort = await getAvailablePort(); + + await run(function* () { + yield* spawn(function* () { + yield* useServiceGraph( + { + worker: { + operation: resource(function* (provide) { + const child = yield* daemon(process.execPath, { + arguments: ["-e", "process.exit(1)"], + }); + yield* child.join(); + yield* waitForOperation(function* () { + const response = yield* until(fetch(`http://127.0.0.1:${controlPort}/status`)); + const status = (yield* until(response.json())) as { + services: Record; + }; + return ( + status.services.worker?.state === "failed" && + status.services.worker.pid === undefined + ); + }); + yield* provide(); + yield* suspend(); + }), + }, + }, + { controlPort }, + )(); + yield* suspend(); + }); + + yield* waitForOperation(function* () { + const response = yield* until(fetch(`http://127.0.0.1:${controlPort}/status`)); + const status = (yield* until(response.json())) as { + services: Record; + }; + return status.services.worker?.state === "failed" && status.services.worker.pid === undefined; + }); + + const response = yield* until(fetch(`http://127.0.0.1:${controlPort}/ready`)); + assert.strictEqual(response.status, 503); + assert.deepStrictEqual(yield* until(response.json()), { + ready: false, + service: "worker", + state: "failed", + }); + }); +}); + +it("allows finite exec helpers to exit during service startup", async () => { + const controlPort = await getAvailablePort(); + + await run(function* () { + const graph = yield* useServiceGraph( + { + worker: { + operation: resource(function* (provide) { + const child = yield* exec(process.execPath, { + arguments: ["-e", "process.exit(0)"], + }); + yield* child.join(); + yield* waitForOperation(function* () { + const response = yield* until(fetch(`http://127.0.0.1:${controlPort}/status`)); + const status = (yield* until(response.json())) as { + services: Record; + }; + return ( + status.services.worker?.state === "starting" && + status.services.worker.pid === undefined + ); + }); + yield* provide(); + yield* suspend(); + }), + }, + }, + { controlPort }, + )(); + + assert.strictEqual(graph.status.get("worker")?.state, "ready"); + const response = yield* until(fetch(`http://127.0.0.1:${controlPort}/ready`)); + assert.strictEqual(response.status, 200); + }); +}); + it("serves individual keys and appropriate status codes", async () => { await run(function* () { const runGraph = yield* useServiceGraph( @@ -90,18 +178,17 @@ it("binds the control service to a requested static port and exposes health/stat const runGraph = yield* useServiceGraph( { api: { - operation: useSimulation("api", () => - createFoundationSimulationServer({ - port: 0, - })(), - ), + operation: useSimulation("api", "./test/fixtures/init-data-sim.ts"), + }, + notSelected: { + operation: useSimulation("not-selected", "./test/fixtures/init-data-sim.ts"), }, }, { globalData: { featureFlag: true }, controlPort, }, - )(); + )(["api"]); yield* waitFor(() => typeof runGraph.status?.get("api")?.port === "number", 3000); @@ -112,14 +199,189 @@ it("binds the control service to a requested static port and exposes health/stat const statusRes = yield* until(fetch(`http://127.0.0.1:${controlPort}/status`)); assert.strictEqual(statusRes.status, 200); const statusJson = (yield* until(statusRes.json())) as { - services: Record; + services: Record< + string, + { + state: string; + port?: number; + pid?: number; + command?: { executable: string; arguments: string[] }; + lastExit?: { requestedSignal?: string; code?: number; signal?: string }; + } + >; }; assert.strictEqual(statusJson.services.simulacrum?.port, controlPort); + assert.strictEqual(statusJson.services.simulacrum?.state, "ready"); + assert.strictEqual(statusJson.services.simulacrum?.pid, process.pid); + assert.strictEqual(statusJson.services.simulacrum?.command?.executable, process.execPath); assert.strictEqual(statusJson.services.api?.port, runGraph.status.get("api")?.port); + assert.strictEqual(statusJson.services.api?.state, "ready"); + assert.strictEqual(statusJson.services.api?.command?.executable, "node"); + assert.ok(statusJson.services.api?.command?.arguments[0]?.includes("run-simulation-child")); + assert.strictEqual("notSelected" in statusJson.services, false); const dataRes = yield* until(fetch(`http://127.0.0.1:${controlPort}/data/featureFlag`)); assert.strictEqual(dataRes.status, 200); assert.deepStrictEqual(yield* until(dataRes.json()), true); + + const apiPid = statusJson.services.api?.pid; + assert.strictEqual(typeof apiPid, "number"); + process.kill(apiPid!, "SIGTERM"); + yield* waitForOperation(function* () { + const response = yield* until(fetch(`http://127.0.0.1:${controlPort}/status`)); + const status = (yield* until(response.json())) as typeof statusJson; + const lastExit = status.services.api?.lastExit; + return ( + status.services.api?.state === "failed" && + status.services.api?.pid === undefined && + status.services.api?.port === undefined && + (typeof lastExit?.code === "number" || typeof lastExit?.signal === "string") + ); + }); }); }); + +it("tracks useService processes under their graph service name", async () => { + const controlPort = await getAvailablePort(); + + await run(function* () { + yield* useServiceGraph( + { + worker: { + operation: useService("display-name", "node ./test/services/service-main.ts"), + }, + }, + { controlPort }, + )(); + + const response = yield* until(fetch(`http://127.0.0.1:${controlPort}/status`)); + const status = (yield* until(response.json())) as { + services: Record< + string, + { state: string; pid?: number; command?: { executable: string; arguments: string[] } } + >; + }; + assert.strictEqual(typeof status.services.worker?.pid, "number"); + assert.strictEqual(status.services.worker?.state, "ready"); + assert.deepStrictEqual(status.services.worker?.command, { + executable: "node ./test/services/service-main.ts", + arguments: [], + shell: true, + }); + assert.strictEqual("display-name" in status.services, false); + }); +}); + +it("fails a service when a non-current tracked daemon exits", async () => { + const controlPort = await getAvailablePort(); + let firstDaemonPid: number | undefined; + let exitFirstDaemon: (() => void) | undefined; + let currentDaemonPid: number | undefined; + + await run(function* () { + yield* useServiceGraph( + { + worker: { + operation: resource(function* (provide) { + const exitOnInput = + 'process.stdin.once("data", () => process.exit(0)); setInterval(() => {}, 1000)'; + const firstDaemon = yield* daemon(process.execPath, { + arguments: ["-e", exitOnInput, "first-daemon"], + }); + firstDaemonPid = firstDaemon.pid; + exitFirstDaemon = () => firstDaemon.stdin.send("exit"); + const currentDaemon = yield* daemon(process.execPath, { + arguments: ["-e", exitOnInput, "current-daemon"], + }); + currentDaemonPid = currentDaemon.pid; + yield* provide(); + yield* suspend(); + }), + }, + }, + { controlPort }, + )(); + + assert.ok(typeof firstDaemonPid === "number"); + assert.ok(exitFirstDaemon); + assert.ok(typeof currentDaemonPid === "number"); + assert.notStrictEqual(firstDaemonPid, currentDaemonPid); + + const readyResponse = yield* until(fetch(`http://127.0.0.1:${controlPort}/status`)); + const readyStatus = (yield* until(readyResponse.json())) as { + services: Record; + }; + assert.strictEqual(readyStatus.services.worker?.pid, currentDaemonPid); + assert.ok(readyStatus.services.worker?.command?.arguments.includes("current-daemon")); + + exitFirstDaemon(); + yield* waitForOperation(function* () { + const response = yield* until(fetch(`http://127.0.0.1:${controlPort}/status`)); + const status = (yield* until(response.json())) as { + services: Record< + string, + { + state?: string; + pid?: number; + command?: { arguments: string[] }; + lastExit?: { code?: number; signal?: string }; + } + >; + }; + return ( + status.services.worker?.state === "failed" && + status.services.worker.pid === currentDaemonPid && + status.services.worker.command?.arguments.includes("current-daemon") === true && + status.services.worker.lastExit?.code === 0 + ); + }); + }); +}); + +it("observes a process exit while its service scope remains alive", async () => { + const controlPort = await getAvailablePort(); + let operationAlive = false; + let exitChild: (() => void) | undefined; + + await run(function* () { + yield* useServiceGraph( + { + worker: { + operation: resource(function* (provide) { + const child = yield* daemon(process.execPath, { + arguments: [ + "-e", + "process.stdin.once('data', () => process.exit(0)); setInterval(() => {}, 1000)", + ], + }); + exitChild = () => child.stdin.send("exit"); + operationAlive = true; + try { + yield* provide(); + } finally { + operationAlive = false; + } + }), + }, + }, + { controlPort }, + )(); + + assert.strictEqual(operationAlive, true); + assert.ok(exitChild); + exitChild(); + + yield* waitForOperation(function* () { + const response = yield* until(fetch(`http://127.0.0.1:${controlPort}/status`)); + const status = (yield* until(response.json())) as { + services: Record; + }; + return status.services.worker?.state === "failed" && status.services.worker.pid === undefined; + }); + + assert.strictEqual(operationAlive, true); + }); + + assert.strictEqual(operationAlive, false); +}); diff --git a/packages/server/test/fixtures/background-graph.ts b/packages/server/test/fixtures/background-graph.ts index 364d626a..1307a7ab 100644 --- a/packages/server/test/fixtures/background-graph.ts +++ b/packages/server/test/fixtures/background-graph.ts @@ -7,9 +7,17 @@ export const services = useServiceGraph( {}, { globalData: { background: true }, + ...(process.env.SIMULACRUM_TEST_GRAPH_CONTROL_PORT + ? { controlPort: Number(process.env.SIMULACRUM_TEST_GRAPH_CONTROL_PORT) } + : {}), }, ); if (process.argv[1] === fileURLToPath(import.meta.url)) { - simulationCLI(services); + simulationCLI(services, { + launchGraph(request, launchDefault) { + process.stdout.write(`custom launcher: ${request.mode}\n`); + return launchDefault(request); + }, + }); } diff --git a/packages/server/test/fixtures/init-data-sim.ts b/packages/server/test/fixtures/init-data-sim.ts index 8f80b12b..61b8bfb3 100644 --- a/packages/server/test/fixtures/init-data-sim.ts +++ b/packages/server/test/fixtures/init-data-sim.ts @@ -2,7 +2,17 @@ import { createFoundationSimulationServer } from "@simulacrum/foundation-simulat import type { FoundationSimulator } from "@simulacrum/foundation-simulator"; -export function simulation(initData?: unknown): FoundationSimulator { +export async function simulation(initData?: unknown): Promise> { + if ( + initData && + typeof initData === "object" && + "startupDelayMs" in initData && + typeof initData.startupDelayMs === "number" + ) { + const delay = initData.startupDelayMs; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + return createFoundationSimulationServer({ port: 0, extendRouter(router) { diff --git a/packages/server/test/fixtures/multi-process-graph.ts b/packages/server/test/fixtures/multi-process-graph.ts new file mode 100644 index 00000000..071ebf1c --- /dev/null +++ b/packages/server/test/fixtures/multi-process-graph.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import { resource, suspend } from "effection"; +import { daemon } from "@effectionx/process"; +import { fileURLToPath } from "node:url"; +import { simulationCLI } from "../../src/cli.ts"; +import { useServiceGraph } from "../../src/service-graph.ts"; + +export const services = useServiceGraph({ + worker: { + operation: resource(function* (provide) { + const first = yield* daemon(process.execPath, { + arguments: ["-e", "setInterval(() => {}, 10000)"], + }); + const second = yield* daemon(process.execPath, { + arguments: ["-e", "setInterval(() => {}, 10000)"], + }); + + process.stdout.write(`tracked-child-pids:${first.pid},${second.pid}\n`); + yield* provide({ pid: second.pid }); + yield* suspend(); + }), + }, + reported: { + operation: resource(function* (provide) { + const pid = Number(process.env.SIMULACRUM_TEST_REPORTED_PID); + yield* provide({ pid }); + yield* suspend(); + }), + }, +}); + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + simulationCLI(services); +} diff --git a/packages/server/test/fixtures/reaper-graph.ts b/packages/server/test/fixtures/reaper-graph.ts new file mode 100644 index 00000000..397faa4c --- /dev/null +++ b/packages/server/test/fixtures/reaper-graph.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { fileURLToPath } from "node:url"; +import { simulationCLI } from "../../src/cli.ts"; +import { useSimulation, useServiceGraph } from "../../src/index.ts"; + +export const services = useServiceGraph( + { + child: { + operation: useSimulation("simulator", "./test/fixtures/init-data-sim.ts"), + }, + }, + { + globalData: { + startupDelayMs: Number(process.env.SIMULACRUM_TEST_STARTUP_DELAY_MS ?? 750), + }, + }, +); + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + simulationCLI(services); +} diff --git a/packages/server/test/reaper.test.ts b/packages/server/test/reaper.test.ts new file mode 100644 index 00000000..263ee0b7 --- /dev/null +++ b/packages/server/test/reaper.test.ts @@ -0,0 +1,218 @@ +import { once } from "node:events"; +import { fork, spawn, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { DEFAULT_PROCESS_SHUTDOWN_GRACE } from "../src/reaper.ts"; + +const reaperPath = fileURLToPath(new URL("../src/run-reaper.ts", import.meta.url)); + +it("allows the shared shutdown grace by default", () => { + assert.strictEqual(DEFAULT_PROCESS_SHUTDOWN_GRACE, 10_000); +}); + +async function waitForReady(child: ReturnType) { + await new Promise((resolve, reject) => { + const onMessage = (message: { type?: string }) => { + if (message.type === "ready") { + child.off("error", onError); + resolve(); + } + }; + const onError = (error: Error) => { + child.off("message", onMessage); + reject(error); + }; + child.on("message", onMessage); + child.once("error", onError); + }); +} + +function waitForExit(child: ReturnType) { + return once(child, "exit") as Promise<[number | null, NodeJS.Signals | null]>; +} + +function startVictim(ignoreTerm = false) { + const script = ignoreTerm + ? "process.on('SIGTERM', () => {}); setInterval(() => {}, 10000)" + : "setInterval(() => {}, 10000)"; + return spawn(process.execPath, ["-e", script], { detached: true, stdio: "ignore" }); +} + +async function waitForPidExit(pid: number) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`timed out waiting for process ${pid} to exit`); +} + +function killProcessTree(pid: number | undefined) { + if (!pid) return; + try { + if (process.platform === "win32") { + spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + } else { + process.kill(-pid, "SIGKILL"); + } + } catch { + // The process tree may already have exited. + } +} + +it("reaps watched processes after its IPC connection closes", async () => { + const victim = startVictim(); + const reaper = fork(reaperPath, ["50"], { + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + try { + await waitForReady(reaper); + const reaperExit = once(reaper, "exit"); + reaper.send({ type: "watch", pid: victim.pid }); + reaper.disconnect(); + + const [code, signal] = await waitForExit(victim); + assert.ok(code !== null || signal !== null); + await reaperExit; + } finally { + if (reaper.connected) reaper.kill(); + killProcessTree(victim.pid); + } +}); + +it("does not reap processes after an explicit shutdown", async () => { + const victim = startVictim(); + const reaper = fork(reaperPath, ["50"], { + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + try { + await waitForReady(reaper); + const reaperExit = once(reaper, "exit"); + reaper.send({ type: "watch", pid: victim.pid }); + reaper.send({ type: "shutdown" }); + await reaperExit; + assert.strictEqual(victim.exitCode, null); + } finally { + if (reaper.connected) reaper.kill(); + killProcessTree(victim.pid); + } +}); + +it("escalates to SIGKILL when a watched process ignores SIGTERM", async () => { + const victim = startVictim(true); + const reaper = fork(reaperPath, ["50"], { + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + try { + await waitForReady(reaper); + const victimExit = waitForExit(victim); + const reaperExit = once(reaper, "exit"); + reaper.send({ type: "watch", pid: victim.pid }); + reaper.disconnect(); + + const [code, signal] = await victimExit; + if (process.platform === "win32") { + assert.ok(code !== null || signal !== null); + } else { + assert.strictEqual(signal, "SIGKILL"); + } + await reaperExit; + } finally { + // cleanup only used to ensure no processes are left running + // if something is broken + if (reaper.connected) reaper.kill(); + killProcessTree(victim.pid); + } +}); + +it("reaps all watched processes but leaves unwatched processes running", async () => { + const watched = [startVictim(), startVictim(), startVictim()]; + const unwatched = startVictim(true); + const reaper = fork(reaperPath, ["50"], { + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + try { + await waitForReady(reaper); + const watchedExits = watched.map(waitForExit); + const unwatchedExit = waitForExit(unwatched); + for (const victim of watched) { + reaper.send({ type: "watch", pid: victim.pid }); + } + reaper.send({ type: "watch", pid: unwatched.pid }); + reaper.send({ type: "unwatch", pid: unwatched.pid }); + const reaperExit = once(reaper, "exit"); + reaper.disconnect(); + + const results = await Promise.all(watchedExits); + assert.ok(results.every(([code, signal]) => code !== null || signal !== null)); + await reaperExit; + assert.strictEqual(unwatched.exitCode, null); + assert.strictEqual(unwatched.signalCode, null); + killProcessTree(unwatched.pid); + await unwatchedExit; + } finally { + // cleanup only used to ensure no processes are left running + // if something is broken + if (reaper.connected) reaper.kill(); + for (const victim of [...watched, unwatched]) { + killProcessTree(victim.pid); + } + } +}); + +it("kills descendants in the watched process group", async () => { + const script = [ + 'const { spawn } = require("node:child_process");', + 'const child = spawn(process.execPath, ["-e", "process.on(\'SIGTERM\', () => {}); setInterval(() => {}, 10000)"], { stdio: "ignore" });', + 'process.on("SIGTERM", () => {});', + "process.stdout.write(`${child.pid}\\n`);", + "setInterval(() => {}, 10000);", + ].join("\n"); + const victim = spawn(process.execPath, ["-e", script], { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + }); + let grandchildPid: number | undefined; + const reaper = fork(reaperPath, ["50"], { + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + try { + const [chunk] = await once(victim.stdout!, "data"); + grandchildPid = Number(chunk.toString().trim()); + assert.ok(Number.isInteger(grandchildPid) && grandchildPid > 0); + + await waitForReady(reaper); + const victimExit = waitForExit(victim); + const reaperExit = once(reaper, "exit"); + reaper.send({ type: "watch", pid: victim.pid }); + reaper.disconnect(); + + await victimExit; + await waitForPidExit(grandchildPid); + await reaperExit; + } finally { + if (reaper.connected) reaper.kill(); + killProcessTree(victim.pid); + if (grandchildPid !== undefined) { + try { + process.kill(grandchildPid, "SIGKILL"); + } catch { + // The reaper may already have terminated the descendant. + } + } + } +}); diff --git a/packages/server/test/service-status.test.ts b/packages/server/test/service-status.test.ts new file mode 100644 index 00000000..04f8e326 --- /dev/null +++ b/packages/server/test/service-status.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { ServiceStatusRecord } from "../src/service-status.ts"; + +it("clears live process details while retaining the last exit", () => { + const status = new ServiceStatusRecord(); + status.update({ + state: "failed", + pid: 42, + port: 8080, + command: { executable: "node", arguments: ["server.js"] }, + lastExit: { code: 143 }, + }); + + status.clear(); + + assert.deepStrictEqual(status.snapshot(), { + state: "failed", + lastExit: { code: 143 }, + }); +}); + +it("clears the in-flight requested signal when a replacement starts", () => { + const status = new ServiceStatusRecord({ state: "stopping" }); + status.update({ + requestedSignal: "SIGTERM", + lastExit: { requestedSignal: "SIGTERM", signal: "SIGTERM" }, + }); + + status.update({ state: "starting" }); + + assert.deepStrictEqual(status.snapshot(), { + state: "starting", + lastExit: { requestedSignal: "SIGTERM", signal: "SIGTERM" }, + }); +}); + +it("preserves a valid command when an update has no usable command", () => { + const status = new ServiceStatusRecord(); + const command = { executable: "node", arguments: ["server.js"] }; + status.update({ pid: 42, command }); + + status.update({ command: undefined }); + status.update({ command: { executable: 42, arguments: "server.js" } }); + + assert.deepStrictEqual(status.snapshot().command, command); +}); diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index f4947fec..bda85fcb 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ name: "server", - entry: ["./src/index.ts", "./src/run-simulation-child.ts"], + entry: ["./src/index.ts", "./src/run-simulation-child.ts", "./src/run-reaper.ts"], deps: { // if we unbundle, we want to skip this as well skipNodeModulesBundle: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25573c1e..166fbced 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: '@effectionx/context-api': specifier: ^0.6.0 version: 0.6.0(effection@4.0.3) + '@effectionx/node': + specifier: ^0.2.4 + version: 0.2.4(effection@4.0.3) '@effectionx/process': specifier: ^0.8.1 version: 0.8.1(effection@4.0.3) @@ -207,6 +210,9 @@ importers: chokidar: specifier: ^5.0.0 version: 5.0.0 + ctrlc-windows: + specifier: ^2.2.0 + version: 2.2.0 effection: specifier: 'catalog:' version: 4.0.3 From 485fbdc72b169d5c4f53790463a9c7eb658f3fd1 Mon Sep 17 00:00:00 2001 From: Jack <48762591+frontsidejack@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:02:33 -0400 Subject: [PATCH 02/13] publish new versions (#382) Co-authored-by: jbolda <2019387+jbolda@users.noreply.github.com> --- .changes/simulacrum-server-launchGraph-option.md | 5 ----- .changes/simulacrum-server-reaper.md | 5 ----- .changes/simulacrum-server-stop-flag.md | 5 ----- packages/server/CHANGELOG.md | 11 +++++++++++ packages/server/package.json | 2 +- 5 files changed, 12 insertions(+), 16 deletions(-) delete mode 100644 .changes/simulacrum-server-launchGraph-option.md delete mode 100644 .changes/simulacrum-server-reaper.md delete mode 100644 .changes/simulacrum-server-stop-flag.md diff --git a/.changes/simulacrum-server-launchGraph-option.md b/.changes/simulacrum-server-launchGraph-option.md deleted file mode 100644 index caa24f32..00000000 --- a/.changes/simulacrum-server-launchGraph-option.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@simulacrum/server": minor:enhance ---- - -Add `launchGraph` option in `simulationCLI` to allow a user to "wrap" the service graph process. For example, a team using only linux could opt to wrap the graph in `unshare` for more direct kernel protections from zombie processes. diff --git a/.changes/simulacrum-server-reaper.md b/.changes/simulacrum-server-reaper.md deleted file mode 100644 index b2c0b671..00000000 --- a/.changes/simulacrum-server-reaper.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@simulacrum/server": minor:enhance ---- - -Add a new reaper process. We hand it the pids of processes when they start and when they shutdown. If the main service graph gets hard killed and the reaper see it lost the parent, the reaper will kill all (now) zombied processes and shut itself down. This is primarily in place to prevent bad shutdown sequences leaving zombie processes. diff --git a/.changes/simulacrum-server-stop-flag.md b/.changes/simulacrum-server-stop-flag.md deleted file mode 100644 index 7da54916..00000000 --- a/.changes/simulacrum-server-stop-flag.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@simulacrum/server": patch:bug ---- - -Allow the /stop endpoint even if the service graph is not backgrounded. Allows one terminal to start but another to stop (or to use the idea of backgrounding through another function such as in tmux, etc.). diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 9f65a003..31004a45 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.11.0] + +### Enhancements + +- [`e1ea716`](https://github.com/thefrontside/simulacrum/commit/e1ea7164c4b6706732e142a6790af00a4ffbbc58) ([#381](https://github.com/thefrontside/simulacrum/pull/381) by [@jbolda](https://github.com/thefrontside/simulacrum/../../jbolda)) Add `launchGraph` option in `simulationCLI` to allow a user to "wrap" the service graph process. For example, a team using only linux could opt to wrap the graph in `unshare` for more direct kernel protections from zombie processes. +- [`e1ea716`](https://github.com/thefrontside/simulacrum/commit/e1ea7164c4b6706732e142a6790af00a4ffbbc58) ([#381](https://github.com/thefrontside/simulacrum/pull/381) by [@jbolda](https://github.com/thefrontside/simulacrum/../../jbolda)) Add a new reaper process. We hand it the pids of processes when they start and when they shutdown. If the main service graph gets hard killed and the reaper see it lost the parent, the reaper will kill all (now) zombied processes and shut itself down. This is primarily in place to prevent bad shutdown sequences leaving zombie processes. + +### Bug Fixes + +- [`e1ea716`](https://github.com/thefrontside/simulacrum/commit/e1ea7164c4b6706732e142a6790af00a4ffbbc58) ([#381](https://github.com/thefrontside/simulacrum/pull/381) by [@jbolda](https://github.com/thefrontside/simulacrum/../../jbolda)) Allow the /stop endpoint even if the service graph is not backgrounded. Allows one terminal to start but another to stop (or to use the idea of backgrounding through another function such as in tmux, etc.). + ## \[0.10.1] ### Bug Fixes diff --git a/packages/server/package.json b/packages/server/package.json index fe543658..9c207608 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@simulacrum/server", - "version": "0.10.1", + "version": "0.11.0", "description": "Helpers and control plane to handle simulators and observability", "keywords": [ "emulation", From 0aadc16f0bd10371efe3db626a902612e0ae6d43 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Fri, 25 Sep 2026 12:51:43 +0000 Subject: [PATCH 03/13] auth0: model user_metadata and app_metadata, expose them to rules Users seeded via initialState can carry user_metadata/app_metadata (default {}), and rules receive both on the user argument as Auth0 Rules do, so claims can be derived from metadata. Neither is copied into the tokens unless a rule adds it as a claim. --- .changes/auth0-user-metadata.md | 5 ++ packages/auth0/README.md | 19 ++++++ packages/auth0/src/handlers/oauth-handlers.ts | 8 ++- packages/auth0/src/rules/types.ts | 2 + packages/auth0/src/store/entities.ts | 2 + packages/auth0/test/entities.test.ts | 24 +++++++ .../rules-metadata/metadata-claims.js | 9 +++ .../rules-metadata/metadata-claims.json | 5 ++ packages/auth0/test/rules.test.ts | 67 ++++++++++++++++++- 9 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 .changes/auth0-user-metadata.md create mode 100644 packages/auth0/test/fixtures/rules-metadata/metadata-claims.js create mode 100644 packages/auth0/test/fixtures/rules-metadata/metadata-claims.json diff --git a/.changes/auth0-user-metadata.md b/.changes/auth0-user-metadata.md new file mode 100644 index 00000000..90e32f1e --- /dev/null +++ b/.changes/auth0-user-metadata.md @@ -0,0 +1,5 @@ +--- +"@simulacrum/auth0-simulator": minor +--- + +Users can carry `user_metadata` and `app_metadata` (seeded via `initialState`), and rules receive both on the `user` argument, as Auth0 Rules do. diff --git a/packages/auth0/README.md b/packages/auth0/README.md index f89cfb90..6e2d4e4c 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -61,6 +61,23 @@ app.listen(4400, () => console.log(`auth0 simulation server started at https://l By passing an `initialState`, you may control the initial users in the store. +```js +const app = simulation({ + initialState: { + users: [ + { + id: "auth0|alice", + name: "Alice", + email: "alice@example.com", + password: "12345", + user_metadata: { theme: "dark" }, + app_metadata: { roles: ["admin"] }, + }, + ], + }, +}); +``` + ### Example The folks at Auth0 maintain many samples such as [github.com/auth0-samples/auth0-react-samples](https://github.com/auth0-samples/auth0-react-samples). Follow the instructions to run the sample, set the configuration in `auth_config.json` to match the defaults as noted above, and run the Auth0 simulation server with `npx auth0-simulator`. @@ -87,6 +104,8 @@ For example, a [sample rules directory](./test/rules) is in the auth0 package fo If we want to run these rules files then we would add the `rulesDirectory` field to the [options object](#options). +As in Auth0, rules receive the stored user's `user_metadata` and `app_metadata` on the `user` argument. Neither is added to the tokens unless a rule copies a value into a claim. + ## Endpoints The following endpoints have been assigned handlers: diff --git a/packages/auth0/src/handlers/oauth-handlers.ts b/packages/auth0/src/handlers/oauth-handlers.ts index 4c206b4d..4dbe2f1e 100644 --- a/packages/auth0/src/handlers/oauth-handlers.ts +++ b/packages/auth0/src/handlers/oauth-handlers.ts @@ -115,7 +115,7 @@ export const createTokens = async ({ .setIssuedAt() .setExpirationTime(`${expiresInHours}h`) .sign(signingKey), - id_token: await new SignJWT({ ...userData, ...context.idToken }) + id_token: await new SignJWT({ ...profileClaims(userData), ...context.idToken }) .setProtectedHeader({ alg: "RS256", kid: JWKS.keys[0].kid }) .setIssuedAt() .setExpirationTime(`${expiresInHours}h`) @@ -156,6 +156,9 @@ export const getIdToken = ({ nickname: body?.nickname, picture: body?.picture ?? user.picture, identities: body?.identities, + // cloned so a rule mutating them can't write through to the store + user_metadata: structuredClone(user.user_metadata), + app_metadata: structuredClone(user.app_metadata), }; assert(!!user.email, "500::User in store requires an email"); @@ -178,6 +181,9 @@ export const getIdToken = ({ return { userData, idTokenData }; }; +// Rules see the metadata, but Auth0 only puts it in a token when a rule adds it as a claim. +const profileClaims = ({ user_metadata: _u, app_metadata: _a, ...claims }: RuleUser) => claims; + export const getBaseAccessToken = ({ iss, grant_type, diff --git a/packages/auth0/src/rules/types.ts b/packages/auth0/src/rules/types.ts index f3836f71..3d33023d 100644 --- a/packages/auth0/src/rules/types.ts +++ b/packages/auth0/src/rules/types.ts @@ -14,6 +14,8 @@ export interface RuleUser { family_name?: string | undefined; name?: string | undefined; identities: IdentityProvider[] | undefined; + user_metadata?: Record | undefined; + app_metadata?: Record | undefined; } type IdentityProvider = { diff --git a/packages/auth0/src/store/entities.ts b/packages/auth0/src/store/entities.ts index 9d94a1c9..bf254206 100644 --- a/packages/auth0/src/store/entities.ts +++ b/packages/auth0/src/store/entities.ts @@ -9,6 +9,8 @@ export const auth0UserSchema = z password: z.string().optional().default("12345"), email: z.string().email().optional(), picture: z.string().url().optional(), + user_metadata: z.record(z.unknown()).default({}), + app_metadata: z.record(z.unknown()).default({}), }) .transform((user) => { if (!user.email) user.email = faker.internet.email({ firstName: user.name }); diff --git a/packages/auth0/test/entities.test.ts b/packages/auth0/test/entities.test.ts index 90f3ab2c..5fe37424 100644 --- a/packages/auth0/test/entities.test.ts +++ b/packages/auth0/test/entities.test.ts @@ -35,4 +35,28 @@ describe("initialState user fields", () => { expect(user.id).toBeTruthy(); expect(user.email).toContain("@"); }); + + it("keeps user_metadata and app_metadata", () => { + const parsed = auth0InitialStoreSchema.parse({ + users: [ + { + name: "dev", + user_metadata: { theme: "dark" }, + app_metadata: { organisation_id: "org_123", roles: ["admin"] }, + }, + ], + }); + const user = Object.values(convertInitialStateToStoreState(parsed)!.users)[0]; + + expect(user.user_metadata).toEqual({ theme: "dark" }); + expect(user.app_metadata).toEqual({ organisation_id: "org_123", roles: ["admin"] }); + }); + + it("defaults metadata to empty objects", () => { + const parsed = auth0InitialStoreSchema.parse({ users: [{ name: "dev" }] }); + const user = Object.values(convertInitialStateToStoreState(parsed)!.users)[0]; + + expect(user.user_metadata).toEqual({}); + expect(user.app_metadata).toEqual({}); + }); }); diff --git a/packages/auth0/test/fixtures/rules-metadata/metadata-claims.js b/packages/auth0/test/fixtures/rules-metadata/metadata-claims.js new file mode 100644 index 00000000..f2eab330 --- /dev/null +++ b/packages/auth0/test/fixtures/rules-metadata/metadata-claims.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function metadataClaims(user, context, callback) { + let namespace = "https://example.nl"; + + context.accessToken[`${namespace}/org`] = user.app_metadata.organisation_id; + context.idToken[`${namespace}/theme`] = user.user_metadata.theme; + + callback(null, user, context); +} diff --git a/packages/auth0/test/fixtures/rules-metadata/metadata-claims.json b/packages/auth0/test/fixtures/rules-metadata/metadata-claims.json new file mode 100644 index 00000000..468daa73 --- /dev/null +++ b/packages/auth0/test/fixtures/rules-metadata/metadata-claims.json @@ -0,0 +1,5 @@ +{ + "enabled": true, + "order": 1, + "stage": "login_success" +} diff --git a/packages/auth0/test/rules.test.ts b/packages/auth0/test/rules.test.ts index ce625f58..8552e356 100644 --- a/packages/auth0/test/rules.test.ts +++ b/packages/auth0/test/rules.test.ts @@ -19,13 +19,20 @@ let Fields = { type FixtureDirectories = | "user" + | "metadata" | "access-token" | "user-dependent" | "async-only" | "sync-wrapper-with-async"; type Fixtures = `test/fixtures/rules-${FixtureDirectories}`; -let person = { +let person: { + name: string; + email: string; + password: string; + user_metadata?: Record; + app_metadata?: Record; +} = { name: "Paul Waters", email: "paulwaters.white@yahoo.com", password: "12345", @@ -162,6 +169,64 @@ describe("rules", () => { }); }); + describe("user and app metadata", () => { + let code: string; + let server: FoundationSimulatorListening; + + beforeEach(async () => { + ({ code, server } = await createSimulation("test/fixtures/rules-metadata", { + user_metadata: { theme: "dark" }, + app_metadata: { organisation_id: "org_123" }, + })); + }); + afterEach(async () => { + await server.ensureClose(); + }); + + it("exposes the stored metadata to rules", async () => { + let res: Response = await fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...Fields, + code, + }), + }); + + expect(res.ok).toBe(true); + let token = (await res.json()) as unknown as { access_token: string; id_token: string }; + + let accessToken = decodeJwt(token.access_token); + let idToken = decodeJwt(token.id_token); + + expect(accessToken["https://example.nl/org"]).toBe("org_123"); + expect(idToken["https://example.nl/theme"]).toBe("dark"); + }); + + it("does not copy the metadata itself into the tokens", async () => { + let res: Response = await fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...Fields, + code, + }), + }); + + let token = (await res.json()) as unknown as { access_token: string; id_token: string }; + + for (let jwt of [token.access_token, token.id_token]) { + let claims = decodeJwt(jwt); + expect(claims).not.toHaveProperty("user_metadata"); + expect(claims).not.toHaveProperty("app_metadata"); + } + }); + }); + describe("rely on user data", () => { it("should trust Fred", async () => { const otherPerson = { From 2d9cdb57970eb8a2ea96a9c959ceedcbbd699174 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Mon, 12 Oct 2026 15:55:42 +0100 Subject: [PATCH 04/13] auth0: run post-login Actions instead of exposing metadata to Rules Auth0 retires Rules on 18 November 2026, so metadata is now exposed the way Actions see it rather than on the Rules `user` argument. - New `actions` option: an ordered list of `{ file | code, secrets }`, each the CommonJS source deployed to Auth0, exporting `onExecutePostLogin`. - Actions run after rules on every login and refresh token exchange, with `event.user` (including metadata), `event.secrets`, `event.stats`, `event.client`, `event.connection` and `event.transaction`. - `api.idToken/accessToken.setCustomClaim` add claims; `api.user.set*Metadata` updates the stored user once the flow ends (`null` removes a key); `api.access.deny` answers 403 access_denied; `api.cache` lasts for the simulation. - Users gain `logins_count`, incremented per login, so actions keyed on a first login can be tested and the count can be seeded. --- .changes/auth0-user-metadata.md | 2 +- packages/auth0/README.md | 43 ++- packages/auth0/src/actions/actions-runner.ts | 181 +++++++++ packages/auth0/src/handlers/auth0-handlers.ts | 18 +- packages/auth0/src/handlers/oauth-handlers.ts | 113 +++++- packages/auth0/src/rules/types.ts | 2 - packages/auth0/src/store/entities.ts | 14 + packages/auth0/src/types.ts | 16 + packages/auth0/test/actions.test.ts | 364 ++++++++++++++++++ packages/auth0/test/entities.test.ts | 8 + .../fixtures/actions/claims-from-metadata.js | 20 + .../test/fixtures/actions/count-visits.js | 6 + packages/auth0/test/fixtures/actions/deny.js | 3 + .../test/fixtures/actions/require-sibling.js | 5 + .../auth0/test/fixtures/actions/sibling.cjs | 1 + .../rules-metadata/metadata-claims.js | 9 - .../rules-metadata/metadata-claims.json | 5 - packages/auth0/test/rules.test.ts | 67 +--- 18 files changed, 782 insertions(+), 95 deletions(-) create mode 100644 packages/auth0/src/actions/actions-runner.ts create mode 100644 packages/auth0/test/actions.test.ts create mode 100644 packages/auth0/test/fixtures/actions/claims-from-metadata.js create mode 100644 packages/auth0/test/fixtures/actions/count-visits.js create mode 100644 packages/auth0/test/fixtures/actions/deny.js create mode 100644 packages/auth0/test/fixtures/actions/require-sibling.js create mode 100644 packages/auth0/test/fixtures/actions/sibling.cjs delete mode 100644 packages/auth0/test/fixtures/rules-metadata/metadata-claims.js delete mode 100644 packages/auth0/test/fixtures/rules-metadata/metadata-claims.json diff --git a/.changes/auth0-user-metadata.md b/.changes/auth0-user-metadata.md index 90e32f1e..c9206bb4 100644 --- a/.changes/auth0-user-metadata.md +++ b/.changes/auth0-user-metadata.md @@ -2,4 +2,4 @@ "@simulacrum/auth0-simulator": minor --- -Users can carry `user_metadata` and `app_metadata` (seeded via `initialState`), and rules receive both on the `user` argument, as Auth0 Rules do. +Run Auth0 post-login Actions through the new `actions` option. Users can carry `user_metadata`, `app_metadata` and `logins_count` (seeded via `initialState`), which actions read from `event.user` and update with `api.user.setUserMetadata` and `api.user.setAppMetadata`. diff --git a/packages/auth0/README.md b/packages/auth0/README.md index 6e2d4e4c..fa410ac8 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -12,6 +12,7 @@ Read about this simulator on our blog: [Simplified Local Development and Testing - [Example](#example) - [Configuration](#configuration) - [Options](#options) + - [Actions](#actions) - [Rules](#rules) - [Endpoints](#endpoints) @@ -72,6 +73,7 @@ const app = simulation({ password: "12345", user_metadata: { theme: "dark" }, app_metadata: { roles: ["admin"] }, + logins_count: 3, // optional, defaults to 0 }, ], }, @@ -92,10 +94,49 @@ The `options` field supports the [auth0 configuration fields](https://auth0.com/ The `scope` also accepts an array of objects containing `clientId`, `scope` and optionally `audience` to enable dynamic scopes from a single simulator. This should allow multiple clients to all use the same simulator. Additionally, setting the `clientId: "default"` will enable a default fallback scope so every client does not need to be included. +An optional [`actions` field](#actions) lists [Auth0 Actions](https://auth0.com/docs/customize/actions) to run after login, more on this [below](#actions). + An optional [`rulesDirectory` field](#rules) can specify a directory of [auth0 rules](https://auth0.com/docs/rules) code files, more on this [below](#rules). +### Actions + +Post-login [Actions](https://auth0.com/docs/customize/actions/explore-triggers/post-login) run in the order given by the `actions` option, like the bindings of Auth0's post-login trigger. Each entry has either a `file` (relative to the current working directory) or the `code` itself, and optionally the action's `secrets`: + +```js +const app = simulation({ + options: { + actions: [ + { file: "auth0/actions/post-login.js", secrets: { NAMESPACE: "https://example.com" } }, + ], + }, +}); +``` + +An action is the same CommonJS source you deploy to Auth0, exporting `onExecutePostLogin`: + +```js +exports.onExecutePostLogin = async (event, api) => { + let { organisation_id } = event.user.app_metadata; + api.accessToken.setCustomClaim(`${event.secrets.NAMESPACE}/organisation_id`, organisation_id); +}; +``` + +Actions run on every login and refresh token exchange through `/oauth/token`, after any [rules](#rules). The simulator provides this subset of the post-login trigger: + +- `event`: `user` (including `user_metadata` and `app_metadata`), `secrets`, `stats.logins_count`, `client.client_id`, `connection`, `transaction.protocol`, `transaction.requested_scopes`, `resource_server.identifier`, `authorization.roles` (always empty) and `request` (`ip`, `hostname`, `method`, `user_agent` and `query`). +- `api.idToken.setCustomClaim` and `api.accessToken.setCustomClaim`. As in Auth0, the registered claims (`iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti`) can't be overwritten. +- `api.user.setUserMetadata` and `api.user.setAppMetadata`. Setting a key to `null` removes it. Later actions see the change, and the user is updated once all actions have run. +- `api.access.deny`, which answers `/oauth/token` with a `403` `access_denied`. +- `api.cache`, kept for the life of the simulation. + +Metadata is never added to the tokens unless an action sets it as a claim. An error thrown by an action ends the flow, as `api.access.deny` does, and `/oauth/token` responds with that error. + +Unlike Auth0, actions run when `/oauth/token` issues the tokens rather than during `/authorize`. So in the authorization code flow, a denied login fails at the code exchange rather than redirecting with an error, and `event.request` describes the `/oauth/token` request rather than the original `/authorize` parameters. + ### Rules +Auth0 is retiring Rules on 18 November 2026, so prefer [Actions](#actions) for anything new. + It is possible to run [auth0 rules](https://auth0.com/docs/rules) if the compiled code files are on disk and all located in the same directory. Set the `rulesDirectory` of the [options field](#options) to a path relative to your current working directory. @@ -104,8 +145,6 @@ For example, a [sample rules directory](./test/rules) is in the auth0 package fo If we want to run these rules files then we would add the `rulesDirectory` field to the [options object](#options). -As in Auth0, rules receive the stored user's `user_metadata` and `app_metadata` on the `user` argument. Neither is added to the tokens unless a rule copies a value into a claim. - ## Endpoints The following endpoints have been assigned handlers: diff --git a/packages/auth0/src/actions/actions-runner.ts b/packages/auth0/src/actions/actions-runner.ts new file mode 100644 index 00000000..8a4f9851 --- /dev/null +++ b/packages/auth0/src/actions/actions-runner.ts @@ -0,0 +1,181 @@ +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { createRequire } from "node:module"; +import { actionSchema, type ActionConfig } from "../types.ts"; +import { mergeMetadata, type Metadata } from "../store/entities.ts"; + +// The subset of Auth0's post-login trigger that the simulator provides. +// https://auth0.com/docs/customize/actions/explore-triggers/post-login + +export interface PostLoginEvent { + user: { + user_id: string; + email?: string | undefined; + email_verified: boolean; + name?: string | undefined; + nickname?: string | undefined; + picture?: string | undefined; + // https://auth0.com/docs/manage-users/user-accounts/metadata + user_metadata: Metadata; + app_metadata: Metadata; + identities: { provider: string; user_id: string; connection: string; isSocial: boolean }[]; + }; + secrets: Record; + request: { + query: Record; + ip?: string | undefined; + hostname?: string | undefined; + method?: string | undefined; + user_agent?: string | undefined; + }; + authorization: { roles: string[] }; + resource_server: { identifier: string }; + stats: { logins_count: number }; + client: { client_id: string }; + connection: { name: string; strategy: string }; + transaction: { protocol: string; requested_scopes: string[] }; +} + +export interface PostLoginResult { + idTokenClaims: Record; + accessTokenClaims: Record; + // the keys actions set, with `null` for a removed key, present only when an action set one + userMetadata?: Metadata; + appMetadata?: Metadata; + denied?: string; + error?: unknown; +} + +export interface ActionsRunner { + postLogin(event: Omit): Promise; +} + +type PostLoginHandler = (event: PostLoginEvent, api: unknown) => unknown; + +interface LoadedAction { + secrets: Record; + cache: ReturnType; + onExecutePostLogin?: PostLoginHandler | undefined; +} + +type CacheRecord = { value: unknown; expires_at?: number | undefined }; + +// api.cache lives for the simulation, shared by every execution of the same action +const createCache = () => { + let records = new Map(); + let live = (key: string) => { + let record = records.get(key); + if (record?.expires_at !== undefined && record.expires_at <= Date.now()) { + records.delete(key); + return undefined; + } + return record; + }; + + return { + get: (key: string) => { + let record = live(key); + return record && { ...record }; + }, + set: (key: string, value: unknown, options: { ttl?: number; expires_at?: number } = {}) => { + let expires_at = + options.expires_at ?? (options.ttl === undefined ? undefined : Date.now() + options.ttl); + let record = { value, expires_at }; + records.set(key, record); + return { type: "success", record: { ...record } }; + }, + delete: (key: string) => { + let record = live(key); + records.delete(key); + return { type: "success", record }; + }, + }; +}; + +// Evaluated like a CommonJS module in this process, as Auth0's Node runtime would, +// so actions get the real globals (fetch, Buffer, ...) and `require` relative to their file. +const loadAction = (config: ActionConfig, index: number): LoadedAction => { + let { file, code, name, secrets = {} } = actionSchema.parse(config); + let filename = path.resolve(file ?? `${name ?? `action-${index}`}.js`); + let source = code ?? fs.readFileSync(filename, "utf8"); + + let module = { exports: {} as { onExecutePostLogin?: PostLoginHandler } }; + let evaluate = vm.compileFunction( + source, + ["exports", "require", "module", "__filename", "__dirname"], + { filename }, + ); + evaluate(module.exports, createRequire(filename), module, filename, path.dirname(filename)); + + return { secrets, cache: createCache(), onExecutePostLogin: module.exports.onExecutePostLogin }; +}; + +// Auth0 ignores custom claims that would overwrite these +const registeredClaims = new Set(["iss", "sub", "aud", "exp", "nbf", "iat", "jti"]); + +const setClaim = (claims: Record, name: string, value: unknown) => { + if (registeredClaims.has(name)) console.warn(`ignoring the restricted claim "${name}"`); + else claims[name] = value; +}; + +export function createActionsRunner(actions: ActionConfig[] | undefined): ActionsRunner { + let loaded = (actions ?? []).map(loadAction); + + return { + async postLogin(input) { + // actions see each other's metadata changes, but not the store's copy + let user = structuredClone(input.user); + let result: PostLoginResult = { idTokenClaims: {}, accessTokenClaims: {} }; + + for (let action of loaded) { + if (!action.onExecutePostLogin) continue; + + let api = { + access: { + deny: (reason: string) => { + result.denied = reason; + return api; + }, + }, + idToken: { + setCustomClaim: (name: string, value: unknown) => { + setClaim(result.idTokenClaims, name, value); + return api; + }, + }, + accessToken: { + setCustomClaim: (name: string, value: unknown) => { + setClaim(result.accessTokenClaims, name, value); + return api; + }, + }, + user: { + setUserMetadata: (name: string, value: unknown) => { + result.userMetadata = { ...result.userMetadata, [name]: value }; + user.user_metadata = mergeMetadata(user.user_metadata, { [name]: value }); + return api; + }, + setAppMetadata: (name: string, value: unknown) => { + result.appMetadata = { ...result.appMetadata, [name]: value }; + user.app_metadata = mergeMetadata(user.app_metadata, { [name]: value }); + return api; + }, + }, + cache: action.cache, + }; + + try { + await action.onExecutePostLogin({ ...input, user, secrets: action.secrets }, api); + } catch (error) { + result.error = error; + } + + // a failed or denied login runs no further actions, but keeps the metadata changes made so far + if (result.error !== undefined || result.denied !== undefined) break; + } + + return result; + }, + }; +} diff --git a/packages/auth0/src/handlers/auth0-handlers.ts b/packages/auth0/src/handlers/auth0-handlers.ts index 09a52bd9..b1146ad9 100644 --- a/packages/auth0/src/handlers/auth0-handlers.ts +++ b/packages/auth0/src/handlers/auth0-handlers.ts @@ -4,7 +4,8 @@ import type { Auth0Configuration, QueryParams, ResponseModes } from "../types.ts import { createLoginRedirectHandler } from "./login-redirect.ts"; import { createWebMessageHandler } from "./web-message.ts"; import { loginView } from "../views/login.ts"; -import { createTokens } from "./oauth-handlers.ts"; +import { AccessDeniedError, createTokens } from "./oauth-handlers.ts"; +import { createActionsRunner } from "../actions/actions-runner.ts"; import { assert } from "assert-ts"; import { stringify } from "querystring"; import { encode } from "base64-url"; @@ -44,6 +45,7 @@ export const createAuth0Handlers = ( debug: boolean, ): Record => { let { audience, scope, clientID, rulesDirectory } = options; + let actionsRunner = createActionsRunner(options.actions); let personQuery = createPersonQuery(simulationStore); let authorizeHandlers: Record = { @@ -200,6 +202,14 @@ export const createAuth0Handlers = ( clientID: responseClientId, audience: responseAudience, rulesDirectory, + actionsRunner, + request: { + query: req.query, + ip: req.ip, + hostname: req.hostname, + method: req.method, + user_agent: req.get("user-agent"), + }, scope, }); @@ -209,7 +219,11 @@ export const createAuth0Handlers = ( token_type: "Bearer", }); } catch (error) { - next(error); + if (error instanceof AccessDeniedError) { + res.status(403).json({ error: "access_denied", error_description: error.message }); + } else { + next(error); + } } }, diff --git a/packages/auth0/src/handlers/oauth-handlers.ts b/packages/auth0/src/handlers/oauth-handlers.ts index 4dbe2f1e..dd5431e4 100644 --- a/packages/auth0/src/handlers/oauth-handlers.ts +++ b/packages/auth0/src/handlers/oauth-handlers.ts @@ -4,6 +4,7 @@ import { epochTime, expiresAt } from "../auth/date.ts"; import { signingKey, JWKS } from "../auth/constants.ts"; import { SignJWT } from "jose"; import { createRulesRunner } from "../rules/rules-runner.ts"; +import type { ActionsRunner, PostLoginEvent } from "../actions/actions-runner.ts"; import { deriveScope, createPersonQuery } from "./utils.ts"; import type { Request } from "express"; @@ -17,7 +18,7 @@ import type { } from "../types.ts"; import { createRefreshToken, issueRefreshToken } from "../auth/refresh-token.ts"; import { type ExtendedSimulationStore } from "../store/index.ts"; -import { type Auth0User } from "../store/entities.ts"; +import { mergeMetadata, type Auth0User } from "../store/entities.ts"; export const createTokens = async ({ body, @@ -25,6 +26,8 @@ export const createTokens = async ({ clientID, audience, rulesDirectory, + actionsRunner, + request, scope: scopeConfig, simulationStore, }: { @@ -33,6 +36,8 @@ export const createTokens = async ({ clientID: string; audience: string; rulesDirectory: string | undefined; + actionsRunner: ActionsRunner; + request: PostLoginEvent["request"]; scope: ScopeConfig; simulationStore: ExtendedSimulationStore; }) => { @@ -105,17 +110,31 @@ export const createTokens = async ({ // the rules mutate the values await rulesRunner(userData, context); + // Auth0 runs Actions after Rules, on login and on refresh token exchange + let actions = await runPostLoginActions({ + actionsRunner, + simulationStore, + user, + grant_type, + connection: body.connection ?? "Username-Password-Authentication", + request, + clientID, + audience, + scope, + }); + return { access_token: await new SignJWT({ ...accessToken, ...context.accessToken, + ...actions.accessTokenClaims, ...(scope.split(" ").includes("email") ? { email: user.email } : {}), }) .setProtectedHeader({ alg: "RS256", kid: JWKS.keys[0].kid }) .setIssuedAt() .setExpirationTime(`${expiresInHours}h`) .sign(signingKey), - id_token: await new SignJWT({ ...profileClaims(userData), ...context.idToken }) + id_token: await new SignJWT({ ...userData, ...context.idToken, ...actions.idTokenClaims }) .setProtectedHeader({ alg: "RS256", kid: JWKS.keys[0].kid }) .setIssuedAt() .setExpirationTime(`${expiresInHours}h`) @@ -133,6 +152,90 @@ export const createTokens = async ({ }; }; +export class AccessDeniedError extends Error {} + +// event.transaction.protocol as Auth0 reports it for each grant +const protocolOf = (grant_type: GrantType) => + ({ + password: "oauth2-resource-owner", + refresh_token: "oauth2-refresh-token", + })[grant_type as string] ?? "oidc-basic-profile"; + +const runPostLoginActions = async ({ + actionsRunner, + simulationStore, + user, + grant_type, + connection, + request, + clientID, + audience, + scope, +}: { + actionsRunner: ActionsRunner; + simulationStore: ExtendedSimulationStore; + user: Auth0User; + grant_type: GrantType; + connection: string; + request: PostLoginEvent["request"]; + clientID: string; + audience: string; + scope: string; +}) => { + // a refresh token exchange runs the actions but isn't a login + let logins = grant_type === "refresh_token" ? 0 : 1; + + let result = await actionsRunner.postLogin({ + user: { + user_id: user.id, + email: user.email, + email_verified: true, + name: user.name, + picture: user.picture, + user_metadata: user.user_metadata, + app_metadata: user.app_metadata, + identities: [ + { + provider: "auth0", + user_id: user.id.replace(/^auth0\|/, ""), + connection, + isSocial: false, + }, + ], + }, + request, + stats: { logins_count: user.logins_count + logins }, + client: { client_id: clientID }, + authorization: { roles: [] }, + resource_server: { identifier: audience }, + connection: { name: connection, strategy: "auth0" }, + transaction: { protocol: protocolOf(grant_type), requested_scopes: scope.split(" ") }, + }); + + // applied to the user as it is now, which overlapping logins or the Management API may have changed + let { schema, store, actions } = simulationStore; + store.dispatch( + actions.batchUpdater([ + (state) => { + let current = schema.users.selectById(state, { id: user.id }); + if (!current) return; // deleted while the actions ran + schema.users.patch({ + [user.id]: { + logins_count: current.logins_count + logins, + user_metadata: mergeMetadata(current.user_metadata, result.userMetadata), + app_metadata: mergeMetadata(current.app_metadata, result.appMetadata), + }, + })(state); + }, + ]), + ); + + if (result.error !== undefined) throw result.error; + if (result.denied !== undefined) throw new AccessDeniedError(result.denied); + + return result; +}; + export const getIdToken = ({ body, iss, @@ -156,9 +259,6 @@ export const getIdToken = ({ nickname: body?.nickname, picture: body?.picture ?? user.picture, identities: body?.identities, - // cloned so a rule mutating them can't write through to the store - user_metadata: structuredClone(user.user_metadata), - app_metadata: structuredClone(user.app_metadata), }; assert(!!user.email, "500::User in store requires an email"); @@ -181,9 +281,6 @@ export const getIdToken = ({ return { userData, idTokenData }; }; -// Rules see the metadata, but Auth0 only puts it in a token when a rule adds it as a claim. -const profileClaims = ({ user_metadata: _u, app_metadata: _a, ...claims }: RuleUser) => claims; - export const getBaseAccessToken = ({ iss, grant_type, diff --git a/packages/auth0/src/rules/types.ts b/packages/auth0/src/rules/types.ts index 3d33023d..f3836f71 100644 --- a/packages/auth0/src/rules/types.ts +++ b/packages/auth0/src/rules/types.ts @@ -14,8 +14,6 @@ export interface RuleUser { family_name?: string | undefined; name?: string | undefined; identities: IdentityProvider[] | undefined; - user_metadata?: Record | undefined; - app_metadata?: Record | undefined; } type IdentityProvider = { diff --git a/packages/auth0/src/store/entities.ts b/packages/auth0/src/store/entities.ts index bf254206..0472d4d9 100644 --- a/packages/auth0/src/store/entities.ts +++ b/packages/auth0/src/store/entities.ts @@ -11,6 +11,7 @@ export const auth0UserSchema = z picture: z.string().url().optional(), user_metadata: z.record(z.unknown()).default({}), app_metadata: z.record(z.unknown()).default({}), + logins_count: z.number().int().nonnegative().default(0), }) .transform((user) => { if (!user.email) user.email = faker.internet.email({ firstName: user.name }); @@ -18,6 +19,19 @@ export const auth0UserSchema = z return user; }); export type Auth0User = z.infer; + +export type Metadata = Record; + +// Top-level merge as Auth0 does it: nested objects are replaced, and `null` removes a key. +export const mergeMetadata = (current: Metadata, update: unknown): Metadata => { + if (!update || typeof update !== "object") return current; + let merged = { ...current }; + for (let [key, value] of Object.entries(update)) { + if (value === null) delete merged[key]; + else merged[key] = value; + } + return merged; +}; export const defaultUser = auth0UserSchema.parse({ name: "default", email: "default@example.com", diff --git a/packages/auth0/src/types.ts b/packages/auth0/src/types.ts index 2171b376..b66e5547 100644 --- a/packages/auth0/src/types.ts +++ b/packages/auth0/src/types.ts @@ -1,5 +1,20 @@ import { z } from "zod"; +// An Action bound to a trigger, as Auth0 runs them: CommonJS source exporting `onExecutePostLogin`. +// https://auth0.com/docs/customize/actions/explore-triggers/post-login +export const actionSchema = z + .object({ + name: z.optional(z.string()), + file: z.optional(z.string()), + code: z.optional(z.string()), + secrets: z.optional(z.record(z.string())), + }) + .refine((action) => (action.file === undefined) !== (action.code === undefined), { + message: "an action needs either `file` or `code`", + }); + +export type ActionConfig = z.infer; + export const configurationSchema = z.object({ port: z.optional( z.number().gt(2999, "port must be greater than 2999").lt(10000, "must be less than 10000"), @@ -19,6 +34,7 @@ export const configurationSchema = z.object({ ]), clientSecret: z.optional(z.string()), rulesDirectory: z.optional(z.string()), + actions: z.optional(z.array(actionSchema)), auth0SessionCookieName: z.optional(z.string()), auth0CookieSecret: z.optional(z.string()), connection: z.optional(z.string()), diff --git a/packages/auth0/test/actions.test.ts b/packages/auth0/test/actions.test.ts new file mode 100644 index 00000000..9d8b9f41 --- /dev/null +++ b/packages/auth0/test/actions.test.ts @@ -0,0 +1,364 @@ +import { describe, it, beforeEach, afterEach, expect } from "vitest"; +import { decodeJwt } from "jose"; +import type { FoundationSimulatorListening } from "@simulacrum/foundation-simulator"; +import { simulation } from "../src/index.ts"; +import { frontendSimulation } from "./helpers.ts"; +import { createActionsRunner, type PostLoginEvent } from "../src/actions/actions-runner.ts"; + +const event = (overrides: Partial = {}): PostLoginEvent => ({ + user: { + user_id: "auth0|alice", + email: "alice@example.com", + email_verified: true, + name: "Alice", + user_metadata: { organisation_id: "org_123" }, + app_metadata: {}, + identities: [], + }, + secrets: {}, + request: { query: {}, ip: "127.0.0.1", hostname: "localhost", method: "POST" }, + authorization: { roles: [] }, + resource_server: { identifier: "https://example.nl" }, + stats: { logins_count: 1 }, + client: { client_id: "client" }, + connection: { name: "Username-Password-Authentication", strategy: "auth0" }, + transaction: { protocol: "oidc-basic-profile", requested_scopes: ["openid"] }, + ...overrides, +}); + +describe("actions runner", () => { + it("runs nothing when no actions are configured", async () => { + let result = await createActionsRunner(undefined).postLogin(event()); + + expect(result).toEqual({ idTokenClaims: {}, accessTokenClaims: {} }); + }); + + it("collects custom claims set from the user's metadata", async () => { + let runner = createActionsRunner([ + { + code: `exports.onExecutePostLogin = async (event, api) => { + api.idToken.setCustomClaim("organisation_id", event.user.user_metadata.organisation_id); + api.accessToken.setCustomClaim("organisation_id", event.user.user_metadata.organisation_id); + };`, + }, + ]); + + let result = await runner.postLogin(event()); + + expect(result.idTokenClaims).toEqual({ organisation_id: "org_123" }); + expect(result.accessTokenClaims).toEqual({ organisation_id: "org_123" }); + }); + + it("gives each action its own secrets", async () => { + let runner = createActionsRunner([ + { + code: `exports.onExecutePostLogin = async (event, api) => { + api.idToken.setCustomClaim("first", event.secrets.KEY); + };`, + secrets: { KEY: "one" }, + }, + { + code: `exports.onExecutePostLogin = async (event, api) => { + api.idToken.setCustomClaim("second", event.secrets.KEY); + };`, + secrets: { KEY: "two" }, + }, + ]); + + let result = await runner.postLogin(event()); + + expect(result.idTokenClaims).toEqual({ first: "one", second: "two" }); + }); + + it("collects metadata updates, removing keys set to null", async () => { + let runner = createActionsRunner([ + { + code: `exports.onExecutePostLogin = async (event, api) => { + api.user.setUserMetadata("theme", "dark"); + api.user.setUserMetadata("organisation_id", null); + api.user.setAppMetadata("roles", ["admin"]); + };`, + }, + ]); + + let result = await runner.postLogin(event()); + + expect(result.userMetadata).toEqual({ theme: "dark", organisation_id: null }); + expect(result.appMetadata).toEqual({ roles: ["admin"] }); + }); + + it("shows metadata updates to later actions without touching the input", async () => { + let input = event(); + let runner = createActionsRunner([ + { + code: `exports.onExecutePostLogin = async (e, api) => api.user.setUserMetadata("seen", 1);`, + }, + { + code: `exports.onExecutePostLogin = async (e, api) => + api.idToken.setCustomClaim("seen", e.user.user_metadata.seen);`, + }, + ]); + + let result = await runner.postLogin(input); + + expect(result.idTokenClaims).toEqual({ seen: 1 }); + expect(input.user.user_metadata).toEqual({ organisation_id: "org_123" }); + }); + + it("stops at api.access.deny but keeps the metadata already set", async () => { + let runner = createActionsRunner([ + { + code: `exports.onExecutePostLogin = async (event, api) => { + api.user.setUserMetadata("tried", true); + api.access.deny("not today"); + };`, + }, + { + code: `exports.onExecutePostLogin = async (event, api) => { + api.idToken.setCustomClaim("unreachable", true); + };`, + }, + ]); + + let result = await runner.postLogin(event()); + + expect(result.denied).toEqual("not today"); + expect(result.idTokenClaims).toEqual({}); + expect(result.userMetadata).toEqual({ tried: true }); + }); + + it("stops at a thrown error but keeps the metadata already set", async () => { + let runner = createActionsRunner([ + { + code: `exports.onExecutePostLogin = async (event, api) => { + api.user.setUserMetadata("tried", true); + throw new Error("boom"); + };`, + }, + { + code: `exports.onExecutePostLogin = async (event, api) => { + api.idToken.setCustomClaim("unreachable", true); + };`, + }, + ]); + + let result = await runner.postLogin(event()); + + expect(result.error).toEqual(new Error("boom")); + expect(result.idTokenClaims).toEqual({}); + expect(result.userMetadata).toEqual({ tried: true }); + }); + + it("ignores registered JWT claims, as Auth0 does", async () => { + let runner = createActionsRunner([ + { + code: `exports.onExecutePostLogin = async (event, api) => { + api.idToken.setCustomClaim("sub", "someone-else"); + api.accessToken.setCustomClaim("aud", "elsewhere"); + api.accessToken.setCustomClaim("kept", true); + };`, + }, + ]); + + let result = await runner.postLogin(event()); + + expect(result.idTokenClaims).toEqual({}); + expect(result.accessTokenClaims).toEqual({ kept: true }); + }); + + it("keeps an action's cache across executions", async () => { + let runner = createActionsRunner([ + { + code: `exports.onExecutePostLogin = async (event, api) => { + let hits = Number(api.cache.get("hits")?.value ?? 0) + 1; + api.cache.set("hits", String(hits)); + // a returned record is a copy + api.cache.get("hits").value = "999"; + api.idToken.setCustomClaim("hits", hits); + };`, + }, + ]); + + await runner.postLogin(event()); + let result = await runner.postLogin(event()); + + expect(result.idTokenClaims).toEqual({ hits: 2 }); + }); + + it("loads an action from a file, with require relative to it", async () => { + let runner = createActionsRunner([{ file: "test/fixtures/actions/require-sibling.js" }]); + + let result = await runner.postLogin(event()); + + expect(result.idTokenClaims).toEqual({ greeting: "hello from a sibling module" }); + }); + + it("skips actions that don't handle post-login", async () => { + let runner = createActionsRunner([ + { code: `exports.onExecuteCredentialsExchange = async () => { throw new Error("no"); };` }, + ]); + + let result = await runner.postLogin(event()); + + expect(result.idTokenClaims).toEqual({}); + }); + + it("rejects an action configured with both file and code", () => { + expect(() => createActionsRunner([{ file: "a.js", code: "" }])).toThrow( + /either `file` or `code`/, + ); + }); +}); + +describe("post-login actions on /oauth/token", () => { + let port = 4440; + let auth0Url = `https://localhost:${port}`; + let person = { + id: "auth0|alice", + name: "Alice", + email: "alice@example.com", + password: "12345", + user_metadata: { organisation_id: "org_123" }, + }; + let server: FoundationSimulatorListening; + + let token = async (body: Record) => + fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: "00000000000000000000000000000000", + audience: "https://example.nl", + scope: "openid profile email offline_access", + ...body, + }), + }); + + let login = () => + token({ grant_type: "password", username: person.email, password: person.password }); + + // the authorization code flow, whose nonce the refresh grant requires + let codeLogin = async () => { + let frontend = await frontendSimulation.listen(); + try { + let fields = { + client_id: "00000000000000000000000000000000", + audience: "https://example.nl", + connection: "Username-Password-Authentication", + nonce: "bm9uY2U=", + redirect_uri: "http://localhost:3000", + response_type: "code", + scope: "openid profile email offline_access", + state: "state", + tenant: `localhost:${port}`, + }; + await fetch(`${auth0Url}/usernamepassword/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...fields, username: person.email, password: person.password }), + }); + let callback = await fetch(`${auth0Url}/login/callback`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `wctx=${encodeURIComponent(JSON.stringify(fields))}`, + }); + let code = new URL(callback.url).searchParams.get("code") as string; + return await token({ grant_type: "authorization_code", code }); + } finally { + await frontend.ensureClose(); + } + }; + + let start = async (file: string, secrets?: Record) => { + server = await simulation({ + initialState: { users: [person] }, + options: { actions: [{ file, secrets }] }, + }).listen(port); + }; + + afterEach(async () => { + await server.ensureClose(); + }); + + describe("claims", () => { + beforeEach(async () => { + await start("test/fixtures/actions/claims-from-metadata.js", { NAMESPACE: "https://ns" }); + }); + + it("adds the action's claims to both tokens", async () => { + let res = await login(); + expect(res.ok).toBe(true); + let { access_token, id_token } = (await res.json()) as Record; + + expect(decodeJwt(access_token)["https://ns/organisation_id"]).toBe("org_123"); + expect(decodeJwt(id_token)["https://ns/organisation_id"]).toBe("org_123"); + expect(decodeJwt(id_token)["https://ns/logins"]).toBe(1); + }); + + it("describes the API and request", async () => { + let { access_token } = (await (await login()).json()) as Record; + + expect(decodeJwt(access_token)["https://ns/api"]).toBe("https://example.nl"); + expect(decodeJwt(access_token)["https://ns/roles"]).toEqual([]); + expect(decodeJwt(access_token)["https://ns/hostname"]).toBe("localhost"); + }); + + it("counts overlapping logins", async () => { + await Promise.all([login(), login()]); + let { id_token } = (await (await login()).json()) as Record; + + expect(decodeJwt(id_token)["https://ns/logins"]).toBe(3); + }); + + it("keeps metadata out of the tokens unless an action adds it", async () => { + let { access_token, id_token } = (await (await login()).json()) as Record; + + for (let claims of [decodeJwt(access_token), decodeJwt(id_token)]) { + expect(claims).not.toHaveProperty("user_metadata"); + expect(claims).not.toHaveProperty("app_metadata"); + } + }); + + it("runs again on refresh token exchange without counting a login", async () => { + await login(); + let { refresh_token } = (await (await codeLogin()).json()) as Record; + + let res = await token({ grant_type: "refresh_token", refresh_token }); + expect(res.ok).toBe(true); + let { id_token } = (await res.json()) as Record; + + expect(decodeJwt(id_token)["https://ns/organisation_id"]).toBe("org_123"); + expect(decodeJwt(id_token)["https://ns/logins"]).toBe(2); + expect(decodeJwt(id_token)["https://ns/protocol"]).toBe("oauth2-refresh-token"); + }); + }); + + describe("metadata updates", () => { + beforeEach(async () => { + await start("test/fixtures/actions/count-visits.js"); + }); + + it("persists metadata set by an action for the next login", async () => { + await login(); + let { id_token } = (await (await login()).json()) as Record; + + expect(decodeJwt(id_token).visits).toBe(2); + }); + }); + + describe("access.deny", () => { + beforeEach(async () => { + await start("test/fixtures/actions/deny.js"); + }); + + it("answers with access_denied and the reason", async () => { + let res = await login(); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: "access_denied", + error_description: "alice@example.com is not allowed", + }); + }); + }); +}); diff --git a/packages/auth0/test/entities.test.ts b/packages/auth0/test/entities.test.ts index 5fe37424..4312832b 100644 --- a/packages/auth0/test/entities.test.ts +++ b/packages/auth0/test/entities.test.ts @@ -59,4 +59,12 @@ describe("initialState user fields", () => { expect(user.user_metadata).toEqual({}); expect(user.app_metadata).toEqual({}); }); + + it("defaults logins_count to zero, or keeps a seeded count", () => { + const parsed = auth0InitialStoreSchema.parse({ + users: [{ name: "new" }, { name: "returning", logins_count: 5 }], + }); + + expect(parsed.users.map((user) => user.logins_count)).toEqual([0, 5]); + }); }); diff --git a/packages/auth0/test/fixtures/actions/claims-from-metadata.js b/packages/auth0/test/fixtures/actions/claims-from-metadata.js new file mode 100644 index 00000000..422a4f01 --- /dev/null +++ b/packages/auth0/test/fixtures/actions/claims-from-metadata.js @@ -0,0 +1,20 @@ +exports.onExecutePostLogin = async (event, api) => { + let namespace = event.secrets.NAMESPACE; + + // let overlapping logins overlap + await new Promise((resolve) => setTimeout(resolve, 20)); + api.accessToken.setCustomClaim(`${namespace}/api`, event.resource_server.identifier); + api.accessToken.setCustomClaim(`${namespace}/roles`, event.authorization.roles); + api.accessToken.setCustomClaim(`${namespace}/hostname`, event.request.hostname); + + api.idToken.setCustomClaim( + `${namespace}/organisation_id`, + event.user.user_metadata.organisation_id, + ); + api.accessToken.setCustomClaim( + `${namespace}/organisation_id`, + event.user.user_metadata.organisation_id, + ); + api.idToken.setCustomClaim(`${namespace}/logins`, event.stats.logins_count); + api.idToken.setCustomClaim(`${namespace}/protocol`, event.transaction.protocol); +}; diff --git a/packages/auth0/test/fixtures/actions/count-visits.js b/packages/auth0/test/fixtures/actions/count-visits.js new file mode 100644 index 00000000..a85ff847 --- /dev/null +++ b/packages/auth0/test/fixtures/actions/count-visits.js @@ -0,0 +1,6 @@ +exports.onExecutePostLogin = async (event, api) => { + let visits = (event.user.user_metadata.visits ?? 0) + 1; + + api.user.setUserMetadata("visits", visits); + api.idToken.setCustomClaim("visits", visits); +}; diff --git a/packages/auth0/test/fixtures/actions/deny.js b/packages/auth0/test/fixtures/actions/deny.js new file mode 100644 index 00000000..a2947f8e --- /dev/null +++ b/packages/auth0/test/fixtures/actions/deny.js @@ -0,0 +1,3 @@ +exports.onExecutePostLogin = async (event, api) => { + api.access.deny(`${event.user.email} is not allowed`); +}; diff --git a/packages/auth0/test/fixtures/actions/require-sibling.js b/packages/auth0/test/fixtures/actions/require-sibling.js new file mode 100644 index 00000000..c8d10765 --- /dev/null +++ b/packages/auth0/test/fixtures/actions/require-sibling.js @@ -0,0 +1,5 @@ +const { greeting } = require("./sibling.cjs"); + +exports.onExecutePostLogin = async (event, api) => { + api.idToken.setCustomClaim("greeting", greeting); +}; diff --git a/packages/auth0/test/fixtures/actions/sibling.cjs b/packages/auth0/test/fixtures/actions/sibling.cjs new file mode 100644 index 00000000..396a3b0c --- /dev/null +++ b/packages/auth0/test/fixtures/actions/sibling.cjs @@ -0,0 +1 @@ +module.exports = { greeting: "hello from a sibling module" }; diff --git a/packages/auth0/test/fixtures/rules-metadata/metadata-claims.js b/packages/auth0/test/fixtures/rules-metadata/metadata-claims.js deleted file mode 100644 index f2eab330..00000000 --- a/packages/auth0/test/fixtures/rules-metadata/metadata-claims.js +++ /dev/null @@ -1,9 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function metadataClaims(user, context, callback) { - let namespace = "https://example.nl"; - - context.accessToken[`${namespace}/org`] = user.app_metadata.organisation_id; - context.idToken[`${namespace}/theme`] = user.user_metadata.theme; - - callback(null, user, context); -} diff --git a/packages/auth0/test/fixtures/rules-metadata/metadata-claims.json b/packages/auth0/test/fixtures/rules-metadata/metadata-claims.json deleted file mode 100644 index 468daa73..00000000 --- a/packages/auth0/test/fixtures/rules-metadata/metadata-claims.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "enabled": true, - "order": 1, - "stage": "login_success" -} diff --git a/packages/auth0/test/rules.test.ts b/packages/auth0/test/rules.test.ts index 8552e356..ce625f58 100644 --- a/packages/auth0/test/rules.test.ts +++ b/packages/auth0/test/rules.test.ts @@ -19,20 +19,13 @@ let Fields = { type FixtureDirectories = | "user" - | "metadata" | "access-token" | "user-dependent" | "async-only" | "sync-wrapper-with-async"; type Fixtures = `test/fixtures/rules-${FixtureDirectories}`; -let person: { - name: string; - email: string; - password: string; - user_metadata?: Record; - app_metadata?: Record; -} = { +let person = { name: "Paul Waters", email: "paulwaters.white@yahoo.com", password: "12345", @@ -169,64 +162,6 @@ describe("rules", () => { }); }); - describe("user and app metadata", () => { - let code: string; - let server: FoundationSimulatorListening; - - beforeEach(async () => { - ({ code, server } = await createSimulation("test/fixtures/rules-metadata", { - user_metadata: { theme: "dark" }, - app_metadata: { organisation_id: "org_123" }, - })); - }); - afterEach(async () => { - await server.ensureClose(); - }); - - it("exposes the stored metadata to rules", async () => { - let res: Response = await fetch(`${auth0Url}/oauth/token`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...Fields, - code, - }), - }); - - expect(res.ok).toBe(true); - let token = (await res.json()) as unknown as { access_token: string; id_token: string }; - - let accessToken = decodeJwt(token.access_token); - let idToken = decodeJwt(token.id_token); - - expect(accessToken["https://example.nl/org"]).toBe("org_123"); - expect(idToken["https://example.nl/theme"]).toBe("dark"); - }); - - it("does not copy the metadata itself into the tokens", async () => { - let res: Response = await fetch(`${auth0Url}/oauth/token`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...Fields, - code, - }), - }); - - let token = (await res.json()) as unknown as { access_token: string; id_token: string }; - - for (let jwt of [token.access_token, token.id_token]) { - let claims = decodeJwt(jwt); - expect(claims).not.toHaveProperty("user_metadata"); - expect(claims).not.toHaveProperty("app_metadata"); - } - }); - }); - describe("rely on user data", () => { it("should trust Fred", async () => { const otherPerson = { From ce531e09ac8a3c8a74ad62014c17ea3a8e8dfed2 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Fri, 25 Sep 2026 12:56:41 +0000 Subject: [PATCH 05/13] auth0: serve a store-backed subset of the Management API Adds /api/v2/users (create/get/patch/delete), /api/v2/users-by-email and /api/v2/tickets/password-change, backed by the simulator's own user store so a created user can log in, a deleted one can't, and a metadata PATCH (top-level merge, null deletes, as in Auth0) reaches the next token. Password-change tickets honour ttl_sec, result_url and mark_email_as_verified, and are redeemed on a minimal /lo/reset page. Requests need a bearer token signed by the simulator. Users gain email_verified (default true for seeded users, so existing tokens are unchanged); API-created users default to false, like Auth0. --- .changes/auth0-management-api.md | 5 + packages/auth0/README.md | 14 + packages/auth0/src/handlers/auth0-handlers.ts | 2 +- packages/auth0/src/handlers/index.ts | 13 +- .../src/handlers/management-api-handlers.ts | 236 ++++++++++++++++ packages/auth0/src/handlers/oauth-handlers.ts | 2 +- packages/auth0/src/store/entities.ts | 9 + packages/auth0/src/store/index.ts | 3 + packages/auth0/src/views/password-reset.ts | 27 ++ packages/auth0/test/management-api.test.ts | 260 ++++++++++++++++++ 10 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 .changes/auth0-management-api.md create mode 100644 packages/auth0/src/handlers/management-api-handlers.ts create mode 100644 packages/auth0/src/views/password-reset.ts create mode 100644 packages/auth0/test/management-api.test.ts diff --git a/.changes/auth0-management-api.md b/.changes/auth0-management-api.md new file mode 100644 index 00000000..d25c8af9 --- /dev/null +++ b/.changes/auth0-management-api.md @@ -0,0 +1,5 @@ +--- +"@simulacrum/auth0-simulator": minor +--- + +Add a store-backed subset of the Management API (`/api/v2/users`, `/api/v2/users-by-email`, `/api/v2/tickets/password-change`) plus a `/lo/reset` page to redeem password-change tickets. Users now carry `email_verified` (default `true` for seeded users), which the tokens and `/userinfo` report. diff --git a/packages/auth0/README.md b/packages/auth0/README.md index fa410ac8..d6d74451 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -158,3 +158,17 @@ The following endpoints have been assigned handlers: - `/v2/logout` - `/.well-known/jwks.json` - `/.well-known/openid-configuration` +- `/lo/reset` (password-change ticket page) + +### Management API + +A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update shows up in the next token. Requests need a bearer token signed by the simulator, e.g. from a `client_credentials` grant on `/oauth/token`. + +- `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|` (generated when omitted), `email_verified` defaults to `false`, and `password` to the same default as seeded users. +- `GET /api/v2/users/:id` +- `PATCH /api/v2/users/:id` — `user_metadata` and `app_metadata` are merged at the top level, and a `null` value removes the key, as in Auth0. +- `DELETE /api/v2/users/:id` +- `GET /api/v2/users-by-email?email=` +- `POST /api/v2/tickets/password-change` — accepts `user_id` (or `email`), `result_url`, `ttl_sec` and `mark_email_as_verified`. The returned ticket URL opens a page on `/lo/reset` that sets the password and, if given, redirects to `result_url`. + +Errors use Auth0's `{ statusCode, error, message, errorCode }` shape. diff --git a/packages/auth0/src/handlers/auth0-handlers.ts b/packages/auth0/src/handlers/auth0-handlers.ts index b1146ad9..bc90c995 100644 --- a/packages/auth0/src/handlers/auth0-handlers.ts +++ b/packages/auth0/src/handlers/auth0-handlers.ts @@ -263,7 +263,7 @@ export const createAuth0Handlers = ( given_name: user.name, family_name: user.name, email: user.email, - email_verified: true, + email_verified: user.email_verified, locale: "en", hd: "okta.com", }; diff --git a/packages/auth0/src/handlers/index.ts b/packages/auth0/src/handlers/index.ts index 09e7ef50..e959c498 100644 --- a/packages/auth0/src/handlers/index.ts +++ b/packages/auth0/src/handlers/index.ts @@ -6,6 +6,7 @@ import { createSession } from "../middleware/session.ts"; import { defaultErrorHandler } from "../middleware/error-handling.ts"; import { createAuth0Handlers } from "./auth0-handlers.ts"; import { createOpenIdHandlers } from "./openid-handlers.ts"; +import { createManagementApiHandlers } from "./management-api-handlers.ts"; import path from "path"; import { type Auth0Configuration } from "../types.ts"; @@ -20,6 +21,7 @@ export const extendRouter = const serviceURL = (request: Request) => `${request.protocol}://${request.get("Host")}/`; const auth0 = createAuth0Handlers(simulationStore, serviceURL, config, debug); const openid = createOpenIdHandlers(serviceURL); + const management = createManagementApiHandlers(simulationStore, serviceURL); router.use(express.static(publicDir)).use(createSession()).use(createCors()).use(noCache()); @@ -42,7 +44,16 @@ export const extendRouter = .get("/userinfo", auth0["/userinfo"]) .get("/v2/logout", auth0["/v2/logout"]) .get("/.well-known/jwks.json", openid["/.well-known/jwks.json"]) - .get("/.well-known/openid-configuration", openid["/.well-known/openid-configuration"]); + .get("/.well-known/openid-configuration", openid["/.well-known/openid-configuration"]) + .get("/lo/reset", management["GET /lo/reset"]) + .post("/lo/reset", management["POST /lo/reset"]) + .use("/api/v2", management.authenticate) + .post("/api/v2/users", management["POST /api/v2/users"]) + .get("/api/v2/users/:id", management["GET /api/v2/users/:id"]) + .patch("/api/v2/users/:id", management["PATCH /api/v2/users/:id"]) + .delete("/api/v2/users/:id", management["DELETE /api/v2/users/:id"]) + .get("/api/v2/users-by-email", management["GET /api/v2/users-by-email"]) + .post("/api/v2/tickets/password-change", management["POST /api/v2/tickets/password-change"]); // needs to be the last middleware added router.use(defaultErrorHandler); diff --git a/packages/auth0/src/handlers/management-api-handlers.ts b/packages/auth0/src/handlers/management-api-handlers.ts new file mode 100644 index 00000000..cbf39f2b --- /dev/null +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -0,0 +1,236 @@ +import { randomUUID } from "node:crypto"; +import { STATUS_CODES } from "node:http"; +import type { Request, RequestHandler, Response } from "express"; +import { createLocalJWKSet, jwtVerify } from "jose"; +import { faker } from "@faker-js/faker"; +import { JWKS } from "../auth/constants.ts"; +import { auth0UserSchema, type Auth0User, type PasswordTicket } from "../store/entities.ts"; +import type { AnyState } from "@simulacrum/foundation-simulator"; +import type { ExtendedSimulationStore } from "../store/index.ts"; +import { passwordResetForm, passwordResetMessage } from "../views/password-reset.ts"; + +export type ManagementRoutes = + | "authenticate" + | "POST /api/v2/users" + | "GET /api/v2/users/:id" + | "PATCH /api/v2/users/:id" + | "DELETE /api/v2/users/:id" + | "GET /api/v2/users-by-email" + | "POST /api/v2/tickets/password-change" + | "GET /lo/reset" + | "POST /lo/reset"; + +type Metadata = Record; + +const jwks = createLocalJWKSet(JWKS as unknown as Parameters[0]); +// Auth0 password-change tickets default to 5 days +const DEFAULT_TICKET_TTL_SEC = 432000; + +// Auth0's Management API error body +const sendError = (res: Response, statusCode: number, message: string, errorCode?: string) => { + res.status(statusCode).json({ statusCode, error: STATUS_CODES[statusCode], message, errorCode }); +}; + +const toApiUser = (user: Auth0User) => ({ + user_id: user.id, + email: user.email, + email_verified: user.email_verified, + name: user.name, + picture: user.picture, + user_metadata: user.user_metadata, + app_metadata: user.app_metadata, + identities: [ + { + connection: "Username-Password-Authentication", + provider: "auth0", + user_id: user.id.replace(/^auth0\|/, ""), + isSocial: false, + }, + ], +}); + +// Top-level merge as Auth0 does it: nested objects are replaced, and `null` removes a key. +const mergeMetadata = (current: Metadata, update: unknown): Metadata => { + if (!update || typeof update !== "object") return current; + let merged = { ...current }; + for (let [key, value] of Object.entries(update)) { + if (value === null) delete merged[key]; + else merged[key] = value; + } + return merged; +}; + +export const createManagementApiHandlers = ( + simulationStore: ExtendedSimulationStore, + serviceURL: (request: Request) => string, +): Record => { + let { schema, store, actions } = simulationStore; + + let update = (...updaters: ((s: AnyState) => void)[]) => + store.dispatch(actions.batchUpdater(updaters)); + let users = () => schema.users.selectTableAsList(store.getState()); + let findById = (id: string) => users().find((user) => user.id === id); + let findByEmail = (email: string) => + users().find((user) => user.email?.toLowerCase() === email.toLowerCase()); + + let validTicket = (id: unknown): PasswordTicket | undefined => { + if (typeof id !== "string") return undefined; + let ticket = schema.passwordTickets.selectById(store.getState(), { id }); + return ticket && ticket.expiresAt > Date.now() ? ticket : undefined; + }; + + return { + authenticate: async function (req, res, next) { + let [scheme, token] = req.headers.authorization?.split(" ") ?? []; + if (scheme !== "Bearer" || !token) { + return sendError(res, 401, "Missing authentication"); + } + try { + await jwtVerify(token, jwks); + } catch { + return sendError(res, 401, "Invalid token"); + } + next(); + }, + + "POST /api/v2/users": function (req, res) { + let { user_id, email, ...body } = req.body ?? {}; + if (typeof email !== "string" || !email) { + return sendError(res, 400, "Payload validation error: 'Missing required property: email'."); + } + if (findByEmail(email)) { + return sendError(res, 409, "The user already exists.", "auth0_idp_error"); + } + + let parsed = auth0UserSchema.safeParse({ + id: `auth0|${user_id ?? faker.database.mongodbObjectId()}`, + name: body.name ?? email, + email: email.toLowerCase(), + // Auth0 marks created users unverified unless told otherwise + email_verified: body.email_verified ?? false, + password: body.password, + picture: body.picture, + user_metadata: body.user_metadata, + app_metadata: body.app_metadata, + }); + if (!parsed.success) { + return sendError(res, 400, `Payload validation error: ${parsed.error.message}`); + } + if (findById(parsed.data.id)) { + return sendError(res, 409, "The user already exists.", "auth0_idp_error"); + } + + update(schema.users.add({ [parsed.data.id]: parsed.data })); + res.status(201).json(toApiUser(parsed.data)); + }, + + "GET /api/v2/users/:id": function (req, res) { + let user = findById(req.params.id as string); + if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); + res.status(200).json(toApiUser(user)); + }, + + "PATCH /api/v2/users/:id": function (req, res) { + let user = findById(req.params.id as string); + if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); + + let body = req.body ?? {}; + let updated: Auth0User = { + ...user, + ...(typeof body.name === "string" && { name: body.name }), + ...(typeof body.email === "string" && { email: body.email.toLowerCase() }), + ...(typeof body.email_verified === "boolean" && { email_verified: body.email_verified }), + ...(typeof body.password === "string" && { password: body.password }), + ...(typeof body.picture === "string" && { picture: body.picture }), + user_metadata: mergeMetadata(user.user_metadata, body.user_metadata), + app_metadata: mergeMetadata(user.app_metadata, body.app_metadata), + }; + + update(schema.users.add({ [user.id]: updated })); + res.status(200).json(toApiUser(updated)); + }, + + "DELETE /api/v2/users/:id": function (req, res) { + update(schema.users.remove([req.params.id as string])); + res.status(204).end(); + }, + + "GET /api/v2/users-by-email": function (req, res) { + let email = req.query.email; + if (typeof email !== "string" || !email) { + return sendError(res, 400, "Query validation error: 'Missing required property: email'."); + } + let user = findByEmail(email); + res.status(200).json(user ? [toApiUser(user)] : []); + }, + + "POST /api/v2/tickets/password-change": function (req, res) { + let body = req.body ?? {}; + let user = typeof body.user_id === "string" ? findById(body.user_id) : undefined; + user ??= typeof body.email === "string" ? findByEmail(body.email) : undefined; + if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); + + let ticket: PasswordTicket = { + id: randomUUID(), + userId: user.id, + expiresAt: Date.now() + (Number(body.ttl_sec) || DEFAULT_TICKET_TTL_SEC) * 1000, + resultUrl: typeof body.result_url === "string" ? body.result_url : undefined, + markEmailAsVerified: body.mark_email_as_verified === true, + }; + update(schema.passwordTickets.add({ [ticket.id]: ticket })); + + res.status(201).json({ ticket: `${serviceURL(req)}lo/reset?ticket=${ticket.id}#` }); + }, + + "GET /lo/reset": function (req, res) { + let ticket = validTicket(req.query.ticket); + let user = ticket && findById(ticket.userId); + res.set("Content-Type", "text/html"); + if (!ticket || !user) { + res + .status(400) + .send(passwordResetMessage("Link expired", "This link has expired or was already used.")); + return; + } + res + .status(200) + .send(passwordResetForm({ ticket: ticket.id, email: user.email ?? user.name })); + }, + + "POST /lo/reset": function (req, res) { + let { ticket: ticketId, password } = req.body ?? {}; + let ticket = validTicket(ticketId); + let user = ticket && findById(ticket.userId); + res.set("Content-Type", "text/html"); + if (!ticket || !user) { + res + .status(400) + .send(passwordResetMessage("Link expired", "This link has expired or was already used.")); + return; + } + if (typeof password !== "string" || !password) { + res + .status(400) + .send(passwordResetForm({ ticket: ticket.id, email: user.email ?? user.name })); + return; + } + + update( + schema.users.add({ + [user.id]: { + ...user, + password, + ...(ticket.markEmailAsVerified && { email_verified: true }), + }, + }), + schema.passwordTickets.remove([ticket.id]), + ); + + if (ticket.resultUrl) { + res.redirect(302, ticket.resultUrl); + return; + } + res.status(200).send(passwordResetMessage("Password changed", "You can now log in.")); + }, + }; +}; diff --git a/packages/auth0/src/handlers/oauth-handlers.ts b/packages/auth0/src/handlers/oauth-handlers.ts index dd5431e4..d63ea824 100644 --- a/packages/auth0/src/handlers/oauth-handlers.ts +++ b/packages/auth0/src/handlers/oauth-handlers.ts @@ -254,7 +254,7 @@ export const getIdToken = ({ let userData: RuleUser = { name: body?.name ?? user.name, email: body?.email ?? user.email, - email_verified: true, + email_verified: user.email_verified, user_id: body?.id ?? user.id, nickname: body?.nickname, picture: body?.picture ?? user.picture, diff --git a/packages/auth0/src/store/entities.ts b/packages/auth0/src/store/entities.ts index 0472d4d9..aeadfbf6 100644 --- a/packages/auth0/src/store/entities.ts +++ b/packages/auth0/src/store/entities.ts @@ -8,6 +8,7 @@ export const auth0UserSchema = z name: z.string(), password: z.string().optional().default("12345"), email: z.string().email().optional(), + email_verified: z.boolean().default(true), picture: z.string().url().optional(), user_metadata: z.record(z.unknown()).default({}), app_metadata: z.record(z.unknown()).default({}), @@ -41,8 +42,16 @@ export const auth0InitialStoreSchema = z.object({ users: z.array(auth0UserSchema), }); export type AuthSession = { username: string; nonce: string }; +export type PasswordTicket = { + id: string; + userId: string; + expiresAt: number; + resultUrl?: string | undefined; + markEmailAsVerified: boolean; +}; export type Auth0Store = z.output & { sessions: AuthSession[]; + passwordTickets: PasswordTicket[]; }; export type Auth0InitialStore = z.input; diff --git a/packages/auth0/src/store/index.ts b/packages/auth0/src/store/index.ts index c4ccb5f5..5c3c4bda 100644 --- a/packages/auth0/src/store/index.ts +++ b/packages/auth0/src/store/index.ts @@ -17,12 +17,14 @@ import { defaultUser, type Auth0User, type AuthSession, + type PasswordTicket, type Auth0InitialStore, } from "./entities.ts"; export type ExtendedSchema = ({ slice }: ExtendSimulationSchema) => { sessions: (n: string) => TableOutput; users: (n: string) => TableOutput; + passwordTickets: (n: string) => TableOutput; }; type ExtendActions = typeof inputActions; type ExtendSelectors = typeof inputSelectors; @@ -40,6 +42,7 @@ const inputSchema = const extended = extendedSchema ? extendedSchema({ slice }) : {}; let slices = { sessions: slice.table(), + passwordTickets: slice.table(), users: slice.table( !storeInitialState ? { diff --git a/packages/auth0/src/views/password-reset.ts b/packages/auth0/src/views/password-reset.ts new file mode 100644 index 00000000..31f67412 --- /dev/null +++ b/packages/auth0/src/views/password-reset.ts @@ -0,0 +1,27 @@ +import { encode } from "html-entities"; + +const page = (title: string, body: string) => ` + + + + ${encode(title)} + + +

${encode(title)}

+ ${body} + +`; + +export const passwordResetForm = ({ ticket, email }: { ticket: string; email: string }) => + page( + "Change your password", + `
+

Set a new password for ${encode(email)}.

+ + + +
`, + ); + +export const passwordResetMessage = (title: string, message: string) => + page(title, `

${encode(message)}

`); diff --git a/packages/auth0/test/management-api.test.ts b/packages/auth0/test/management-api.test.ts new file mode 100644 index 00000000..ef934830 --- /dev/null +++ b/packages/auth0/test/management-api.test.ts @@ -0,0 +1,260 @@ +import { describe, it, beforeAll, afterAll, expect } from "vitest"; +import { simulation } from "../src/index.ts"; +import type { FoundationSimulatorListening } from "@simulacrum/foundation-simulator"; +import { decodeJwt } from "jose"; + +let basePort = 4430; +let auth0Url = `https://localhost:${basePort}`; +let clientId = "00000000000000000000000000000000"; + +let seeded = { + id: "auth0|seeded", + name: "Seeded", + email: "seeded@example.com", + password: "seeded-pw", + app_metadata: { organisation_id: "org_1" }, +}; + +describe("Management API", () => { + let server: FoundationSimulatorListening; + let token: string; + + let api = (path: string, init: { method?: string; body?: unknown; token?: string } = {}) => + fetch(`${auth0Url}/api/v2${path}`, { + method: init.method ?? "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${init.token ?? token}`, + }, + ...(init.body !== undefined && { body: JSON.stringify(init.body) }), + }); + + let login = (username: string, password: string) => + fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ grant_type: "password", client_id: clientId, username, password }), + }); + + let createUser = async (body: Record) => { + let res = await api("/users", { method: "POST", body }); + expect(res.status).toBe(201); + return (await res.json()) as Record; + }; + + beforeAll(async () => { + server = await simulation({ + initialState: { users: [seeded] }, + options: { rulesDirectory: "test/fixtures/rules-metadata" }, + }).listen(basePort); + + let res = await fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "client_credentials", + client_id: clientId, + client_secret: "x", + audience: `${auth0Url}/api/v2/`, + }), + }); + token = ((await res.json()) as { access_token: string }).access_token; + }); + afterAll(async () => { + await server.ensureClose(); + }); + + describe("authentication", () => { + it("rejects a request without a bearer token", async () => { + let res = await fetch(`${auth0Url}/api/v2/users/${encodeURIComponent(seeded.id)}`); + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ statusCode: 401, error: "Unauthorized" }); + }); + + it("rejects a token the simulator did not sign", async () => { + let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { token: "not.a.jwt" }); + expect(res.status).toBe(401); + }); + }); + + describe("users", () => { + it("creates a user in the store that can then log in", async () => { + let user = await createUser({ + email: "New.User@example.com", + password: "pw-1", + connection: "Username-Password-Authentication", + user_metadata: { theme: "dark" }, + }); + + expect(user.user_id).toMatch(/^auth0\|/); + expect(user.email).toBe("new.user@example.com"); + expect(user.email_verified).toBe(false); + expect(user.user_metadata).toEqual({ theme: "dark" }); + expect(user.app_metadata).toEqual({}); + expect(user).not.toHaveProperty("password"); + + let res = await login("new.user@example.com", "pw-1"); + expect(res.status).toBe(200); + let { id_token } = (await res.json()) as { id_token: string }; + expect(decodeJwt(id_token).sub).toBe(user.user_id); + }); + + it("prefixes a caller-supplied user_id", async () => { + let user = await createUser({ email: "custom-id@example.com", user_id: "custom-1" }); + expect(user.user_id).toBe("auth0|custom-1"); + }); + + it("answers 409 for an email that is already taken", async () => { + let res = await api("/users", { method: "POST", body: { email: "SEEDED@example.com" } }); + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ + statusCode: 409, + message: "The user already exists.", + }); + }); + + it("answers 400 for a missing or invalid email", async () => { + expect((await api("/users", { method: "POST", body: {} })).status).toBe(400); + expect((await api("/users", { method: "POST", body: { email: "nope" } })).status).toBe(400); + }); + + it("gets a user by id and by email", async () => { + let res = await api(`/users/${encodeURIComponent(seeded.id)}`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ user_id: seeded.id, email: seeded.email }); + + res = await api(`/users-by-email?email=${encodeURIComponent("Seeded@Example.com")}`); + expect(await res.json()).toMatchObject([{ user_id: seeded.id }]); + + res = await api(`/users-by-email?email=nobody%40example.com`); + expect(await res.json()).toEqual([]); + + res = await api(`/users/${encodeURIComponent("auth0|missing")}`); + expect(res.status).toBe(404); + }); + + it("merges metadata at the top level and deletes keys set to null", async () => { + let user = await createUser({ + email: "merge@example.com", + user_metadata: { a: 1, b: { nested: true }, c: 3 }, + }); + + let res = await api(`/users/${encodeURIComponent(user.user_id)}`, { + method: "PATCH", + body: { name: "Merged", user_metadata: { b: { replaced: true }, c: null, d: 4 } }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + name: "Merged", + user_metadata: { a: 1, b: { replaced: true }, d: 4 }, + }); + }); + + it("shows a metadata update in the next token, including for seeded users", async () => { + let claim = async () => { + let res = await login(seeded.email, seeded.password); + let { access_token } = (await res.json()) as { access_token: string }; + return decodeJwt(access_token)["https://example.nl/org"]; + }; + + expect(await claim()).toBe("org_1"); + + let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { + method: "PATCH", + body: { app_metadata: { organisation_id: "org_2" } }, + }); + expect(res.status).toBe(200); + + expect(await claim()).toBe("org_2"); + }); + + it("answers 404 when patching an unknown user", async () => { + let res = await api(`/users/${encodeURIComponent("auth0|missing")}`, { + method: "PATCH", + body: { name: "x" }, + }); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ message: "The user does not exist." }); + }); + + it("deletes a user, which revokes their login", async () => { + let user = await createUser({ email: "doomed@example.com", password: "pw" }); + + let res = await api(`/users/${encodeURIComponent(user.user_id)}`, { method: "DELETE" }); + expect(res.status).toBe(204); + + expect((await login("doomed@example.com", "pw")).status).toBe(401); + expect((await api(`/users/${encodeURIComponent(user.user_id)}`)).status).toBe(404); + }); + }); + + describe("password-change tickets", () => { + let createTicket = async (body: Record) => { + let res = await api("/tickets/password-change", { method: "POST", body }); + expect(res.status).toBe(201); + let { ticket } = (await res.json()) as { ticket: string }; + return new URL(ticket); + }; + + let redeem = (ticketUrl: URL, password: string) => + fetch(`${auth0Url}/lo/reset`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + ticket: ticketUrl.searchParams.get("ticket")!, + password, + }).toString(), + redirect: "manual", + }); + + it("sets the password through the ticket page, once", async () => { + let user = await createUser({ email: "invitee@example.com" }); + let ticketUrl = await createTicket({ + user_id: user.user_id, + mark_email_as_verified: true, + }); + + expect(ticketUrl.pathname).toBe("/lo/reset"); + let page = await fetch(ticketUrl); + expect(page.status).toBe(200); + expect(await page.text()).toContain('name="password"'); + + expect((await redeem(ticketUrl, "chosen-pw")).status).toBe(200); + + let res = await login("invitee@example.com", "chosen-pw"); + expect(res.status).toBe(200); + let { id_token } = (await res.json()) as { id_token: string }; + expect(decodeJwt(id_token).email_verified).toBe(true); + + expect((await redeem(ticketUrl, "again")).status).toBe(400); + }); + + it("redirects to result_url after redeeming", async () => { + let ticketUrl = await createTicket({ + user_id: seeded.id, + result_url: "https://app.example.com/welcome", + }); + + let res = await redeem(ticketUrl, "new-seeded-pw"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("https://app.example.com/welcome"); + }); + + it("refuses an expired ticket", async () => { + let ticketUrl = await createTicket({ user_id: seeded.id, ttl_sec: 1 }); + await new Promise((resolve) => setTimeout(resolve, 1100)); + + expect((await fetch(ticketUrl)).status).toBe(400); + expect((await redeem(ticketUrl, "too-late")).status).toBe(400); + }); + + it("answers 404 for an unknown user", async () => { + let res = await api("/tickets/password-change", { + method: "POST", + body: { user_id: "auth0|missing" }, + }); + expect(res.status).toBe(404); + }); + }); +}); From fb30f936240c1c5bced39a8f178fc128ee9496e4 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Fri, 25 Sep 2026 18:32:56 +0000 Subject: [PATCH 06/13] auth0: tighten Management API per review - PATCH rejects an invalid email (400) or one another user has (409) - users created without a password get a random one instead of the known default, so only a password-change ticket can open the account - tokens must be for the https:///api/v2/ audience, so login tokens are refused (Auth0 parity; the signing key is public) --- packages/auth0/README.md | 4 +- .../src/handlers/management-api-handlers.ts | 19 ++++++++- packages/auth0/test/management-api.test.ts | 41 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/auth0/README.md b/packages/auth0/README.md index d6d74451..b912cebe 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -162,9 +162,9 @@ The following endpoints have been assigned handlers: ### Management API -A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update shows up in the next token. Requests need a bearer token signed by the simulator, e.g. from a `client_credentials` grant on `/oauth/token`. +A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update shows up in the next token. Requests need a bearer token signed by the simulator for the audience `https:///api/v2/`, e.g. from a `client_credentials` grant on `/oauth/token`. Scopes are not checked. -- `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|` (generated when omitted), `email_verified` defaults to `false`, and `password` to the same default as seeded users. +- `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|` (generated when omitted), `email_verified` defaults to `false`, and a user created without a `password` gets a random one, so they can only log in once a password-change ticket has set it. - `GET /api/v2/users/:id` - `PATCH /api/v2/users/:id` — `user_metadata` and `app_metadata` are merged at the top level, and a `null` value removes the key, as in Auth0. - `DELETE /api/v2/users/:id` diff --git a/packages/auth0/src/handlers/management-api-handlers.ts b/packages/auth0/src/handlers/management-api-handlers.ts index cbf39f2b..e4d11dfc 100644 --- a/packages/auth0/src/handlers/management-api-handlers.ts +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -3,6 +3,7 @@ import { STATUS_CODES } from "node:http"; import type { Request, RequestHandler, Response } from "express"; import { createLocalJWKSet, jwtVerify } from "jose"; import { faker } from "@faker-js/faker"; +import { z } from "zod"; import { JWKS } from "../auth/constants.ts"; import { auth0UserSchema, type Auth0User, type PasswordTicket } from "../store/entities.ts"; import type { AnyState } from "@simulacrum/foundation-simulator"; @@ -86,7 +87,8 @@ export const createManagementApiHandlers = ( return sendError(res, 401, "Missing authentication"); } try { - await jwtVerify(token, jwks); + // the key is public, so this is Auth0 parity rather than security: login tokens are refused + await jwtVerify(token, jwks, { audience: `${serviceURL(req)}api/v2/` }); } catch { return sendError(res, 401, "Invalid token"); } @@ -108,7 +110,8 @@ export const createManagementApiHandlers = ( email: email.toLowerCase(), // Auth0 marks created users unverified unless told otherwise email_verified: body.email_verified ?? false, - password: body.password, + // Auth0 requires one; a random one keeps the account closed until a ticket sets it + password: body.password ?? randomUUID(), picture: body.picture, user_metadata: body.user_metadata, app_metadata: body.app_metadata, @@ -135,6 +138,18 @@ export const createManagementApiHandlers = ( if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); let body = req.body ?? {}; + if (typeof body.email === "string") { + if (!z.string().email().safeParse(body.email).success) { + return sendError( + res, + 400, + "Payload validation error: 'Object didn't pass validation for format email'.", + ); + } + if (findByEmail(body.email) && findByEmail(body.email) !== user) { + return sendError(res, 409, "The specified new email already exists", "auth0_idp_error"); + } + } let updated: Auth0User = { ...user, ...(typeof body.name === "string" && { name: body.name }), diff --git a/packages/auth0/test/management-api.test.ts b/packages/auth0/test/management-api.test.ts index ef934830..a9c82cba 100644 --- a/packages/auth0/test/management-api.test.ts +++ b/packages/auth0/test/management-api.test.ts @@ -71,6 +71,18 @@ describe("Management API", () => { expect(await res.json()).toMatchObject({ statusCode: 401, error: "Unauthorized" }); }); + it("rejects a simulator token for another audience", async () => { + let res = await fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ grant_type: "client_credentials", client_id: clientId }), + }); + let { access_token } = (await res.json()) as { access_token: string }; + + let apiRes = await api(`/users/${encodeURIComponent(seeded.id)}`, { token: access_token }); + expect(apiRes.status).toBe(401); + }); + it("rejects a token the simulator did not sign", async () => { let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { token: "not.a.jwt" }); expect(res.status).toBe(401); @@ -99,6 +111,11 @@ describe("Management API", () => { expect(decodeJwt(id_token).sub).toBe(user.user_id); }); + it("does not give a user created without a password a guessable one", async () => { + await createUser({ email: "no-password@example.com" }); + expect((await login("no-password@example.com", "12345")).status).toBe(401); + }); + it("prefixes a caller-supplied user_id", async () => { let user = await createUser({ email: "custom-id@example.com", user_id: "custom-1" }); expect(user.user_id).toBe("auth0|custom-1"); @@ -169,6 +186,30 @@ describe("Management API", () => { expect(await claim()).toBe("org_2"); }); + it("refuses to patch in an invalid email", async () => { + let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { + method: "PATCH", + body: { email: "nope" }, + }); + expect(res.status).toBe(400); + }); + + it("refuses to patch in another user's email, but accepts the user's own", async () => { + let user = await createUser({ email: "taken@example.com" }); + + let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { + method: "PATCH", + body: { email: "Taken@example.com" }, + }); + expect(res.status).toBe(409); + + res = await api(`/users/${encodeURIComponent(user.user_id)}`, { + method: "PATCH", + body: { email: "TAKEN@example.com" }, + }); + expect(res.status).toBe(200); + }); + it("answers 404 when patching an unknown user", async () => { let res = await api(`/users/${encodeURIComponent("auth0|missing")}`, { method: "PATCH", From 2021c434370c122e5aafc5420ffa1712fd18314b Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Fri, 25 Sep 2026 18:42:44 +0000 Subject: [PATCH 07/13] auth0: Management API takes M2M tokens only, validates patched users - tokens must come from a client_credentials grant; a user's token for the /api/v2/ audience no longer gets store-wide access - PATCH validates the whole merged user with auth0UserSchema (400) before the duplicate-email check (409) --- packages/auth0/README.md | 2 +- .../src/handlers/management-api-handlers.ts | 30 +++++++++---------- packages/auth0/test/management-api.test.ts | 27 +++++++++++++++++ 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/auth0/README.md b/packages/auth0/README.md index b912cebe..154dc104 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -162,7 +162,7 @@ The following endpoints have been assigned handlers: ### Management API -A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update shows up in the next token. Requests need a bearer token signed by the simulator for the audience `https:///api/v2/`, e.g. from a `client_credentials` grant on `/oauth/token`. Scopes are not checked. +A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update shows up in the next token. Requests need a bearer token signed by the simulator from a `client_credentials` grant on `/oauth/token` with the audience `https:///api/v2/`. Scopes are not checked. - `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|` (generated when omitted), `email_verified` defaults to `false`, and a user created without a `password` gets a random one, so they can only log in once a password-change ticket has set it. - `GET /api/v2/users/:id` diff --git a/packages/auth0/src/handlers/management-api-handlers.ts b/packages/auth0/src/handlers/management-api-handlers.ts index e4d11dfc..7cfb6455 100644 --- a/packages/auth0/src/handlers/management-api-handlers.ts +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -3,7 +3,6 @@ import { STATUS_CODES } from "node:http"; import type { Request, RequestHandler, Response } from "express"; import { createLocalJWKSet, jwtVerify } from "jose"; import { faker } from "@faker-js/faker"; -import { z } from "zod"; import { JWKS } from "../auth/constants.ts"; import { auth0UserSchema, type Auth0User, type PasswordTicket } from "../store/entities.ts"; import type { AnyState } from "@simulacrum/foundation-simulator"; @@ -88,7 +87,9 @@ export const createManagementApiHandlers = ( } try { // the key is public, so this is Auth0 parity rather than security: login tokens are refused - await jwtVerify(token, jwks, { audience: `${serviceURL(req)}api/v2/` }); + let { payload } = await jwtVerify(token, jwks, { audience: `${serviceURL(req)}api/v2/` }); + // user tokens only get self-service scopes on Auth0; store-wide access is for M2M + if (payload.gty !== "client-credentials") throw new Error("not a client_credentials token"); } catch { return sendError(res, 401, "Invalid token"); } @@ -138,19 +139,7 @@ export const createManagementApiHandlers = ( if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); let body = req.body ?? {}; - if (typeof body.email === "string") { - if (!z.string().email().safeParse(body.email).success) { - return sendError( - res, - 400, - "Payload validation error: 'Object didn't pass validation for format email'.", - ); - } - if (findByEmail(body.email) && findByEmail(body.email) !== user) { - return sendError(res, 409, "The specified new email already exists", "auth0_idp_error"); - } - } - let updated: Auth0User = { + let parsed = auth0UserSchema.safeParse({ ...user, ...(typeof body.name === "string" && { name: body.name }), ...(typeof body.email === "string" && { email: body.email.toLowerCase() }), @@ -159,7 +148,16 @@ export const createManagementApiHandlers = ( ...(typeof body.picture === "string" && { picture: body.picture }), user_metadata: mergeMetadata(user.user_metadata, body.user_metadata), app_metadata: mergeMetadata(user.app_metadata, body.app_metadata), - }; + }); + if (!parsed.success) { + return sendError(res, 400, `Payload validation error: ${parsed.error.message}`); + } + let updated = parsed.data; + + let owner = updated.email && findByEmail(updated.email); + if (owner && owner.id !== user.id) { + return sendError(res, 409, "The specified new email already exists", "auth0_idp_error"); + } update(schema.users.add({ [user.id]: updated })); res.status(200).json(toApiUser(updated)); diff --git a/packages/auth0/test/management-api.test.ts b/packages/auth0/test/management-api.test.ts index a9c82cba..feba5ab4 100644 --- a/packages/auth0/test/management-api.test.ts +++ b/packages/auth0/test/management-api.test.ts @@ -83,6 +83,25 @@ describe("Management API", () => { expect(apiRes.status).toBe(401); }); + it("rejects a user's token even for the Management API audience", async () => { + let res = await fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "password", + client_id: clientId, + username: seeded.email, + password: seeded.password, + audience: `${auth0Url}/api/v2/`, + }), + }); + let { access_token } = (await res.json()) as { access_token: string }; + expect(decodeJwt(access_token).aud).toBe(`${auth0Url}/api/v2/`); + + let apiRes = await api(`/users/${encodeURIComponent(seeded.id)}`, { token: access_token }); + expect(apiRes.status).toBe(401); + }); + it("rejects a token the simulator did not sign", async () => { let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { token: "not.a.jwt" }); expect(res.status).toBe(401); @@ -194,6 +213,14 @@ describe("Management API", () => { expect(res.status).toBe(400); }); + it("validates the whole patched user", async () => { + let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { + method: "PATCH", + body: { picture: "not-a-url" }, + }); + expect(res.status).toBe(400); + }); + it("refuses to patch in another user's email, but accepts the user's own", async () => { let user = await createUser({ email: "taken@example.com" }); From 109e07735ae4587713d6a957cf950a4d79198429 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Mon, 12 Oct 2026 15:57:50 +0100 Subject: [PATCH 08/13] auth0: share the user shape between the Management API and Actions - Actions see the stored `email_verified`, now that users carry it. - The Management API returns `logins_count`, and `PATCH` with an empty metadata object clears it, as in Auth0. - `identitiesOf` lives with the user entity, so the Management API and the Actions `event.user` report the same identity, and `PATCH` uses the shared `mergeMetadata`. - The metadata round-trip test uses a post-login Action instead of a rule. --- packages/auth0/README.md | 4 +- .../src/handlers/management-api-handlers.ts | 41 ++++++++----------- packages/auth0/src/handlers/oauth-handlers.ts | 13 ++---- packages/auth0/src/store/entities.ts | 10 +++++ packages/auth0/test/actions.test.ts | 25 ++++++++++- .../test/fixtures/actions/email-verified.js | 3 ++ packages/auth0/test/management-api.test.ts | 38 ++++++++++++++++- 7 files changed, 97 insertions(+), 37 deletions(-) create mode 100644 packages/auth0/test/fixtures/actions/email-verified.js diff --git a/packages/auth0/README.md b/packages/auth0/README.md index 154dc104..439a24b7 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -162,11 +162,11 @@ The following endpoints have been assigned handlers: ### Management API -A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update shows up in the next token. Requests need a bearer token signed by the simulator from a `client_credentials` grant on `/oauth/token` with the audience `https:///api/v2/`. Scopes are not checked. +A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update reaches the next login's [actions](#actions). Actions can call it too, at the simulator's own URL. Requests need a bearer token signed by the simulator from a `client_credentials` grant on `/oauth/token` with the audience `https:///api/v2/`. Scopes are not checked. - `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|` (generated when omitted), `email_verified` defaults to `false`, and a user created without a `password` gets a random one, so they can only log in once a password-change ticket has set it. - `GET /api/v2/users/:id` -- `PATCH /api/v2/users/:id` — `user_metadata` and `app_metadata` are merged at the top level, and a `null` value removes the key, as in Auth0. +- `PATCH /api/v2/users/:id` — `user_metadata` and `app_metadata` are merged at the top level, a `null` value removes the key, and an empty object clears it, as in Auth0. - `DELETE /api/v2/users/:id` - `GET /api/v2/users-by-email?email=` - `POST /api/v2/tickets/password-change` — accepts `user_id` (or `email`), `result_url`, `ttl_sec` and `mark_email_as_verified`. The returned ticket URL opens a page on `/lo/reset` that sets the password and, if given, redirects to `result_url`. diff --git a/packages/auth0/src/handlers/management-api-handlers.ts b/packages/auth0/src/handlers/management-api-handlers.ts index 7cfb6455..df6c46f5 100644 --- a/packages/auth0/src/handlers/management-api-handlers.ts +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -4,7 +4,14 @@ import type { Request, RequestHandler, Response } from "express"; import { createLocalJWKSet, jwtVerify } from "jose"; import { faker } from "@faker-js/faker"; import { JWKS } from "../auth/constants.ts"; -import { auth0UserSchema, type Auth0User, type PasswordTicket } from "../store/entities.ts"; +import { + auth0UserSchema, + identitiesOf, + mergeMetadata, + type Auth0User, + type Metadata, + type PasswordTicket, +} from "../store/entities.ts"; import type { AnyState } from "@simulacrum/foundation-simulator"; import type { ExtendedSimulationStore } from "../store/index.ts"; import { passwordResetForm, passwordResetMessage } from "../views/password-reset.ts"; @@ -20,8 +27,6 @@ export type ManagementRoutes = | "GET /lo/reset" | "POST /lo/reset"; -type Metadata = Record; - const jwks = createLocalJWKSet(JWKS as unknown as Parameters[0]); // Auth0 password-change tickets default to 5 days const DEFAULT_TICKET_TTL_SEC = 432000; @@ -39,26 +44,16 @@ const toApiUser = (user: Auth0User) => ({ picture: user.picture, user_metadata: user.user_metadata, app_metadata: user.app_metadata, - identities: [ - { - connection: "Username-Password-Authentication", - provider: "auth0", - user_id: user.id.replace(/^auth0\|/, ""), - isSocial: false, - }, - ], + logins_count: user.logins_count, + identities: identitiesOf(user), }); -// Top-level merge as Auth0 does it: nested objects are replaced, and `null` removes a key. -const mergeMetadata = (current: Metadata, update: unknown): Metadata => { - if (!update || typeof update !== "object") return current; - let merged = { ...current }; - for (let [key, value] of Object.entries(update)) { - if (value === null) delete merged[key]; - else merged[key] = value; - } - return merged; -}; +// PATCH merges metadata, except that an empty object clears it +// https://auth0.com/docs/manage-users/user-accounts/metadata/manage-user-metadata +const patchMetadata = (current: Metadata, update: unknown): Metadata => + update && typeof update === "object" && Object.keys(update).length === 0 + ? {} + : mergeMetadata(current, update); export const createManagementApiHandlers = ( simulationStore: ExtendedSimulationStore, @@ -146,8 +141,8 @@ export const createManagementApiHandlers = ( ...(typeof body.email_verified === "boolean" && { email_verified: body.email_verified }), ...(typeof body.password === "string" && { password: body.password }), ...(typeof body.picture === "string" && { picture: body.picture }), - user_metadata: mergeMetadata(user.user_metadata, body.user_metadata), - app_metadata: mergeMetadata(user.app_metadata, body.app_metadata), + user_metadata: patchMetadata(user.user_metadata, body.user_metadata), + app_metadata: patchMetadata(user.app_metadata, body.app_metadata), }); if (!parsed.success) { return sendError(res, 400, `Payload validation error: ${parsed.error.message}`); diff --git a/packages/auth0/src/handlers/oauth-handlers.ts b/packages/auth0/src/handlers/oauth-handlers.ts index d63ea824..d9994044 100644 --- a/packages/auth0/src/handlers/oauth-handlers.ts +++ b/packages/auth0/src/handlers/oauth-handlers.ts @@ -18,7 +18,7 @@ import type { } from "../types.ts"; import { createRefreshToken, issueRefreshToken } from "../auth/refresh-token.ts"; import { type ExtendedSimulationStore } from "../store/index.ts"; -import { mergeMetadata, type Auth0User } from "../store/entities.ts"; +import { identitiesOf, mergeMetadata, type Auth0User } from "../store/entities.ts"; export const createTokens = async ({ body, @@ -189,19 +189,12 @@ const runPostLoginActions = async ({ user: { user_id: user.id, email: user.email, - email_verified: true, + email_verified: user.email_verified, name: user.name, picture: user.picture, user_metadata: user.user_metadata, app_metadata: user.app_metadata, - identities: [ - { - provider: "auth0", - user_id: user.id.replace(/^auth0\|/, ""), - connection, - isSocial: false, - }, - ], + identities: identitiesOf(user, connection), }, request, stats: { logins_count: user.logins_count + logins }, diff --git a/packages/auth0/src/store/entities.ts b/packages/auth0/src/store/entities.ts index aeadfbf6..2ffea09d 100644 --- a/packages/auth0/src/store/entities.ts +++ b/packages/auth0/src/store/entities.ts @@ -33,6 +33,16 @@ export const mergeMetadata = (current: Metadata, update: unknown): Metadata => { } return merged; }; + +// every simulated user signs in with a database connection +export const identitiesOf = (user: Auth0User, connection = "Username-Password-Authentication") => [ + { + connection, + provider: "auth0", + user_id: user.id.replace(/^auth0\|/, ""), + isSocial: false, + }, +]; export const defaultUser = auth0UserSchema.parse({ name: "default", email: "default@example.com", diff --git a/packages/auth0/test/actions.test.ts b/packages/auth0/test/actions.test.ts index 9d8b9f41..ed869131 100644 --- a/packages/auth0/test/actions.test.ts +++ b/packages/auth0/test/actions.test.ts @@ -213,7 +213,14 @@ describe("actions runner", () => { describe("post-login actions on /oauth/token", () => { let port = 4440; let auth0Url = `https://localhost:${port}`; - let person = { + let person: { + id: string; + name: string; + email: string; + password: string; + user_metadata: Record; + email_verified?: boolean; + } = { id: "auth0|alice", name: "Alice", email: "alice@example.com", @@ -346,6 +353,22 @@ describe("post-login actions on /oauth/token", () => { }); }); + describe("event.user", () => { + beforeEach(async () => { + person.email_verified = false; + await start("test/fixtures/actions/email-verified.js"); + }); + afterEach(() => { + delete person.email_verified; + }); + + it("carries the stored email_verified", async () => { + let { id_token } = (await (await login()).json()) as Record; + + expect(decodeJwt(id_token).event_email_verified).toBe(false); + }); + }); + describe("access.deny", () => { beforeEach(async () => { await start("test/fixtures/actions/deny.js"); diff --git a/packages/auth0/test/fixtures/actions/email-verified.js b/packages/auth0/test/fixtures/actions/email-verified.js new file mode 100644 index 00000000..f2cb113d --- /dev/null +++ b/packages/auth0/test/fixtures/actions/email-verified.js @@ -0,0 +1,3 @@ +exports.onExecutePostLogin = async (event, api) => { + api.idToken.setCustomClaim("event_email_verified", event.user.email_verified); +}; diff --git a/packages/auth0/test/management-api.test.ts b/packages/auth0/test/management-api.test.ts index feba5ab4..f826187f 100644 --- a/packages/auth0/test/management-api.test.ts +++ b/packages/auth0/test/management-api.test.ts @@ -45,7 +45,15 @@ describe("Management API", () => { beforeAll(async () => { server = await simulation({ initialState: { users: [seeded] }, - options: { rulesDirectory: "test/fixtures/rules-metadata" }, + options: { + actions: [ + { + code: `exports.onExecutePostLogin = async (event, api) => { + api.accessToken.setCustomClaim("https://example.nl/org", event.user.app_metadata.organisation_id); + };`, + }, + ], + }, }).listen(basePort); let res = await fetch(`${auth0Url}/oauth/token`, { @@ -187,6 +195,23 @@ describe("Management API", () => { }); }); + it("clears metadata patched to an empty object, leaving omitted metadata alone", async () => { + let user = await createUser({ + email: "clear@example.com", + user_metadata: { a: 1 }, + app_metadata: { b: 2 }, + }); + + let res = await api(`/users/${encodeURIComponent(user.user_id)}`, { + method: "PATCH", + body: { user_metadata: {} }, + }); + + let patched = (await res.json()) as Record; + expect(patched.user_metadata).toEqual({}); + expect(patched.app_metadata).toEqual({ b: 2 }); + }); + it("shows a metadata update in the next token, including for seeded users", async () => { let claim = async () => { let res = await login(seeded.email, seeded.password); @@ -205,6 +230,17 @@ describe("Management API", () => { expect(await claim()).toBe("org_2"); }); + it("counts logins on the user", async () => { + let before = (await (await api(`/users/${encodeURIComponent(seeded.id)}`)).json()) as { + logins_count: number; + }; + + await login(seeded.email, seeded.password); + + let after = await api(`/users/${encodeURIComponent(seeded.id)}`); + expect(await after.json()).toMatchObject({ logins_count: before.logins_count + 1 }); + }); + it("refuses to patch in an invalid email", async () => { let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { method: "PATCH", From d4092daff9ac617e2b61d0926c2eedea6ef43345 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 13 Oct 2026 13:29:44 +0000 Subject: [PATCH 09/13] auth0: reject a ttl_sec that isn't a non-negative integer A negative, fractional or non-numeric ttl_sec minted an already-expired or never-expiring ticket. 0 still means the default, as in Auth0. --- .../auth0/src/handlers/management-api-handlers.ts | 12 +++++++++++- packages/auth0/test/management-api.test.ts | 8 ++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/auth0/src/handlers/management-api-handlers.ts b/packages/auth0/src/handlers/management-api-handlers.ts index df6c46f5..dbfa720a 100644 --- a/packages/auth0/src/handlers/management-api-handlers.ts +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -178,10 +178,20 @@ export const createManagementApiHandlers = ( user ??= typeof body.email === "string" ? findByEmail(body.email) : undefined; if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); + // as in Auth0, an omitted or 0 ttl_sec means the default + let ttl = Number(body.ttl_sec ?? 0); + if (!Number.isSafeInteger(ttl) || ttl < 0) { + return sendError( + res, + 400, + "Payload validation error: 'ttl_sec' must be a positive integer.", + ); + } + let ticket: PasswordTicket = { id: randomUUID(), userId: user.id, - expiresAt: Date.now() + (Number(body.ttl_sec) || DEFAULT_TICKET_TTL_SEC) * 1000, + expiresAt: Date.now() + (ttl || DEFAULT_TICKET_TTL_SEC) * 1000, resultUrl: typeof body.result_url === "string" ? body.result_url : undefined, markEmailAsVerified: body.mark_email_as_verified === true, }; diff --git a/packages/auth0/test/management-api.test.ts b/packages/auth0/test/management-api.test.ts index f826187f..c6cf5a86 100644 --- a/packages/auth0/test/management-api.test.ts +++ b/packages/auth0/test/management-api.test.ts @@ -353,6 +353,14 @@ describe("Management API", () => { expect((await redeem(ticketUrl, "too-late")).status).toBe(400); }); + it.each([-1, 1.5, "Infinity", "soon"])("answers 400 for a ttl_sec of %j", async (ttl_sec) => { + let res = await api("/tickets/password-change", { + method: "POST", + body: { user_id: seeded.id, ttl_sec }, + }); + expect(res.status).toBe(400); + }); + it("answers 404 for an unknown user", async () => { let res = await api("/tickets/password-change", { method: "POST", From 4517b005660551c62bdb7648a33ce20f7c228e6a Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 13 Oct 2026 13:29:45 +0000 Subject: [PATCH 10/13] auth0: show actions the /authorize query in the authorization code flow Auth0 runs post-login Actions during /authorize, so event.request.query carries its parameters, including custom ext-* ones. The simulator runs actions at the code exchange, so it now keeps the /authorize query with the login's nonce and hands it to the actions there. --- .changes/auth0-management-api.md | 2 +- packages/auth0/README.md | 2 +- packages/auth0/src/handlers/auth0-handlers.ts | 12 +++++++ packages/auth0/src/handlers/oauth-handlers.ts | 6 ++++ packages/auth0/src/store/entities.ts | 6 +++- packages/auth0/test/actions.test.ts | 33 ++++++++++++++++++- .../test/fixtures/actions/request-query.js | 3 ++ 7 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 packages/auth0/test/fixtures/actions/request-query.js diff --git a/.changes/auth0-management-api.md b/.changes/auth0-management-api.md index d25c8af9..95e5aadc 100644 --- a/.changes/auth0-management-api.md +++ b/.changes/auth0-management-api.md @@ -2,4 +2,4 @@ "@simulacrum/auth0-simulator": minor --- -Add a store-backed subset of the Management API (`/api/v2/users`, `/api/v2/users-by-email`, `/api/v2/tickets/password-change`) plus a `/lo/reset` page to redeem password-change tickets. Users now carry `email_verified` (default `true` for seeded users), which the tokens and `/userinfo` report. +Add a store-backed subset of the Management API (`/api/v2/users`, `/api/v2/users-by-email`, `/api/v2/tickets/password-change`) plus a `/lo/reset` page to redeem password-change tickets. Users now carry `email_verified` (default `true` for seeded users), which the tokens and `/userinfo` report. In the authorization code flow, actions now see the `/authorize` query as `event.request.query`, as in Auth0. diff --git a/packages/auth0/README.md b/packages/auth0/README.md index 439a24b7..dca54bbc 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -131,7 +131,7 @@ Actions run on every login and refresh token exchange through `/oauth/token`, af Metadata is never added to the tokens unless an action sets it as a claim. An error thrown by an action ends the flow, as `api.access.deny` does, and `/oauth/token` responds with that error. -Unlike Auth0, actions run when `/oauth/token` issues the tokens rather than during `/authorize`. So in the authorization code flow, a denied login fails at the code exchange rather than redirecting with an error, and `event.request` describes the `/oauth/token` request rather than the original `/authorize` parameters. +Unlike Auth0, actions run when `/oauth/token` issues the tokens rather than during `/authorize`. So in the authorization code flow, a denied login fails at the code exchange rather than redirecting with an error. `event.request.query` is still the original `/authorize` query, as in Auth0, so custom `ext-*` parameters reach the action, but the rest of `event.request` describes the `/oauth/token` request. ### Rules diff --git a/packages/auth0/src/handlers/auth0-handlers.ts b/packages/auth0/src/handlers/auth0-handlers.ts index bc90c995..4fbc1c86 100644 --- a/packages/auth0/src/handlers/auth0-handlers.ts +++ b/packages/auth0/src/handlers/auth0-handlers.ts @@ -72,6 +72,18 @@ export const createAuth0Handlers = ( assert(!!req.session, "no session"); + // Auth0 shows an authorization code login's Actions the /authorize query, not /oauth/token's + let nonce = req.query.nonce; + if (typeof nonce === "string" && nonce) { + simulationStore.store.dispatch( + simulationStore.actions.batchUpdater([ + simulationStore.schema.sessions.patch({ + [nonce]: { nonce, authorizeQuery: req.query as Record }, + }), + ]), + ); + } + if (currentUser) { // the request is a silent login. // We fake an existing login by diff --git a/packages/auth0/src/handlers/oauth-handlers.ts b/packages/auth0/src/handlers/oauth-handlers.ts index d9994044..22c3f019 100644 --- a/packages/auth0/src/handlers/oauth-handlers.ts +++ b/packages/auth0/src/handlers/oauth-handlers.ts @@ -87,6 +87,12 @@ export const createTokens = async ({ user = result.user; nonce = result.nonce; + + let authorizeQuery = + nonce && + simulationStore.schema.sessions.selectById(simulationStore.store.getState(), { id: nonce }) + ?.authorizeQuery; + if (authorizeQuery) request = { ...request, query: authorizeQuery }; } assert(!!user, "500::No user found"); diff --git a/packages/auth0/src/store/entities.ts b/packages/auth0/src/store/entities.ts index 2ffea09d..93046c58 100644 --- a/packages/auth0/src/store/entities.ts +++ b/packages/auth0/src/store/entities.ts @@ -51,7 +51,11 @@ export const defaultUser = auth0UserSchema.parse({ export const auth0InitialStoreSchema = z.object({ users: z.array(auth0UserSchema), }); -export type AuthSession = { username: string; nonce: string }; +export type AuthSession = { + username?: string; + nonce: string; + authorizeQuery?: Record; +}; export type PasswordTicket = { id: string; userId: string; diff --git a/packages/auth0/test/actions.test.ts b/packages/auth0/test/actions.test.ts index ed869131..68371066 100644 --- a/packages/auth0/test/actions.test.ts +++ b/packages/auth0/test/actions.test.ts @@ -245,7 +245,7 @@ describe("post-login actions on /oauth/token", () => { token({ grant_type: "password", username: person.email, password: person.password }); // the authorization code flow, whose nonce the refresh grant requires - let codeLogin = async () => { + let codeLogin = async (authorizeParams: Record = {}) => { let frontend = await frontendSimulation.listen(); try { let fields = { @@ -259,6 +259,13 @@ describe("post-login actions on /oauth/token", () => { state: "state", tenant: `localhost:${port}`, }; + let { tenant: _, connection: __, ...authorize } = fields; + await fetch( + `${auth0Url}/authorize?${new URLSearchParams({ ...authorize, ...authorizeParams })}`, + { + redirect: "manual", + }, + ); await fetch(`${auth0Url}/usernamepassword/login`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -340,6 +347,30 @@ describe("post-login actions on /oauth/token", () => { }); }); + describe("event.request.query", () => { + beforeEach(async () => { + await start("test/fixtures/actions/request-query.js"); + }); + + it("is the /authorize query for an authorization code login", async () => { + let res = await codeLogin({ "ext-invitation": "invite-token" }); + expect(res.ok).toBe(true); + let { id_token } = (await res.json()) as Record; + + expect(decodeJwt(id_token).query).toMatchObject({ + "ext-invitation": "invite-token", + response_type: "code", + }); + }); + + it("is the token request's own query for a password grant", async () => { + await codeLogin({ "ext-invitation": "invite-token" }); + let { id_token } = (await (await login()).json()) as Record; + + expect(decodeJwt(id_token).query).toEqual({}); + }); + }); + describe("metadata updates", () => { beforeEach(async () => { await start("test/fixtures/actions/count-visits.js"); diff --git a/packages/auth0/test/fixtures/actions/request-query.js b/packages/auth0/test/fixtures/actions/request-query.js new file mode 100644 index 00000000..b8508c2b --- /dev/null +++ b/packages/auth0/test/fixtures/actions/request-query.js @@ -0,0 +1,3 @@ +exports.onExecutePostLogin = async (event, api) => { + api.idToken.setCustomClaim("query", event.request.query); +}; From d34b91d44e48efeb08ebbf5dddd616aacb361b64 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 13 Oct 2026 13:39:18 +0000 Subject: [PATCH 11/13] auth0: require a password when creating a user, as Auth0 does A database-connection user can't be created without one in Auth0, so the Management API now answers 400 rather than inventing a password. --- packages/auth0/README.md | 2 +- .../auth0/src/handlers/management-api-handlers.ts | 10 ++++++++-- packages/auth0/test/management-api.test.ts | 15 ++++++++++----- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/auth0/README.md b/packages/auth0/README.md index dca54bbc..4801ca38 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -164,7 +164,7 @@ The following endpoints have been assigned handlers: A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update reaches the next login's [actions](#actions). Actions can call it too, at the simulator's own URL. Requests need a bearer token signed by the simulator from a `client_credentials` grant on `/oauth/token` with the audience `https:///api/v2/`. Scopes are not checked. -- `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|` (generated when omitted), `email_verified` defaults to `false`, and a user created without a `password` gets a random one, so they can only log in once a password-change ticket has set it. +- `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|` (generated when omitted), `email_verified` defaults to `false`, and `password` is required, as it is for an Auth0 database connection. - `GET /api/v2/users/:id` - `PATCH /api/v2/users/:id` — `user_metadata` and `app_metadata` are merged at the top level, a `null` value removes the key, and an empty object clears it, as in Auth0. - `DELETE /api/v2/users/:id` diff --git a/packages/auth0/src/handlers/management-api-handlers.ts b/packages/auth0/src/handlers/management-api-handlers.ts index dbfa720a..d4b4f275 100644 --- a/packages/auth0/src/handlers/management-api-handlers.ts +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -96,6 +96,13 @@ export const createManagementApiHandlers = ( if (typeof email !== "string" || !email) { return sendError(res, 400, "Payload validation error: 'Missing required property: email'."); } + if (typeof body.password !== "string" || !body.password) { + return sendError( + res, + 400, + "Payload validation error: 'Missing required property: password'.", + ); + } if (findByEmail(email)) { return sendError(res, 409, "The user already exists.", "auth0_idp_error"); } @@ -106,8 +113,7 @@ export const createManagementApiHandlers = ( email: email.toLowerCase(), // Auth0 marks created users unverified unless told otherwise email_verified: body.email_verified ?? false, - // Auth0 requires one; a random one keeps the account closed until a ticket sets it - password: body.password ?? randomUUID(), + password: body.password, picture: body.picture, user_metadata: body.user_metadata, app_metadata: body.app_metadata, diff --git a/packages/auth0/test/management-api.test.ts b/packages/auth0/test/management-api.test.ts index c6cf5a86..f2822936 100644 --- a/packages/auth0/test/management-api.test.ts +++ b/packages/auth0/test/management-api.test.ts @@ -37,7 +37,7 @@ describe("Management API", () => { }); let createUser = async (body: Record) => { - let res = await api("/users", { method: "POST", body }); + let res = await api("/users", { method: "POST", body: { password: "initial-pw", ...body } }); expect(res.status).toBe(201); return (await res.json()) as Record; }; @@ -138,9 +138,11 @@ describe("Management API", () => { expect(decodeJwt(id_token).sub).toBe(user.user_id); }); - it("does not give a user created without a password a guessable one", async () => { - await createUser({ email: "no-password@example.com" }); - expect((await login("no-password@example.com", "12345")).status).toBe(401); + it("answers 400 for a missing password, as for an Auth0 database connection", async () => { + let res = await api("/users", { method: "POST", body: { email: "no-password@example.com" } }); + expect(res.status).toBe(400); + expect((await api("/users-by-email?email=no-password@example.com")).status).toBe(200); + expect(await (await api("/users-by-email?email=no-password@example.com")).json()).toEqual([]); }); it("prefixes a caller-supplied user_id", async () => { @@ -149,7 +151,10 @@ describe("Management API", () => { }); it("answers 409 for an email that is already taken", async () => { - let res = await api("/users", { method: "POST", body: { email: "SEEDED@example.com" } }); + let res = await api("/users", { + method: "POST", + body: { email: "SEEDED@example.com", password: "pw" }, + }); expect(res.status).toBe(409); expect(await res.json()).toMatchObject({ statusCode: 409, From bc3b2bc2f1b1ac5d65a5cd6d2eac78fd25fb415a Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 13 Oct 2026 18:38:12 +0000 Subject: [PATCH 12/13] auth0: reject a user_id that isn't a non-empty string Auth0 refuses one, while the simulator stored the coerced value, e.g. auth0|[object Object]. Also gives the result_url ticket test its own user, so redeeming it no longer changes the seeded user's password for later tests. --- .../auth0/src/handlers/management-api-handlers.ts | 7 +++++++ packages/auth0/test/management-api.test.ts | 13 +++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/auth0/src/handlers/management-api-handlers.ts b/packages/auth0/src/handlers/management-api-handlers.ts index d4b4f275..440161e9 100644 --- a/packages/auth0/src/handlers/management-api-handlers.ts +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -103,6 +103,13 @@ export const createManagementApiHandlers = ( "Payload validation error: 'Missing required property: password'.", ); } + if (user_id !== undefined && (typeof user_id !== "string" || !user_id)) { + return sendError( + res, + 400, + "Payload validation error: 'user_id' must be a non-empty string.", + ); + } if (findByEmail(email)) { return sendError(res, 409, "The user already exists.", "auth0_idp_error"); } diff --git a/packages/auth0/test/management-api.test.ts b/packages/auth0/test/management-api.test.ts index f2822936..1b99a75a 100644 --- a/packages/auth0/test/management-api.test.ts +++ b/packages/auth0/test/management-api.test.ts @@ -150,6 +150,14 @@ describe("Management API", () => { expect(user.user_id).toBe("auth0|custom-1"); }); + it.each([{}, 42, "", null])("answers 400 for a user_id of %j", async (user_id) => { + let res = await api("/users", { + method: "POST", + body: { email: "bad-id@example.com", password: "pw", user_id }, + }); + expect(res.status).toBe(400); + }); + it("answers 409 for an email that is already taken", async () => { let res = await api("/users", { method: "POST", @@ -340,12 +348,13 @@ describe("Management API", () => { }); it("redirects to result_url after redeeming", async () => { + let user = await createUser({ email: "redirect@example.com" }); let ticketUrl = await createTicket({ - user_id: seeded.id, + user_id: user.user_id, result_url: "https://app.example.com/welcome", }); - let res = await redeem(ticketUrl, "new-seeded-pw"); + let res = await redeem(ticketUrl, "redirect-pw"); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("https://app.example.com/welcome"); }); From 29379a2014e1e9ac962b62735a96926aa085d6fc Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 13 Oct 2026 18:42:07 +0000 Subject: [PATCH 13/13] auth0: only accept a JSON integer for ttl_sec Number() let true through as a one-second ticket and turned null or "" into the default, where Auth0 rejects anything but an integer. --- .../src/handlers/management-api-handlers.ts | 4 ++-- packages/auth0/test/management-api.test.ts | 17 ++++++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/auth0/src/handlers/management-api-handlers.ts b/packages/auth0/src/handlers/management-api-handlers.ts index 440161e9..6d209776 100644 --- a/packages/auth0/src/handlers/management-api-handlers.ts +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -192,8 +192,8 @@ export const createManagementApiHandlers = ( if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); // as in Auth0, an omitted or 0 ttl_sec means the default - let ttl = Number(body.ttl_sec ?? 0); - if (!Number.isSafeInteger(ttl) || ttl < 0) { + let ttl: unknown = body.ttl_sec === undefined ? 0 : body.ttl_sec; + if (typeof ttl !== "number" || !Number.isSafeInteger(ttl) || ttl < 0) { return sendError( res, 400, diff --git a/packages/auth0/test/management-api.test.ts b/packages/auth0/test/management-api.test.ts index 1b99a75a..78081fa6 100644 --- a/packages/auth0/test/management-api.test.ts +++ b/packages/auth0/test/management-api.test.ts @@ -367,13 +367,16 @@ describe("Management API", () => { expect((await redeem(ticketUrl, "too-late")).status).toBe(400); }); - it.each([-1, 1.5, "Infinity", "soon"])("answers 400 for a ttl_sec of %j", async (ttl_sec) => { - let res = await api("/tickets/password-change", { - method: "POST", - body: { user_id: seeded.id, ttl_sec }, - }); - expect(res.status).toBe(400); - }); + it.each([-1, 1.5, "Infinity", "soon", "60", true, null, ""])( + "answers 400 for a ttl_sec of %j", + async (ttl_sec) => { + let res = await api("/tickets/password-change", { + method: "POST", + body: { user_id: seeded.id, ttl_sec }, + }); + expect(res.status).toBe(400); + }, + ); it("answers 404 for an unknown user", async () => { let res = await api("/tickets/password-change", {