@rebasepro/server 0.13.0 → 0.13.1-canary.g249daa1

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 (56) hide show
  1. package/dist/{admin_block-CcSGdH7M.js → admin_block-CLeUMG8G.js} +3 -1
  2. package/dist/admin_block-CLeUMG8G.js.map +1 -0
  3. package/dist/api/errors.d.ts +0 -6
  4. package/dist/api/openapi-generator.d.ts +14 -0
  5. package/dist/api/rest/idempotency.d.ts +41 -4
  6. package/dist/api/rest/write-validation.d.ts +12 -5
  7. package/dist/auth/index.d.ts +1 -1
  8. package/dist/auth/jwt.d.ts +12 -0
  9. package/dist/{auth-CuC9M2x6.js → auth-BANhRhN9.js} +194 -84
  10. package/dist/auth-BANhRhN9.js.map +1 -0
  11. package/dist/{backup-CVggVhR2.js → backup-BJiY_mM-.js} +2 -2
  12. package/dist/{backup-CVggVhR2.js.map → backup-BJiY_mM-.js.map} +1 -1
  13. package/dist/boot/boot.d.ts +19 -0
  14. package/dist/boot/ddl-bootstrap.d.ts +70 -0
  15. package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-FSt0Nyfk.js} +2 -2
  16. package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-FSt0Nyfk.js.map} +1 -1
  17. package/dist/cron/cron-scheduler.d.ts +8 -3
  18. package/dist/cron/define-cron.d.ts +5 -3
  19. package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-D5a9v1HY.js} +2 -2
  20. package/dist/{cron-routes-CrQ0tK-_.js.map → cron-routes-D5a9v1HY.js.map} +1 -1
  21. package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-CsQtYLMq.js} +28 -3
  22. package/dist/cron-scheduler-CsQtYLMq.js.map +1 -0
  23. package/dist/{cron-store-BywZsyfZ.js → cron-store-ZbjfuvMb.js} +65 -46
  24. package/dist/cron-store-ZbjfuvMb.js.map +1 -0
  25. package/dist/ddl-bootstrap-Bp3mr6Td.js +183 -0
  26. package/dist/ddl-bootstrap-Bp3mr6Td.js.map +1 -0
  27. package/dist/{errors-CgkCzoj7.js → errors-BpudWAVU.js} +2 -10
  28. package/dist/errors-BpudWAVU.js.map +1 -0
  29. package/dist/functions/define-function.d.ts +1 -1
  30. package/dist/history/history-routes.d.ts +6 -0
  31. package/dist/index.es.js +835 -276
  32. package/dist/index.es.js.map +1 -1
  33. package/dist/{jwt-DD6EtpGj.js → jwt-CzeviDcB.js} +17 -2
  34. package/dist/{jwt-DD6EtpGj.js.map → jwt-CzeviDcB.js.map} +1 -1
  35. package/dist/{openapi-generator-BEwyiaAr.js → openapi-generator-VB0MUH8h.js} +340 -128
  36. package/dist/openapi-generator-VB0MUH8h.js.map +1 -0
  37. package/dist/{schema-editor-routes-CbF20Mf1.js → schema-editor-routes-BqjzvvWU.js} +4 -4
  38. package/dist/{schema-editor-routes-CbF20Mf1.js.map → schema-editor-routes-BqjzvvWU.js.map} +1 -1
  39. package/dist/{src-_qQ3RNCK.js → src-Ca6NhxKs.js} +79 -2
  40. package/dist/src-Ca6NhxKs.js.map +1 -0
  41. package/dist/{src-Cum9kox5.js → src-DVb5a0bp.js} +153 -39
  42. package/dist/src-DVb5a0bp.js.map +1 -0
  43. package/dist/storage/keys.d.ts +65 -0
  44. package/dist/utils/logging.d.ts +0 -4
  45. package/dist/utils/sql.d.ts +11 -6
  46. package/package.json +6 -6
  47. package/dist/admin_block-CcSGdH7M.js.map +0 -1
  48. package/dist/auth-CuC9M2x6.js.map +0 -1
  49. package/dist/backend-CIxN4FVm.js +0 -15
  50. package/dist/backend-CIxN4FVm.js.map +0 -1
  51. package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
  52. package/dist/cron-store-BywZsyfZ.js.map +0 -1
  53. package/dist/errors-CgkCzoj7.js.map +0 -1
  54. package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
  55. package/dist/src-Cum9kox5.js.map +0 -1
  56. package/dist/src-_qQ3RNCK.js.map +0 -1
