@rebasepro/server 0.20.0 → 0.21.0

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 (68) hide show
  1. package/dist/{admin_block-0Xu0r6eZ.js → admin_block-DxKLmdiv.js} +26 -8
  2. package/dist/{admin_block-0Xu0r6eZ.js.map → admin_block-DxKLmdiv.js.map} +1 -1
  3. package/dist/api/ast-schema-editor.d.ts +15 -0
  4. package/dist/api/rest/api-generator.d.ts +28 -0
  5. package/dist/{ast-schema-editor-C6mDz0XN.js → ast-schema-editor-CslO8Oje.js} +24 -4
  6. package/dist/ast-schema-editor-CslO8Oje.js.map +1 -0
  7. package/dist/auth/jwt.d.ts +67 -0
  8. package/dist/auth/rls-scope.d.ts +14 -0
  9. package/dist/{auth-DJsLXsCR.js → auth-gzoRjnuh.js} +24 -12
  10. package/dist/auth-gzoRjnuh.js.map +1 -0
  11. package/dist/boot/env.d.ts +8 -2
  12. package/dist/boot/fetch-bundle.d.ts +19 -0
  13. package/dist/boot/role.d.ts +2 -0
  14. package/dist/contract-routes-eLxV0le1.js.map +1 -1
  15. package/dist/{cron-routes-Bfwni8Zg.js → cron-routes-BamgO0Xr.js} +2 -2
  16. package/dist/{cron-routes-Bfwni8Zg.js.map → cron-routes-BamgO0Xr.js.map} +1 -1
  17. package/dist/{cron-store-Bsiw4Q6u.js → cron-store-yXOyIEmn.js} +2 -3
  18. package/dist/{cron-store-Bsiw4Q6u.js.map → cron-store-yXOyIEmn.js.map} +1 -1
  19. package/dist/db/ambient-transaction.d.ts +48 -0
  20. package/dist/ddl-bootstrap-CfNvxMuK.js.map +1 -1
  21. package/dist/deploy/pod-contract.d.ts +1 -1
  22. package/dist/errors-DMImyqyR.js.map +1 -1
  23. package/dist/functions/index.js.map +1 -1
  24. package/dist/{history-recorder-r5_IzSHK.js → history-recorder-hBGuXcZc.js} +2 -2
  25. package/dist/{history-recorder-r5_IzSHK.js.map → history-recorder-hBGuXcZc.js.map} +1 -1
  26. package/dist/{history-store-D4RVK-uZ.js → history-store-C9EtGtoL.js} +5 -6
  27. package/dist/history-store-C9EtGtoL.js.map +1 -0
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.es.js +2266 -93
  30. package/dist/index.es.js.map +1 -1
  31. package/dist/init/callback-client.d.ts +16 -0
  32. package/dist/init/surfaces.d.ts +37 -7
  33. package/dist/jobs/types.d.ts +5 -0
  34. package/dist/{jobs-CW5lm_Ix.js → jobs-DZ8DLLcU.js} +82 -13
  35. package/dist/jobs-DZ8DLLcU.js.map +1 -0
  36. package/dist/{jwt-DATvkKB_.js → jwt-C4OW-DNq.js} +136 -4
  37. package/dist/jwt-C4OW-DNq.js.map +1 -0
  38. package/dist/logger-DO2PZc4i.js.map +1 -1
  39. package/dist/mcp/consent-page.d.ts +75 -0
  40. package/dist/mcp/mcp-routes.d.ts +52 -0
  41. package/dist/mcp/mcp-tools.d.ts +51 -0
  42. package/dist/mcp/oauth-metadata.d.ts +123 -0
  43. package/dist/mcp/oauth-routes.d.ts +52 -0
  44. package/dist/mcp/oauth-store.d.ts +157 -0
  45. package/dist/metrics/index.d.ts +16 -1
  46. package/dist/{openapi-generator-DGyLbISS.js → openapi-generator-CAFKF36E.js} +2 -8
  47. package/dist/openapi-generator-CAFKF36E.js.map +1 -0
  48. package/dist/{query-parser-BQiPZrM-.js → query-parser-uqzkZ82i.js} +2 -2
  49. package/dist/{query-parser-BQiPZrM-.js.map → query-parser-uqzkZ82i.js.map} +1 -1
  50. package/dist/{schema-editor-routes-C3TLZqAC.js → schema-editor-routes-BKOmdf4M.js} +2 -2
  51. package/dist/{schema-editor-routes-C3TLZqAC.js.map → schema-editor-routes-BKOmdf4M.js.map} +1 -1
  52. package/dist/services/webhook-service.d.ts +11 -8
  53. package/dist/sql-rows-C6GEc2oE.js +45 -0
  54. package/dist/sql-rows-C6GEc2oE.js.map +1 -0
  55. package/dist/src-Br6ARbs6.js.map +1 -1
  56. package/dist/{src-DqZ9YiGA.js → src-DW1cQ_28.js} +261 -17
  57. package/dist/src-DW1cQ_28.js.map +1 -0
  58. package/dist/utils/portable-crypto.d.ts +8 -0
  59. package/package.json +5 -5
  60. package/dist/ast-schema-editor-C6mDz0XN.js.map +0 -1
  61. package/dist/auth-DJsLXsCR.js.map +0 -1
  62. package/dist/history-store-D4RVK-uZ.js.map +0 -1
  63. package/dist/internal-tables-DYVcFFSv.js +0 -117
  64. package/dist/internal-tables-DYVcFFSv.js.map +0 -1
  65. package/dist/jobs-CW5lm_Ix.js.map +0 -1
  66. package/dist/jwt-DATvkKB_.js.map +0 -1
  67. package/dist/openapi-generator-DGyLbISS.js.map +0 -1
  68. package/dist/src-DqZ9YiGA.js.map +0 -1
