@rebasepro/server 0.17.3 → 0.18.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.
- package/LICENSE +0 -1
- package/README.md +5 -1
- package/bin/rebase-server.js +16 -1
- package/dist/{GCSStorageController-Bl7nmhdv.js → GCSStorageController-ZXPoNqW3.js} +21 -10
- package/dist/GCSStorageController-ZXPoNqW3.js.map +1 -0
- package/dist/{S3StorageController-CWvvrRpF.js → S3StorageController-5pAXyv31.js} +24 -10
- package/dist/S3StorageController-5pAXyv31.js.map +1 -0
- package/dist/admin-roles-vYdp_Pil.js +36 -0
- package/dist/admin-roles-vYdp_Pil.js.map +1 -0
- package/dist/{admin_block-BX6YULvJ.js → admin_block-0Xu0r6eZ.js} +4 -1
- package/dist/admin_block-0Xu0r6eZ.js.map +1 -0
- package/dist/api/errors.d.ts +37 -0
- package/dist/api/live-schema-routes.d.ts +14 -0
- package/dist/api/logs-routes.d.ts +8 -0
- package/dist/api/rest/api-generator.d.ts +33 -0
- package/dist/api/root-error-handler.d.ts +62 -0
- package/dist/api/schema-drift.d.ts +39 -0
- package/dist/api/types.d.ts +55 -0
- package/dist/{ast-schema-editor-CvkYOvri.js → ast-schema-editor-CNgFJ3NF.js} +23 -7
- package/dist/ast-schema-editor-CNgFJ3NF.js.map +1 -0
- package/dist/auth/api-keys/api-key-routes.d.ts +12 -0
- package/dist/auth/auth-hooks.d.ts +13 -2
- package/dist/auth/exclude-auth-secrets.d.ts +14 -0
- package/dist/auth/jwt.d.ts +22 -1
- package/dist/auth/mfa-routes.d.ts +10 -1
- package/dist/auth/middleware.d.ts +22 -0
- package/dist/auth/rate-limiter.d.ts +50 -0
- package/dist/auth/registration-policy.d.ts +27 -0
- package/dist/auth/rls-scope.d.ts +9 -1
- package/dist/auth/routes.d.ts +8 -1
- package/dist/auth/seed-admin.d.ts +73 -0
- package/dist/auth/session-routes.d.ts +10 -0
- package/dist/{auth-DkbzMZUS.js → auth-BRiOuyq8.js} +283 -198
- package/dist/auth-BRiOuyq8.js.map +1 -0
- package/dist/{backup-CRdZkA6c.js → backup-C8P6Cl3G.js} +2 -2
- package/dist/{backup-CRdZkA6c.js.map → backup-C8P6Cl3G.js.map} +1 -1
- package/dist/boot/boot.d.ts +18 -0
- package/dist/boot/bundle.d.ts +29 -6
- package/dist/boot/ddl-bootstrap.d.ts +2 -0
- package/dist/boot/env.d.ts +30 -1
- package/dist/boot/fetch-bundle.d.ts +24 -1
- package/dist/boot/options.d.ts +0 -8
- package/dist/boot/provision.d.ts +58 -18
- package/dist/boot/resource-adapters.d.ts +11 -1
- package/dist/boot/resource-resolvers.d.ts +96 -0
- package/dist/boot/sources.d.ts +27 -20
- package/dist/collections/validate-config.d.ts +29 -0
- package/dist/{contract-routes-CHPVKTZW.js → contract-routes-CusnEB5h.js} +24 -9
- package/dist/{contract-routes-CHPVKTZW.js.map → contract-routes-CusnEB5h.js.map} +1 -1
- package/dist/cron/cron-loader.d.ts +7 -1
- package/dist/cron/cron-scheduler.d.ts +48 -2
- package/dist/{cron-loader-d9WMFENB.js → cron-loader-DnmIePn_.js} +23 -4
- package/dist/cron-loader-DnmIePn_.js.map +1 -0
- package/dist/{cron-routes-DSC-kZXI.js → cron-routes-D3x2ydMa.js} +9 -6
- package/dist/cron-routes-D3x2ydMa.js.map +1 -0
- package/dist/{cron-scheduler-BB82dWuU.js → cron-scheduler-BpPKpl4i.js} +148 -28
- package/dist/cron-scheduler-BpPKpl4i.js.map +1 -0
- package/dist/{cron-store-KeN8rN63.js → cron-store-CCQXwgVL.js} +5 -5
- package/dist/{cron-store-KeN8rN63.js.map → cron-store-CCQXwgVL.js.map} +1 -1
- package/dist/{ddl-bootstrap-5YZCZ8qk.js → ddl-bootstrap-CfNvxMuK.js} +26 -2
- package/dist/{ddl-bootstrap-5YZCZ8qk.js.map → ddl-bootstrap-CfNvxMuK.js.map} +1 -1
- package/dist/email/index.d.ts +1 -0
- package/dist/email/unconfigured.d.ts +20 -0
- package/dist/env.d.ts +25 -0
- package/dist/errors-HjfaPlvY.js +385 -0
- package/dist/errors-HjfaPlvY.js.map +1 -0
- package/dist/{function-loader-LLdmBFoL.js → function-loader-xnbDAPfa.js} +9 -3
- package/dist/{function-loader-LLdmBFoL.js.map → function-loader-xnbDAPfa.js.map} +1 -1
- package/dist/function-routes-gQ0EShVG.js +267 -0
- package/dist/function-routes-gQ0EShVG.js.map +1 -0
- package/dist/functions/define-function.d.ts +6 -3
- package/dist/functions/function-routes.d.ts +8 -4
- package/dist/functions/index.js +128 -23
- package/dist/functions/index.js.map +1 -1
- package/dist/{history-recorder-B2pL3X8b.js → history-recorder-B1FwXx9J.js} +3 -3
- package/dist/{history-recorder-B2pL3X8b.js.map → history-recorder-B1FwXx9J.js.map} +1 -1
- package/dist/{history-store-BUJ-37Sm.js → history-store-LHXaQywp.js} +2 -2
- package/dist/{history-store-BUJ-37Sm.js.map → history-store-LHXaQywp.js.map} +1 -1
- package/dist/index.d.ts +16 -4
- package/dist/index.es.js +16229 -7676
- package/dist/index.es.js.map +1 -1
- package/dist/init.d.ts +33 -13
- package/dist/{jobs-XXXcZrmj.js → jobs-DkkD9mPV.js} +5 -5
- package/dist/{jobs-XXXcZrmj.js.map → jobs-DkkD9mPV.js.map} +1 -1
- package/dist/{jwt-DNeOn39r.js → jwt-DkhXwMzR.js} +9 -314
- package/dist/jwt-DkhXwMzR.js.map +1 -0
- package/dist/keys-g8lbVC_o.js +230 -0
- package/dist/keys-g8lbVC_o.js.map +1 -0
- package/dist/{logger-DS03e908.js → logger-DO2PZc4i.js} +197 -18
- package/dist/logger-DO2PZc4i.js.map +1 -0
- package/dist/{logs-routes-DB72iQSr.js → logs-routes-CbsTpozn.js} +74 -6
- package/dist/logs-routes-CbsTpozn.js.map +1 -0
- package/dist/{openapi-generator-CpZp2Fai.js → openapi-generator-BIBbO1Tq.js} +221 -38
- package/dist/openapi-generator-BIBbO1Tq.js.map +1 -0
- package/dist/{proxy-CMymhnwG.js → proxy-Czngl3p9.js} +2 -2
- package/dist/{proxy-CMymhnwG.js.map → proxy-Czngl3p9.js.map} +1 -1
- package/dist/{query-parser-DqXNaBIH.js → query-parser-C68Q9EX4.js} +35 -6
- package/dist/{query-parser-DqXNaBIH.js.map → query-parser-C68Q9EX4.js.map} +1 -1
- package/dist/queues/runtime.d.ts +56 -0
- package/dist/{request-timeout-C8gkc-j7.js → request-timeout-DESvlfrS.js} +4 -6
- package/dist/request-timeout-DESvlfrS.js.map +1 -0
- package/dist/schema-edit/project-root.d.ts +4 -4
- package/dist/{schema-editor-routes-DNV_nBhW.js → schema-editor-routes-CcZKh50q.js} +4 -4
- package/dist/{schema-editor-routes-DNV_nBhW.js.map → schema-editor-routes-CcZKh50q.js.map} +1 -1
- package/dist/{schemas-DBxgjM9A.js → schemas-C3234HWE.js} +2746 -72
- package/dist/schemas-C3234HWE.js.map +1 -0
- package/dist/singleton.d.ts +6 -4
- package/dist/src-DHK4fHkw.js +1002 -0
- package/dist/src-DHK4fHkw.js.map +1 -0
- package/dist/{src-C9gKj23J.js → src-Dq-I3Ybx.js} +507 -54
- package/dist/src-Dq-I3Ybx.js.map +1 -0
- package/dist/storage/GCSStorageController.d.ts +5 -0
- package/dist/storage/LocalStorageController.d.ts +14 -0
- package/dist/storage/S3StorageController.d.ts +8 -0
- package/dist/storage/image-transform.d.ts +35 -0
- package/dist/storage/keys.d.ts +23 -0
- package/dist/storage/policies.d.ts +22 -0
- package/dist/storage/routes.d.ts +9 -0
- package/dist/storage/tus-handler.d.ts +43 -1
- package/dist/storage/types.d.ts +28 -0
- package/dist/types-BfKcm9do.js.map +1 -1
- package/dist/utils/dev-port.d.ts +18 -0
- package/dist/utils/logger.d.ts +60 -0
- package/dist/utils/request-logger.d.ts +2 -1
- package/package.json +30 -24
- package/dist/GCSStorageController-Bl7nmhdv.js.map +0 -1
- package/dist/S3StorageController-CWvvrRpF.js.map +0 -1
- package/dist/admin_block-BX6YULvJ.js.map +0 -1
- package/dist/ast-schema-editor-CvkYOvri.js.map +0 -1
- package/dist/auth-DkbzMZUS.js.map +0 -1
- package/dist/cron-loader-d9WMFENB.js.map +0 -1
- package/dist/cron-routes-DSC-kZXI.js.map +0 -1
- package/dist/cron-scheduler-BB82dWuU.js.map +0 -1
- package/dist/errors-DBwpj9N8.js +0 -231
- package/dist/errors-DBwpj9N8.js.map +0 -1
- package/dist/function-routes-ClT6UQpD.js +0 -41
- package/dist/function-routes-ClT6UQpD.js.map +0 -1
- package/dist/jwt-DNeOn39r.js.map +0 -1
- package/dist/logger-DS03e908.js.map +0 -1
- package/dist/logs-routes-DB72iQSr.js.map +0 -1
- package/dist/openapi-generator-CpZp2Fai.js.map +0 -1
- package/dist/request-timeout-C8gkc-j7.js.map +0 -1
- package/dist/schemas-DBxgjM9A.js.map +0 -1
- package/dist/src-C9gKj23J.js.map +0 -1
- package/dist/src-CcOl05Oq.js +0 -552
- package/dist/src-CcOl05Oq.js.map +0 -1
- package/dist/utils/logging.d.ts +0 -5
package/dist/init.d.ts
CHANGED
|
@@ -354,19 +354,20 @@ export interface RebaseBackendConfig {
|
|
|
354
354
|
* such handle to give (it gave the connection to the adapter instead), so it
|
|
355
355
|
* leaves this unset and the adapter falls back to its own connection.
|
|
356
356
|
*
|
|
357
|
-
*
|
|
357
|
+
* @internal Set by `bootFromBundle`; not part of the ordinary configuration
|
|
358
|
+
* surface.
|
|
358
359
|
*/
|
|
359
360
|
provisioningDriverResult?: InitializedDriver;
|
|
360
361
|
/**
|
|
361
|
-
*
|
|
362
|
+
* Database bootstrappers, one per data source.
|
|
362
363
|
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
*
|
|
364
|
+
* The `id` of each is the key `collection.dataSource` routes against, so a
|
|
365
|
+
* project with several databases passes several — `initializeDataSources`
|
|
366
|
+
* builds them from the declared graph. The list of *declared* sources is no
|
|
367
|
+
* longer an option here: it is read from the resource graph, which is the
|
|
368
|
+
* one place a project declares them.
|
|
369
|
+
*
|
|
370
|
+
* @internal Built by `initializeDataSources` from the declared resource graph. Pass `database` instead.
|
|
370
371
|
*/
|
|
371
372
|
bootstrappers?: BackendBootstrapper[];
|
|
372
373
|
/**
|
|
@@ -464,9 +465,16 @@ export interface RebaseBackendConfig {
|
|
|
464
465
|
* Allow unauthenticated read access to stored files (default: false).
|
|
465
466
|
*
|
|
466
467
|
* Set this only when the bucket is genuinely a public, read-only CDN.
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
468
|
+
* Because it is a deliberate statement that reads are public, it also
|
|
469
|
+
* satisfies the production storage boot guard (see {@link storageAuthorize}).
|
|
470
|
+
*
|
|
471
|
+
* On its own — no {@link storagePolicies}, no {@link storageAuthorize} — it
|
|
472
|
+
* means public READ and nothing else: writes, deletes and listings are
|
|
473
|
+
* admin-only. The sentence "writes still require authentication" used to be
|
|
474
|
+
* the whole of it, and it was not enough, because the deployment this flag
|
|
475
|
+
* is for is the public site, which commonly runs with `requireAuth` off —
|
|
476
|
+
* so "requires authentication" required nothing. A deployment that wants
|
|
477
|
+
* anonymous or per-user writes says where, with a policy or a hook.
|
|
470
478
|
*/
|
|
471
479
|
storagePublicRead?: boolean;
|
|
472
480
|
/**
|
|
@@ -562,6 +570,8 @@ export interface RebaseBackendConfig {
|
|
|
562
570
|
* EXISTS` reads the catalog and then writes to it, so N processes racing to
|
|
563
571
|
* provision the same schema is a state nobody designed. `bootFromBundle`
|
|
564
572
|
* derives it from `REBASE_ROLE`.
|
|
573
|
+
*
|
|
574
|
+
* @internal Derived from `REBASE_ROLE` by `bootFromBundle`.
|
|
565
575
|
*/
|
|
566
576
|
provisionSchema?: boolean;
|
|
567
577
|
surfaces?: RuntimeSurfaceOptions;
|
|
@@ -598,6 +608,8 @@ export interface RebaseBackendConfig {
|
|
|
598
608
|
* `REBASE_FUNCTIONS_ONLY` / `REBASE_FUNCTIONS_EXCLUDE`.
|
|
599
609
|
*
|
|
600
610
|
* A name that is not in the bundle fails the boot — see `selectFunctions`.
|
|
611
|
+
*
|
|
612
|
+
* @internal Filled from `REBASE_FUNCTIONS_ONLY` / `REBASE_FUNCTIONS_EXCLUDE`.
|
|
601
613
|
*/
|
|
602
614
|
functionsSelection?: import("./functions/selection.js").FunctionSelection;
|
|
603
615
|
/**
|
|
@@ -606,6 +618,8 @@ export interface RebaseBackendConfig {
|
|
|
606
618
|
* Only consulted when the `functions` surface is off — a process that serves
|
|
607
619
|
* them has nothing to forward. `bootFromBundle` fills this from
|
|
608
620
|
* `REBASE_FUNCTIONS_UPSTREAM` on the `api` role.
|
|
621
|
+
*
|
|
622
|
+
* @internal Filled from `REBASE_FUNCTIONS_UPSTREAM` on the `api` role.
|
|
609
623
|
*/
|
|
610
624
|
functionsUpstream?: string;
|
|
611
625
|
cronsDir?: string;
|
|
@@ -695,6 +709,8 @@ export interface RebaseBackendConfig {
|
|
|
695
709
|
*
|
|
696
710
|
* Suppresses the "no CORS configuration detected" warning, which exists for
|
|
697
711
|
* hand-wired backends that genuinely have no origin policy.
|
|
712
|
+
*
|
|
713
|
+
* @internal Set by the boot path that installs CORS itself.
|
|
698
714
|
*/
|
|
699
715
|
corsHandled?: boolean;
|
|
700
716
|
/**
|
|
@@ -707,7 +723,11 @@ export interface RebaseBackendConfig {
|
|
|
707
723
|
* useful for a built bundle that already knows its own answer.
|
|
708
724
|
*/
|
|
709
725
|
schemaVersion?: string;
|
|
710
|
-
/**
|
|
726
|
+
/**
|
|
727
|
+
* Runtime version reported by the contract endpoint. Informational.
|
|
728
|
+
*
|
|
729
|
+
* @internal Read from the bundle manifest by `bootFromBundle`.
|
|
730
|
+
*/
|
|
711
731
|
runtimeVersion?: string;
|
|
712
732
|
/**
|
|
713
733
|
* Periodic row-level-security audit of the database this server uses.
|
|
@@ -3,11 +3,11 @@ 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-
|
|
7
|
-
import "./src-
|
|
8
|
-
import { n as createDdlBootstrapper, o as isSQLAdmin, r as hasInCauseChain } from "./ddl-bootstrap-
|
|
6
|
+
import "./src-Dq-I3Ybx.js";
|
|
7
|
+
import "./src-DHK4fHkw.js";
|
|
8
|
+
import { n as createDdlBootstrapper, o as isSQLAdmin, r as hasInCauseChain } from "./ddl-bootstrap-CfNvxMuK.js";
|
|
9
9
|
import { t as revokeInternalTableSql } from "./internal-tables-DYVcFFSv.js";
|
|
10
|
-
import {
|
|
10
|
+
import { r as logger } from "./logger-DO2PZc4i.js";
|
|
11
11
|
import { randomUUID } from "crypto";
|
|
12
12
|
//#region src/jobs/job-store.ts
|
|
13
13
|
/**
|
|
@@ -325,4 +325,4 @@ var jobs_exports = /* @__PURE__ */ __exportAll({
|
|
|
325
325
|
//#endregion
|
|
326
326
|
export { createJobStore as i, createJobQueue as n, defaultBackoff as r, jobs_exports as t };
|
|
327
327
|
|
|
328
|
-
//# sourceMappingURL=jobs-
|
|
328
|
+
//# sourceMappingURL=jobs-DkkD9mPV.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"jobs-XXXcZrmj.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":"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"}
|
|
1
|
+
{"version":3,"file":"jobs-DkkD9mPV.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":"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"}
|
|
@@ -3,122 +3,9 @@ import __rebaseProcess from "process";
|
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { i as __toESM, n as __exportAll, r as __require, t as __commonJSMin } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import "./
|
|
7
|
-
import {
|
|
6
|
+
import { r as logger } from "./logger-DO2PZc4i.js";
|
|
7
|
+
import { i as canonicalStorageId } from "./keys-g8lbVC_o.js";
|
|
8
8
|
import { createPrivateKey, createPublicKey } from "crypto";
|
|
9
|
-
//#region ../types/src/types/storage_source.ts
|
|
10
|
-
/**
|
|
11
|
-
* Describes a named storage backend — a place files live.
|
|
12
|
-
*
|
|
13
|
-
* Declared once and shared front + back: the frontend uses it to decide
|
|
14
|
-
* transport (HTTP proxy vs direct SDK), the backend uses the same `key`
|
|
15
|
-
* to resolve a StorageController, and collection properties reference
|
|
16
|
-
* a definition by its `key` via `StorageConfig.storageSource`.
|
|
17
|
-
*
|
|
18
|
-
* This mirrors the {@link DataSourceDefinition} pattern used for databases.
|
|
19
|
-
*
|
|
20
|
-
* @group Models
|
|
21
|
-
*/
|
|
22
|
-
/**
|
|
23
|
-
* The default storage source key, used when a property does not specify
|
|
24
|
-
* a `storageSource`. Shared by the frontend and backend registries so
|
|
25
|
-
* both agree on "the default storage backend".
|
|
26
|
-
* @group Models
|
|
27
|
-
*/
|
|
28
|
-
var DEFAULT_STORAGE_SOURCE_KEY = "(default)";
|
|
29
|
-
/**
|
|
30
|
-
* The environment-variable suffix for a storage or data source key.
|
|
31
|
-
*
|
|
32
|
-
* `""` for the default source — so a single-bucket project keeps configuring
|
|
33
|
-
* plain `S3_BUCKET` — and `__<KEY>` for every named one, uppercased with
|
|
34
|
-
* non-alphanumerics collapsed to underscores: `media-cdn` → `S3_BUCKET__MEDIA_CDN`.
|
|
35
|
-
*
|
|
36
|
-
* The rule derives the variable name from the declared key rather than
|
|
37
|
-
* discovering keys by scanning the environment. Scanning would have to guess how
|
|
38
|
-
* `S3_BUCKET__MEDIA_CDN` splits into a key; deriving cannot be ambiguous, and a
|
|
39
|
-
* typo surfaces as a missing source at boot instead of a silently ignored
|
|
40
|
-
* variable.
|
|
41
|
-
*
|
|
42
|
-
* It lives in this package, with no dependencies, because four things must agree
|
|
43
|
-
* on it exactly: the CLI (validating a build), the runtime (reading its own
|
|
44
|
-
* environment), the control plane (writing a tenant's Secret), and the docs. A
|
|
45
|
-
* second implementation of a naming convention is a second chance to disagree.
|
|
46
|
-
*
|
|
47
|
-
* @group Models
|
|
48
|
-
*/
|
|
49
|
-
function storageEnvSuffix(key, defaultKey = DEFAULT_STORAGE_SOURCE_KEY) {
|
|
50
|
-
if (!key || key === defaultKey) return "";
|
|
51
|
-
const normalized = key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
|
|
52
|
-
if (!normalized) throw new Error(`Source key "${key}" cannot be turned into an environment variable name. Use a key containing at least one letter or digit.`);
|
|
53
|
-
return `__${normalized}`;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Two distinct keys that collapse onto the same variable name, or `null`.
|
|
57
|
-
*
|
|
58
|
-
* `media-cdn` and `media_cdn` are different source keys but the same suffix, so
|
|
59
|
-
* without this one of them silently reads the other's configuration. Returns the
|
|
60
|
-
* offending pair rather than throwing, so each caller can raise it in its own
|
|
61
|
-
* idiom — a `BundleError` at boot, a build failure in the CLI, a rejected deploy
|
|
62
|
-
* in a control plane.
|
|
63
|
-
*
|
|
64
|
-
* @group Models
|
|
65
|
-
*/
|
|
66
|
-
function findStorageSuffixCollision(keys, defaultKey = DEFAULT_STORAGE_SOURCE_KEY) {
|
|
67
|
-
const seen = /* @__PURE__ */ new Map();
|
|
68
|
-
for (const key of keys) {
|
|
69
|
-
const suffix = storageEnvSuffix(key, defaultKey);
|
|
70
|
-
const existing = seen.get(suffix);
|
|
71
|
-
if (existing !== void 0 && existing !== key) return {
|
|
72
|
-
a: existing,
|
|
73
|
-
b: key,
|
|
74
|
-
suffix
|
|
75
|
-
};
|
|
76
|
-
seen.set(suffix, key);
|
|
77
|
-
}
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Merge the two places a project may declare storage sources into one list.
|
|
82
|
-
*
|
|
83
|
-
* `rebase.json` is authoritative for every field it states. Config code may add
|
|
84
|
-
* sources it does not mention and fill in fields it left out, but may not
|
|
85
|
-
* contradict it: the manifest is what a host reads to decide which buckets need
|
|
86
|
-
* configuring, and a runtime that quietly disagreed with it would put the
|
|
87
|
-
* console back to describing a topology the tenant does not have — the exact
|
|
88
|
-
* failure this whole mechanism exists to end.
|
|
89
|
-
*
|
|
90
|
-
* Note what is *not* here: no default source is invented when both inputs are
|
|
91
|
-
* empty. That decision belongs to the resolver, which knows whether declaring
|
|
92
|
-
* nothing means "one plain bucket" (it does) or "no storage at all".
|
|
93
|
-
*
|
|
94
|
-
* @group Models
|
|
95
|
-
*/
|
|
96
|
-
function normalizeStorageSources(declared, exported) {
|
|
97
|
-
const merged = /* @__PURE__ */ new Map();
|
|
98
|
-
const declaredEntries = Array.isArray(declared) ? declared.filter((d) => d?.key).map((d) => [d.key, d]) : Object.entries(declared ?? {});
|
|
99
|
-
for (const [key, config] of declaredEntries) merged.set(key, {
|
|
100
|
-
key,
|
|
101
|
-
engine: config.engine,
|
|
102
|
-
transport: config.transport ?? "server",
|
|
103
|
-
...config.label !== void 0 ? { label: config.label } : {}
|
|
104
|
-
});
|
|
105
|
-
for (const definition of exported ?? []) {
|
|
106
|
-
if (!definition?.key) continue;
|
|
107
|
-
const existing = merged.get(definition.key);
|
|
108
|
-
if (!existing) {
|
|
109
|
-
merged.set(definition.key, {
|
|
110
|
-
key: definition.key,
|
|
111
|
-
engine: definition.engine,
|
|
112
|
-
transport: definition.transport ?? "server",
|
|
113
|
-
...definition.label !== void 0 ? { label: definition.label } : {}
|
|
114
|
-
});
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
if (existing.label === void 0 && definition.label !== void 0) existing.label = definition.label;
|
|
118
|
-
}
|
|
119
|
-
return Array.from(merged.values());
|
|
120
|
-
}
|
|
121
|
-
//#endregion
|
|
122
9
|
//#region src/utils/portable-crypto.ts
|
|
123
10
|
/**
|
|
124
11
|
* The cryptography the request path needs, on primitives every runtime has.
|
|
@@ -4415,200 +4302,6 @@ function decodeProtectedHeader(token) {
|
|
|
4415
4302
|
}
|
|
4416
4303
|
}
|
|
4417
4304
|
//#endregion
|
|
4418
|
-
//#region src/storage/keys.ts
|
|
4419
|
-
/**
|
|
4420
|
-
* Canonical storage keys and bucket names.
|
|
4421
|
-
*
|
|
4422
|
-
* Storage is not under RLS, so a `storageAuthorize` hook is the whole access
|
|
4423
|
-
* control model — and a hook can only be correct if the key it is shown is the
|
|
4424
|
-
* key that is written. That is the invariant this module exists to hold: one
|
|
4425
|
-
* canonical string, computed once per request, handed to the hook, to the
|
|
4426
|
-
* controller, and to the download token alike.
|
|
4427
|
-
*
|
|
4428
|
-
* ## Why rejecting beats stripping
|
|
4429
|
-
*
|
|
4430
|
-
* The previous `sanitizeStorageKey` *stripped* `../` in a single pass. Two
|
|
4431
|
-
* things were wrong with that, and only one of them was the obvious one.
|
|
4432
|
-
*
|
|
4433
|
-
* The obvious one: a single pass is not a fixed point. `....//` contains `../`
|
|
4434
|
-
* at offset 2, so removing it leaves `../` behind — the sanitizer manufactured
|
|
4435
|
-
* the traversal it was there to remove. `users/alice/....//bob/x` came out as
|
|
4436
|
-
* `users/alice/../bob/x`, which a prefix hook reads as alice's (it starts with
|
|
4437
|
-
* `users/alice/`) and the filesystem reads as bob's. The hook approved one
|
|
4438
|
-
* object and the controller wrote another.
|
|
4439
|
-
*
|
|
4440
|
-
* The subtler one, and the reason this is a rewrite rather than a loop: even a
|
|
4441
|
-
* correct strip is a silent rewrite. A caller who asks to store at `a/../b` and
|
|
4442
|
-
* gets an object at `a/b` was not protected, they were misled — and every later
|
|
4443
|
-
* read, ownership row and audit line now refers to a path nobody chose. So a key
|
|
4444
|
-
* that means something other than what it says is refused (400), not repaired.
|
|
4445
|
-
*
|
|
4446
|
-
* Note what is NOT traversal under this rule: `....` is an ordinary directory
|
|
4447
|
-
* name, and `users/alice/....//bob/x` canonicalizes to
|
|
4448
|
-
* `users/alice/..../bob/x` — still comfortably inside alice's prefix, which is
|
|
4449
|
-
* exactly right. Only a real `..` segment is refused.
|
|
4450
|
-
*/
|
|
4451
|
-
/**
|
|
4452
|
-
* `path.posix.normalize`, without `node:path`.
|
|
4453
|
-
*
|
|
4454
|
-
* A storage key is a POSIX-shaped string that never touches a filesystem, so
|
|
4455
|
-
* reaching for `node:path` to fold `.` and `//` out of it was always a little
|
|
4456
|
-
* wrong on its own terms — on Windows the platform `path` would have applied
|
|
4457
|
-
* different rules to the same key. It also put the module that every storage
|
|
4458
|
-
* read, ownership row and audit line goes through on the list of things that
|
|
4459
|
-
* only run in a Node process.
|
|
4460
|
-
*
|
|
4461
|
-
* This is the algorithm Node implements, transcribed: segments are folded left
|
|
4462
|
-
* to right, `..` pops unless it would climb past the root of a relative path,
|
|
4463
|
-
* and both the leading and the trailing separator survive the round trip. The
|
|
4464
|
-
* `..` branch is unreachable from {@link canonicalStorageKey}, which refuses
|
|
4465
|
-
* those keys outright before it gets here — it exists so that this function is
|
|
4466
|
-
* *the* normalizer rather than a subset of one, and
|
|
4467
|
-
* `storage-keys.property.test.ts` holds it to that with a property test against
|
|
4468
|
-
* `path.posix.normalize` itself. Exported for exactly that — nothing outside
|
|
4469
|
-
* this module should be normalizing a key.
|
|
4470
|
-
*/
|
|
4471
|
-
function normalizePosix(input) {
|
|
4472
|
-
if (input.length === 0) return ".";
|
|
4473
|
-
const isAbsolute = input.startsWith("/");
|
|
4474
|
-
const trailingSeparator = input.endsWith("/");
|
|
4475
|
-
const segments = [];
|
|
4476
|
-
for (const segment of input.split("/")) {
|
|
4477
|
-
if (segment === "" || segment === ".") continue;
|
|
4478
|
-
if (segment === "..") {
|
|
4479
|
-
if (segments.length > 0 && segments[segments.length - 1] !== "..") segments.pop();
|
|
4480
|
-
else if (!isAbsolute) segments.push("..");
|
|
4481
|
-
continue;
|
|
4482
|
-
}
|
|
4483
|
-
segments.push(segment);
|
|
4484
|
-
}
|
|
4485
|
-
const joined = segments.join("/");
|
|
4486
|
-
if (joined === "") {
|
|
4487
|
-
if (isAbsolute) return "/";
|
|
4488
|
-
return trailingSeparator ? "./" : ".";
|
|
4489
|
-
}
|
|
4490
|
-
const withTrailing = trailingSeparator ? `${joined}/` : joined;
|
|
4491
|
-
return isAbsolute ? `/${withTrailing}` : withTrailing;
|
|
4492
|
-
}
|
|
4493
|
-
/** Longest key accepted, in UTF-16 code units. Matches the previous cap. */
|
|
4494
|
-
var MAX_STORAGE_KEY_LENGTH = 1024;
|
|
4495
|
-
/**
|
|
4496
|
-
* A key that cannot be canonicalized. Carries no path back to the caller
|
|
4497
|
-
* beyond what they sent, so it is safe to surface as a 400 message.
|
|
4498
|
-
*/
|
|
4499
|
-
var InvalidStorageKeyError = class extends Error {
|
|
4500
|
-
constructor(message) {
|
|
4501
|
-
super(message);
|
|
4502
|
-
this.name = "InvalidStorageKeyError";
|
|
4503
|
-
}
|
|
4504
|
-
};
|
|
4505
|
-
/**
|
|
4506
|
-
* Canonicalize a caller-supplied storage key, or throw
|
|
4507
|
-
* {@link InvalidStorageKeyError}.
|
|
4508
|
-
*
|
|
4509
|
-
* Normalizations applied (safe, idempotent, and meaning-preserving):
|
|
4510
|
-
* - leading slashes removed — `/a/b` and `a/b` name the same object
|
|
4511
|
-
* - `.` segments and repeated slashes collapsed
|
|
4512
|
-
*
|
|
4513
|
-
* Refusals (the key means something other than what it says):
|
|
4514
|
-
* - any `..` segment, on either separator, at any depth
|
|
4515
|
-
* - null bytes
|
|
4516
|
-
* - keys longer than {@link MAX_STORAGE_KEY_LENGTH}
|
|
4517
|
-
*
|
|
4518
|
-
* A trailing slash is preserved: it is how the folder route marks a prefix.
|
|
4519
|
-
*/
|
|
4520
|
-
function canonicalStorageKey(rawKey) {
|
|
4521
|
-
if (rawKey.includes("\0")) throw new InvalidStorageKeyError("Storage key contains a null byte.");
|
|
4522
|
-
if (rawKey.length > 1024) throw new InvalidStorageKeyError(`Storage key exceeds the maximum length of ${MAX_STORAGE_KEY_LENGTH} characters.`);
|
|
4523
|
-
if (rawKey.split(/[\\/]/).some((segment) => segment === "..")) throw new InvalidStorageKeyError("Storage key contains a '..' path segment. Keys must name an object directly.");
|
|
4524
|
-
const withoutLeadingSlashes = rawKey.replace(/^\/+/, "");
|
|
4525
|
-
if (withoutLeadingSlashes === "") return "";
|
|
4526
|
-
const denotesDirectory = /(?:^|\/)\.?$/.test(withoutLeadingSlashes);
|
|
4527
|
-
const normalized = normalizePosix(withoutLeadingSlashes);
|
|
4528
|
-
if (normalized === "." || normalized === "./") return "";
|
|
4529
|
-
const key = normalized.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
4530
|
-
if (key === "") return "";
|
|
4531
|
-
return denotesDirectory && !key.endsWith("/") ? `${key}/` : key;
|
|
4532
|
-
}
|
|
4533
|
-
/**
|
|
4534
|
-
* Canonicalize, or return `null` when the key is not canonicalizable.
|
|
4535
|
-
*
|
|
4536
|
-
* For callers that must fail closed without an exception — the download-token
|
|
4537
|
-
* middleware compares a request path against a granted path, and a key it
|
|
4538
|
-
* cannot canonicalize simply matches nothing.
|
|
4539
|
-
*/
|
|
4540
|
-
function tryCanonicalStorageKey(rawKey) {
|
|
4541
|
-
try {
|
|
4542
|
-
return canonicalStorageKey(rawKey);
|
|
4543
|
-
} catch {
|
|
4544
|
-
return null;
|
|
4545
|
-
}
|
|
4546
|
-
}
|
|
4547
|
-
/**
|
|
4548
|
-
* Canonicalize the storage *source* a request names (`?storageId=`), so that
|
|
4549
|
-
* "the default source" has exactly one spelling.
|
|
4550
|
-
*
|
|
4551
|
-
* The default source can be asked for three ways — the parameter omitted, sent
|
|
4552
|
-
* empty, or sent as the literal `(default)` — and all three resolve to the same
|
|
4553
|
-
* controller. Without a single spelling, the value derived when a download
|
|
4554
|
-
* token is minted and the value derived when it is presented can differ for the
|
|
4555
|
-
* same object, which is either a spurious 403 or, if the comparison is dropped
|
|
4556
|
-
* to stop those, no scoping at all.
|
|
4557
|
-
*
|
|
4558
|
-
* Deliberately *not* validated against the registry: this is a naming rule, not
|
|
4559
|
-
* an existence check, and it must give the same answer in `auth/` (which has no
|
|
4560
|
-
* registry) as in the storage routes. An id that names no source still
|
|
4561
|
-
* canonicalizes to itself and simply matches only itself.
|
|
4562
|
-
*/
|
|
4563
|
-
function canonicalStorageId(rawStorageId) {
|
|
4564
|
-
if (rawStorageId === void 0 || rawStorageId === null) return DEFAULT_STORAGE_SOURCE_KEY;
|
|
4565
|
-
const trimmed = rawStorageId.trim();
|
|
4566
|
-
return trimmed === "" ? DEFAULT_STORAGE_SOURCE_KEY : trimmed;
|
|
4567
|
-
}
|
|
4568
|
-
/** A bucket name that does not name a bucket. See {@link canonicalStorageBucket}. */
|
|
4569
|
-
var InvalidStorageBucketError = class extends Error {
|
|
4570
|
-
constructor(message) {
|
|
4571
|
-
super(message);
|
|
4572
|
-
this.name = "InvalidStorageBucketError";
|
|
4573
|
-
}
|
|
4574
|
-
};
|
|
4575
|
-
/**
|
|
4576
|
-
* One path segment: letters, digits, `.`, `_`, `-`, first character
|
|
4577
|
-
* alphanumeric. Deliberately narrow — it is the intersection of what S3, GCS
|
|
4578
|
-
* and a filesystem directory all accept, and it makes `..`, `.tus-uploads`,
|
|
4579
|
-
* absolute paths and anything containing a separator unrepresentable.
|
|
4580
|
-
*/
|
|
4581
|
-
var STORAGE_BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
4582
|
-
/**
|
|
4583
|
-
* Canonicalize a caller-supplied bucket name, or throw
|
|
4584
|
-
* {@link InvalidStorageBucketError}.
|
|
4585
|
-
*
|
|
4586
|
-
* The bucket is the *other* caller-controlled routing value in an upload
|
|
4587
|
-
* request, and it was the one nobody validated. `LocalStorageController`
|
|
4588
|
-
* builds `join(basePath, bucket)` and then checks containment against that
|
|
4589
|
-
* result, so a bucket of `../../etc` moved the boundary rather than crossing
|
|
4590
|
-
* it — the guard passed because the guard's reference point was the attacker's.
|
|
4591
|
-
* The containment check now resolves against the storage root, and this is the
|
|
4592
|
-
* check at the route boundary that stops the value before it gets there.
|
|
4593
|
-
*
|
|
4594
|
-
* A bucket is configuration, not user data: there is no legitimate caller that
|
|
4595
|
-
* needs a separator, a leading dot, or a `..` in one. So this refuses rather
|
|
4596
|
-
* than repairs, exactly as {@link canonicalStorageKey} does — a rewritten
|
|
4597
|
-
* bucket would silently store the object somewhere the caller did not ask for.
|
|
4598
|
-
*
|
|
4599
|
-
* Returns `undefined` when the caller named no bucket (absent, or an empty
|
|
4600
|
-
* form field), which is how every controller spells "use my default". An empty
|
|
4601
|
-
* string used to reach `getFullPath` and resolve to the storage *root* rather
|
|
4602
|
-
* than the `default` bucket, which is a third place a bare key did not
|
|
4603
|
-
* round-trip.
|
|
4604
|
-
*/
|
|
4605
|
-
function canonicalStorageBucket(rawBucket) {
|
|
4606
|
-
if (rawBucket === void 0 || rawBucket === null || rawBucket === "") return void 0;
|
|
4607
|
-
if (rawBucket.length > 63) throw new InvalidStorageBucketError(`Storage bucket exceeds the maximum length of 63 characters.`);
|
|
4608
|
-
if (!STORAGE_BUCKET_PATTERN.test(rawBucket)) throw new InvalidStorageBucketError("Storage bucket must be a single name of letters, digits, '.', '_' or '-', starting with a letter or digit.");
|
|
4609
|
-
return rawBucket;
|
|
4610
|
-
}
|
|
4611
|
-
//#endregion
|
|
4612
4305
|
//#region src/auth/jwt-keys.ts
|
|
4613
4306
|
/**
|
|
4614
4307
|
* The algorithm a key type implies.
|
|
@@ -4822,13 +4515,14 @@ function isJwtConfigured() {
|
|
|
4822
4515
|
/**
|
|
4823
4516
|
* Generate an access token (short-lived, 1 hour by default)
|
|
4824
4517
|
*/
|
|
4825
|
-
async function generateAccessToken(uid, roles, aal = "aal1", customClaims) {
|
|
4518
|
+
async function generateAccessToken(uid, roles, aal = "aal1", customClaims, isAnonymous = false) {
|
|
4826
4519
|
if (!jwtConfig.secret) throw new Error("JWT secret not configured. Call configureJwt() first.");
|
|
4827
4520
|
const payload = {
|
|
4521
|
+
...customClaims,
|
|
4828
4522
|
uid,
|
|
4829
4523
|
roles,
|
|
4830
|
-
|
|
4831
|
-
|
|
4524
|
+
aal,
|
|
4525
|
+
...isAnonymous ? { isAnonymous: true } : {}
|
|
4832
4526
|
};
|
|
4833
4527
|
if (activeSigningKey) return signJwt(payload, activeSigningKey.privateKey, {
|
|
4834
4528
|
expiresIn: jwtConfig.accessExpiresIn,
|
|
@@ -4895,6 +4589,7 @@ async function verifyAccessToken(token) {
|
|
|
4895
4589
|
uid: id,
|
|
4896
4590
|
roles: decoded.roles || [],
|
|
4897
4591
|
aal,
|
|
4592
|
+
...decoded.isAnonymous === true ? { isAnonymous: true } : {},
|
|
4898
4593
|
iat: decoded.iat
|
|
4899
4594
|
};
|
|
4900
4595
|
} catch (error) {
|
|
@@ -5051,6 +4746,6 @@ async function verifyDownloadToken(token) {
|
|
|
5051
4746
|
}
|
|
5052
4747
|
}
|
|
5053
4748
|
//#endregion
|
|
5054
|
-
export {
|
|
4749
|
+
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 };
|
|
5055
4750
|
|
|
5056
|
-
//# sourceMappingURL=jwt-
|
|
4751
|
+
//# sourceMappingURL=jwt-DkhXwMzR.js.map
|