@@ -44,6 +44,7 @@ var ADMIN_COLLECTION_KEYS = [
44
44
  "defaultSize",
45
45
  "defaultViewMode",
46
46
  "disableDefaultActions",
47
+ "display",
47
48
  "enabledViews",
48
49
  "entityActions",
49
50
  "entityViews",
@@ -54,6 +55,7 @@ var ADMIN_COLLECTION_KEYS = [
54
55
  "formAutoSave",
55
56
  "formView",
56
57
  "group",
58
+ "hideFromEntityViews",
57
59
  "hideFromNavigation",
58
60
  "hideIdFromCollection",
59
61
  "hideIdFromForm",
@@ -121,4 +123,4 @@ var ADMIN_PROPERTY_KEYS = [
121
123
  //#endregion
122
124
  export { ADMIN_PROPERTY_KEYS as n, ADMIN_COLLECTION_KEYS as t };
123
125
 
124
- //# sourceMappingURL=admin_block-CcSGdH7M.js.map
126
+ //# sourceMappingURL=admin_block-CLeUMG8G.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin_block-CLeUMG8G.js","names":[],"sources":["../../types/src/types/admin_block.ts"],"sourcesContent":["/**\n * The keys of a collection's admin block, as data.\n *\n * There is no *type* for the block in this package any more, and that is the point:\n * `admin` is not declared on `BaseCollectionConfig` or on any property here, so a\n * BaaS install cannot even write one. `@rebasepro/admin-types` adds the field back by\n * declaration merging, which is why installing it is what makes the admin surface\n * appear.\n *\n * The *list* still has to live here, because three runtime consumers need it and two\n * of them are core — see below.\n */\n\n/**\n * Every key that belongs inside a collection's `admin` block, as data.\n *\n * The type that describes these fields is `AdminCollectionOptions` in\n * `@rebasepro/admin-types`, and it is erased at build time — but three runtime\n * consumers need the list, and two of them are core:\n *\n * - `serializeCollections`, to drop the block from the contract\n * - the ts-morph schema editor in `@rebasepro/server`, which rewrites collection\n * files on disk from the admin panel and has to know where each key goes. A key\n * missing from this list gets written to the *top level* of the file, where the\n * backend ignores it and the panel never finds it again.\n * - the `collections-admin-block` codemod\n *\n * `@rebasepro/admin-types` re-exports this and asserts it names only real option\n * keys; the count is pinned by a test there.\n *\n * @group Models\n */\nexport const ADMIN_COLLECTION_KEYS = [\n \"Actions\",\n \"additionalFields\",\n \"alwaysApplyDefaultValues\",\n \"components\",\n \"defaultEntityAction\",\n \"defaultFilter\",\n \"defaultSelectedView\",\n \"defaultSize\",\n \"defaultViewMode\",\n \"disableDefaultActions\",\n \"display\",\n \"enabledViews\",\n \"entityActions\",\n \"entityViews\",\n \"exportable\",\n \"filterPresets\",\n \"fixedFilter\",\n \"form\",\n \"formAutoSave\",\n \"formView\",\n \"group\",\n \"hideFromEntityViews\",\n \"hideFromNavigation\",\n \"hideIdFromCollection\",\n \"hideIdFromForm\",\n \"icon\",\n \"includeJsonView\",\n \"inlineEditing\",\n \"kanban\",\n \"listProperties\",\n \"localChangesBackup\",\n \"openEntityMode\",\n \"orderProperty\",\n \"pagination\",\n \"previewProperties\",\n \"propertiesOrder\",\n \"selectionController\",\n \"selectionEnabled\",\n \"sideDialogWidth\",\n \"sort\",\n \"titleProperty\"\n] as const;\n\n/** A key of a collection's `admin` block. @group Models */\nexport type AdminCollectionKey = typeof ADMIN_COLLECTION_KEYS[number];\n\n/**\n * Every key that belongs inside a *property's* `admin` block, as data.\n *\n * The union of `AdminPropertyOptions` and its per-type extensions\n * (`AdminStringOptions`, `AdminArrayOptions`, …) in `@rebasepro/admin-types`.\n * It lives here for the same reason {@link ADMIN_COLLECTION_KEYS} does: the\n * runtime consumers are core packages that the BaaS guard forbids from\n * importing `@rebasepro/admin-types`. Here it is the boot-time collection\n * validator in `@rebasepro/server`, which has to tell \"you left `readOnly` at\n * the top of the property, where nothing reads it\" apart from \"you invented a\n * key we have never heard of\".\n *\n * `@rebasepro/admin-types` re-exports this and asserts it names only real\n * option keys.\n *\n * @group Models\n */\nexport const ADMIN_PROPERTY_KEYS = [\n \"canAddElements\",\n \"clearable\",\n \"columnWidth\",\n \"customProps\",\n \"disabled\",\n \"expanded\",\n \"Field\",\n \"Filter\",\n \"filterOperators\",\n \"fixedFilter\",\n \"hideFromCollection\",\n \"includeEntityLink\",\n \"includeId\",\n \"markdown\",\n \"minimalistView\",\n \"multiline\",\n \"Preview\",\n \"previewAsTag\",\n \"previewProperties\",\n \"readOnly\",\n \"sortable\",\n \"span\",\n \"spreadChildren\",\n \"urlPreview\",\n \"widget\",\n] as const;\n\n/** A key of a property's `admin` block. @group Models */\nexport type AdminPropertyKey = typeof ADMIN_PROPERTY_KEYS[number];\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,IAAa,wBAAwB;CACjC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACJ;;;;;;;;;;;;;;;;;;AAsBA,IAAa,sBAAsB;CAC/B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACJ"}
@@ -55,12 +55,6 @@ export interface RebaseApiError extends Error {
55
55
  code?: string;
56
56
  details?: unknown;
57
57
  }
58
- /**
59
- * Type guard for errors that carry optional API metadata (statusCode, code, details).
60
- * Returns true for any Error instance — the optional properties are then
61
- * checked via normal property access.
62
- */
63
- export declare function isRebaseApiError(error: unknown): error is RebaseApiError;
64
58
  /**
65
59
  * Hono error-handling middleware (`app.onError`).
66
60
  * Converts any error into the canonical `{ error: { message, code } }` shape.
@@ -12,5 +12,19 @@ export interface OpenApiGeneratorOptions {
12
12
  basePath?: string;
13
13
  /** Whether auth is enabled on data routes. Defaults to true. */
14
14
  requireAuth?: boolean;
15
+ /**
16
+ * The list-pagination bounds the REST layer applies, so the spec states the
17
+ * ones a request will actually meet.
18
+ *
19
+ * These were hardcoded as `default: 20, maximum: 100` — neither of which
20
+ * the server has ever used. The spec drives the API Explorer and is what a
21
+ * generated client is built from, so an understated ceiling is a request
22
+ * the client refuses to make, and an overstated one is a 400 nobody
23
+ * predicted.
24
+ */
25
+ listLimits?: {
26
+ defaultLimit?: number;
27
+ maxLimit?: number;
28
+ };
15
29
  }
16
30
  export declare function generateOpenApiSpec(collections: CollectionConfig[], options?: OpenApiGeneratorOptions): Record<string, unknown>;
@@ -1,9 +1,46 @@
1
1
  import { DataDriver } from "@rebasepro/types";
2
+ /**
3
+ * What a caller may do with a key.
4
+ *
5
+ * `claimed` is the only outcome that permits the write. Recalling and then
6
+ * writing was not atomic: two requests carrying one key both missed the recall,
7
+ * both wrote, and `ON CONFLICT DO NOTHING` protected only the second row in
8
+ * *this* table — the duplicate row in the caller's collection, which is the
9
+ * thing the mechanism exists to prevent, was inserted anyway. The offline queue
10
+ * is shared across tabs, so two tabs reconnecting together is the ordinary case
11
+ * rather than an exotic one.
12
+ */
13
+ export type IdempotencyClaim =
14
+ /** This key has already been answered; serve that answer again. */
15
+ {
16
+ status: "replay";
17
+ response: unknown;
18
+ }
19
+ /** Another request holds this key and has not answered yet. */
20
+ | {
21
+ status: "in-flight";
22
+ }
23
+ /** The key is ours; do the write, then `complete` or `release` it. */
24
+ | {
25
+ status: "claimed";
26
+ };
2
27
  export interface IdempotencyStore {
3
- /** What this key answered before, or `undefined` if it is new. */
4
- recall(key: string, uid: string | undefined): Promise<unknown | undefined>;
5
- /** Record what this key answered. Never throws see {@link createIdempotencyStore}. */
6
- remember(key: string, uid: string | undefined, response: unknown): Promise<void>;
28
+ /**
29
+ * Take the key if it is free, atomically. Never throws a store that
30
+ * cannot answer reports `claimed`, which degrades to no idempotency rather
31
+ * than to a refused write.
32
+ */
33
+ claim(key: string, uid: string | undefined): Promise<IdempotencyClaim>;
34
+ /** Record what this key answered. Never throws. */
35
+ complete(key: string, uid: string | undefined, response: unknown): Promise<void>;
36
+ /**
37
+ * Give the key back after the write it was claimed for failed.
38
+ *
39
+ * Without this a transient failure strands the key: every retry would be
40
+ * told `in-flight` until the row aged out, turning one failed write into a
41
+ * day of refusals.
42
+ */
43
+ release(key: string, uid: string | undefined): Promise<void>;
7
44
  }
8
45
  /**
9
46
  * Returns `undefined` when the driver cannot run SQL, which disables the whole
@@ -33,11 +33,18 @@ export declare function assertKnownWriteFields(values: Record<string, unknown>,
33
33
  * This shapes the *response*, which is what the parameter says it does; it is
34
34
  * not a column pushdown, so it saves bandwidth rather than database work.
35
35
  *
36
- * `id` always survives. Rows are addressed by it everywhere above this layer —
37
- * the admin table, realtime reconciliation, the offline cache — and a row that
38
- * arrives without one is not a smaller row, it is an unusable one. Asking for
39
- * `fields=title` and being unable to open the record you clicked is a worse
40
- * answer than one extra key.
36
+ * The collection's key always survives. Rows are addressed by it everywhere
37
+ * above this layer — the admin table, realtime reconciliation, the offline
38
+ * cache — and a row that arrives without one is not a smaller row, it is an
39
+ * unusable one. Asking for `fields=title` and being unable to open the record
40
+ * you clicked is a worse answer than one extra key.
41
+ *
42
+ * That is a statement about the key, not about the name `id`: a collection
43
+ * keyed on `slug`, or on `user_id + role_id`, was projected down to the fields
44
+ * asked for and nothing else, which is the very outcome the paragraph above
45
+ * describes. `assertKnownWriteFields` one function up already has a dedicated
46
+ * error for collections with no `id` column, so this layer had no excuse for
47
+ * assuming one.
41
48
  */
42
49
  export declare function projectResponseFields<T extends Record<string, unknown>>(rows: T[], fields: readonly string[] | undefined, collection: CollectionConfig, options?: {
43
50
  include?: readonly string[];
@@ -1,5 +1,5 @@
1
1
  export * from "./interfaces";
2
- export { configureJwt, generateAccessToken, verifyAccessToken, generateRefreshToken, hashRefreshToken, getRefreshTokenExpiry, getAccessTokenExpiry, generateDownloadToken, verifyDownloadToken } from "./jwt";
2
+ export { configureJwt, isJwtConfigured, generateAccessToken, verifyAccessToken, generateRefreshToken, hashRefreshToken, getRefreshTokenExpiry, getAccessTokenExpiry, generateDownloadToken, verifyDownloadToken } from "./jwt";
3
3
  export type { JwtConfig, AccessTokenPayload, DownloadTokenPayload } from "./jwt";
4
4
  export { hashPassword, verifyPassword, validatePasswordStrength } from "./password";
5
5
  export { safeCompare } from "./crypto-utils";
@@ -30,6 +30,18 @@ export interface AccessTokenPayload {
30
30
  * Validates the secret strength to prevent deployment with default/weak secrets.
31
31
  */
32
32
  export declare function configureJwt(config: JwtConfig): void;
33
+ /**
34
+ * Has this server been given a JWT secret?
35
+ *
36
+ * False on every backend that authenticates through an adapter — Firebase,
37
+ * Clerk, anything with its own tokens — because nothing calls
38
+ * {@link configureJwt} there. Signing paths still throw when it is false, since
39
+ * asking for a token from a server that cannot mint one is a mistake worth
40
+ * hearing about. Paths whose contract is *"tolerate the absence of auth"* must
41
+ * ask first: `optionalAuth` crashing a request because this backend does not do
42
+ * JWT is a 500 on a route that had already decided anonymous was acceptable.
43
+ */
44
+ export declare function isJwtConfigured(): boolean;
33
45
  /**
34
46
  * Generate an access token (short-lived, 1 hour by default)
35
47
  */
@@ -2,38 +2,18 @@ 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 } from "./rolldown-runtime-DSJWtz9O.js";
5
- import { _ as ANONYMOUS_USER_ID, v as isAnonymousUid } from "./src-Cum9kox5.js";
6
- import "./src-_qQ3RNCK.js";
7
- import { t as isSQLAdmin } from "./backend-CIxN4FVm.js";
5
+ import { S as isAnonymousUid, b as resolveClientListLimit, x as ANONYMOUS_USER_ID } from "./src-DVb5a0bp.js";
6
+ import "./src-Ca6NhxKs.js";
7
+ import { i as isSQLAdmin, r as revokeInternalTableSql, t as createDdlBootstrapper } from "./ddl-bootstrap-Bp3mr6Td.js";
8
8
  import { t as logger } from "./logger-BYU66ENZ.js";
9
- import { n as errorHandler, t as ApiError } from "./errors-CgkCzoj7.js";
10
- import { a as generateRefreshToken, c as getRefreshTokenTtlMs, d as verifyAccessToken, f as verifyDownloadToken, i as generateDownloadToken, l as hashRefreshToken, n as configureJwt, o as getAccessTokenExpiry, p as require_jsonwebtoken, r as generateAccessToken, s as getRefreshTokenExpiry, t as MAX_COOKIE_AGE_MS } from "./jwt-DD6EtpGj.js";
9
+ import { n as errorHandler, t as ApiError } from "./errors-BpudWAVU.js";
10
+ import { a as generateRefreshToken, c as getRefreshTokenTtlMs, f as verifyAccessToken, i as generateDownloadToken, l as hashRefreshToken, m as require_jsonwebtoken, n as configureJwt, o as getAccessTokenExpiry, p as verifyDownloadToken, r as generateAccessToken, s as getRefreshTokenExpiry, t as MAX_COOKIE_AGE_MS, u as isJwtConfigured } from "./jwt-CzeviDcB.js";
11
11
  import { createHash, randomBytes, randomInt } from "node:crypto";
12
12
  import { Hono } from "hono";
13
13
  import { promisify } from "util";
14
14
  import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
15
- /** Hard ceiling clamped onto any client-supplied `limit`, on every surface. */
16
- var MAX_LIST_LIMIT = 1e3;
17
- /**
18
- * Resolve a client-supplied list `limit` into a safe, always-defined value.
19
- *
20
- * - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
21
- * so `0`, negatives, and absurd values can never bypass the cap.
22
- * - An absent / blank / non-numeric limit falls back to the mode default:
23
- * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
24
- *
25
- * The return is never `undefined` — no ingress that routes its client limit
26
- * through this can produce an unbounded read.
27
- */
28
- function resolveClientListLimit(rawLimit, opts = {}) {
29
- const maxLimit = opts.maxLimit ?? 1e3;
30
- if (rawLimit != null && String(rawLimit).trim() !== "") {
31
- const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
32
- if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
33
- }
34
- return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
35
- }
36
- //#endregion
15
+ import path from "node:path";
16
+ import { getConnInfo } from "@hono/node-server/conninfo";
37
17
  //#region ../types/src/controllers/storage.ts
38
18
  /**
39
19
  * Path prefix that marks an object as **public**. Files stored under this
@@ -461,6 +441,91 @@ function createApiKeyPreAuth(options) {
461
441
  };
462
442
  }
463
443
  //#endregion
444
+ //#region src/storage/keys.ts
445
+ /**
446
+ * Canonical storage keys.
447
+ *
448
+ * Storage is not under RLS, so a `storageAuthorize` hook is the whole access
449
+ * control model — and a hook can only be correct if the key it is shown is the
450
+ * key that is written. That is the invariant this module exists to hold: one
451
+ * canonical string, computed once per request, handed to the hook, to the
452
+ * controller, and to the download token alike.
453
+ *
454
+ * ## Why rejecting beats stripping
455
+ *
456
+ * The previous `sanitizeStorageKey` *stripped* `../` in a single pass. Two
457
+ * things were wrong with that, and only one of them was the obvious one.
458
+ *
459
+ * The obvious one: a single pass is not a fixed point. `....//` contains `../`
460
+ * at offset 2, so removing it leaves `../` behind — the sanitizer manufactured
461
+ * the traversal it was there to remove. `users/alice/....//bob/x` came out as
462
+ * `users/alice/../bob/x`, which a prefix hook reads as alice's (it starts with
463
+ * `users/alice/`) and the filesystem reads as bob's. The hook approved one
464
+ * object and the controller wrote another.
465
+ *
466
+ * The subtler one, and the reason this is a rewrite rather than a loop: even a
467
+ * correct strip is a silent rewrite. A caller who asks to store at `a/../b` and
468
+ * gets an object at `a/b` was not protected, they were misled — and every later
469
+ * read, ownership row and audit line now refers to a path nobody chose. So a key
470
+ * that means something other than what it says is refused (400), not repaired.
471
+ *
472
+ * Note what is NOT traversal under this rule: `....` is an ordinary directory
473
+ * name, and `users/alice/....//bob/x` canonicalizes to
474
+ * `users/alice/..../bob/x` — still comfortably inside alice's prefix, which is
475
+ * exactly right. Only a real `..` segment is refused.
476
+ */
477
+ /** Longest key accepted, in UTF-16 code units. Matches the previous cap. */
478
+ var MAX_STORAGE_KEY_LENGTH = 1024;
479
+ /**
480
+ * A key that cannot be canonicalized. Carries no path back to the caller
481
+ * beyond what they sent, so it is safe to surface as a 400 message.
482
+ */
483
+ var InvalidStorageKeyError = class extends Error {
484
+ constructor(message) {
485
+ super(message);
486
+ this.name = "InvalidStorageKeyError";
487
+ }
488
+ };
489
+ /**
490
+ * Canonicalize a caller-supplied storage key, or throw
491
+ * {@link InvalidStorageKeyError}.
492
+ *
493
+ * Normalizations applied (safe, idempotent, and meaning-preserving):
494
+ * - leading slashes removed — `/a/b` and `a/b` name the same object
495
+ * - `.` segments and repeated slashes collapsed
496
+ *
497
+ * Refusals (the key means something other than what it says):
498
+ * - any `..` segment, on either separator, at any depth
499
+ * - null bytes
500
+ * - keys longer than {@link MAX_STORAGE_KEY_LENGTH}
501
+ *
502
+ * A trailing slash is preserved: it is how the folder route marks a prefix.
503
+ */
504
+ function canonicalStorageKey(rawKey) {
505
+ if (rawKey.includes("\0")) throw new InvalidStorageKeyError("Storage key contains a null byte.");
506
+ if (rawKey.length > 1024) throw new InvalidStorageKeyError(`Storage key exceeds the maximum length of ${MAX_STORAGE_KEY_LENGTH} characters.`);
507
+ if (rawKey.split(/[\\/]/).some((segment) => segment === "..")) throw new InvalidStorageKeyError("Storage key contains a '..' path segment. Keys must name an object directly.");
508
+ const withoutLeadingSlashes = rawKey.replace(/^\/+/, "");
509
+ if (withoutLeadingSlashes === "") return "";
510
+ const normalized = path.posix.normalize(withoutLeadingSlashes);
511
+ if (normalized === "." || normalized === "./") return "";
512
+ return normalized.replace(/^\.\//, "").replace(/^\/+/, "");
513
+ }
514
+ /**
515
+ * Canonicalize, or return `null` when the key is not canonicalizable.
516
+ *
517
+ * For callers that must fail closed without an exception — the download-token
518
+ * middleware compares a request path against a granted path, and a key it
519
+ * cannot canonicalize simply matches nothing.
520
+ */
521
+ function tryCanonicalStorageKey(rawKey) {
522
+ try {
523
+ return canonicalStorageKey(rawKey);
524
+ } catch {
525
+ return null;
526
+ }
527
+ }
528
+ //#endregion
464
529
  //#region src/auth/middleware.ts
465
530
  /**
466
531
  * Hono middleware that requires a valid JWT token via Authorization header.
@@ -551,7 +616,7 @@ var requireAdmin = async (c, next) => {
551
616
  var optionalAuth = async (c, next) => {
552
617
  if (c.get("user")) return next();
553
618
  const token = extractBearerToken(c.req.header("authorization"));
554
- if (token !== void 0) {
619
+ if (token !== void 0 && isJwtConfigured()) {
555
620
  const payload = verifyAccessToken(token);
556
621
  if (payload) c.set("user", payload);
557
622
  }
@@ -561,6 +626,7 @@ var optionalAuth = async (c, next) => {
561
626
  * Extract user from token - for WebSocket authentication
562
627
  */
563
628
  function extractUserFromToken(token) {
629
+ if (!isJwtConfigured()) return null;
564
630
  return verifyAccessToken(token);
565
631
  }
566
632
  /**
@@ -711,7 +777,7 @@ function createAuthMiddleware(options) {
711
777
  var queryTokenAuth = async (c, next) => {
712
778
  if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
713
779
  const queryToken = c.req.query("token");
714
- if (queryToken) {
780
+ if (queryToken && isJwtConfigured()) {
715
781
  const payload = verifyAccessToken(queryToken);
716
782
  if (payload) c.set("user", payload);
717
783
  }
@@ -782,15 +848,25 @@ var fileTokenAuth = async (c, next) => {
782
848
  if (idx < 0) return "";
783
849
  return fullPath.substring(idx + prefix.length + 1);
784
850
  };
851
+ /**
852
+ * Must agree with `parseBucketAndPath` in `storage/routes.ts`, because the
853
+ * path a token GRANTS is minted there and the path a request ASKS FOR is
854
+ * derived here. `/metadata` canonicalizes before signing, so a raw
855
+ * comparison here would 403 an otherwise-valid request for any key whose
856
+ * URL form is not already canonical (`a//b.txt`, `a/./b.txt`).
857
+ *
858
+ * A key that cannot be canonicalized yields null, which matches no grant —
859
+ * the same fail-closed answer `isPathMatch` gives a `..` segment.
860
+ */
785
861
  const parseBucketPath = (filePath) => {
786
862
  const parts = filePath.split("/");
787
863
  if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
788
864
  bucket: "default",
789
- resolvedPath: parts.slice(1).join("/")
865
+ resolvedPath: tryCanonicalStorageKey(parts.slice(1).join("/"))
790
866
  };
791
867
  return {
792
868
  bucket: "default",
793
- resolvedPath: filePath
869
+ resolvedPath: tryCanonicalStorageKey(filePath)
794
870
  };
795
871
  };
796
872
  const bearerToken = extractBearerToken(authHeader);
@@ -800,7 +876,8 @@ var fileTokenAuth = async (c, next) => {
800
876
  const rawPath = extractWildcard(c);
801
877
  if (rawPath) {
802
878
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
803
- if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
879
+ const requestedFullPath = resolvedPath === null ? null : `${bucket}/${resolvedPath}`;
880
+ if (requestedFullPath !== null && isPathMatch(requestedFullPath, payload.path)) {
804
881
  c.set("user", {
805
882
  uid: "download-token",
806
883
  roles: ["reader"]
@@ -824,7 +901,8 @@ var fileTokenAuth = async (c, next) => {
824
901
  const rawPath = extractWildcard(c);
825
902
  if (rawPath) {
826
903
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
827
- if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
904
+ const requestedFullPath = resolvedPath === null ? null : `${bucket}/${resolvedPath}`;
905
+ if (requestedFullPath !== null && isPathMatch(requestedFullPath, payload.path)) {
828
906
  c.set("user", {
829
907
  uid: "download-token",
830
908
  roles: ["reader"]
@@ -1458,11 +1536,11 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
1458
1536
  await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
1459
1537
  const setPasswordUrl = `${ctx.emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
1460
1538
  const appName = ctx.emailConfig?.appName || "Rebase";
1461
- const templateFn = ctx.emailConfig?.templates?.passwordReset;
1539
+ const templateFn = ctx.emailConfig?.templates?.userInvitation;
1462
1540
  const emailContent = templateFn ? templateFn(setPasswordUrl, {
1463
1541
  email: entity.values.email,
1464
1542
  displayName: entity.values.displayName
1465
- }) : getPasswordResetTemplate(setPasswordUrl, {
1543
+ }) : getUserInvitationTemplate(setPasswordUrl, {
1466
1544
  email: entity.values.email,
1467
1545
  displayName: entity.values.displayName
1468
1546
  }, appName);
@@ -1491,6 +1569,11 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
1491
1569
  var scryptAsync = promisify(scrypt);
1492
1570
  var SALT_LENGTH = 32;
1493
1571
  var KEY_LENGTH = 64;
1572
+ var SCRYPT_PARAMS = {
1573
+ N: 16384,
1574
+ r: 8,
1575
+ p: 1
1576
+ };
1494
1577
  /**
1495
1578
  * Password requirements:
1496
1579
  * - Minimum 8 characters
@@ -1515,7 +1598,7 @@ function validatePasswordStrength(password) {
1515
1598
  */
1516
1599
  async function hashPassword(password) {
1517
1600
  const salt = randomBytes$1(SALT_LENGTH);
1518
- const derivedKey = await scryptAsync(password, salt, KEY_LENGTH);
1601
+ const derivedKey = await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS);
1519
1602
  return `${salt.toString("hex")}:${derivedKey.toString("hex")}`;
1520
1603
  }
1521
1604
  /**
@@ -1527,7 +1610,7 @@ async function verifyPassword(password, storedHash) {
1527
1610
  if (!saltHex || !hashHex) return false;
1528
1611
  const salt = Buffer.from(saltHex, "hex");
1529
1612
  const storedKey = Buffer.from(hashHex, "hex");
1530
- return timingSafeEqual(await scryptAsync(password, salt, KEY_LENGTH), storedKey);
1613
+ return timingSafeEqual(await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS), storedKey);
1531
1614
  }
1532
1615
  //#endregion
1533
1616
  //#region src/auth/auth-hooks.ts
@@ -1686,7 +1769,23 @@ function createRateLimiter(options = {}) {
1686
1769
  };
1687
1770
  }
1688
1771
  /**
1689
- * Default key generator: extract client IP from standard headers.
1772
+ * The address the socket is actually connected to, or `undefined` on a runtime
1773
+ * that cannot say. Unforgeable, unlike every header below.
1774
+ *
1775
+ * `getConnInfo` is Node-specific and throws elsewhere, so the failure is
1776
+ * swallowed: a runtime without connection info falls back to the shared bucket,
1777
+ * which is a availability trade the caller cannot exploit.
1778
+ */
1779
+ function socketAddress(c) {
1780
+ try {
1781
+ return getConnInfo(c).remote.address;
1782
+ } catch {
1783
+ return;
1784
+ }
1785
+ }
1786
+ /**
1787
+ * Default key generator: the client's address, from the most trustworthy source
1788
+ * this deployment has.
1690
1789
  *
1691
1790
  * `X-Forwarded-For` is a client-writable header; only the entries appended by
1692
1791
  * trusted reverse proxies can be believed. With `trustedProxyHops` proxies in
@@ -1695,8 +1794,20 @@ function createRateLimiter(options = {}) {
1695
1794
  * client-supplied and must be ignored. This is what prevents a caller from
1696
1795
  * spoofing `X-Forwarded-For` to spread its requests across many rate-limit keys.
1697
1796
  *
1698
- * With `trustedProxyHops === 0` (server exposed directly, no proxy),
1699
- * `X-Forwarded-For` is not trusted at all and `X-Real-IP` is used instead.
1797
+ * `X-Real-IP` is the *same* kind of header and needs the same rule, which it
1798
+ * did not have: it was read unconditionally, including under
1799
+ * `trustedProxyHops === 0` — the mode whose entire meaning is "no proxy is in
1800
+ * front of me". With no proxy there, nothing writes `X-Real-IP` except the
1801
+ * caller, so the key was theirs to choose: one header per request bought an
1802
+ * unlimited number of buckets, and the limiters on login, registration and
1803
+ * password reset counted to one. The reasoning had been done carefully for one
1804
+ * spelling of a proxy header and not carried to its twin.
1805
+ *
1806
+ * So `X-Real-IP` is now believed only where a proxy is declared to exist. With
1807
+ * none, the connection's own address is used — unforgeable, and available
1808
+ * because the server runs on `@hono/node-server`. `"unknown"` is the last
1809
+ * resort only, and it is a single shared bucket by design: better that
1810
+ * anonymous callers throttle each other than that any of them throttles nobody.
1700
1811
  */
1701
1812
  function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
1702
1813
  if (trustedProxyHops > 0) {
@@ -1705,8 +1816,10 @@ function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
1705
1816
  const ips = forwardedFor.split(",").map((s) => s.trim()).filter(Boolean);
1706
1817
  if (ips.length > 0) return ips[Math.max(0, ips.length - trustedProxyHops)];
1707
1818
  }
1819
+ const realIp = c.req.header("x-real-ip");
1820
+ if (realIp) return realIp;
1708
1821
  }
1709
- return c.req.header("x-real-ip") || "unknown";
1822
+ return socketAddress(c) ?? "unknown";
1710
1823
  }
1711
1824
  /**
1712
1825
  * Pre-configured rate limiter for general auth endpoints (login, register).
@@ -6168,7 +6281,7 @@ function mountSessionRoutes(opts) {
6168
6281
  clearRefreshCookie(c, config.cookieAuth);
6169
6282
  const accessToken = extractBearerToken(c.req.header("authorization"));
6170
6283
  if (ops.afterLogout && accessToken !== void 0) {
6171
- const { verifyAccessToken } = await import("./jwt-DD6EtpGj.js").then((n) => n.u);
6284
+ const { verifyAccessToken } = await import("./jwt-CzeviDcB.js").then((n) => n.d);
6172
6285
  const payload = verifyAccessToken(accessToken);
6173
6286
  if (payload) ops.afterLogout(payload.uid).catch((err) => {
6174
6287
  logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
@@ -6549,11 +6662,6 @@ function createAuthRoutes(config) {
6549
6662
  newPassword: string().min(1, "New password is required").max(128)
6550
6663
  });
6551
6664
  const refreshSchema = object({ refreshToken: string().min(1).optional() });
6552
- object({ refreshToken: string().optional() });
6553
- object({
6554
- displayName: string().max(255).optional(),
6555
- photoURL: string().url().max(2048).optional()
6556
- });
6557
6665
  /** Parse a Zod schema against the request body, throwing ApiError on failure */
6558
6666
  function parseBody(schema, body) {
6559
6667
  const result = schema.safeParse(body);
@@ -8591,44 +8699,45 @@ function createApiKeyStore(driver) {
8591
8699
  return;
8592
8700
  }
8593
8701
  const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
8702
+ const ddl = createDdlBootstrapper(exec, "api-key-store");
8594
8703
  return {
8595
8704
  async ensureTable() {
8596
- try {
8597
- await exec("CREATE SCHEMA IF NOT EXISTS rebase");
8598
- await exec(`
8599
- CREATE TABLE IF NOT EXISTS ${TABLE} (
8600
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
8601
- name TEXT NOT NULL,
8602
- key_prefix TEXT NOT NULL,
8603
- key_hash TEXT NOT NULL UNIQUE,
8604
- permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
8605
- admin BOOLEAN NOT NULL DEFAULT FALSE,
8606
- rate_limit INTEGER,
8607
- created_by TEXT NOT NULL,
8608
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8609
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8610
- last_used_at TIMESTAMPTZ,
8611
- expires_at TIMESTAMPTZ,
8612
- revoked_at TIMESTAMPTZ
8613
- )
8614
- `);
8615
- await exec(`
8616
- CREATE INDEX IF NOT EXISTS idx_api_keys_hash
8617
- ON ${TABLE}(key_hash)
8618
- `);
8619
- await exec(`
8620
- CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
8621
- ON ${TABLE}(key_prefix)
8622
- `);
8623
- await exec(`
8624
- ALTER TABLE ${TABLE}
8625
- ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
8626
- `);
8627
- logger.info("✅ API keys table ready");
8628
- } catch (err) {
8629
- logger.error("❌ Failed to create API keys table", { error: err });
8630
- logger.warn("⚠️ Continuing without API keys support.");
8705
+ await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
8706
+ await ddl.ensureObject(`Creating ${TABLE}`, `
8707
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
8708
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
8709
+ name TEXT NOT NULL,
8710
+ key_prefix TEXT NOT NULL,
8711
+ key_hash TEXT NOT NULL UNIQUE,
8712
+ permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
8713
+ admin BOOLEAN NOT NULL DEFAULT FALSE,
8714
+ rate_limit INTEGER,
8715
+ created_by TEXT NOT NULL,
8716
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8717
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8718
+ last_used_at TIMESTAMPTZ,
8719
+ expires_at TIMESTAMPTZ,
8720
+ revoked_at TIMESTAMPTZ
8721
+ )
8722
+ `);
8723
+ await ddl.ensureObject("Creating idx_api_keys_hash", `
8724
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash
8725
+ ON ${TABLE}(key_hash)
8726
+ `);
8727
+ await ddl.ensureObject("Creating idx_api_keys_prefix", `
8728
+ CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
8729
+ ON ${TABLE}(key_prefix)
8730
+ `);
8731
+ await ddl.ensureObject(`Adding ${TABLE}.admin`, `
8732
+ ALTER TABLE ${TABLE}
8733
+ ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
8734
+ `);
8735
+ if (!await ddl.isReadable(TABLE)) {
8736
+ logger.error(`❌ [api-key-store] ${TABLE} is unavailable — every API-key authenticated request will be rejected on this instance, and the table could not be taken back off the end-user role.`);
8737
+ return;
8631
8738
  }
8739
+ await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
8740
+ logger.info("✅ API keys table ready");
8632
8741
  },
8633
8742
  async createApiKey(request, createdBy) {
8634
8743
  const plaintext = generateApiKey();
@@ -8939,6 +9048,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
8939
9048
  hashToken: () => hashToken,
8940
9049
  httpMethodToOperation: () => httpMethodToOperation,
8941
9050
  isApiKeyToken: () => isApiKeyToken,
9051
+ isJwtConfigured: () => isJwtConfigured,
8942
9052
  isOperationAllowed: () => isOperationAllowed,
8943
9053
  mountMagicLinkRoutes: () => mountMagicLinkRoutes,
8944
9054
  optionalAuth: () => optionalAuth,
@@ -8956,6 +9066,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
8956
9066
  verifyPassword: () => verifyPassword
8957
9067
  });
8958
9068
  //#endregion
8959
- export { httpMethodToOperation as $, getEmailVerificationTemplate as A, optionalAuth as B, createDataRateLimiter as C, validatePasswordStrength as D, hashPassword as E, createAdapterAuthMiddleware as F, createApiKeyPreAuth as G, queryTokenAuth as H, createAuthMiddleware as I, isApiKeyToken as J, createFunctionApiKeyGuard as K, createRequireAuth as L, getPasswordResetTemplate as M, getUserInvitationTemplate as N, verifyPassword as O, getWelcomeEmailTemplate as P, scopeDataDriver as Q, extractUserFromToken as R, _coercedNumber as S, resolveAuthHooks as T, requireAdmin as U, publicObjectAuth as V, requireAuth as W, extractBearerToken as X, validateApiKey as Y, safeCompare as Z, createBuiltinAuthAdapter as _, createSpotifyProvider as a, object as b, createGitLabProvider as c, createFacebookProvider as d, isOperationAllowed as et, createAppleProvider as f, createGoogleProvider as g, createLinkedinProvider as h, createApiKeyStore as i, resolveClientListLimit as it, getMagicLinkTemplate as j, generateSecurePassword as k, createDiscordProvider as l, createGitHubProvider as m, createCustomAuthAdapter as n, isPublicStoragePath as nt, createSlackProvider as o, createMicrosoftProvider as p, createStorageApiKeyGuard as q, createApiKeyRoutes as r, MAX_LIST_LIMIT as rt, createBitbucketProvider as s, auth_exports as t, PUBLIC_STORAGE_PREFIX as tt, createTwitterProvider as u, ZodNumber as v, MemoryRateLimitStore as w, string as x, _enum as y, fileTokenAuth as z };
9069
+ export { safeCompare as $, getEmailVerificationTemplate as A, optionalAuth as B, createDataRateLimiter as C, validatePasswordStrength as D, hashPassword as E, createAdapterAuthMiddleware as F, InvalidStorageKeyError as G, queryTokenAuth as H, createAuthMiddleware as I, createFunctionApiKeyGuard as J, canonicalStorageKey as K, createRequireAuth as L, getPasswordResetTemplate as M, getUserInvitationTemplate as N, verifyPassword as O, getWelcomeEmailTemplate as P, extractBearerToken as Q, extractUserFromToken as R, _coercedNumber as S, resolveAuthHooks as T, requireAdmin as U, publicObjectAuth as V, requireAuth as W, isApiKeyToken as X, createStorageApiKeyGuard as Y, validateApiKey as Z, createBuiltinAuthAdapter as _, createSpotifyProvider as a, object as b, createGitLabProvider as c, createFacebookProvider as d, scopeDataDriver as et, createAppleProvider as f, createGoogleProvider as g, createLinkedinProvider as h, createApiKeyStore as i, isPublicStoragePath as it, getMagicLinkTemplate as j, generateSecurePassword as k, createDiscordProvider as l, createGitHubProvider as m, createCustomAuthAdapter as n, isOperationAllowed as nt, createSlackProvider as o, createMicrosoftProvider as p, createApiKeyPreAuth as q, createApiKeyRoutes as r, PUBLIC_STORAGE_PREFIX as rt, createBitbucketProvider as s, auth_exports as t, httpMethodToOperation as tt, createTwitterProvider as u, ZodNumber as v, MemoryRateLimitStore as w, string as x, _enum as y, fileTokenAuth as z };
8960
9070
 
8961
- //# sourceMappingURL=auth-CuC9M2x6.js.map
9071
+ //# sourceMappingURL=auth-BANhRhN9.js.map