@rebasepro/server 0.14.1-canary.g7e666eb → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/api/rest/query-parser.d.ts +22 -0
  2. package/dist/auth/index.d.ts +3 -1
  3. package/dist/auth/jwks-routes.d.ts +17 -0
  4. package/dist/auth/jwt-keys.d.ts +108 -0
  5. package/dist/auth/jwt.d.ts +32 -0
  6. package/dist/{auth-BQcdhMBL.js → auth-BobZVd0j.js} +82 -6
  7. package/dist/auth-BobZVd0j.js.map +1 -0
  8. package/dist/boot/boot.d.ts +36 -50
  9. package/dist/boot/ddl-bootstrap.d.ts +15 -0
  10. package/dist/boot/env.d.ts +20 -0
  11. package/dist/boot/provision.d.ts +182 -0
  12. package/dist/boot/role.d.ts +88 -0
  13. package/dist/{cron-store-D5dUNviq.js → cron-store-CB1x-Ken.js} +2 -2
  14. package/dist/{cron-store-D5dUNviq.js.map → cron-store-CB1x-Ken.js.map} +1 -1
  15. package/dist/{ddl-bootstrap-BhXbTnBl.js → ddl-bootstrap-Cywoj8Ta.js} +40 -2
  16. package/dist/{ddl-bootstrap-BhXbTnBl.js.map → ddl-bootstrap-Cywoj8Ta.js.map} +1 -1
  17. package/dist/env.d.ts +2 -0
  18. package/dist/functions/proxy.d.ts +41 -0
  19. package/dist/functions/selection.d.ts +45 -0
  20. package/dist/index.d.ts +7 -3
  21. package/dist/index.es.js +815 -351
  22. package/dist/index.es.js.map +1 -1
  23. package/dist/init/shutdown.d.ts +4 -0
  24. package/dist/init/surfaces.d.ts +79 -0
  25. package/dist/init.d.ts +121 -1
  26. package/dist/jobs/index.d.ts +5 -0
  27. package/dist/jobs/job-queue.d.ts +14 -0
  28. package/dist/jobs/job-store.d.ts +22 -0
  29. package/dist/jobs/types.d.ts +125 -0
  30. package/dist/jobs-DR4SjGrD.js +326 -0
  31. package/dist/jobs-DR4SjGrD.js.map +1 -0
  32. package/dist/{jwt-CYGFT0ih.js → jwt-VJyXTdQQ.js} +152 -6
  33. package/dist/jwt-VJyXTdQQ.js.map +1 -0
  34. package/dist/{openapi-generator-BWL8F2La.js → openapi-generator-DQeQ_q2f.js} +45 -1
  35. package/dist/openapi-generator-DQeQ_q2f.js.map +1 -0
  36. package/dist/proxy-Bj5DVllb.js +139 -0
  37. package/dist/proxy-Bj5DVllb.js.map +1 -0
  38. package/dist/selection-_z6TM1DB.js +64 -0
  39. package/dist/selection-_z6TM1DB.js.map +1 -0
  40. package/dist/services/webhook-service.d.ts +43 -5
  41. package/dist/src-8XDWyDfR.js.map +1 -1
  42. package/package.json +5 -5
  43. package/dist/auth-BQcdhMBL.js.map +0 -1
  44. package/dist/jwt-CYGFT0ih.js.map +0 -1
  45. package/dist/openapi-generator-BWL8F2La.js.map +0 -1