@@ -0,0 +1,16 @@
1
+ import type { InitializedDriver } from "@rebasepro/types";
2
+ /**
3
+ * Give every data source's driver the server client its callbacks read as
4
+ * `context.client`.
5
+ *
6
+ * A driver is created before the client — the client depends on the mounted
7
+ * Hono app — so the client is injected afterwards. It used to be injected into
8
+ * the DEFAULT driver only. Each declared `database(...)` gets a bootstrapper and
9
+ * a driver of its own, so a collection on a second source ran its callbacks with
10
+ * `context.client === undefined`, typed as present through a cast in the driver:
11
+ * a `beforeSave` calling `context.client.functions.invoke(...)` worked on the
12
+ * default database and threw on the other one.
13
+ *
14
+ * Returns how many drivers took the client, which is what a test can hold.
15
+ */
16
+ export declare function injectCallbackClient(results: Iterable<InitializedDriver>, client: unknown): number;
@@ -32,6 +32,12 @@
32
32
  * - `cron` — `${basePath}/cron`, the cron *admin* surface. Whether jobs actually
33
33
  * fire is {@link RuntimeOwnership.cronScheduler}, not this.
34
34
  * - `meta` — `${basePath}/meta`, the contract endpoint a generated SDK reads.
35
+ * - `mcp` — `/mcp` for AI clients, plus the OAuth authorization server that
36
+ * issues its tokens (`${basePath}/oauth`) and the two `.well-known` documents
37
+ * they are discovered through. One name for all three because they are
38
+ * useless apart: the endpoint without the authorization server cannot be
39
+ * reached, and the authorization server without the endpoint issues tokens
40
+ * for a resource that does not answer.
35
41
  * - `realtime` — the websocket server, and with it this process's appetite for
36
42
  * change events: whether it opens a `LISTEN` connection and consumes CDC at
37
43
  * all. Not a Hono route like the others — the websocket server is attached to
@@ -40,7 +46,7 @@
40
46
  * `worker` pod hold a dedicated database connection to deliver events to
41
47
  * nobody.
42
48
  */
43
- export type RuntimeSurface = "auth" | "data" | "storage" | "admin" | "functions" | "cron" | "meta" | "realtime";
49
+ export type RuntimeSurface = "auth" | "data" | "storage" | "admin" | "functions" | "cron" | "meta" | "realtime" | "mcp";
44
50
  /** Every surface, in a stable order. */
45
51
  export declare const ALL_RUNTIME_SURFACES: readonly RuntimeSurface[];
46
52
  /** A fully-resolved answer for every surface. */
@@ -82,15 +88,39 @@ export type RuntimeSurfaceOptions = Partial<ResolvedSurfaces>;
82
88
  /** Ownership as a caller may express it: name only what differs. */
83
89
  export type RuntimeOwnershipOptions = Partial<ResolvedOwnership>;
84
90
  /**
85
- * Resolve a partial surface set against the default, which is everything on.
91
+ * Resolve a partial surface set against the default, which is everything on
92
+ * except {@link DEFAULT_OFF}.
86
93
  *
87
- * Defaulting to on rather than off is the whole compatibility story: a caller
88
- * that says nothing gets the process this server has always booted, and a
89
- * surface added later is mounted by every existing deployment without anyone
90
- * editing a list.
94
+ * Defaulting to on rather than off is the compatibility story: a caller that
95
+ * says nothing gets the process this server has always booted, and a surface
96
+ * added later is mounted by every existing deployment without anyone editing a
97
+ * list. See {@link DEFAULT_OFF} for the one place that rule is wrong and why.
91
98
  */
92
99
  export declare function resolveSurfaces(options?: RuntimeSurfaceOptions): ResolvedSurfaces;
93
100
  /** Resolve partial ownership against the default, which is owning everything. */
94
101
  export declare function resolveOwnership(options?: RuntimeOwnershipOptions): ResolvedOwnership;
95
- /** The surfaces a process is not serving, for a boot log line. */
102
+ /** The surfaces a process is not serving. Literal: off is off, however it got there. */
96
103
  export declare function disabledSurfaces(resolved: ResolvedSurfaces): RuntimeSurface[];
