@rebasepro/server 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/api/rest/query-parser.d.ts +37 -1
  2. package/dist/api/rest/write-validation.d.ts +26 -0
  3. package/dist/auth/index.d.ts +3 -1
  4. package/dist/auth/interfaces.d.ts +14 -1
  5. package/dist/auth/jwks-routes.d.ts +17 -0
  6. package/dist/auth/jwt-keys.d.ts +108 -0
  7. package/dist/auth/jwt.d.ts +62 -2
  8. package/dist/{auth-CYoPVf-E.js → auth-BobZVd0j.js} +142 -167
  9. package/dist/auth-BobZVd0j.js.map +1 -0
  10. package/dist/boot/boot.d.ts +36 -50
  11. package/dist/boot/ddl-bootstrap.d.ts +15 -0
  12. package/dist/boot/env.d.ts +20 -0
  13. package/dist/boot/provision.d.ts +182 -0
  14. package/dist/boot/role.d.ts +88 -0
  15. package/dist/{cron-store-Dvr4Y1sZ.js → cron-store-CB1x-Ken.js} +3 -3
  16. package/dist/{cron-store-Dvr4Y1sZ.js.map → cron-store-CB1x-Ken.js.map} +1 -1
  17. package/dist/{ddl-bootstrap-BhXbTnBl.js → ddl-bootstrap-Cywoj8Ta.js} +40 -2
  18. package/dist/ddl-bootstrap-Cywoj8Ta.js.map +1 -0
  19. package/dist/env.d.ts +2 -0
  20. package/dist/functions/proxy.d.ts +41 -0
  21. package/dist/functions/selection.d.ts +45 -0
  22. package/dist/index.d.ts +8 -3
  23. package/dist/index.es.js +1113 -661
  24. package/dist/index.es.js.map +1 -1
  25. package/dist/init/shutdown.d.ts +4 -0
  26. package/dist/init/surfaces.d.ts +79 -0
  27. package/dist/init.d.ts +121 -1
  28. package/dist/jobs/index.d.ts +5 -0
  29. package/dist/jobs/job-queue.d.ts +14 -0
  30. package/dist/jobs/job-store.d.ts +22 -0
  31. package/dist/jobs/types.d.ts +125 -0
  32. package/dist/jobs-DR4SjGrD.js +326 -0
  33. package/dist/jobs-DR4SjGrD.js.map +1 -0
  34. package/dist/{jwt-_IFqfTOg.js → jwt-VJyXTdQQ.js} +447 -11
  35. package/dist/jwt-VJyXTdQQ.js.map +1 -0
  36. package/dist/{openapi-generator-DPKtUC9X.js → openapi-generator-DQeQ_q2f.js} +68 -3
  37. package/dist/openapi-generator-DQeQ_q2f.js.map +1 -0
  38. package/dist/proxy-Bj5DVllb.js +139 -0
  39. package/dist/proxy-Bj5DVllb.js.map +1 -0
  40. package/dist/{request-timeout-RivJsME0.js → request-timeout-BuFoEKwT.js} +6 -3
  41. package/dist/request-timeout-BuFoEKwT.js.map +1 -0
  42. package/dist/selection-_z6TM1DB.js +64 -0
  43. package/dist/selection-_z6TM1DB.js.map +1 -0
  44. package/dist/services/webhook-service.d.ts +43 -5
  45. package/dist/{src-C7rkDGxA.js → src-8XDWyDfR.js} +84 -13
  46. package/dist/src-8XDWyDfR.js.map +1 -0
  47. package/dist/src-Cz9nMgUR.js.map +1 -1
  48. package/dist/storage/keys.d.ts +17 -0
  49. package/dist/storage/routes.d.ts +1 -1
  50. package/dist/storage/storage-registry.d.ts +46 -4
  51. package/dist/storage/tus-handler.d.ts +1 -1
  52. package/package.json +5 -5
  53. package/dist/auth-CYoPVf-E.js.map +0 -1
  54. package/dist/ddl-bootstrap-BhXbTnBl.js.map +0 -1
  55. package/dist/jwt-_IFqfTOg.js.map +0 -1
  56. package/dist/openapi-generator-DPKtUC9X.js.map +0 -1
  57. package/dist/request-timeout-RivJsME0.js.map +0 -1
  58. package/dist/src-C7rkDGxA.js.map +0 -1