@@ -0,0 +1,326 @@
1
+ import { createRequire as __createRequire } from "module";
2
+ import process from "process";
3
+ __createRequire(import.meta.url);
4
+ import { n as __exportAll } from "./rolldown-runtime-DSJWtz9O.js";
5
+ import "./src-8XDWyDfR.js";
6
+ import "./src-Cz9nMgUR.js";
7
+ import { n as createDdlBootstrapper, o as revokeInternalTableSql, r as hasInCauseChain, s as isSQLAdmin } from "./ddl-bootstrap-Cywoj8Ta.js";
8
+ import { t as logger } from "./logger-DfvF_8r-.js";
9
+ import { randomUUID } from "crypto";
10
+ //#region src/jobs/job-store.ts
11
+ /**
12
+ * The queue's storage, as SQL against `rebase.jobs`.
13
+ *
14
+ * Split from the worker for the same reason `cron-store` is split from
15
+ * `cron-scheduler`: the interesting parts here are three statements that have
16
+ * to be exactly right under concurrency, and they are much easier to reason
17
+ * about — and to test — away from a polling loop.
18
+ */
19
+ var TABLE = "rebase.jobs";
20
+ /** How long finished jobs are kept before the boot sweep removes them. */
21
+ var SUCCEEDED_RETENTION_DAYS = 3;
22
+ /**
23
+ * Failures outlive successes by a lot. A dead-lettered job is evidence, and the
24
+ * person who needs it is usually looking on Monday for something that happened
25
+ * on Friday night.
26
+ */
27
+ var FAILED_RETENTION_DAYS = 30;
28
+ function toRecord(row) {
29
+ return {
30
+ id: row.id,
31
+ task: row.task,
32
+ payload: row.payload,
33
+ status: row.status,
34
+ runAt: new Date(row.run_at).toISOString(),
35
+ attempts: Number(row.attempts),
36
+ maxAttempts: Number(row.max_attempts),
37
+ lastError: row.last_error,
38
+ createdAt: new Date(row.created_at).toISOString(),
39
+ updatedAt: new Date(row.updated_at).toISOString()
40
+ };
41
+ }
42
+ /** Same rule as `cron-store`: match the SQLSTATE, never the message. */
43
+ function isUniqueViolation(err) {
44
+ return hasInCauseChain(err, (e) => e.code === "23505");
45
+ }
46
+ function createJobStore(driver) {
47
+ const admin = driver.admin;
48
+ if (!isSQLAdmin(admin)) {
49
+ logger.warn("⚠️ [jobs] DataDriver does not support SQL admin — the durable job queue is unavailable. Work that would have been queued runs inline instead.");
50
+ return;
51
+ }
52
+ const execRaw = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
53
+ const exec = (sqlText, params) => execRaw(sqlText, params ? { params } : void 0);
54
+ const ddl = createDdlBootstrapper(execRaw, "jobs");
55
+ return {
56
+ async ensureTable() {
57
+ await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
58
+ await ddl.ensureObject(`Creating ${TABLE}`, `
59
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
60
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
61
+ task TEXT NOT NULL,
62
+ payload JSONB,
63
+ status TEXT NOT NULL DEFAULT 'pending',
64
+ run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
65
+ attempts INTEGER NOT NULL DEFAULT 0,
66
+ max_attempts INTEGER NOT NULL DEFAULT 3,
67
+ locked_at TIMESTAMPTZ,
68
+ locked_by TEXT,
69
+ idempotency_key TEXT,
70
+ last_error TEXT,
71
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
72
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
73
+ )
74
+ `);
75
+ await ddl.ensureObject("Creating idx_jobs_runnable", `
76
+ CREATE INDEX IF NOT EXISTS idx_jobs_runnable
77
+ ON ${TABLE}(run_at, created_at)
78
+ WHERE status = 'pending'
79
+ `);
80
+ await ddl.ensureObject("Creating idx_jobs_running", `
81
+ CREATE INDEX IF NOT EXISTS idx_jobs_running
82
+ ON ${TABLE}(locked_at)
83
+ WHERE status = 'running'
84
+ `);
85
+ await ddl.ensureObject("Creating idx_jobs_idempotency", `
86
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_idempotency
87
+ ON ${TABLE}(idempotency_key)
88
+ WHERE idempotency_key IS NOT NULL AND status IN ('pending', 'running')
89
+ `);
90
+ if (await ddl.isReadable(TABLE)) {
91
+ await ddl.step("Job retention sweep", async () => {
92
+ await exec(`DELETE FROM ${TABLE}
93
+ WHERE (status = 'succeeded' AND updated_at < now() - make_interval(days => $1))
94
+ OR (status = 'failed' AND updated_at < now() - make_interval(days => $2))`, [SUCCEEDED_RETENTION_DAYS, FAILED_RETENTION_DAYS]);
95
+ });
96
+ await ddl.step("Revoking end-user access to jobs", () => exec(revokeInternalTableSql("rebase", "jobs")));
97
+ logger.info("✅ Job queue table ready");
98
+ } else logger.error(`❌ [jobs] ${TABLE} is unavailable — nothing can be queued and nothing queued earlier will run. Callers fall back to running the work inline.`);
99
+ },
100
+ async insert(job) {
101
+ try {
102
+ return (await exec(`INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)
103
+ VALUES ($1, $2::jsonb, $3, $4, $5)
104
+ RETURNING id`, [
105
+ job.task,
106
+ JSON.stringify(job.payload ?? null),
107
+ job.runAt.toISOString(),
108
+ job.maxAttempts,
109
+ job.idempotencyKey ?? null
110
+ ]))?.[0]?.id ?? null;
111
+ } catch (error) {
112
+ if (isUniqueViolation(error)) return null;
113
+ throw error;
114
+ }
115
+ },
116
+ async claim(limit, workerId) {
117
+ return (await exec(`UPDATE ${TABLE} SET
118
+ status = 'running',
119
+ attempts = attempts + 1,
120
+ locked_at = now(),
121
+ locked_by = $2,
122
+ updated_at = now()
123
+ WHERE id IN (
124
+ SELECT id FROM ${TABLE}
125
+ WHERE status = 'pending' AND run_at <= now()
126
+ ORDER BY run_at, created_at
127
+ LIMIT $1
128
+ FOR UPDATE SKIP LOCKED
129
+ )
130
+ RETURNING *`, [limit, workerId])).map(toRecord);
131
+ },
132
+ async complete(id) {
133
+ await exec(`UPDATE ${TABLE} SET status = 'succeeded', locked_at = NULL, locked_by = NULL,
134
+ last_error = NULL, updated_at = now()
135
+ WHERE id = $1`, [id]);
136
+ },
137
+ async fail(id, error, retryAt) {
138
+ if (retryAt) {
139
+ await exec(`UPDATE ${TABLE} SET status = 'pending', run_at = $2, locked_at = NULL,
140
+ locked_by = NULL, last_error = $3, updated_at = now()
141
+ WHERE id = $1`, [
142
+ id,
143
+ retryAt.toISOString(),
144
+ error
145
+ ]);
146
+ return;
147
+ }
148
+ await exec(`UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,
149
+ last_error = $2, updated_at = now()
150
+ WHERE id = $1`, [id, error]);
151
+ },
152
+ async reapExpired(visibilityTimeoutMs) {
153
+ const seconds = Math.max(1, Math.round(visibilityTimeoutMs / 1e3));
154
+ const revived = await exec(`UPDATE ${TABLE} SET status = 'pending', locked_at = NULL, locked_by = NULL,
155
+ last_error = 'Worker stopped responding; the job was reclaimed', updated_at = now()
156
+ WHERE status = 'running'
157
+ AND locked_at < now() - make_interval(secs => $1)
158
+ AND attempts < max_attempts
159
+ RETURNING id`, [seconds]);
160
+ const buried = await exec(`UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,
161
+ last_error = 'Worker stopped responding on the final attempt', updated_at = now()
162
+ WHERE status = 'running'
163
+ AND locked_at < now() - make_interval(secs => $1)
164
+ AND attempts >= max_attempts
165
+ RETURNING id`, [seconds]);
166
+ const count = (revived?.length ?? 0) + (buried?.length ?? 0);
167
+ if (count > 0) logger.warn(`[jobs] Reclaimed ${count} job(s) from a worker that stopped responding (${revived?.length ?? 0} retryable, ${buried?.length ?? 0} dead-lettered)`);
168
+ return count;
169
+ },
170
+ async fetch(id) {
171
+ const row = (await exec(`SELECT * FROM ${TABLE} WHERE id = $1`, [id]))[0];
172
+ return row ? toRecord(row) : null;
173
+ }
174
+ };
175
+ }
176
+ //#endregion
177
+ //#region src/jobs/job-queue.ts
178
+ /**
179
+ * The worker: claim, run, record, repeat.
180
+ *
181
+ * Everything difficult about running jobs concurrently is in `job-store.ts`,
182
+ * where one `UPDATE … FOR UPDATE SKIP LOCKED` does the arbitration. What is
183
+ * left here is a loop and the decisions around a handler that throws.
184
+ */
185
+ var DEFAULT_CONCURRENCY = 5;
186
+ var DEFAULT_POLL_INTERVAL_MS = 2e3;
187
+ var DEFAULT_VISIBILITY_TIMEOUT_MS = 5 * 6e4;
188
+ var DEFAULT_MAX_ATTEMPTS = 3;
189
+ /** 1s, 5s, 25s, 125s … capped at an hour. */
190
+ function defaultBackoff(attempt) {
191
+ return Math.min(1e3 * Math.pow(5, Math.max(0, attempt - 1)), 60 * 6e4);
192
+ }
193
+ /**
194
+ * How often the reaper runs, relative to the visibility timeout.
195
+ *
196
+ * A quarter of it, so a stranded job waits at most 1.25× the timeout rather
197
+ * than 2× — and so the sweep is not itself a per-poll query against a table
198
+ * whose interesting rows are, almost always, none.
199
+ */
200
+ var REAP_INTERVAL_FACTOR = .25;
201
+ function createJobQueue(store, options = {}) {
202
+ const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
203
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
204
+ const visibilityTimeoutMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS;
205
+ const defaultMaxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
206
+ const backoff = options.backoff ?? defaultBackoff;
207
+ const handlers = /* @__PURE__ */ new Map();
208
+ for (const [task, handler] of Object.entries(options.tasks ?? {})) handlers.set(task, handler);
209
+ const workerId = `${process.pid}-${randomUUID().slice(0, 8)}`;
210
+ let timer = null;
211
+ let running = false;
212
+ let draining = false;
213
+ /** Resolves when the in-flight poll finishes, so `stop()` can wait for it. */
214
+ let inFlight = Promise.resolve();
215
+ let lastReapAt = 0;
216
+ async function runJob(job) {
217
+ const handler = handlers.get(job.task);
218
+ if (!handler) {
219
+ logger.warn(`[jobs] No handler registered for task "${job.task}" — returning the job to the queue`);
220
+ await store.fail(job.id, `No handler registered for task "${job.task}"`, job.attempts < job.maxAttempts ? new Date(Date.now() + backoff(job.attempts)) : null);
221
+ return;
222
+ }
223
+ try {
224
+ await handler({
225
+ id: job.id,
226
+ task: job.task,
227
+ payload: job.payload,
228
+ attempt: job.attempts,
229
+ maxAttempts: job.maxAttempts
230
+ });
231
+ await store.complete(job.id);
232
+ } catch (error) {
233
+ const message = error instanceof Error ? error.stack ?? error.message : String(error);
234
+ const willRetry = job.attempts < job.maxAttempts;
235
+ await store.fail(job.id, message.slice(0, 4e3), willRetry ? new Date(Date.now() + backoff(job.attempts)) : null);
236
+ if (willRetry) logger.warn(`[jobs] "${job.task}" failed on attempt ${job.attempts}/${job.maxAttempts}; retrying`, { jobId: job.id });
237
+ else logger.error(`[jobs] "${job.task}" failed permanently after ${job.attempts} attempts`, {
238
+ jobId: job.id,
239
+ error: message
240
+ });
241
+ }
242
+ }
243
+ async function poll() {
244
+ const now = Date.now();
245
+ if (now - lastReapAt > visibilityTimeoutMs * REAP_INTERVAL_FACTOR) {
246
+ lastReapAt = now;
247
+ try {
248
+ await store.reapExpired(visibilityTimeoutMs);
249
+ } catch (error) {
250
+ logger.error("[jobs] Failed to reclaim expired jobs", { error });
251
+ }
252
+ }
253
+ const jobs = await store.claim(concurrency, workerId);
254
+ if (jobs.length === 0) return 0;
255
+ await Promise.allSettled(jobs.map(runJob));
256
+ return jobs.length;
257
+ }
258
+ function schedule(delayMs) {
259
+ if (!running) return;
260
+ timer = setTimeout(() => {
261
+ tick();
262
+ }, delayMs);
263
+ timer.unref?.();
264
+ }
265
+ async function tick() {
266
+ if (!running) return;
267
+ const work = (async () => {
268
+ try {
269
+ return await poll();
270
+ } catch (error) {
271
+ logger.error("[jobs] Poll failed", { error });
272
+ return 0;
273
+ }
274
+ })();
275
+ inFlight = work;
276
+ schedule(await work >= concurrency ? 0 : pollIntervalMs);
277
+ }
278
+ return {
279
+ start() {
280
+ if (running) return;
281
+ running = true;
282
+ logger.info(`[jobs] Worker started (concurrency ${concurrency}, poll ${pollIntervalMs}ms)`);
283
+ schedule(0);
284
+ },
285
+ async stop() {
286
+ running = false;
287
+ if (timer) {
288
+ clearTimeout(timer);
289
+ timer = null;
290
+ }
291
+ if (draining) return;
292
+ draining = true;
293
+ await inFlight.catch(() => void 0);
294
+ draining = false;
295
+ },
296
+ runOnce() {
297
+ return poll();
298
+ },
299
+ register(task, handler) {
300
+ if (handlers.has(task)) logger.warn(`[jobs] Task "${task}" was already registered; the later handler wins`);
301
+ handlers.set(task, handler);
302
+ },
303
+ isRunning() {
304
+ return running;
305
+ },
306
+ async enqueue(task, payload, enqueueOptions = {}) {
307
+ return store.insert({
308
+ task,
309
+ payload: payload ?? null,
310
+ runAt: new Date(Date.now() + (enqueueOptions.delayMs ?? 0)),
311
+ maxAttempts: enqueueOptions.maxAttempts ?? defaultMaxAttempts,
312
+ idempotencyKey: enqueueOptions.idempotencyKey
313
+ });
314
+ }
315
+ };
316
+ }
317
+ //#endregion
318
+ //#region src/jobs/index.ts
319
+ var jobs_exports = /* @__PURE__ */ __exportAll({
320
+ createJobQueue: () => createJobQueue,
321
+ createJobStore: () => createJobStore
322
+ });
323
+ //#endregion
324
+ export { createJobStore as i, createJobQueue as n, defaultBackoff as r, jobs_exports as t };
325
+
326
+ //# sourceMappingURL=jobs-DR4SjGrD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jobs-DR4SjGrD.js","names":[],"sources":["../src/jobs/job-store.ts","../src/jobs/job-queue.ts","../src/jobs/index.ts"],"sourcesContent":["import type { DataDriver } from \"@rebasepro/types\";\nimport { isSQLAdmin } from \"@rebasepro/types\";\nimport { revokeInternalTableSql } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain, type SqlExec } from \"../boot/ddl-bootstrap.js\";\nimport type { JobRecord } from \"./types.js\";\n\n/**\n * The queue's storage, as SQL against `rebase.jobs`.\n *\n * Split from the worker for the same reason `cron-store` is split from\n * `cron-scheduler`: the interesting parts here are three statements that have\n * to be exactly right under concurrency, and they are much easier to reason\n * about — and to test — away from a polling loop.\n */\n\nconst TABLE = \"rebase.jobs\";\n\n/** How long finished jobs are kept before the boot sweep removes them. */\nconst SUCCEEDED_RETENTION_DAYS = 3;\n/**\n * Failures outlive successes by a lot. A dead-lettered job is evidence, and the\n * person who needs it is usually looking on Monday for something that happened\n * on Friday night.\n */\nconst FAILED_RETENTION_DAYS = 30;\n\n/** A row as Postgres returns it. */\ninterface JobRow {\n id: string;\n task: string;\n payload: unknown;\n status: string;\n run_at: string;\n attempts: number;\n max_attempts: number;\n last_error: string | null;\n created_at: string;\n updated_at: string;\n}\n\nfunction toRecord(row: JobRow): JobRecord {\n return {\n id: row.id,\n task: row.task,\n payload: row.payload,\n status: row.status as JobRecord[\"status\"],\n runAt: new Date(row.run_at).toISOString(),\n attempts: Number(row.attempts),\n maxAttempts: Number(row.max_attempts),\n lastError: row.last_error,\n createdAt: new Date(row.created_at).toISOString(),\n updatedAt: new Date(row.updated_at).toISOString()\n };\n}\n\n/** Same rule as `cron-store`: match the SQLSTATE, never the message. */\nfunction isUniqueViolation(err: unknown): boolean {\n return hasInCauseChain(err, (e) => e.code === \"23505\");\n}\n\nexport interface JobStore {\n ensureTable(): Promise<void>;\n /** Returns the new job's id, or `null` if an idempotency key matched unfinished work. */\n insert(job: {\n task: string;\n payload: unknown;\n runAt: Date;\n maxAttempts: number;\n idempotencyKey?: string;\n }): Promise<string | null>;\n /** Atomically take up to `limit` runnable jobs for this worker. */\n claim(limit: number, workerId: string): Promise<JobRecord[]>;\n complete(id: string): Promise<void>;\n /** Back to `pending` with a later `runAt`, or `failed` when out of attempts. */\n fail(id: string, error: string, retryAt: Date | null): Promise<void>;\n /** Return jobs stranded by a worker that died holding them. Resolves with how many. */\n reapExpired(visibilityTimeoutMs: number): Promise<number>;\n fetch(id: string): Promise<JobRecord | null>;\n}\n\nexport function createJobStore(driver: DataDriver): JobStore | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) {\n logger.warn(\n \"⚠️ [jobs] DataDriver does not support SQL admin — the durable job queue is unavailable. \" +\n \"Work that would have been queued runs inline instead.\"\n );\n return undefined;\n }\n\n // Two shapes of the same call: the bootstrapper's `SqlExec` takes an\n // options object, while everything below reads better with a positional\n // parameter array.\n const execRaw: SqlExec = (sqlText, options) =>\n admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);\n const exec = (sqlText: string, params?: unknown[]) =>\n execRaw(sqlText, params ? { params } : undefined);\n\n const ddl = createDdlBootstrapper(execRaw, \"jobs\");\n\n return {\n async ensureTable(): Promise<void> {\n await ddl.ensureObject(\"Creating schema rebase\", \"CREATE SCHEMA IF NOT EXISTS rebase\");\n\n await ddl.ensureObject(`Creating ${TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${TABLE} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n task TEXT NOT NULL,\n payload JSONB,\n status TEXT NOT NULL DEFAULT 'pending',\n run_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n locked_at TIMESTAMPTZ,\n locked_by TEXT,\n idempotency_key TEXT,\n last_error TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT now()\n )\n `);\n\n // The claim query's index. Partial, because the rows it has to find\n // fast are a shrinking minority of a table that also holds every\n // success and every dead letter.\n await ddl.ensureObject(\"Creating idx_jobs_runnable\", `\n CREATE INDEX IF NOT EXISTS idx_jobs_runnable\n ON ${TABLE}(run_at, created_at)\n WHERE status = 'pending'\n `);\n\n // Finds jobs stranded by a dead worker.\n await ddl.ensureObject(\"Creating idx_jobs_running\", `\n CREATE INDEX IF NOT EXISTS idx_jobs_running\n ON ${TABLE}(locked_at)\n WHERE status = 'running'\n `);\n\n // What makes `idempotencyKey` a guarantee rather than a\n // check-then-act race: two instances reacting to one event both\n // reach the INSERT, and the index decides. Partial on unfinished\n // work — see `EnqueueOptions.idempotencyKey` for why a key must be\n // reusable once its job is done.\n await ddl.ensureObject(\"Creating idx_jobs_idempotency\", `\n CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_idempotency\n ON ${TABLE}(idempotency_key)\n WHERE idempotency_key IS NOT NULL AND status IN ('pending', 'running')\n `);\n\n const ready = await ddl.isReadable(TABLE);\n\n if (ready) {\n await ddl.step(\"Job retention sweep\", async () => {\n await exec(\n `DELETE FROM ${TABLE}\n WHERE (status = 'succeeded' AND updated_at < now() - make_interval(days => $1))\n OR (status = 'failed' AND updated_at < now() - make_interval(days => $2))`,\n [SUCCEEDED_RETENTION_DAYS, FAILED_RETENTION_DAYS]\n );\n });\n\n // Payloads are arbitrary application data — a webhook body, a\n // user id, whatever was passed — and a writable queue lets any\n // signed-in user schedule work of their choosing under the\n // server's own authority. Same reasoning, and the same\n // unconditional re-application, as `cron_claims`.\n await ddl.step(\"Revoking end-user access to jobs\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"jobs\")));\n\n logger.info(\"✅ Job queue table ready\");\n } else {\n logger.error(\n `❌ [jobs] ${TABLE} is unavailable — nothing can be queued and nothing queued earlier ` +\n \"will run. Callers fall back to running the work inline.\"\n );\n }\n },\n\n async insert(job): Promise<string | null> {\n try {\n const rows = await exec(\n `INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)\n VALUES ($1, $2::jsonb, $3, $4, $5)\n RETURNING id`,\n [\n job.task,\n JSON.stringify(job.payload ?? null),\n job.runAt.toISOString(),\n job.maxAttempts,\n job.idempotencyKey ?? null\n ]\n );\n return (rows?.[0]?.id as string) ?? null;\n } catch (error) {\n // The losing side of an idempotency race. Not an error: the\n // work the caller wanted is already queued, which is the\n // outcome they asked for.\n if (isUniqueViolation(error)) return null;\n throw error;\n }\n },\n\n async claim(limit: number, workerId: string): Promise<JobRecord[]> {\n // `FOR UPDATE SKIP LOCKED` is the whole design. The inner select\n // takes row locks on the jobs it picks and *skips* any a concurrent\n // worker already holds, so N workers polling the same table divide\n // the work instead of contending for the head of it — and no job is\n // ever handed to two of them.\n //\n // `attempts` is incremented here, on claim, rather than on failure.\n // A worker that is killed mid-job never reports anything, so\n // counting on failure would let a job that crashes the process be\n // retried forever, once per restart, taking the process down each\n // time.\n const rows = await exec(\n `UPDATE ${TABLE} SET\n status = 'running',\n attempts = attempts + 1,\n locked_at = now(),\n locked_by = $2,\n updated_at = now()\n WHERE id IN (\n SELECT id FROM ${TABLE}\n WHERE status = 'pending' AND run_at <= now()\n ORDER BY run_at, created_at\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n )\n RETURNING *`,\n [limit, workerId]\n );\n return (rows as unknown as JobRow[]).map(toRecord);\n },\n\n async complete(id: string): Promise<void> {\n await exec(\n `UPDATE ${TABLE} SET status = 'succeeded', locked_at = NULL, locked_by = NULL,\n last_error = NULL, updated_at = now()\n WHERE id = $1`,\n [id]\n );\n },\n\n async fail(id: string, error: string, retryAt: Date | null): Promise<void> {\n if (retryAt) {\n await exec(\n `UPDATE ${TABLE} SET status = 'pending', run_at = $2, locked_at = NULL,\n locked_by = NULL, last_error = $3, updated_at = now()\n WHERE id = $1`,\n [id, retryAt.toISOString(), error]\n );\n return;\n }\n await exec(\n `UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,\n last_error = $2, updated_at = now()\n WHERE id = $1`,\n [id, error]\n );\n },\n\n async reapExpired(visibilityTimeoutMs: number): Promise<number> {\n // A worker killed while holding a job cannot release it, so nothing\n // but a timeout will ever free the row. Jobs that still have\n // attempts left go back to `pending`; the rest are dead-lettered\n // with an error that says what happened, because \"attempts: 3,\n // lastError: null\" is otherwise a genuinely baffling row to find.\n const seconds = Math.max(1, Math.round(visibilityTimeoutMs / 1000));\n\n const revived = await exec(\n `UPDATE ${TABLE} SET status = 'pending', locked_at = NULL, locked_by = NULL,\n last_error = 'Worker stopped responding; the job was reclaimed', updated_at = now()\n WHERE status = 'running'\n AND locked_at < now() - make_interval(secs => $1)\n AND attempts < max_attempts\n RETURNING id`,\n [seconds]\n );\n\n const buried = await exec(\n `UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,\n last_error = 'Worker stopped responding on the final attempt', updated_at = now()\n WHERE status = 'running'\n AND locked_at < now() - make_interval(secs => $1)\n AND attempts >= max_attempts\n RETURNING id`,\n [seconds]\n );\n\n const count = (revived?.length ?? 0) + (buried?.length ?? 0);\n if (count > 0) {\n logger.warn(\n `[jobs] Reclaimed ${count} job(s) from a worker that stopped responding ` +\n `(${revived?.length ?? 0} retryable, ${buried?.length ?? 0} dead-lettered)`\n );\n }\n return count;\n },\n\n async fetch(id: string): Promise<JobRecord | null> {\n const rows = await exec(`SELECT * FROM ${TABLE} WHERE id = $1`, [id]);\n const row = (rows as unknown as JobRow[])[0];\n return row ? toRecord(row) : null;\n }\n };\n}\n","import { randomUUID } from \"crypto\";\nimport { logger } from \"../utils/logger.js\";\nimport type { JobStore } from \"./job-store.js\";\nimport type { EnqueueOptions, JobHandler, JobQueueClient, JobQueueOptions, JobRecord } from \"./types.js\";\n\n/**\n * The worker: claim, run, record, repeat.\n *\n * Everything difficult about running jobs concurrently is in `job-store.ts`,\n * where one `UPDATE … FOR UPDATE SKIP LOCKED` does the arbitration. What is\n * left here is a loop and the decisions around a handler that throws.\n */\n\nconst DEFAULT_CONCURRENCY = 5;\nconst DEFAULT_POLL_INTERVAL_MS = 2_000;\nconst DEFAULT_VISIBILITY_TIMEOUT_MS = 5 * 60_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\n\n/** 1s, 5s, 25s, 125s … capped at an hour. */\nexport function defaultBackoff(attempt: number): number {\n return Math.min(1_000 * Math.pow(5, Math.max(0, attempt - 1)), 60 * 60_000);\n}\n\n/**\n * How often the reaper runs, relative to the visibility timeout.\n *\n * A quarter of it, so a stranded job waits at most 1.25× the timeout rather\n * than 2× — and so the sweep is not itself a per-poll query against a table\n * whose interesting rows are, almost always, none.\n */\nconst REAP_INTERVAL_FACTOR = 0.25;\n\nexport interface JobQueue extends JobQueueClient {\n start(): void;\n stop(): Promise<void>;\n /** Run one poll's worth of work and return how many jobs ran. For tests and for `/jobs/drain`. */\n runOnce(): Promise<number>;\n /** Registered after construction — how `tasks` from config and internal producers meet. */\n register<P = unknown>(task: string, handler: JobHandler<P>): void;\n isRunning(): boolean;\n}\n\nexport function createJobQueue(store: JobStore, options: JobQueueOptions = {}): JobQueue {\n const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const visibilityTimeoutMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS;\n const defaultMaxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const backoff = options.backoff ?? defaultBackoff;\n\n const handlers = new Map<string, JobHandler<never>>();\n for (const [task, handler] of Object.entries(options.tasks ?? {})) {\n handlers.set(task, handler);\n }\n\n // Identifies this process in `locked_by`. Purely diagnostic — the claim is\n // enforced by the row lock, not by this — but \"which pod had it when it\n // stopped\" is the first question anyone asks of a stuck job.\n const workerId = `${process.pid}-${randomUUID().slice(0, 8)}`;\n\n let timer: NodeJS.Timeout | null = null;\n let running = false;\n let draining = false;\n /** Resolves when the in-flight poll finishes, so `stop()` can wait for it. */\n let inFlight: Promise<unknown> = Promise.resolve();\n let lastReapAt = 0;\n\n async function runJob(job: JobRecord): Promise<void> {\n const handler = handlers.get(job.task);\n\n if (!handler) {\n // Not a failure. A rolling deploy runs old and new code at once, and\n // an instance that has not been updated yet must not burn the\n // attempts of a job belonging to one that has. Give the row back\n // and let a peer — or this process after its next deploy — take it.\n //\n // The attempt increment from the claim is deliberately not undone:\n // a task nobody in the fleet implements would otherwise cycle\n // forever, and this way it dead-letters after `maxAttempts` with an\n // error naming the task.\n logger.warn(`[jobs] No handler registered for task \"${job.task}\" — returning the job to the queue`);\n await store.fail(\n job.id,\n `No handler registered for task \"${job.task}\"`,\n job.attempts < job.maxAttempts ? new Date(Date.now() + backoff(job.attempts)) : null\n );\n return;\n }\n\n try {\n await handler({\n id: job.id,\n task: job.task,\n payload: job.payload as never,\n attempt: job.attempts,\n maxAttempts: job.maxAttempts\n } as never);\n await store.complete(job.id);\n } catch (error) {\n const message = error instanceof Error ? (error.stack ?? error.message) : String(error);\n const willRetry = job.attempts < job.maxAttempts;\n\n // Truncated, because `last_error` holds a stack and a queue that\n // accumulates megabytes of them is its own outage.\n await store.fail(job.id, message.slice(0, 4_000), willRetry ? new Date(Date.now() + backoff(job.attempts)) : null);\n\n if (willRetry) {\n logger.warn(`[jobs] \"${job.task}\" failed on attempt ${job.attempts}/${job.maxAttempts}; retrying`, { jobId: job.id });\n } else {\n // The last attempt is an error, not a warning: nothing else will\n // touch this job, and if nobody looks at the table it is simply\n // lost work.\n logger.error(`[jobs] \"${job.task}\" failed permanently after ${job.attempts} attempts`, { jobId: job.id, error: message });\n }\n }\n }\n\n async function poll(): Promise<number> {\n // The reaper, on its own cadence.\n const now = Date.now();\n if (now - lastReapAt > visibilityTimeoutMs * REAP_INTERVAL_FACTOR) {\n lastReapAt = now;\n try {\n await store.reapExpired(visibilityTimeoutMs);\n } catch (error) {\n logger.error(\"[jobs] Failed to reclaim expired jobs\", { error });\n }\n }\n\n const jobs = await store.claim(concurrency, workerId);\n if (jobs.length === 0) return 0;\n\n // Settled, not `all`: `runJob` handles its own errors, but a store\n // write failing inside it must not abandon this batch's siblings.\n await Promise.allSettled(jobs.map(runJob));\n return jobs.length;\n }\n\n function schedule(delayMs: number): void {\n if (!running) return;\n timer = setTimeout(() => {\n void tick();\n }, delayMs);\n // Never hold the process open. A queue with nothing to do should not be\n // the reason `rebase dev` will not exit.\n timer.unref?.();\n }\n\n async function tick(): Promise<void> {\n if (!running) return;\n const work = (async () => {\n try {\n return await poll();\n } catch (error) {\n logger.error(\"[jobs] Poll failed\", { error });\n return 0;\n }\n })();\n inFlight = work;\n const count = await work;\n\n // A full batch means there is probably more waiting, so go straight\n // back rather than sleeping through a backlog.\n schedule(count >= concurrency ? 0 : pollIntervalMs);\n }\n\n return {\n start(): void {\n if (running) return;\n running = true;\n logger.info(`[jobs] Worker started (concurrency ${concurrency}, poll ${pollIntervalMs}ms)`);\n schedule(0);\n },\n\n async stop(): Promise<void> {\n running = false;\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (draining) return;\n draining = true;\n // Jobs in flight keep their claim until they finish or the\n // visibility timeout expires, so waiting here is what turns a\n // graceful shutdown into \"no job runs twice\".\n await inFlight.catch(() => undefined);\n draining = false;\n },\n\n runOnce(): Promise<number> {\n return poll();\n },\n\n register<P = unknown>(task: string, handler: JobHandler<P>): void {\n if (handlers.has(task)) {\n logger.warn(`[jobs] Task \"${task}\" was already registered; the later handler wins`);\n }\n handlers.set(task, handler as JobHandler<never>);\n },\n\n isRunning(): boolean {\n return running;\n },\n\n async enqueue<P = unknown>(task: string, payload?: P, enqueueOptions: EnqueueOptions = {}): Promise<string | null> {\n return store.insert({\n task,\n payload: payload ?? null,\n runAt: new Date(Date.now() + (enqueueOptions.delayMs ?? 0)),\n maxAttempts: enqueueOptions.maxAttempts ?? defaultMaxAttempts,\n idempotencyKey: enqueueOptions.idempotencyKey\n });\n }\n };\n}\n","export { createJobStore } from \"./job-store\";\nexport type { JobStore } from \"./job-store\";\nexport { createJobQueue, defaultBackoff } from \"./job-queue\";\nexport type { JobQueue } from \"./job-queue\";\nexport type {\n EnqueueOptions,\n JobContext,\n JobHandler,\n JobQueueClient,\n JobQueueOptions,\n JobRecord,\n JobStatus\n} from \"./types\";\n"],"mappings":";;;;;;;;;;;;;;;;;;AAgBA,IAAM,QAAQ;;AAGd,IAAM,2BAA2B;;;;;;AAMjC,IAAM,wBAAwB;AAgB9B,SAAS,SAAS,KAAwB;CACtC,OAAO;EACH,IAAI,IAAI;EACR,MAAM,IAAI;EACV,SAAS,IAAI;EACb,QAAQ,IAAI;EACZ,OAAO,IAAI,KAAK,IAAI,MAAM,CAAC,CAAC,YAAY;EACxC,UAAU,OAAO,IAAI,QAAQ;EAC7B,aAAa,OAAO,IAAI,YAAY;EACpC,WAAW,IAAI;EACf,WAAW,IAAI,KAAK,IAAI,UAAU,CAAC,CAAC,YAAY;EAChD,WAAW,IAAI,KAAK,IAAI,UAAU,CAAC,CAAC,YAAY;CACpD;AACJ;;AAGA,SAAS,kBAAkB,KAAuB;CAC9C,OAAO,gBAAgB,MAAM,MAAM,EAAE,SAAS,OAAO;AACzD;AAsBA,SAAgB,eAAe,QAA0C;CACrE,MAAM,QAAQ,OAAO;CACrB,IAAI,CAAC,WAAW,KAAK,GAAG;EACpB,OAAO,KACH,+IAEJ;EACA;CACJ;CAKA,MAAM,WAAoB,SAAS,YAC/B,MAAM,WAAW,SAAS,SAAS,SAAS,EAAE,QAAQ,QAAQ,OAAO,IAAI,KAAA,CAAS;CACtF,MAAM,QAAQ,SAAiB,WAC3B,QAAQ,SAAS,SAAS,EAAE,OAAO,IAAI,KAAA,CAAS;CAEpD,MAAM,MAAM,sBAAsB,SAAS,MAAM;CAEjD,OAAO;EACH,MAAM,cAA6B;GAC/B,MAAM,IAAI,aAAa,0BAA0B,oCAAoC;GAErF,MAAM,IAAI,aAAa,YAAY,SAAS;6CACX,MAAM;;;;;;;;;;;;;;;aAetC;GAKD,MAAM,IAAI,aAAa,8BAA8B;;qBAE5C,MAAM;;aAEd;GAGD,MAAM,IAAI,aAAa,6BAA6B;;qBAE3C,MAAM;;aAEd;GAOD,MAAM,IAAI,aAAa,iCAAiC;;qBAE/C,MAAM;;aAEd;GAID,IAAI,MAFgB,IAAI,WAAW,KAAK,GAE7B;IACP,MAAM,IAAI,KAAK,uBAAuB,YAAY;KAC9C,MAAM,KACF,eAAe,MAAM;;2GAGrB,CAAC,0BAA0B,qBAAqB,CACpD;IACJ,CAAC;IAOD,MAAM,IAAI,KAAK,0CACX,KAAK,uBAAuB,UAAU,MAAM,CAAC,CAAC;IAElD,OAAO,KAAK,yBAAyB;GACzC,OACI,OAAO,MACH,YAAY,MAAM,2HAEtB;EAER;EAEA,MAAM,OAAO,KAA6B;GACtC,IAAI;IAaA,QAAQ,MAZW,KACf,eAAe,MAAM;;oCAGrB;KACI,IAAI;KACJ,KAAK,UAAU,IAAI,WAAW,IAAI;KAClC,IAAI,MAAM,YAAY;KACtB,IAAI;KACJ,IAAI,kBAAkB;IAC1B,CACJ,EAAA,GACe,EAAE,EAAE,MAAiB;GACxC,SAAS,OAAO;IAIZ,IAAI,kBAAkB,KAAK,GAAG,OAAO;IACrC,MAAM;GACV;EACJ;EAEA,MAAM,MAAM,OAAe,UAAwC;GA6B/D,QAAQ,MAjBW,KACf,UAAU,MAAM;;;;;;;sCAOM,MAAM;;;;;;+BAO5B,CAAC,OAAO,QAAQ,CACpB,EAAA,CACqC,IAAI,QAAQ;EACrD;EAEA,MAAM,SAAS,IAA2B;GACtC,MAAM,KACF,UAAU,MAAM;;iCAGhB,CAAC,EAAE,CACP;EACJ;EAEA,MAAM,KAAK,IAAY,OAAe,SAAqC;GACvE,IAAI,SAAS;IACT,MAAM,KACF,UAAU,MAAM;;qCAGhB;KAAC;KAAI,QAAQ,YAAY;KAAG;IAAK,CACrC;IACA;GACJ;GACA,MAAM,KACF,UAAU,MAAM;;iCAGhB,CAAC,IAAI,KAAK,CACd;EACJ;EAEA,MAAM,YAAY,qBAA8C;GAM5D,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,sBAAsB,GAAI,CAAC;GAElE,MAAM,UAAU,MAAM,KAClB,UAAU,MAAM;;;;;gCAMhB,CAAC,OAAO,CACZ;GAEA,MAAM,SAAS,MAAM,KACjB,UAAU,MAAM;;;;;gCAMhB,CAAC,OAAO,CACZ;GAEA,MAAM,SAAS,SAAS,UAAU,MAAM,QAAQ,UAAU;GAC1D,IAAI,QAAQ,GACR,OAAO,KACH,oBAAoB,MAAM,iDACtB,SAAS,UAAU,EAAE,cAAc,QAAQ,UAAU,EAAE,gBAC/D;GAEJ,OAAO;EACX;EAEA,MAAM,MAAM,IAAuC;GAE/C,MAAM,OAAO,MADM,KAAK,iBAAiB,MAAM,iBAAiB,CAAC,EAAE,CAAC,EAAA,CAC1B;GAC1C,OAAO,MAAM,SAAS,GAAG,IAAI;EACjC;CACJ;AACJ;;;;;;;;;;ACrSA,IAAM,sBAAsB;AAC5B,IAAM,2BAA2B;AACjC,IAAM,gCAAgC,IAAI;AAC1C,IAAM,uBAAuB;;AAG7B,SAAgB,eAAe,SAAyB;CACpD,OAAO,KAAK,IAAI,MAAQ,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,UAAU,CAAC,CAAC,GAAG,KAAK,GAAM;AAC9E;;;;;;;;AASA,IAAM,uBAAuB;AAY7B,SAAgB,eAAe,OAAiB,UAA2B,CAAC,GAAa;CACrF,MAAM,cAAc,QAAQ,eAAe;CAC3C,MAAM,iBAAiB,QAAQ,kBAAkB;CACjD,MAAM,sBAAsB,QAAQ,uBAAuB;CAC3D,MAAM,qBAAqB,QAAQ,eAAe;CAClD,MAAM,UAAU,QAAQ,WAAW;CAEnC,MAAM,2BAAW,IAAI,IAA+B;CACpD,KAAK,MAAM,CAAC,MAAM,YAAY,OAAO,QAAQ,QAAQ,SAAS,CAAC,CAAC,GAC5D,SAAS,IAAI,MAAM,OAAO;CAM9B,MAAM,WAAW,GAAG,QAAQ,IAAI,GAAG,WAAW,CAAC,CAAC,MAAM,GAAG,CAAC;CAE1D,IAAI,QAA+B;CACnC,IAAI,UAAU;CACd,IAAI,WAAW;;CAEf,IAAI,WAA6B,QAAQ,QAAQ;CACjD,IAAI,aAAa;CAEjB,eAAe,OAAO,KAA+B;EACjD,MAAM,UAAU,SAAS,IAAI,IAAI,IAAI;EAErC,IAAI,CAAC,SAAS;GAUV,OAAO,KAAK,0CAA0C,IAAI,KAAK,mCAAmC;GAClG,MAAM,MAAM,KACR,IAAI,IACJ,mCAAmC,IAAI,KAAK,IAC5C,IAAI,WAAW,IAAI,cAAc,IAAI,KAAK,KAAK,IAAI,IAAI,QAAQ,IAAI,QAAQ,CAAC,IAAI,IACpF;GACA;EACJ;EAEA,IAAI;GACA,MAAM,QAAQ;IACV,IAAI,IAAI;IACR,MAAM,IAAI;IACV,SAAS,IAAI;IACb,SAAS,IAAI;IACb,aAAa,IAAI;GACrB,CAAU;GACV,MAAM,MAAM,SAAS,IAAI,EAAE;EAC/B,SAAS,OAAO;GACZ,MAAM,UAAU,iBAAiB,QAAS,MAAM,SAAS,MAAM,UAAW,OAAO,KAAK;GACtF,MAAM,YAAY,IAAI,WAAW,IAAI;GAIrC,MAAM,MAAM,KAAK,IAAI,IAAI,QAAQ,MAAM,GAAG,GAAK,GAAG,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,QAAQ,IAAI,QAAQ,CAAC,IAAI,IAAI;GAEjH,IAAI,WACA,OAAO,KAAK,WAAW,IAAI,KAAK,sBAAsB,IAAI,SAAS,GAAG,IAAI,YAAY,aAAa,EAAE,OAAO,IAAI,GAAG,CAAC;QAKpH,OAAO,MAAM,WAAW,IAAI,KAAK,6BAA6B,IAAI,SAAS,YAAY;IAAE,OAAO,IAAI;IAAI,OAAO;GAAQ,CAAC;EAEhI;CACJ;CAEA,eAAe,OAAwB;EAEnC,MAAM,MAAM,KAAK,IAAI;EACrB,IAAI,MAAM,aAAa,sBAAsB,sBAAsB;GAC/D,aAAa;GACb,IAAI;IACA,MAAM,MAAM,YAAY,mBAAmB;GAC/C,SAAS,OAAO;IACZ,OAAO,MAAM,yCAAyC,EAAE,MAAM,CAAC;GACnE;EACJ;EAEA,MAAM,OAAO,MAAM,MAAM,MAAM,aAAa,QAAQ;EACpD,IAAI,KAAK,WAAW,GAAG,OAAO;EAI9B,MAAM,QAAQ,WAAW,KAAK,IAAI,MAAM,CAAC;EACzC,OAAO,KAAK;CAChB;CAEA,SAAS,SAAS,SAAuB;EACrC,IAAI,CAAC,SAAS;EACd,QAAQ,iBAAiB;GACrB,KAAU;EACd,GAAG,OAAO;EAGV,MAAM,QAAQ;CAClB;CAEA,eAAe,OAAsB;EACjC,IAAI,CAAC,SAAS;EACd,MAAM,QAAQ,YAAY;GACtB,IAAI;IACA,OAAO,MAAM,KAAK;GACtB,SAAS,OAAO;IACZ,OAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC;IAC5C,OAAO;GACX;EACJ,EAAA,CAAG;EACH,WAAW;EAKX,SAAS,MAJW,QAIF,cAAc,IAAI,cAAc;CACtD;CAEA,OAAO;EACH,QAAc;GACV,IAAI,SAAS;GACb,UAAU;GACV,OAAO,KAAK,sCAAsC,YAAY,SAAS,eAAe,IAAI;GAC1F,SAAS,CAAC;EACd;EAEA,MAAM,OAAsB;GACxB,UAAU;GACV,IAAI,OAAO;IACP,aAAa,KAAK;IAClB,QAAQ;GACZ;GACA,IAAI,UAAU;GACd,WAAW;GAIX,MAAM,SAAS,YAAY,KAAA,CAAS;GACpC,WAAW;EACf;EAEA,UAA2B;GACvB,OAAO,KAAK;EAChB;EAEA,SAAsB,MAAc,SAA8B;GAC9D,IAAI,SAAS,IAAI,IAAI,GACjB,OAAO,KAAK,gBAAgB,KAAK,iDAAiD;GAEtF,SAAS,IAAI,MAAM,OAA4B;EACnD;EAEA,YAAqB;GACjB,OAAO;EACX;EAEA,MAAM,QAAqB,MAAc,SAAa,iBAAiC,CAAC,GAA2B;GAC/G,OAAO,MAAM,OAAO;IAChB;IACA,SAAS,WAAW;IACpB,OAAO,IAAI,KAAK,KAAK,IAAI,KAAK,eAAe,WAAW,EAAE;IAC1D,aAAa,eAAe,eAAe;IAC3C,gBAAgB,eAAe;GACnC,CAAC;EACL;CACJ;AACJ"}
@@ -4,7 +4,7 @@ __createRequire(import.meta.url);
4
4
  import { i as __toESM, n as __exportAll, r as __require, t as __commonJSMin } from "./rolldown-runtime-DSJWtz9O.js";