104
+ /**
105
+ * The surfaces whose absence is worth a line in the boot log.
106
+ *
107
+ * NOT the same question as {@link disabledSurfaces}, and conflating them made
108
+ * every deployment in the fleet announce itself as partial.
109
+ *
110
+ * The line exists to tell one thing from another: a request answering 404
111
+ * because this process was never meant to serve it is, from the client side,
112
+ * indistinguishable from a broken deployment. That is worth saying — about a
113
+ * surface somebody TURNED OFF.
114
+ *
115
+ * A {@link DEFAULT_OFF} surface is a different fact. Nobody turned `mcp` off;
116
+ * it has never been on, and a deployment without it is not partial, it is the
117
+ * deployment. Counting it made `offSurfaces` non-empty on every single boot —
118
+ * managed tenant, self-hosted container, local dev — so a line that means
119
+ * "somebody trimmed this process" started appearing on processes nobody had
120
+ * trimmed. `split-roles-e2e.test.ts` calls this "the compatibility assertion for
121
+ * the whole feature", and it has been failing since the MCP surface landed.
122
+ *
123
+ * An operator who explicitly disables a default-off surface still gets no line,
124
+ * which is right: the process serves exactly what it would have served anyway.
125
+ */
126
+ export declare function trimmedSurfaces(resolved: ResolvedSurfaces): RuntimeSurface[];
@@ -13,6 +13,11 @@
13
13
  * can look at it. The table is the queue: there is nothing to install, nothing
14
14
  * to run alongside Postgres, and a job enqueued in a transaction that rolls
15
15
  * back was never enqueued.
16
+ *
17
+ * That last clause is kept by the store, not by luck: an enqueue from inside a
18
+ * write goes on that write's transaction through `rebase.enqueue_job` (see
19
+ * `job-store.ts` and `db/ambient-transaction.ts`). For a long time it went on
20
+ * its own connection instead, and the promise was simply untrue.
16
21
  */
17
22
  /** Where a job is in its life. */
18
23
  export type JobStatus =
@@ -3,12 +3,20 @@ import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
5
  import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
6
- import "./src-DqZ9YiGA.js";
6
+ import { N as REBASE_USER_ROLE, P as revokeInternalTableSql } from "./src-DW1cQ_28.js";
7
7
  import "./src-Br6ARbs6.js";
8
8
  import { n as createDdlBootstrapper, o as isSQLAdmin, r as hasInCauseChain } from "./ddl-bootstrap-CfNvxMuK.js";