@@ -2,8 +2,123 @@ import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { i as __toESM, n as __exportAll, r as __require, t as __commonJSMin } from "./rolldown-runtime-DSJWtz9O.js";
5
+ import "./src-Cz9nMgUR.js";
5
6
  import { t as logger } from "./logger-DfvF_8r-.js";
6
- import { createHash, randomBytes } from "crypto";
7
+ import { createHash, createPrivateKey, createPublicKey, randomBytes } from "crypto";
8
+ import path from "node:path";
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
7
122
  //#region ../../node_modules/.pnpm/safe-buffer@5.2.1/node_modules/safe-buffer/index.js
8
123
  var require_safe_buffer = /* @__PURE__ */ __commonJSMin(((exports, module) => {
9
124
  /*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
@@ -3904,7 +4019,7 @@ var require_sign = /* @__PURE__ */ __commonJSMin(((exports, module) => {
3904
4019
  var isPlainObject = require_lodash_isplainobject();
3905
4020
  var isString = require_lodash_isstring();
3906
4021
  var once = require_lodash_once();
3907
- var { KeyObject, createSecretKey, createPrivateKey } = __require("crypto");
4022
+ var { KeyObject, createSecretKey, createPrivateKey: createPrivateKey$1 } = __require("crypto");
3908
4023
  var SUPPORTED_ALGS = [
3909
4024
  "RS256",
3910
4025
  "RS384",
@@ -4045,7 +4160,7 @@ var require_sign = /* @__PURE__ */ __commonJSMin(((exports, module) => {
4045
4160
  }
4046
4161
  if (!secretOrPrivateKey && options.algorithm !== "none") return failure(/* @__PURE__ */ new Error("secretOrPrivateKey must have a value"));
4047
4162
  if (secretOrPrivateKey != null && !(secretOrPrivateKey instanceof KeyObject)) try {
4048
- secretOrPrivateKey = createPrivateKey(secretOrPrivateKey);
4163
+ secretOrPrivateKey = createPrivateKey$1(secretOrPrivateKey);
4049
4164
  } catch (_) {
4050
4165
  try {
4051
4166
  secretOrPrivateKey = createSecretKey(typeof secretOrPrivateKey === "string" ? Buffer.from(secretOrPrivateKey) : secretOrPrivateKey);
@@ -4147,6 +4262,264 @@ var require_jsonwebtoken = /* @__PURE__ */ __commonJSMin(((exports, module) => {
4147
4262
  };
4148
4263
  }));
4149
4264
  //#endregion
4265
+ //#region src/storage/keys.ts
4266
+ var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
4267
+ /**
4268
+ * Canonical storage keys and bucket names.
4269
+ *
4270
+ * Storage is not under RLS, so a `storageAuthorize` hook is the whole access
4271
+ * control model — and a hook can only be correct if the key it is shown is the
4272
+ * key that is written. That is the invariant this module exists to hold: one
4273
+ * canonical string, computed once per request, handed to the hook, to the
4274
+ * controller, and to the download token alike.
4275
+ *
4276
+ * ## Why rejecting beats stripping
4277
+ *
4278
+ * The previous `sanitizeStorageKey` *stripped* `../` in a single pass. Two
4279
+ * things were wrong with that, and only one of them was the obvious one.
4280
+ *
4281
+ * The obvious one: a single pass is not a fixed point. `....//` contains `../`
4282
+ * at offset 2, so removing it leaves `../` behind — the sanitizer manufactured
4283
+ * the traversal it was there to remove. `users/alice/....//bob/x` came out as
4284
+ * `users/alice/../bob/x`, which a prefix hook reads as alice's (it starts with
4285
+ * `users/alice/`) and the filesystem reads as bob's. The hook approved one
4286
+ * object and the controller wrote another.
4287
+ *
4288
+ * The subtler one, and the reason this is a rewrite rather than a loop: even a
4289
+ * correct strip is a silent rewrite. A caller who asks to store at `a/../b` and
4290
+ * gets an object at `a/b` was not protected, they were misled — and every later
4291
+ * read, ownership row and audit line now refers to a path nobody chose. So a key
4292
+ * that means something other than what it says is refused (400), not repaired.
4293
+ *
4294
+ * Note what is NOT traversal under this rule: `....` is an ordinary directory
4295
+ * name, and `users/alice/....//bob/x` canonicalizes to
4296
+ * `users/alice/..../bob/x` — still comfortably inside alice's prefix, which is
4297
+ * exactly right. Only a real `..` segment is refused.
4298
+ */
4299
+ /** Longest key accepted, in UTF-16 code units. Matches the previous cap. */
4300
+ var MAX_STORAGE_KEY_LENGTH = 1024;
4301
+ /**
4302
+ * A key that cannot be canonicalized. Carries no path back to the caller
4303
+ * beyond what they sent, so it is safe to surface as a 400 message.
4304
+ */
4305
+ var InvalidStorageKeyError = class extends Error {
4306
+ constructor(message) {
4307
+ super(message);
4308
+ this.name = "InvalidStorageKeyError";
4309
+ }
4310
+ };
4311
+ /**
4312
+ * Canonicalize a caller-supplied storage key, or throw
4313
+ * {@link InvalidStorageKeyError}.
4314
+ *
4315
+ * Normalizations applied (safe, idempotent, and meaning-preserving):
4316
+ * - leading slashes removed — `/a/b` and `a/b` name the same object
4317
+ * - `.` segments and repeated slashes collapsed
4318
+ *
4319
+ * Refusals (the key means something other than what it says):
4320
+ * - any `..` segment, on either separator, at any depth
4321
+ * - null bytes
4322
+ * - keys longer than {@link MAX_STORAGE_KEY_LENGTH}
4323
+ *
4324
+ * A trailing slash is preserved: it is how the folder route marks a prefix.
4325
+ */
4326
+ function canonicalStorageKey(rawKey) {
4327
+ if (rawKey.includes("\0")) throw new InvalidStorageKeyError("Storage key contains a null byte.");
4328
+ if (rawKey.length > 1024) throw new InvalidStorageKeyError(`Storage key exceeds the maximum length of ${MAX_STORAGE_KEY_LENGTH} characters.`);
4329
+ if (rawKey.split(/[\\/]/).some((segment) => segment === "..")) throw new InvalidStorageKeyError("Storage key contains a '..' path segment. Keys must name an object directly.");
4330
+ const withoutLeadingSlashes = rawKey.replace(/^\/+/, "");
4331
+ if (withoutLeadingSlashes === "") return "";
4332
+ const denotesDirectory = /(?:^|\/)\.?$/.test(withoutLeadingSlashes);
4333
+ const normalized = path.posix.normalize(withoutLeadingSlashes);
4334
+ if (normalized === "." || normalized === "./") return "";
4335
+ const key = normalized.replace(/^\.\//, "").replace(/^\/+/, "");
4336
+ if (key === "") return "";
4337
+ return denotesDirectory && !key.endsWith("/") ? `${key}/` : key;
4338
+ }
4339
+ /**
4340
+ * Canonicalize, or return `null` when the key is not canonicalizable.
4341
+ *
4342
+ * For callers that must fail closed without an exception — the download-token
4343
+ * middleware compares a request path against a granted path, and a key it
4344
+ * cannot canonicalize simply matches nothing.
4345
+ */
4346
+ function tryCanonicalStorageKey(rawKey) {
4347
+ try {
4348
+ return canonicalStorageKey(rawKey);
4349
+ } catch {
4350
+ return null;
4351
+ }
4352
+ }
4353
+ /**
4354
+ * Canonicalize the storage *source* a request names (`?storageId=`), so that
4355
+ * "the default source" has exactly one spelling.
4356
+ *
4357
+ * The default source can be asked for three ways — the parameter omitted, sent
4358
+ * empty, or sent as the literal `(default)` — and all three resolve to the same
4359
+ * controller. Without a single spelling, the value derived when a download
4360
+ * token is minted and the value derived when it is presented can differ for the
4361
+ * same object, which is either a spurious 403 or, if the comparison is dropped
4362
+ * to stop those, no scoping at all.
4363
+ *
4364
+ * Deliberately *not* validated against the registry: this is a naming rule, not
4365
+ * an existence check, and it must give the same answer in `auth/` (which has no
4366
+ * registry) as in the storage routes. An id that names no source still
4367
+ * canonicalizes to itself and simply matches only itself.
4368
+ */
4369
+ function canonicalStorageId(rawStorageId) {
4370
+ if (rawStorageId === void 0 || rawStorageId === null) return DEFAULT_STORAGE_SOURCE_KEY;
4371
+ const trimmed = rawStorageId.trim();
4372
+ return trimmed === "" ? DEFAULT_STORAGE_SOURCE_KEY : trimmed;
4373
+ }
4374
+ /** A bucket name that does not name a bucket. See {@link canonicalStorageBucket}. */
4375
+ var InvalidStorageBucketError = class extends Error {
4376
+ constructor(message) {
4377
+ super(message);
4378
+ this.name = "InvalidStorageBucketError";
4379
+ }
4380
+ };
4381
+ /**
4382
+ * One path segment: letters, digits, `.`, `_`, `-`, first character
4383
+ * alphanumeric. Deliberately narrow — it is the intersection of what S3, GCS
4384
+ * and a filesystem directory all accept, and it makes `..`, `.tus-uploads`,
4385
+ * absolute paths and anything containing a separator unrepresentable.
4386
+ */
4387
+ var STORAGE_BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
4388
+ /**
4389
+ * Canonicalize a caller-supplied bucket name, or throw
4390
+ * {@link InvalidStorageBucketError}.
4391
+ *
4392
+ * The bucket is the *other* caller-controlled routing value in an upload
4393
+ * request, and it was the one nobody validated. `LocalStorageController`
4394
+ * builds `join(basePath, bucket)` and then checks containment against that
4395
+ * result, so a bucket of `../../etc` moved the boundary rather than crossing
4396
+ * it — the guard passed because the guard's reference point was the attacker's.
4397
+ * The containment check now resolves against the storage root, and this is the
4398
+ * check at the route boundary that stops the value before it gets there.
4399
+ *
4400
+ * A bucket is configuration, not user data: there is no legitimate caller that
4401
+ * needs a separator, a leading dot, or a `..` in one. So this refuses rather
4402
+ * than repairs, exactly as {@link canonicalStorageKey} does — a rewritten
4403
+ * bucket would silently store the object somewhere the caller did not ask for.
4404
+ *
4405
+ * Returns `undefined` when the caller named no bucket (absent, or an empty
4406
+ * form field), which is how every controller spells "use my default". An empty
4407
+ * string used to reach `getFullPath` and resolve to the storage *root* rather
4408
+ * than the `default` bucket, which is a third place a bare key did not
4409
+ * round-trip.
4410
+ */
4411
+ function canonicalStorageBucket(rawBucket) {
4412
+ if (rawBucket === void 0 || rawBucket === null || rawBucket === "") return void 0;
4413
+ if (rawBucket.length > 63) throw new InvalidStorageBucketError(`Storage bucket exceeds the maximum length of 63 characters.`);
4414
+ 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.");
4415
+ return rawBucket;
4416
+ }
4417
+ //#endregion
4418
+ //#region src/auth/jwt-keys.ts
4419
+ /**
4420
+ * The algorithm a key type implies.
4421
+ *
4422
+ * Rejecting anything else here rather than defaulting is deliberate: an Ed25519
4423
+ * key configured by someone expecting it to work would otherwise be signed with
4424
+ * `RS256` in the header and fail verification everywhere, at runtime, on tokens
4425
+ * already handed to users.
4426
+ */
4427
+ function algorithmForKey(key, kid) {
4428
+ switch (key.asymmetricKeyType) {
4429
+ case "rsa":
4430
+ case "rsa-pss": return "RS256";
4431
+ case "ec": return "ES256";
4432
+ default: throw new Error(`JWT signing key "${kid}" is a ${key.asymmetricKeyType ?? "non-asymmetric"} key. Supported: RSA (RS256) and EC P-256 (ES256). Generate one with: openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out jwt-key.pem`);
4433
+ }
4434
+ }
4435
+ /**
4436
+ * An EC key of the wrong curve is the other way a key parses cleanly and then
4437
+ * fails to verify: `ES256` means P-256 specifically, and a P-384 key signs a
4438
+ * token whose header says P-256's algorithm.
4439
+ */
4440
+ function assertCurveMatches(publicKey, kid) {
4441
+ const jwk = publicKey.export({ format: "jwk" });
4442
+ if (jwk.crv && jwk.crv !== "P-256") throw new Error(`JWT signing key "${kid}" uses curve ${jwk.crv}, but ES256 requires P-256. Generate one with: openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out jwt-key.pem`);
4443
+ }
4444
+ /**
4445
+ * Parse the configured keys, deriving each public half from its private key.
4446
+ *
4447
+ * Throws on anything malformed. This runs at boot, from `configureJwt`, so a
4448
+ * key that cannot sign takes the process down at start rather than at the first
4449
+ * login — the same bargain every other credential in this file makes.
4450
+ */
4451
+ function resolveSigningKeys(configs) {
4452
+ const seen = /* @__PURE__ */ new Set();
4453
+ return configs.map((config) => {
4454
+ if (!config.kid) throw new Error("Every JWT signing key needs a `kid`; it is what the JWKS and the token header agree on.");
4455
+ if (seen.has(config.kid)) throw new Error(`Duplicate JWT signing key id "${config.kid}". A \`kid\` selects exactly one key at verification time, so two keys sharing one is a token that verifies or does not depending on order.`);
4456
+ seen.add(config.kid);
4457
+ let privateKey;
4458
+ try {
4459
+ privateKey = createPrivateKey(config.privateKey);
4460
+ } catch (error) {
4461
+ throw new Error(`JWT signing key "${config.kid}" is not a readable PEM private key: ${error instanceof Error ? error.message : String(error)}`);
4462
+ }
4463
+ const publicKey = createPublicKey(privateKey);
4464
+ const derived = algorithmForKey(privateKey, config.kid);
4465
+ const algorithm = config.algorithm ?? derived;
4466
+ if (algorithm !== derived) throw new Error(`JWT signing key "${config.kid}" is declared as ${algorithm} but is a ${privateKey.asymmetricKeyType} key, which signs ${derived}.`);
4467
+ if (algorithm === "ES256") assertCurveMatches(publicKey, config.kid);
4468
+ return {
4469
+ kid: config.kid,
4470
+ algorithm,
4471
+ privateKey,
4472
+ publicKey
4473
+ };
4474
+ });
4475
+ }
4476
+ /**
4477
+ * The key a token names, or `null` if it names none we hold.
4478
+ *
4479
+ * The returned algorithm is the *key's*, and the caller must verify with that
4480
+ * one alone. See the module docblock for what happens otherwise.
4481
+ */
4482
+ function resolveVerificationKey(keys, kid) {
4483
+ if (!kid) return null;
4484
+ return keys.find((key) => key.kid === kid) ?? null;
4485
+ }
4486
+ /**
4487
+ * A PEM as an environment variable can actually carry it.
4488
+ *
4489
+ * A PEM is multi-line and environment variables are not, so every deployment
4490
+ * tool solves it differently: `.env` files and most secret managers escape the
4491
+ * newlines to `\n`, Kubernetes and Docker secrets pass the bytes through
4492
+ * intact, and CI systems that mangle both are usually fed base64. All three
4493
+ * arrive here, and guessing wrong produces "not a readable PEM private key" at
4494
+ * boot with a key the operator can see is perfectly valid.
4495
+ *
4496
+ * Detection is on content, not on a flag: a PEM says so on its first line, and
4497
+ * anything that does not is tried as base64.
4498
+ */
4499
+ function normalizePemFromEnv(value) {
4500
+ const trimmed = value.trim();
4501
+ if (trimmed.includes("-----BEGIN")) return trimmed.replace(/\\n/g, "\n");
4502
+ return Buffer.from(trimmed, "base64").toString("utf8");
4503
+ }
4504
+ /**
4505
+ * The public halves, in JWKS form.
4506
+ *
4507
+ * Node exports a JWK containing only public parameters for a public
4508
+ * `KeyObject` — no `d`, no primes — so the private material cannot leak
4509
+ * through this path even if a private key were passed by mistake. The keys are
4510
+ * derived from `publicKey` regardless, and this is asserted in the tests,
4511
+ * because "cannot" is worth checking on the one endpoint whose entire job is to
4512
+ * be world-readable.
4513
+ */
4514
+ function toJwks(keys) {
4515
+ return { keys: keys.map((key) => ({
4516
+ ...key.publicKey.export({ format: "jwk" }),
4517
+ kid: key.kid,
4518
+ alg: key.algorithm,
4519
+ use: "sig"
4520
+ })) };
4521
+ }
4522
+ //#endregion
4150
4523
  //#region src/auth/jwt.ts
4151
4524
  var jwt_exports = /* @__PURE__ */ __exportAll({
4152
4525
  MAX_COOKIE_AGE_MS: () => MAX_COOKIE_AGE_MS,
@@ -4158,21 +4531,31 @@ var jwt_exports = /* @__PURE__ */ __exportAll({
4158
4531
  generateRefreshToken: () => generateRefreshToken,
4159
4532
  getAccessTokenExpiry: () => getAccessTokenExpiry,
4160
4533
  getAccessTokenExpiryMs: () => getAccessTokenExpiryMs,
4534
+ getJwks: () => getJwks,
4161
4535
  getRefreshTokenExpiry: () => getRefreshTokenExpiry,
4162
4536
  getRefreshTokenTtlMs: () => getRefreshTokenTtlMs,
4537
+ hasAsymmetricSigningKey: () => hasAsymmetricSigningKey,
4163
4538
  hashRefreshToken: () => hashRefreshToken,
4164
4539
  isJwtConfigured: () => isJwtConfigured,
4165
4540
  verifyAccessToken: () => verifyAccessToken,
4166
4541
  verifyDownloadToken: () => verifyDownloadToken,
4167
4542
  verifyMfaPendingToken: () => verifyMfaPendingToken
4168
4543
  });
4169
- var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
4170
4544
  var jwtConfig = {
4171
4545
  secret: "",
4172
4546
  accessExpiresIn: "1h",
4173
4547
  refreshExpiresIn: "400d"
4174
4548
  };
4175
4549
  /**
4550
+ * The parsed signing keys, and which one mints new access tokens.
4551
+ *
4552
+ * Both are module state beside `jwtConfig` for the same reason it is: every
4553
+ * signing and verifying path in the server reads them through this file, and
4554
+ * there is exactly one JWT configuration per process.
4555
+ */
4556
+ var signingKeys = [];
4557
+ var activeSigningKey = null;
4558
+ /**
4176
4559
  * Configure JWT settings - call this during initialization.
4177
4560
  * Validates the secret strength to prevent deployment with default/weak secrets.
4178
4561
  */
@@ -4200,10 +4583,33 @@ function configureJwt(config) {
4200
4583
  ]);
4201
4584
  if (!config.secret || config.secret.length < 32) throw new Error("JWT secret is too short. Must be at least 32 characters. Generate one with: node -e \"logger.info(require('crypto').randomBytes(48).toString('base64'))\"");
4202
4585
  if (weakSecrets.has(config.secret.toLowerCase())) throw new Error("JWT secret is a known default/weak value. Please use a strong, randomly generated secret. Generate one with: node -e \"logger.info(require('crypto').randomBytes(48).toString('base64'))\"");
4586
+ const resolved = config.signingKeys ? resolveSigningKeys(config.signingKeys) : [];
4587
+ let active = null;
4588
+ if (resolved.length > 0) if (config.activeKid) {
4589
+ active = resolved.find((key) => key.kid === config.activeKid) ?? null;
4590
+ if (!active) throw new Error(`auth.activeKid is "${config.activeKid}", which is not among the configured signing keys (${resolved.map((k) => `"${k.kid}"`).join(", ")}). Signing with a key nobody published produces tokens no verifier can check.`);
4591
+ } else active = resolved[0];
4203
4592
  jwtConfig = {
4204
4593
  ...jwtConfig,
4205
4594
  ...config
4206
4595
  };
4596
+ signingKeys = resolved;
4597
+ activeSigningKey = active;
4598
+ }
4599
+ /**
4600
+ * The public keys, in JWKS form, for `/.well-known/jwks.json`.
4601
+ *
4602
+ * An empty `keys` array on a backend with no asymmetric keys configured is the
4603
+ * correct answer rather than a 404: it says "this issuer publishes none",
4604
+ * which a verifier can act on, where a 404 is indistinguishable from a
4605
+ * misconfigured URL.
4606
+ */
4607
+ function getJwks() {
4608
+ return toJwks(signingKeys);
4609
+ }
4610
+ /** Is this backend signing access tokens asymmetrically? */
4611
+ function hasAsymmetricSigningKey() {
4612
+ return activeSigningKey !== null;
4207
4613
  }
4208
4614
  /**
4209
4615
  * Has this server been given a JWT secret?
@@ -4230,6 +4636,11 @@ function generateAccessToken(uid, roles, aal = "aal1", customClaims) {
4230
4636
  ...customClaims,
4231
4637
  aal
4232
4638
  };
4639
+ if (activeSigningKey) return import_jsonwebtoken.default.sign(payload, activeSigningKey.privateKey, {
4640
+ expiresIn: jwtConfig.accessExpiresIn,
4641
+ algorithm: activeSigningKey.algorithm,
4642
+ keyid: activeSigningKey.kid
4643
+ });
4233
4644
  return import_jsonwebtoken.default.sign(payload, jwtConfig.secret, {
4234
4645
  expiresIn: jwtConfig.accessExpiresIn,
4235
4646
  algorithm: "HS256"
@@ -4273,7 +4684,9 @@ function getAccessTokenExpiry() {
4273
4684
  function verifyAccessToken(token) {
4274
4685
  if (!jwtConfig.secret) throw new Error("JWT secret not configured. Call configureJwt() first.");
4275
4686
  try {
4276
- const decoded = import_jsonwebtoken.default.verify(token, jwtConfig.secret, { algorithms: ["HS256"] });
4687
+ const header = import_jsonwebtoken.default.decode(token, { complete: true })?.header;
4688
+ const namedKey = resolveVerificationKey(signingKeys, header?.kid);
4689
+ const decoded = namedKey ? import_jsonwebtoken.default.verify(token, namedKey.publicKey, { algorithms: [namedKey.algorithm] }) : import_jsonwebtoken.default.verify(token, jwtConfig.secret, { algorithms: ["HS256"] });
4277
4690
  if (decoded.purpose) {
4278
4691
  logger.error("[JWT] Verification failed: a purpose-scoped token is not an access token", { purpose: decoded.purpose });
4279
4692
  return null;
@@ -4392,12 +4805,27 @@ function verifyMfaPendingToken(token) {
4392
4805
  }
4393
4806
  /**
4394
4807
  * Generate a short-lived download token scoped to a specific file path or prefix
4808
+ * *within one storage source*.
4809
+ *
4810
+ * Both halves of that scope are load-bearing. A key is only unique inside its
4811
+ * own bucket, and a project with more than one source routinely holds the same
4812
+ * key in several of them — `avatars/u1.png` in `(default)` and in `media` are
4813
+ * different objects, quite possibly with different owners. A token that names
4814
+ * only the path is therefore a grant on every source at once: authorize a read
4815
+ * on the source whose `storageAuthorize` hook says yes, then spend the token
4816
+ * against `?storageId=` pointing somewhere else.
4817
+ *
4818
+ * `storageId` is optional here only because omitting it *is* the default
4819
+ * source, which is what the overwhelming majority of deployments have. A mint
4820
+ * site that forgets to pass a named source produces a default-scoped token,
4821
+ * which fails closed at `/file/*` rather than over-granting.
4395
4822
  */
4396
- function generateDownloadToken(path, expiresInSeconds = 300) {
4823
+ function generateDownloadToken(path, expiresInSeconds = 300, storageId) {
4397
4824
  if (!jwtConfig.secret) throw new Error("JWT secret not configured. Call configureJwt() first.");
4398
4825
  const payload = {
4399
4826
  purpose: "file-read",
4400
- path
4827
+ path,
4828
+ storageId: canonicalStorageId(storageId)
4401
4829
  };
4402
4830
  return import_jsonwebtoken.default.sign(payload, jwtConfig.secret, {
4403
4831
  expiresIn: expiresInSeconds,
@@ -4405,7 +4833,14 @@ function generateDownloadToken(path, expiresInSeconds = 300) {
4405
4833
  });
4406
4834
  }
4407
4835
  /**
4408
- * Verify and decode a download token
4836
+ * Verify and decode a download token.
4837
+ *
4838
+ * A token minted before `storageId` existed carries no such claim. It is read
4839
+ * as a grant on the **default source** rather than on all of them: that is the
4840
+ * fail-closed reading, and it is what such a token almost always was, since a
4841
+ * named source has to be asked for explicitly. The cost is bounded by the
4842
+ * five-minute TTL — for at most that long after a deploy, an in-flight token
4843
+ * for a *named* source is refused and the client re-fetches `/metadata`.
4409
4844
  */
4410
4845
  function verifyDownloadToken(token) {
4411
4846
  if (!jwtConfig.secret) throw new Error("JWT secret not configured. Call configureJwt() first.");
@@ -4413,7 +4848,8 @@ function verifyDownloadToken(token) {
4413
4848
  const decoded = import_jsonwebtoken.default.verify(token, jwtConfig.secret, { algorithms: ["HS256"] });
4414
4849
  if (decoded && decoded.purpose === "file-read" && typeof decoded.path === "string") return {
4415
4850
  purpose: "file-read",
4416
- path: decoded.path
4851
+ path: decoded.path,
4852
+ storageId: canonicalStorageId(typeof decoded.storageId === "string" ? decoded.storageId : null)
4417
4853
  };
4418
4854
  return null;
4419
4855
  } catch (error) {
@@ -4422,6 +4858,6 @@ function verifyDownloadToken(token) {
4422
4858
  }
4423
4859
  }
4424
4860
  //#endregion
4425
- export { generateMfaPendingToken as a, getRefreshTokenExpiry as c, isJwtConfigured as d, jwt_exports as f, require_jsonwebtoken as g, verifyMfaPendingToken as h, generateDownloadToken as i, getRefreshTokenTtlMs as l, verifyDownloadToken as m, configureJwt as n, generateRefreshToken as o, verifyAccessToken as p, generateAccessToken as r, getAccessTokenExpiry as s, MAX_COOKIE_AGE_MS as t, hashRefreshToken as u };
4861
+ export { canonicalStorageKey as C, findStorageSuffixCollision as D, DEFAULT_STORAGE_SOURCE_KEY as E, normalizeStorageSources as O, canonicalStorageId as S, require_jsonwebtoken as T, verifyMfaPendingToken as _, generateMfaPendingToken as a, InvalidStorageKeyError as b, getJwks as c, hasAsymmetricSigningKey as d, hashRefreshToken as f, verifyDownloadToken as g, verifyAccessToken as h, generateDownloadToken as i, storageEnvSuffix as k, getRefreshTokenExpiry as l, jwt_exports as m, configureJwt as n, generateRefreshToken as o, isJwtConfigured as p, generateAccessToken as r, getAccessTokenExpiry as s, MAX_COOKIE_AGE_MS as t, getRefreshTokenTtlMs as u, normalizePemFromEnv as v, tryCanonicalStorageKey as w, canonicalStorageBucket as x, InvalidStorageBucketError as y };
4426
4862
 
4427
- //# sourceMappingURL=jwt-_IFqfTOg.js.map
4863
+ //# sourceMappingURL=jwt-VJyXTdQQ.js.map