5
5
  import "./src-Cz9nMgUR.js";
6
6
  import { t as logger } from "./logger-DfvF_8r-.js";
7
- import { createHash, randomBytes } from "crypto";
7
+ import { createHash, createPrivateKey, createPublicKey, randomBytes } from "crypto";
8
8
  import path from "node:path";
9
9
  //#region ../types/src/types/storage_source.ts
10
10
  /**
@@ -4019,7 +4019,7 @@ var require_sign = /* @__PURE__ */ __commonJSMin(((exports, module) => {
4019
4019
  var isPlainObject = require_lodash_isplainobject();
4020
4020
  var isString = require_lodash_isstring();
4021
4021
  var once = require_lodash_once();
4022
- var { KeyObject, createSecretKey, createPrivateKey } = __require("crypto");
4022
+ var { KeyObject, createSecretKey, createPrivateKey: createPrivateKey$1 } = __require("crypto");
4023
4023
  var SUPPORTED_ALGS = [
4024
4024
  "RS256",
4025
4025
  "RS384",
@@ -4160,7 +4160,7 @@ var require_sign = /* @__PURE__ */ __commonJSMin(((exports, module) => {
4160
4160
  }
4161
4161
  if (!secretOrPrivateKey && options.algorithm !== "none") return failure(/* @__PURE__ */ new Error("secretOrPrivateKey must have a value"));
4162
4162
  if (secretOrPrivateKey != null && !(secretOrPrivateKey instanceof KeyObject)) try {
4163
- secretOrPrivateKey = createPrivateKey(secretOrPrivateKey);
4163
+ secretOrPrivateKey = createPrivateKey$1(secretOrPrivateKey);
4164
4164
  } catch (_) {
4165
4165
  try {
4166
4166
  secretOrPrivateKey = createSecretKey(typeof secretOrPrivateKey === "string" ? Buffer.from(secretOrPrivateKey) : secretOrPrivateKey);
@@ -4415,6 +4415,111 @@ function canonicalStorageBucket(rawBucket) {
4415
4415
  return rawBucket;
4416
4416
  }
4417
4417
  //#endregion
4418
+ //#region src/auth/jwt-keys.ts
4419
+ /**
4420
+ * The algorithm a key type implies.
4421
+ *
4422
+ * Rejecting anything else here rather than defaulting is deliberate: an Ed25519
4423
+ * key configured by someone expecting it to work would otherwise be signed with
4424
+ * `RS256` in the header and fail verification everywhere, at runtime, on tokens
4425
+ * already handed to users.
4426
+ */
4427
+ function algorithmForKey(key, kid) {
4428
+ switch (key.asymmetricKeyType) {
4429
+ case "rsa":
4430
+ case "rsa-pss": return "RS256";
4431
+ case "ec": return "ES256";
4432
+ default: throw new Error(`JWT signing key "${kid}" is a ${key.asymmetricKeyType ?? "non-asymmetric"} key. Supported: RSA (RS256) and EC P-256 (ES256). Generate one with: openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out jwt-key.pem`);
4433
+ }
4434
+ }
4435
+ /**
4436
+ * An EC key of the wrong curve is the other way a key parses cleanly and then
4437
+ * fails to verify: `ES256` means P-256 specifically, and a P-384 key signs a
4438
+ * token whose header says P-256's algorithm.
4439
+ */
4440
+ function assertCurveMatches(publicKey, kid) {
4441
+ const jwk = publicKey.export({ format: "jwk" });
4442
+ if (jwk.crv && jwk.crv !== "P-256") throw new Error(`JWT signing key "${kid}" uses curve ${jwk.crv}, but ES256 requires P-256. Generate one with: openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out jwt-key.pem`);
4443
+ }
4444
+ /**
4445
+ * Parse the configured keys, deriving each public half from its private key.
4446
+ *
4447
+ * Throws on anything malformed. This runs at boot, from `configureJwt`, so a
4448
+ * key that cannot sign takes the process down at start rather than at the first
4449
+ * login — the same bargain every other credential in this file makes.
4450
+ */
4451
+ function resolveSigningKeys(configs) {
4452
+ const seen = /* @__PURE__ */ new Set();
4453
+ return configs.map((config) => {
4454
+ if (!config.kid) throw new Error("Every JWT signing key needs a `kid`; it is what the JWKS and the token header agree on.");
4455
+ if (seen.has(config.kid)) throw new Error(`Duplicate JWT signing key id "${config.kid}". A \`kid\` selects exactly one key at verification time, so two keys sharing one is a token that verifies or does not depending on order.`);
4456
+ seen.add(config.kid);
4457
+ let privateKey;
4458
+ try {
4459
+ privateKey = createPrivateKey(config.privateKey);
4460
+ } catch (error) {
4461
+ throw new Error(`JWT signing key "${config.kid}" is not a readable PEM private key: ${error instanceof Error ? error.message : String(error)}`);
4462
+ }
4463
+ const publicKey = createPublicKey(privateKey);
4464
+ const derived = algorithmForKey(privateKey, config.kid);
4465
+ const algorithm = config.algorithm ?? derived;
4466
+ if (algorithm !== derived) throw new Error(`JWT signing key "${config.kid}" is declared as ${algorithm} but is a ${privateKey.asymmetricKeyType} key, which signs ${derived}.`);
4467
+ if (algorithm === "ES256") assertCurveMatches(publicKey, config.kid);
4468
+ return {
4469
+ kid: config.kid,
4470
+ algorithm,
4471
+ privateKey,
4472
+ publicKey
4473
+ };
4474
+ });
4475
+ }
4476
+ /**
4477
+ * The key a token names, or `null` if it names none we hold.
4478
+ *
4479
+ * The returned algorithm is the *key's*, and the caller must verify with that
4480
+ * one alone. See the module docblock for what happens otherwise.
4481
+ */
4482
+ function resolveVerificationKey(keys, kid) {
4483
+ if (!kid) return null;
4484
+ return keys.find((key) => key.kid === kid) ?? null;
4485
+ }
4486
+ /**
4487
+ * A PEM as an environment variable can actually carry it.
4488
+ *
4489
+ * A PEM is multi-line and environment variables are not, so every deployment
4490
+ * tool solves it differently: `.env` files and most secret managers escape the
4491
+ * newlines to `\n`, Kubernetes and Docker secrets pass the bytes through
4492
+ * intact, and CI systems that mangle both are usually fed base64. All three
4493
+ * arrive here, and guessing wrong produces "not a readable PEM private key" at
4494
+ * boot with a key the operator can see is perfectly valid.
4495
+ *
4496
+ * Detection is on content, not on a flag: a PEM says so on its first line, and
4497
+ * anything that does not is tried as base64.
4498
+ */
4499
+ function normalizePemFromEnv(value) {
4500
+ const trimmed = value.trim();
4501
+ if (trimmed.includes("-----BEGIN")) return trimmed.replace(/\\n/g, "\n");
4502
+ return Buffer.from(trimmed, "base64").toString("utf8");
4503
+ }
4504
+ /**
4505
+ * The public halves, in JWKS form.
4506
+ *
4507
+ * Node exports a JWK containing only public parameters for a public
4508
+ * `KeyObject` — no `d`, no primes — so the private material cannot leak
4509
+ * through this path even if a private key were passed by mistake. The keys are
4510
+ * derived from `publicKey` regardless, and this is asserted in the tests,
4511
+ * because "cannot" is worth checking on the one endpoint whose entire job is to
4512
+ * be world-readable.
4513
+ */
4514
+ function toJwks(keys) {
4515
+ return { keys: keys.map((key) => ({
4516
+ ...key.publicKey.export({ format: "jwk" }),
4517
+ kid: key.kid,
4518
+ alg: key.algorithm,
4519
+ use: "sig"
4520
+ })) };
4521
+ }
4522
+ //#endregion
4418
4523
  //#region src/auth/jwt.ts
4419
4524
  var jwt_exports = /* @__PURE__ */ __exportAll({
4420
4525
  MAX_COOKIE_AGE_MS: () => MAX_COOKIE_AGE_MS,
@@ -4426,8 +4531,10 @@ var jwt_exports = /* @__PURE__ */ __exportAll({
4426
4531
  generateRefreshToken: () => generateRefreshToken,
4427
4532
  getAccessTokenExpiry: () => getAccessTokenExpiry,
4428
4533
  getAccessTokenExpiryMs: () => getAccessTokenExpiryMs,
4534
+ getJwks: () => getJwks,
4429
4535
  getRefreshTokenExpiry: () => getRefreshTokenExpiry,
4430
4536
  getRefreshTokenTtlMs: () => getRefreshTokenTtlMs,
4537
+ hasAsymmetricSigningKey: () => hasAsymmetricSigningKey,
4431
4538
  hashRefreshToken: () => hashRefreshToken,
4432
4539
  isJwtConfigured: () => isJwtConfigured,
4433
4540
  verifyAccessToken: () => verifyAccessToken,
@@ -4440,6 +4547,15 @@ var jwtConfig = {
4440
4547
  refreshExpiresIn: "400d"
4441
4548
  };
4442
4549
  /**
4550
+ * The parsed signing keys, and which one mints new access tokens.
4551
+ *
4552
+ * Both are module state beside `jwtConfig` for the same reason it is: every
4553
+ * signing and verifying path in the server reads them through this file, and
4554
+ * there is exactly one JWT configuration per process.
4555
+ */
4556
+ var signingKeys = [];
4557
+ var activeSigningKey = null;
4558
+ /**
4443
4559
  * Configure JWT settings - call this during initialization.
4444
4560
  * Validates the secret strength to prevent deployment with default/weak secrets.
4445
4561
  */
@@ -4467,10 +4583,33 @@ function configureJwt(config) {
4467
4583
  ]);
4468
4584
  if (!config.secret || config.secret.length < 32) throw new Error("JWT secret is too short. Must be at least 32 characters. Generate one with: node -e \"logger.info(require('crypto').randomBytes(48).toString('base64'))\"");
4469
4585
  if (weakSecrets.has(config.secret.toLowerCase())) throw new Error("JWT secret is a known default/weak value. Please use a strong, randomly generated secret. Generate one with: node -e \"logger.info(require('crypto').randomBytes(48).toString('base64'))\"");
4586
+ const resolved = config.signingKeys ? resolveSigningKeys(config.signingKeys) : [];
4587
+ let active = null;
4588
+ if (resolved.length > 0) if (config.activeKid) {
4589
+ active = resolved.find((key) => key.kid === config.activeKid) ?? null;
4590
+ if (!active) throw new Error(`auth.activeKid is "${config.activeKid}", which is not among the configured signing keys (${resolved.map((k) => `"${k.kid}"`).join(", ")}). Signing with a key nobody published produces tokens no verifier can check.`);
4591
+ } else active = resolved[0];
4470
4592
  jwtConfig = {
4471
4593
  ...jwtConfig,
4472
4594
  ...config
4473
4595
  };
4596
+ signingKeys = resolved;
4597
+ activeSigningKey = active;
4598
+ }
4599
+ /**
4600
+ * The public keys, in JWKS form, for `/.well-known/jwks.json`.
4601
+ *
4602
+ * An empty `keys` array on a backend with no asymmetric keys configured is the
4603
+ * correct answer rather than a 404: it says "this issuer publishes none",
4604
+ * which a verifier can act on, where a 404 is indistinguishable from a
4605
+ * misconfigured URL.
4606
+ */
4607
+ function getJwks() {
4608
+ return toJwks(signingKeys);
4609
+ }
4610
+ /** Is this backend signing access tokens asymmetrically? */
4611
+ function hasAsymmetricSigningKey() {
4612
+ return activeSigningKey !== null;
4474
4613
  }
4475
4614
  /**
4476
4615
  * Has this server been given a JWT secret?
@@ -4497,6 +4636,11 @@ function generateAccessToken(uid, roles, aal = "aal1", customClaims) {
4497
4636
  ...customClaims,
4498
4637
  aal
4499
4638
  };
4639
+ if (activeSigningKey) return import_jsonwebtoken.default.sign(payload, activeSigningKey.privateKey, {
4640
+ expiresIn: jwtConfig.accessExpiresIn,
4641
+ algorithm: activeSigningKey.algorithm,
4642
+ keyid: activeSigningKey.kid
4643
+ });
4500
4644
  return import_jsonwebtoken.default.sign(payload, jwtConfig.secret, {
4501
4645
  expiresIn: jwtConfig.accessExpiresIn,
4502
4646
  algorithm: "HS256"
@@ -4540,7 +4684,9 @@ function getAccessTokenExpiry() {
4540
4684
  function verifyAccessToken(token) {
4541
4685
  if (!jwtConfig.secret) throw new Error("JWT secret not configured. Call configureJwt() first.");
4542
4686
  try {
4543
- const decoded = import_jsonwebtoken.default.verify(token, jwtConfig.secret, { algorithms: ["HS256"] });
4687
+ const header = import_jsonwebtoken.default.decode(token, { complete: true })?.header;
4688
+ const namedKey = resolveVerificationKey(signingKeys, header?.kid);
4689
+ const decoded = namedKey ? import_jsonwebtoken.default.verify(token, namedKey.publicKey, { algorithms: [namedKey.algorithm] }) : import_jsonwebtoken.default.verify(token, jwtConfig.secret, { algorithms: ["HS256"] });
4544
4690
  if (decoded.purpose) {
4545
4691
  logger.error("[JWT] Verification failed: a purpose-scoped token is not an access token", { purpose: decoded.purpose });
4546
4692
  return null;
@@ -4712,6 +4858,6 @@ function verifyDownloadToken(token) {
4712
4858
  }
4713
4859
  }
4714
4860
  //#endregion
4715
- export { DEFAULT_STORAGE_SOURCE_KEY as C, storageEnvSuffix as E, require_jsonwebtoken as S, normalizeStorageSources as T, InvalidStorageKeyError as _, generateMfaPendingToken as a, canonicalStorageKey as b, getRefreshTokenExpiry as c, isJwtConfigured as d, jwt_exports as f, InvalidStorageBucketError as g, verifyMfaPendingToken as h, generateDownloadToken as i, getRefreshTokenTtlMs as l, verifyDownloadToken as m, configureJwt as n, generateRefreshToken as o, verifyAccessToken as p, generateAccessToken as r, getAccessTokenExpiry as s, MAX_COOKIE_AGE_MS as t, hashRefreshToken as u, canonicalStorageBucket as v, findStorageSuffixCollision as w, tryCanonicalStorageKey as x, canonicalStorageId as y };
4861
+ export { canonicalStorageKey as C, findStorageSuffixCollision as D, DEFAULT_STORAGE_SOURCE_KEY as E, normalizeStorageSources as O, canonicalStorageId as S, require_jsonwebtoken as T, verifyMfaPendingToken as _, generateMfaPendingToken as a, InvalidStorageKeyError as b, getJwks as c, hasAsymmetricSigningKey as d, hashRefreshToken as f, verifyDownloadToken as g, verifyAccessToken as h, generateDownloadToken as i, storageEnvSuffix as k, getRefreshTokenExpiry as l, jwt_exports as m, configureJwt as n, generateRefreshToken as o, isJwtConfigured as p, generateAccessToken as r, getAccessTokenExpiry as s, MAX_COOKIE_AGE_MS as t, getRefreshTokenTtlMs as u, normalizePemFromEnv as v, tryCanonicalStorageKey as w, canonicalStorageBucket as x, InvalidStorageBucketError as y };
4716
4862
 
4717
- //# sourceMappingURL=jwt-CYGFT0ih.js.map
4863
+ //# sourceMappingURL=jwt-VJyXTdQQ.js.map