9
- import { t as revokeInternalTableSql } from "./internal-tables-DYVcFFSv.js";
9
+ import { n as sqlRows, t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
10
10
  import { r as logger } from "./logger-DO2PZc4i.js";
11
11
  import { randomUUID } from "crypto";
12
+ //#region src/db/ambient-transaction.ts
13
+ /** Frozen — see above. `server-postgres/src/services/write-transaction-scope.ts` writes it. */
14
+ var RESOLVER_SLOT = Symbol.for("rebase.server.ambientTransactionResolver");
15
+ /** The write transaction the caller is inside, or `undefined` outside one. */
16
+ function currentAmbientTransaction() {
17
+ return globalThis[RESOLVER_SLOT]?.();
18
+ }
19
+ //#endregion
12
20
  //#region src/jobs/job-store.ts
13
21
  /**
14
22
  * The queue's storage, as SQL against `rebase.jobs`.
@@ -45,6 +53,30 @@ function toRecord(row) {
45
53
  function isUniqueViolation(err) {
46
54
  return hasInCauseChain(err, (e) => e.code === "23505");
47
55
  }
56
+ /**
57
+ * How a job gets into `rebase.jobs` from inside somebody else's transaction.
58
+ *
59
+ * An enqueue from a collection callback has to ride the write's transaction —
60
+ * committed with it, discarded with it, invisible to workers until then — and
61
+ * that transaction runs as the request's restricted role, which has no access
62
+ * to `rebase.jobs` at all (see the revoke in `ensureTable`). This function is
63
+ * the one door: `SECURITY DEFINER`, so the insert runs with the owner's rights
64
+ * for that one statement and the transaction's role never changes. Switching
65
+ * the role for the insert instead would elevate the whole connection for as
66
+ * long as the insert took, and a callback's un-awaited statement would run
67
+ * in that window as the owner.
68
+ *
69
+ * The door is narrow on purpose. It takes the five columns an enqueue sets and
70
+ * nothing else, so it cannot claim, complete or rewrite a job — and only the
71
+ * request role is granted it, not PUBLIC. That role runs statements the server
72
+ * writes; nobody outside gets SQL as it.
73
+ *
74
+ * `ON CONFLICT DO NOTHING` rather than a caught unique violation: an error
75
+ * inside a transaction aborts the whole transaction, so an idempotency clash
76
+ * would have failed the customer's write. A clash returns no row, hence NULL.
77
+ */
78
+ var ENQUEUE_FUNCTION = "rebase.enqueue_job";
79
+ var ENQUEUE_SIGNATURE = `${ENQUEUE_FUNCTION}(text, jsonb, timestamptz, integer, text)`;
48
80
  function createJobStore(driver) {
49
81
  const admin = driver.admin;
50
82
  if (!isSQLAdmin(admin)) {
@@ -54,6 +86,13 @@ function createJobStore(driver) {
54
86
  const execRaw = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
55
87
  const exec = (sqlText, params) => execRaw(sqlText, params ? { params } : void 0);
56
88
  const ddl = createDdlBootstrapper(execRaw, "jobs");
89
+ /**
90
+ * Whether {@link ENQUEUE_FUNCTION} exists, so an enqueue inside a write can
91
+ * ride its transaction. Decided at boot rather than assumed: a database
92
+ * that refused the function would otherwise fail every write whose callback
93
+ * enqueues, where falling back to the old behaviour only loses atomicity.
94
+ */
95
+ let enqueueInTransaction = false;
57
96
  return {
58
97
  async ensureTable() {
59
98
  await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
@@ -96,27 +135,57 @@ function createJobStore(driver) {
96
135
  OR (status = 'failed' AND updated_at < now() - make_interval(days => $2))`, [SUCCEEDED_RETENTION_DAYS, FAILED_RETENTION_DAYS]);
97
136
  });
98
137
  await ddl.step("Revoking end-user access to jobs", () => exec(revokeInternalTableSql("rebase", "jobs")));
138
+ await ddl.ensureObject(`Creating ${ENQUEUE_FUNCTION}`, `
139
+ CREATE OR REPLACE FUNCTION ${ENQUEUE_FUNCTION}(
140
+ p_task text, p_payload jsonb, p_run_at timestamptz,
141
+ p_max_attempts integer, p_idempotency_key text
142
+ ) RETURNS text
143
+ LANGUAGE sql
144
+ SECURITY DEFINER
145
+ SET search_path = pg_catalog, pg_temp
146
+ AS $fn$
147
+ INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)
148
+ VALUES (p_task, p_payload, p_run_at, p_max_attempts, p_idempotency_key)
149
+ ON CONFLICT DO NOTHING
150
+ RETURNING id
151
+ $fn$
152
+ `);
153
+ await ddl.step(`Granting ${ENQUEUE_FUNCTION} to the request role`, async () => {
154
+ await exec(`REVOKE ALL ON FUNCTION ${ENQUEUE_SIGNATURE} FROM PUBLIC`);
155
+ await exec(`
156
+ DO $grant$ BEGIN
157
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}') THEN
158
+ GRANT EXECUTE ON FUNCTION ${ENQUEUE_SIGNATURE} TO ${REBASE_USER_ROLE};
159
+ END IF;
160
+ END $grant$
161
+ `);
162
+ });
163
+ enqueueInTransaction = firstSqlRow(await exec(`SELECT to_regprocedure('${ENQUEUE_SIGNATURE}') IS NOT NULL AS present`))?.present === true;
164
+ if (!enqueueInTransaction) logger.warn(`⚠️ [jobs] ${ENQUEUE_FUNCTION} could not be created, so a job enqueued inside a write commits on its own: it stays queued if the write rolls back.`);
99
165
  logger.info("✅ Job queue table ready");
100
166
  } 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.`);
101
167
  },
102
168
  async insert(job) {
169
+ const params = [
170
+ job.task,
171
+ JSON.stringify(job.payload ?? null),
172
+ job.runAt.toISOString(),
173
+ job.maxAttempts,
174
+ job.idempotencyKey ?? null
175
+ ];
176
+ const ambient = enqueueInTransaction ? currentAmbientTransaction() : void 0;
177
+ if (ambient) return (await ambient.exec(`SELECT ${ENQUEUE_FUNCTION}($1::text, $2::jsonb, $3::timestamptz, $4::integer, $5::text) AS id`, params))?.[0]?.id ?? null;
103
178
  try {
104
179
  return (await exec(`INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)
105
180
  VALUES ($1, $2::jsonb, $3, $4, $5)
106
- RETURNING id`, [
107
- job.task,
108
- JSON.stringify(job.payload ?? null),
109
- job.runAt.toISOString(),
110
- job.maxAttempts,
111
- job.idempotencyKey ?? null
112
- ]))?.[0]?.id ?? null;
181
+ RETURNING id`, params))?.[0]?.id ?? null;
113
182
  } catch (error) {
114
183
  if (isUniqueViolation(error)) return null;
115
184
  throw error;
116
185
  }
117
186
  },
118
187
  async claim(limit, workerId) {
119
- return (await exec(`UPDATE ${TABLE} SET
188
+ return sqlRows(await exec(`UPDATE ${TABLE} SET
120
189
  status = 'running',
121
190
  attempts = attempts + 1,
122
191
  locked_at = now(),
@@ -170,7 +239,7 @@ function createJobStore(driver) {
170
239
  return count;
171
240
  },
172
241
  async fetch(id) {
173
- const row = (await exec(`SELECT * FROM ${TABLE} WHERE id = $1`, [id]))[0];
242
+ const row = firstSqlRow(await exec(`SELECT * FROM ${TABLE} WHERE id = $1`, [id]));
174
243
  return row ? toRecord(row) : null;
175
244
  }
176
245
  };
@@ -323,6 +392,6 @@ var jobs_exports = /* @__PURE__ */ __exportAll({
323
392
  createJobStore: () => createJobStore
324
393
  });
325
394
  //#endregion
326
- export { createJobStore as i, createJobQueue as n, defaultBackoff as r, jobs_exports as t };
395
+ export { currentAmbientTransaction as a, createJobStore as i, createJobQueue as n, defaultBackoff as r, jobs_exports as t };
327
396
 
328
- //# sourceMappingURL=jobs-CW5lm_Ix.js.map
397
+ //# sourceMappingURL=jobs-DZ8DLLcU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jobs-DZ8DLLcU.js","names":[],"sources":["../src/db/ambient-transaction.ts","../src/jobs/job-store.ts","../src/jobs/job-queue.ts","../src/jobs/index.ts"],"sourcesContent":["/**\n * The write transaction the current code is running inside, when there is one.\n *\n * A collection callback runs inside the transaction that carries its write.\n * Work it hands off — a job, a queue message, a topic event, a webhook\n * delivery — has to share that write's fate: queued when it commits, never\n * queued when it rolls back, and invisible to a worker until the commit. The\n * code that queues it lives in this package and knows no driver; the\n * transaction belongs to the driver. So the driver publishes the transaction it\n * opened for a write, and the queueing code asks for it here.\n *\n * Until this existed the job store wrote through the default driver's own\n * connection, in autocommit, whatever called it. A job enqueued from an\n * `afterSave` that then threw stayed queued for a row that was never written,\n * and a worker could claim it before the row it was about committed.\n *\n * The resolver lives on `globalThis` under a registered symbol, like the\n * `rebase` singleton: a managed runtime can load two copies of this package,\n * and the copy a driver registers with must be the one the job store reads.\n * No Node imports — the store is reachable from code that has to stay portable.\n *\n * **The symbol's key is a contract between packages, and frozen.** The\n * Postgres driver writes the slot itself rather than importing a setter from\n * here: the managed runtime supplies this package from the image while a\n * bundle brings its own driver, and a driver importing a function an older\n * image does not export would fail to link and take the boot down. Written\n * through the slot, any pairing of versions degrades to the old behaviour —\n * jobs committing on their own — instead.\n */\nexport interface AmbientTransaction {\n /**\n * Run one statement on the transaction, `$1…` placeholders as\n * `executeSql` takes them. It runs as the transaction's current role — on\n * Postgres the request's restricted one — so a statement that needs more\n * goes through a function granted to that role.\n */\n exec(sqlText: string, params?: unknown[]): Promise<Record<string, unknown>[]>;\n /** Run `fn` after the transaction commits. Never runs if it rolls back. */\n afterCommit(fn: () => void): void;\n}\n\n/** Frozen — see above. `server-postgres/src/services/write-transaction-scope.ts` writes it. */\nconst RESOLVER_SLOT = Symbol.for(\"rebase.server.ambientTransactionResolver\");\n\ntype ResolverSlot = { [RESOLVER_SLOT]?: () => AmbientTransaction | undefined };\n\n/**\n * Register how to find the write transaction the caller is inside; pass\n * `undefined` to unregister. For tests and for drivers in this repository's\n * lockstep — a driver shipped separately writes the slot, as above.\n */\nexport function setAmbientTransactionResolver(resolve: (() => AmbientTransaction | undefined) | undefined): void {\n (globalThis as ResolverSlot)[RESOLVER_SLOT] = resolve;\n}\n\n/** The write transaction the caller is inside, or `undefined` outside one. */\nexport function currentAmbientTransaction(): AmbientTransaction | undefined {\n return (globalThis as ResolverSlot)[RESOLVER_SLOT]?.();\n}\n","import type { DataDriver } from \"@rebasepro/types\";\nimport { isSQLAdmin } from \"@rebasepro/types\";\nimport { REBASE_USER_ROLE, revokeInternalTableSql, sqlRows, firstSqlRow } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain, type SqlExec } from \"../boot/ddl-bootstrap.js\";\nimport { currentAmbientTransaction } from \"../db/ambient-transaction.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\n/**\n * How a job gets into `rebase.jobs` from inside somebody else's transaction.\n *\n * An enqueue from a collection callback has to ride the write's transaction —\n * committed with it, discarded with it, invisible to workers until then — and\n * that transaction runs as the request's restricted role, which has no access\n * to `rebase.jobs` at all (see the revoke in `ensureTable`). This function is\n * the one door: `SECURITY DEFINER`, so the insert runs with the owner's rights\n * for that one statement and the transaction's role never changes. Switching\n * the role for the insert instead would elevate the whole connection for as\n * long as the insert took, and a callback's un-awaited statement would run\n * in that window as the owner.\n *\n * The door is narrow on purpose. It takes the five columns an enqueue sets and\n * nothing else, so it cannot claim, complete or rewrite a job — and only the\n * request role is granted it, not PUBLIC. That role runs statements the server\n * writes; nobody outside gets SQL as it.\n *\n * `ON CONFLICT DO NOTHING` rather than a caught unique violation: an error\n * inside a transaction aborts the whole transaction, so an idempotency clash\n * would have failed the customer's write. A clash returns no row, hence NULL.\n */\nconst ENQUEUE_FUNCTION = \"rebase.enqueue_job\";\nconst ENQUEUE_SIGNATURE = `${ENQUEUE_FUNCTION}(text, jsonb, timestamptz, integer, text)`;\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 /**\n * Whether {@link ENQUEUE_FUNCTION} exists, so an enqueue inside a write can\n * ride its transaction. Decided at boot rather than assumed: a database\n * that refused the function would otherwise fail every write whose callback\n * enqueues, where falling back to the old behaviour only loses atomicity.\n */\n let enqueueInTransaction = false;\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 await ddl.ensureObject(`Creating ${ENQUEUE_FUNCTION}`, `\n CREATE OR REPLACE FUNCTION ${ENQUEUE_FUNCTION}(\n p_task text, p_payload jsonb, p_run_at timestamptz,\n p_max_attempts integer, p_idempotency_key text\n ) RETURNS text\n LANGUAGE sql\n SECURITY DEFINER\n SET search_path = pg_catalog, pg_temp\n AS $fn$\n INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)\n VALUES (p_task, p_payload, p_run_at, p_max_attempts, p_idempotency_key)\n ON CONFLICT DO NOTHING\n RETURNING id\n $fn$\n `);\n // Re-applied every boot: a new function is executable by PUBLIC\n // until told otherwise, and the request role may be provisioned\n // after the function was first made.\n await ddl.step(`Granting ${ENQUEUE_FUNCTION} to the request role`, async () => {\n await exec(`REVOKE ALL ON FUNCTION ${ENQUEUE_SIGNATURE} FROM PUBLIC`);\n await exec(`\n DO $grant$ BEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}') THEN\n GRANT EXECUTE ON FUNCTION ${ENQUEUE_SIGNATURE} TO ${REBASE_USER_ROLE};\n END IF;\n END $grant$\n `);\n });\n enqueueInTransaction = firstSqlRow<{ present: boolean }>(await exec(\n `SELECT to_regprocedure('${ENQUEUE_SIGNATURE}') IS NOT NULL AS present`\n ))?.present === true;\n if (!enqueueInTransaction) {\n logger.warn(\n `⚠️ [jobs] ${ENQUEUE_FUNCTION} could not be created, so a job enqueued inside a write ` +\n \"commits on its own: it stays queued if the write rolls back.\"\n );\n }\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 const params = [\n job.task,\n JSON.stringify(job.payload ?? null),\n job.runAt.toISOString(),\n job.maxAttempts,\n job.idempotencyKey ?? null\n ];\n // Inside a write: on its transaction, through the definer function\n // — see `ENQUEUE_FUNCTION`. Checked and used with no `await`\n // between, so the transaction cannot close in the gap.\n const ambient = enqueueInTransaction ? currentAmbientTransaction() : undefined;\n if (ambient) {\n const rows = await ambient.exec(\n `SELECT ${ENQUEUE_FUNCTION}($1::text, $2::jsonb, $3::timestamptz, $4::integer, $5::text) AS id`,\n params\n );\n return (rows?.[0]?.id as string | null | undefined) ?? null;\n }\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 params\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 sqlRows<JobRow>(rows).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 = firstSqlRow<JobRow>(rows);\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":"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"}
@@ -40,8 +40,18 @@ var encoder = new TextEncoder();
40
40
  * is one keyword.
41
41
  */
42
42
  async function sha256Hex(input) {
43
+ return hex(await sha256Bytes(input));
44
+ }
45
+ /**
46
+ * SHA-256 of a string, as raw bytes.
47
+ *
48
+ * The primitive {@link sha256Hex} is built on, exported because PKCE wants the
49
+ * digest base64url-encoded rather than hex (RFC 7636 §4.2), and routing that
50
+ * through hex means decoding a string we had just encoded.
51
+ */
52
+ async function sha256Bytes(input) {
43
53
  const digest = await crypto.subtle.digest("SHA-256", encoder.encode(input));
44
- return hex(new Uint8Array(digest));
54
+ return new Uint8Array(digest);
45
55
  }
46
56
  /**
47
57
  * Constant-time string comparison, for secrets.
@@ -4410,10 +4420,12 @@ function toJwks(keys) {
4410
4420
  //#region src/auth/jwt.ts
4411
4421
  var jwt_exports = /* @__PURE__ */ __exportAll({
4412
4422
  MAX_COOKIE_AGE_MS: () => MAX_COOKIE_AGE_MS,
4423
+ MCP_ACCESS_PURPOSE: () => MCP_ACCESS_PURPOSE,
4413
4424
  MFA_PENDING_PURPOSE: () => MFA_PENDING_PURPOSE,
4414
4425
  configureJwt: () => configureJwt,
4415
4426
  generateAccessToken: () => generateAccessToken,
4416
4427
  generateDownloadToken: () => generateDownloadToken,
4428
+ generateMcpAccessToken: () => generateMcpAccessToken,
4417
4429
  generateMfaPendingToken: () => generateMfaPendingToken,
4418
4430
  generateRefreshToken: () => generateRefreshToken,
4419
4431
  getAccessTokenExpiry: () => getAccessTokenExpiry,
@@ -4424,9 +4436,12 @@ var jwt_exports = /* @__PURE__ */ __exportAll({
4424
4436
  hasAsymmetricSigningKey: () => hasAsymmetricSigningKey,
4425
4437
  hashRefreshToken: () => hashRefreshToken,
4426
4438
  isJwtConfigured: () => isJwtConfigured,
4439
+ signPurposeToken: () => signPurposeToken,
4427
4440
  verifyAccessToken: () => verifyAccessToken,
4428
4441
  verifyDownloadToken: () => verifyDownloadToken,
4429
- verifyMfaPendingToken: () => verifyMfaPendingToken
4442
+ verifyMcpAccessToken: () => verifyMcpAccessToken,
4443
+ verifyMfaPendingToken: () => verifyMfaPendingToken,
4444
+ verifyPurposeToken: () => verifyPurposeToken
4430
4445
  });
4431
4446
  /**
4432
4447
  * Claims the token's own identity owns, and which therefore never appear in
@@ -4781,7 +4796,124 @@ async function verifyDownloadToken(token) {
4781
4796
  return null;
4782
4797
  }
4783
4798
  }
4799
+ /**
4800
+ * Sign a short-lived token that carries state across a redirect.
4801
+ *
4802
+ * The generalisation of the MFA-pending token: a signed blob the server hands
4803
+ * out and expects back, where the point is that the holder cannot edit it. The
4804
+ * OAuth authorize flow needs one to carry the authorization request across the
4805
+ * sign-in hop — without it, the endpoint the consent page posts to would mint a
4806
+ * code for whatever `client_id` and `redirect_uri` its caller typed, skipping
4807
+ * every check the authorize endpoint performs.
4808
+ *
4809
+ * The `purpose` is not decoration. {@link verifyAccessToken} refuses any token
4810
+ * that has one, so nothing minted here can be replayed as a session, and
4811
+ * {@link verifyPurposeToken} refuses a token whose purpose is not the one the
4812
+ * caller named — so a token minted for one hop cannot be spent on another.
4813
+ */
4814
+ async function signPurposeToken(purpose, claims, expiresInSeconds) {
4815
+ if (!jwtConfig.secret) throw new Error("JWT secret not configured. Call configureJwt() first.");
4816
+ return signJwt({
4817
+ ...claims,
4818
+ purpose
4819
+ }, jwtConfig.secret, {
4820
+ expiresIn: expiresInSeconds,
4821
+ algorithm: "HS256"
4822
+ });
4823
+ }
4824
+ /** Verify a token minted by {@link signPurposeToken}, for one named purpose. */
4825
+ async function verifyPurposeToken(purpose, token) {
4826
+ if (!jwtConfig.secret) throw new Error("JWT secret not configured. Call configureJwt() first.");
4827
+ if (!token) return null;
4828
+ try {
4829
+ const decoded = await verifyJwt(token, jwtConfig.secret, { algorithms: ["HS256"] });
4830
+ if (!decoded || decoded.purpose !== purpose) return null;
4831
+ return decoded;
4832
+ } catch (error) {
4833
+ logger.error("[JWT] Purpose token verification failed", {
4834
+ purpose,
4835
+ error
4836
+ });
4837
+ return null;
4838
+ }
4839
+ }
4840
+ /** The `purpose` that marks a token as belonging to the MCP resource. */
4841
+ var MCP_ACCESS_PURPOSE = "mcp-access";
4842
+ /**
4843
+ * Mint an access token for the MCP endpoint.
4844
+ *
4845
+ * Deliberately **not** an ordinary session token, and the `purpose` claim is
4846
+ * what enforces that: {@link verifyAccessToken} refuses any token carrying one,
4847
+ * so a credential issued to a third-party MCP client — Claude, an IDE, anything
4848
+ * that completed the OAuth flow — cannot be replayed against `/api/data`,
4849
+ * `/api/admin` or the websocket. It buys exactly the one resource it names.
4850
+ *
4851
+ * That asymmetry matters more than it first looks. These tokens are handed to
4852
+ * software the user authorized but the operator never vetted, they live in that
4853
+ * software's storage, and they are the only credential in this system minted
4854
+ * for a third party. Without the purpose separation, "connect Claude to your
4855
+ * data" would silently mean "give Claude a full session".
4856
+ *
4857
+ * The audience is the canonical resource URI, and {@link verifyMcpAccessToken}
4858
+ * requires the caller to say which resource it is checking for. A token minted
4859
+ * for another Rebase project — or another path on this one — fails there.
4860
+ *
4861
+ * HS256 with the shared secret, like the download token: the authorization
4862
+ * server and the resource server are the same process, so nothing outside it
4863
+ * ever needs to verify one, and an asymmetric key here would be a second key
4864
+ * surface bought for nobody.
4865
+ */
4866
+ async function generateMcpAccessToken(payload, expiresInSeconds) {
4867
+ if (!jwtConfig.secret) throw new Error("JWT secret not configured. Call configureJwt() first.");
4868
+ return signJwt({
4869
+ purpose: MCP_ACCESS_PURPOSE,
4870
+ uid: payload.uid,
4871
+ roles: payload.roles,
4872
+ scope: payload.scope,
4873
+ clientId: payload.clientId,
4874
+ aud: payload.aud,
4875
+ iss: payload.iss
4876
+ }, jwtConfig.secret, {
4877
+ expiresIn: expiresInSeconds,
4878
+ algorithm: "HS256"
4879
+ });
4880
+ }
4881
+ /**
4882
+ * Verify an MCP access token **for a specific resource**.
4883
+ *
4884
+ * `expectedAudience` has no default. The MCP specification requires a resource
4885
+ * server to validate that a token was issued for it specifically, and a default
4886
+ * would be the thing that quietly turns that check off — the caller has to say
4887
+ * what it is, every time, so a new call site cannot forget by omission.
4888
+ */
4889
+ async function verifyMcpAccessToken(token, expectedAudience) {
4890
+ if (!jwtConfig.secret) throw new Error("JWT secret not configured. Call configureJwt() first.");
4891
+ try {
4892
+ const decoded = await verifyJwt(token, jwtConfig.secret, { algorithms: ["HS256"] });
4893
+ if (!decoded || decoded.purpose !== "mcp-access") return null;
4894
+ if (typeof decoded.uid !== "string" || !decoded.uid) return null;
4895
+ if (typeof decoded.aud !== "string" || decoded.aud !== expectedAudience) {
4896
+ logger.error("[JWT] MCP token rejected: audience mismatch", {
4897
+ expected: expectedAudience,
4898
+ got: decoded.aud
4899
+ });
4900
+ return null;
4901
+ }
4902
+ return {
4903
+ purpose: MCP_ACCESS_PURPOSE,
4904
+ uid: decoded.uid,
4905
+ roles: Array.isArray(decoded.roles) ? decoded.roles.map(String) : [],
4906
+ scope: typeof decoded.scope === "string" ? decoded.scope : "",
4907
+ clientId: typeof decoded.clientId === "string" ? decoded.clientId : "",
4908
+ aud: decoded.aud,
4909
+ iss: typeof decoded.iss === "string" ? decoded.iss : ""
4910
+ };
4911
+ } catch (error) {
4912
+ logger.error("[JWT] MCP token verification failed", { error });
4913
+ return null;
4914
+ }
4915
+ }
4784
4916
  //#endregion
4785
- export { sha256Hex as S, verifyMfaPendingToken as _, generateMfaPendingToken as a, constantTimeEqual as b, getJwks as c, hasAsymmetricSigningKey as d, hashRefreshToken as f, verifyDownloadToken as g, verifyAccessToken as h, generateDownloadToken as i, 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, randomInt as x, require_jsonwebtoken as y };
4917
+ export { require_jsonwebtoken as C, sha256Bytes as D, randomInt as E, sha256Hex as O, normalizePemFromEnv as S, randomHex as T, verifyAccessToken as _, generateMcpAccessToken as a, verifyMfaPendingToken as b, getAccessTokenExpiry as c, getRefreshTokenTtlMs as d, hasAsymmetricSigningKey as f, signPurposeToken as g, jwt_exports as h, generateDownloadToken as i, getJwks as l, isJwtConfigured as m, configureJwt as n, generateMfaPendingToken as o, hashRefreshToken as p, generateAccessToken as r, generateRefreshToken as s, MAX_COOKIE_AGE_MS as t, getRefreshTokenExpiry as u, verifyDownloadToken as v, constantTimeEqual as w, verifyPurposeToken as x, verifyMcpAccessToken as y };
4786
4918
 
4787
- //# sourceMappingURL=jwt-DATvkKB_.js.map
4919
+ //# sourceMappingURL=jwt-C4OW-DNq.js.map