@rebasepro/server 0.17.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +5 -1
  3. package/bin/rebase-server.js +16 -1
  4. package/dist/{GCSStorageController-Bl7nmhdv.js → GCSStorageController-ZXPoNqW3.js} +21 -10
  5. package/dist/GCSStorageController-ZXPoNqW3.js.map +1 -0
  6. package/dist/{S3StorageController-CWvvrRpF.js → S3StorageController-5pAXyv31.js} +24 -10
  7. package/dist/S3StorageController-5pAXyv31.js.map +1 -0
  8. package/dist/admin-roles-vYdp_Pil.js +36 -0
  9. package/dist/admin-roles-vYdp_Pil.js.map +1 -0
  10. package/dist/{admin_block-BX6YULvJ.js → admin_block-0Xu0r6eZ.js} +4 -1
  11. package/dist/admin_block-0Xu0r6eZ.js.map +1 -0
  12. package/dist/api/errors.d.ts +37 -0
  13. package/dist/api/live-schema-routes.d.ts +14 -0
  14. package/dist/api/logs-routes.d.ts +8 -0
  15. package/dist/api/rest/api-generator.d.ts +33 -0
  16. package/dist/api/root-error-handler.d.ts +62 -0
  17. package/dist/api/schema-drift.d.ts +39 -0
  18. package/dist/api/types.d.ts +55 -0
  19. package/dist/{ast-schema-editor-CvkYOvri.js → ast-schema-editor-CNgFJ3NF.js} +23 -7
  20. package/dist/ast-schema-editor-CNgFJ3NF.js.map +1 -0
  21. package/dist/auth/api-keys/api-key-routes.d.ts +12 -0
  22. package/dist/auth/auth-hooks.d.ts +13 -2
  23. package/dist/auth/exclude-auth-secrets.d.ts +14 -0
  24. package/dist/auth/jwt.d.ts +22 -1
  25. package/dist/auth/mfa-routes.d.ts +10 -1
  26. package/dist/auth/middleware.d.ts +22 -0
  27. package/dist/auth/rate-limiter.d.ts +50 -0
  28. package/dist/auth/registration-policy.d.ts +27 -0
  29. package/dist/auth/rls-scope.d.ts +9 -1
  30. package/dist/auth/routes.d.ts +8 -1
  31. package/dist/auth/seed-admin.d.ts +73 -0
  32. package/dist/auth/session-routes.d.ts +10 -0
  33. package/dist/{auth-DkbzMZUS.js → auth-BRiOuyq8.js} +283 -198
  34. package/dist/auth-BRiOuyq8.js.map +1 -0
  35. package/dist/{backup-CRdZkA6c.js → backup-C8P6Cl3G.js} +2 -2
  36. package/dist/{backup-CRdZkA6c.js.map → backup-C8P6Cl3G.js.map} +1 -1
  37. package/dist/boot/boot.d.ts +18 -0
  38. package/dist/boot/bundle.d.ts +29 -6
  39. package/dist/boot/ddl-bootstrap.d.ts +2 -0
  40. package/dist/boot/env.d.ts +30 -1
  41. package/dist/boot/fetch-bundle.d.ts +24 -1
  42. package/dist/boot/options.d.ts +0 -8
  43. package/dist/boot/provision.d.ts +58 -18
  44. package/dist/boot/resource-adapters.d.ts +11 -1
  45. package/dist/boot/resource-resolvers.d.ts +96 -0
  46. package/dist/boot/sources.d.ts +27 -20
  47. package/dist/collections/validate-config.d.ts +29 -0
  48. package/dist/{contract-routes-CHPVKTZW.js → contract-routes-CusnEB5h.js} +24 -9
  49. package/dist/{contract-routes-CHPVKTZW.js.map → contract-routes-CusnEB5h.js.map} +1 -1
  50. package/dist/cron/cron-loader.d.ts +7 -1
  51. package/dist/cron/cron-scheduler.d.ts +48 -2
  52. package/dist/{cron-loader-d9WMFENB.js → cron-loader-DnmIePn_.js} +23 -4
  53. package/dist/cron-loader-DnmIePn_.js.map +1 -0
  54. package/dist/{cron-routes-DSC-kZXI.js → cron-routes-D3x2ydMa.js} +9 -6
  55. package/dist/cron-routes-D3x2ydMa.js.map +1 -0
  56. package/dist/{cron-scheduler-BB82dWuU.js → cron-scheduler-BpPKpl4i.js} +148 -28
  57. package/dist/cron-scheduler-BpPKpl4i.js.map +1 -0
  58. package/dist/{cron-store-KeN8rN63.js → cron-store-CCQXwgVL.js} +5 -5
  59. package/dist/{cron-store-KeN8rN63.js.map → cron-store-CCQXwgVL.js.map} +1 -1
  60. package/dist/{ddl-bootstrap-5YZCZ8qk.js → ddl-bootstrap-CfNvxMuK.js} +26 -2
  61. package/dist/{ddl-bootstrap-5YZCZ8qk.js.map → ddl-bootstrap-CfNvxMuK.js.map} +1 -1
  62. package/dist/email/index.d.ts +1 -0
  63. package/dist/email/unconfigured.d.ts +20 -0
  64. package/dist/env.d.ts +25 -0
  65. package/dist/errors-HjfaPlvY.js +385 -0
  66. package/dist/errors-HjfaPlvY.js.map +1 -0
  67. package/dist/{function-loader-LLdmBFoL.js → function-loader-xnbDAPfa.js} +9 -3
  68. package/dist/{function-loader-LLdmBFoL.js.map → function-loader-xnbDAPfa.js.map} +1 -1
  69. package/dist/function-routes-gQ0EShVG.js +267 -0
  70. package/dist/function-routes-gQ0EShVG.js.map +1 -0
  71. package/dist/functions/define-function.d.ts +6 -3
  72. package/dist/functions/function-routes.d.ts +8 -4
  73. package/dist/functions/index.js +128 -23
  74. package/dist/functions/index.js.map +1 -1
  75. package/dist/{history-recorder-B2pL3X8b.js → history-recorder-B1FwXx9J.js} +3 -3
  76. package/dist/{history-recorder-B2pL3X8b.js.map → history-recorder-B1FwXx9J.js.map} +1 -1
  77. package/dist/{history-store-BUJ-37Sm.js → history-store-LHXaQywp.js} +2 -2
  78. package/dist/{history-store-BUJ-37Sm.js.map → history-store-LHXaQywp.js.map} +1 -1
  79. package/dist/index.d.ts +16 -4
  80. package/dist/index.es.js +16229 -7676
  81. package/dist/index.es.js.map +1 -1
  82. package/dist/init.d.ts +33 -13
  83. package/dist/{jobs-XXXcZrmj.js → jobs-DkkD9mPV.js} +5 -5
  84. package/dist/{jobs-XXXcZrmj.js.map → jobs-DkkD9mPV.js.map} +1 -1
  85. package/dist/{jwt-DNeOn39r.js → jwt-DkhXwMzR.js} +9 -314
  86. package/dist/jwt-DkhXwMzR.js.map +1 -0
  87. package/dist/keys-g8lbVC_o.js +230 -0
  88. package/dist/keys-g8lbVC_o.js.map +1 -0
  89. package/dist/{logger-DS03e908.js → logger-DO2PZc4i.js} +197 -18
  90. package/dist/logger-DO2PZc4i.js.map +1 -0
  91. package/dist/{logs-routes-DB72iQSr.js → logs-routes-CbsTpozn.js} +74 -6
  92. package/dist/logs-routes-CbsTpozn.js.map +1 -0
  93. package/dist/{openapi-generator-CpZp2Fai.js → openapi-generator-BIBbO1Tq.js} +221 -38
  94. package/dist/openapi-generator-BIBbO1Tq.js.map +1 -0
  95. package/dist/{proxy-CMymhnwG.js → proxy-Czngl3p9.js} +2 -2
  96. package/dist/{proxy-CMymhnwG.js.map → proxy-Czngl3p9.js.map} +1 -1
  97. package/dist/{query-parser-DqXNaBIH.js → query-parser-C68Q9EX4.js} +35 -6
  98. package/dist/{query-parser-DqXNaBIH.js.map → query-parser-C68Q9EX4.js.map} +1 -1
  99. package/dist/queues/runtime.d.ts +56 -0
  100. package/dist/{request-timeout-C8gkc-j7.js → request-timeout-DESvlfrS.js} +4 -6
  101. package/dist/request-timeout-DESvlfrS.js.map +1 -0
  102. package/dist/schema-edit/project-root.d.ts +4 -4
  103. package/dist/{schema-editor-routes-DNV_nBhW.js → schema-editor-routes-CcZKh50q.js} +4 -4
  104. package/dist/{schema-editor-routes-DNV_nBhW.js.map → schema-editor-routes-CcZKh50q.js.map} +1 -1
  105. package/dist/{schemas-DBxgjM9A.js → schemas-C3234HWE.js} +2746 -72
  106. package/dist/schemas-C3234HWE.js.map +1 -0
  107. package/dist/singleton.d.ts +6 -4
  108. package/dist/src-DHK4fHkw.js +1002 -0
  109. package/dist/src-DHK4fHkw.js.map +1 -0
  110. package/dist/{src-C9gKj23J.js → src-Dq-I3Ybx.js} +507 -54
  111. package/dist/src-Dq-I3Ybx.js.map +1 -0
  112. package/dist/storage/GCSStorageController.d.ts +5 -0
  113. package/dist/storage/LocalStorageController.d.ts +14 -0
  114. package/dist/storage/S3StorageController.d.ts +8 -0
  115. package/dist/storage/image-transform.d.ts +35 -0
  116. package/dist/storage/keys.d.ts +23 -0
  117. package/dist/storage/policies.d.ts +22 -0
  118. package/dist/storage/routes.d.ts +9 -0
  119. package/dist/storage/tus-handler.d.ts +43 -1
  120. package/dist/storage/types.d.ts +28 -0
  121. package/dist/types-BfKcm9do.js.map +1 -1
  122. package/dist/utils/dev-port.d.ts +18 -0
  123. package/dist/utils/logger.d.ts +60 -0
  124. package/dist/utils/request-logger.d.ts +2 -1
  125. package/package.json +30 -24
  126. package/dist/GCSStorageController-Bl7nmhdv.js.map +0 -1
  127. package/dist/S3StorageController-CWvvrRpF.js.map +0 -1
  128. package/dist/admin_block-BX6YULvJ.js.map +0 -1
  129. package/dist/ast-schema-editor-CvkYOvri.js.map +0 -1
  130. package/dist/auth-DkbzMZUS.js.map +0 -1
  131. package/dist/cron-loader-d9WMFENB.js.map +0 -1
  132. package/dist/cron-routes-DSC-kZXI.js.map +0 -1
  133. package/dist/cron-scheduler-BB82dWuU.js.map +0 -1
  134. package/dist/errors-DBwpj9N8.js +0 -231
  135. package/dist/errors-DBwpj9N8.js.map +0 -1
  136. package/dist/function-routes-ClT6UQpD.js +0 -41
  137. package/dist/function-routes-ClT6UQpD.js.map +0 -1
  138. package/dist/jwt-DNeOn39r.js.map +0 -1
  139. package/dist/logger-DS03e908.js.map +0 -1
  140. package/dist/logs-routes-DB72iQSr.js.map +0 -1
  141. package/dist/openapi-generator-CpZp2Fai.js.map +0 -1
  142. package/dist/request-timeout-C8gkc-j7.js.map +0 -1
  143. package/dist/schemas-DBxgjM9A.js.map +0 -1
  144. package/dist/src-C9gKj23J.js.map +0 -1
  145. package/dist/src-CcOl05Oq.js +0 -552
  146. package/dist/src-CcOl05Oq.js.map +0 -1
  147. package/dist/utils/logging.d.ts +0 -5
@@ -20,11 +20,66 @@ export type HonoEnv = {
20
20
  * bucket — a limiter in name only. See `auth/otp-routes.ts`.
21
21
  */
22
22
  otpEmail?: string;
23
+ /**
24
+ * The address a mail-sending auth route named, lower-cased.
25
+ *
26
+ * The generalisation of `otpEmail`, for the same reason and used the
27
+ * same way: `recipientEmailLimiter` bounds how many messages one
28
+ * MAILBOX receives, and the address it keys on lives in a body the
29
+ * limiter runs before anyone has parsed. See `auth/rate-limiter.ts`.
30
+ */
31
+ recipientEmail?: string;
23
32
  driver?: DataDriver;
24
33
  /** Set when the request is authenticated via a Service API Key. */
25
34
  apiKey?: ApiKeyMasked;
26
35
  /** Unique request correlation ID (generated or propagated from X-Request-ID header). */
27
36
  requestId?: string;
37
+ /**
38
+ * The collection this request is about, when it is about one.
39
+ *
40
+ * Set by the REST generator from the first path segment, validated
41
+ * against the collections it actually serves — so it is a slug this
42
+ * backend knows, not whatever the caller typed. Read by the request
43
+ * logger: "a 403 on /api/data/orders" and "a 403" are different amounts
44
+ * of help at 3am, and the path alone does not survive being aggregated.
45
+ */
46
+ collection?: string;
47
+ /**
48
+ * What the error handler answered, for the request log line.
49
+ *
50
+ * The handler and the request logger both wrote a line for the same
51
+ * failed request, each holding half of it: the handler had the code and
52
+ * the message, the logger had the user and the latency. Neither was
53
+ * enough, and two lines per failure is its own cost. The handler now
54
+ * leaves what it knows here and stays quiet where the logger will
55
+ * speak. See `utils/request-logger.ts`.
56
+ */
57
+ errorSummary?: {
58
+ code: string;
59
+ message: string;
60
+ };
61
+ /**
62
+ * Set by `requestLogger` before the handler runs, so the error handler
63
+ * can tell whether one line per request is already guaranteed.
64
+ *
65
+ * A router mounted without the request logger — a project wiring routes
66
+ * onto its own Hono app — still gets the handler's own line, because
67
+ * there nothing else would report the failure at all.
68
+ */
69
+ requestLogged?: boolean;
70
+ /**
71
+ * The caller's `x-rebase-schema` stamp and this server's, when they
72
+ * disagree.
73
+ *
74
+ * Set by `createSchemaDriftDetector` and read only by the error handler,
75
+ * which attaches it as the cause of a 400 or 404 the request would have
76
+ * produced anyway. Never a reason to refuse: an SDK generated against an
77
+ * older schema is usually still compatible.
78
+ */
79
+ schemaDrift?: {
80
+ client: string;
81
+ server: string;
82
+ };
28
83
  };
29
84
  };
30
85
  /**
@@ -2,8 +2,8 @@ import { createRequire as __rebaseCreateRequire } from "module";
2
2
  import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
- import "./src-CcOl05Oq.js";
6
- import { i as nestAdminPropertyKeys, r as nestAdminCollectionKeys } from "./admin_block-BX6YULvJ.js";
5
+ import "./src-DHK4fHkw.js";
6
+ import { i as nestAdminPropertyKeys, r as nestAdminCollectionKeys } from "./admin_block-0Xu0r6eZ.js";
7
7
  import * as fs$1 from "fs";
8
8
  import * as path$1 from "path";
9
9
  import { IndentationText, Node, Project, SyntaxKind } from "ts-morph";
@@ -19,6 +19,20 @@ import { IndentationText, Node, Project, SyntaxKind } from "ts-morph";
19
19
  */
20
20
  var COLLECTION_FACTORIES = /* @__PURE__ */ new Set(["defineCollection"]);
21
21
  /**
22
+ * The only relation-target expression this editor will write through verbatim.
23
+ *
24
+ * A relation's `target` is emitted as SOURCE, not as a string literal, because
25
+ * it has to be `() => otherCollection`. The test for "is this already a thunk?"
26
+ * used to be "does it contain an arrow", which `() => { require("child_process")
27
+ * .execSync("…") }` also satisfies — and `rebase dev` re-imports the file the
28
+ * moment it changes, so the payload ran without anyone deploying anything.
29
+ *
30
+ * An arrow returning a single identifier is the whole grammar. Anything else is
31
+ * read as a collection name and turned into a thunk by `targetThunk`, which
32
+ * resolves it against the collections directory and refuses a name with no file.
33
+ */
34
+ var ARROW_TO_IDENTIFIER = /^\(\s*\)\s*=>\s*[A-Za-z_$][A-Za-z0-9_$]*$/;
35
+ /**
22
36
  * A value that must be emitted as source code rather than as JSON.
23
37
  *
24
38
  * Everything reaching the writer has been through `JSON.stringify` on the wire,
@@ -204,7 +218,7 @@ var AstSchemaEditor = class AstSchemaEditor {
204
218
  const init = oldProp.getInitializer();
205
219
  if (init) {
206
220
  const kind = init.getKind();
207
- if (kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.FunctionExpression || kind === SyntaxKind.Identifier || kind === SyntaxKind.CallExpression || kind === SyntaxKind.JsxElement || name === "target" || name === "callbacks" || name === "permissions" || name === "securityRules") preservedProps.push(`${AstSchemaEditor.quoteKey(name)}: ${init.getText()}`);
221
+ if (kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.FunctionExpression || kind === SyntaxKind.Identifier || kind === SyntaxKind.CallExpression || kind === SyntaxKind.JsxElement || name === "target" || name === "callbacks" || name === "browserCallbacks" || name === "permissions" || name === "securityRules") preservedProps.push(`${AstSchemaEditor.quoteKey(name)}: ${init.getText()}`);
208
222
  }
209
223
  }
210
224
  }
@@ -333,7 +347,7 @@ var AstSchemaEditor = class AstSchemaEditor {
333
347
  const newInit = this.convertJsonToAstString(collectionData[key], 1, oldAstNode);
334
348
  if (prop) prop.setInitializer(newInit);
335
349
  else collectionObj.addPropertyAssignment({
336
- name: key,
350
+ name: AstSchemaEditor.quoteKey(key),
337
351
  initializer: newInit
338
352
  });
339
353
  }
@@ -378,8 +392,10 @@ var AstSchemaEditor = class AstSchemaEditor {
378
392
  const relationName = typeof relation.relationName === "string" ? relation.relationName : void 0;
379
393
  const rawTarget = relation.target;
380
394
  let targetText;
381
- if (typeof rawTarget === "string" && rawTarget.trim().length > 0) targetText = rawTarget.includes("=>") ? rawTarget.trim() : this.targetThunk(file, rawTarget.trim());
382
- else if (relationName && oldTargetsByName.has(relationName)) targetText = oldTargetsByName.get(relationName);
395
+ if (typeof rawTarget === "string" && rawTarget.trim().length > 0) {
396
+ const trimmed = rawTarget.trim();
397
+ targetText = ARROW_TO_IDENTIFIER.test(trimmed) ? trimmed : this.targetThunk(file, trimmed);
398
+ } else if (relationName && oldTargetsByName.has(relationName)) targetText = oldTargetsByName.get(relationName);
383
399
  else if (value.length === oldElements.length) targetText = anonymousTargetsByIndex[index];
384
400
  if (!targetText) throw new Error(`Relation "${relationName ?? `#${index}`}" on collection "${collectionId}" has no target collection. Pick one before saving.`);
385
401
  relation.target = new RawExpression(targetText);
@@ -426,4 +442,4 @@ var AstSchemaEditor = class AstSchemaEditor {
426
442
  //#endregion
427
443
  export { AstSchemaEditor };
428
444
 
429
- //# sourceMappingURL=ast-schema-editor-CvkYOvri.js.map
445
+ //# sourceMappingURL=ast-schema-editor-CNgFJ3NF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ast-schema-editor-CNgFJ3NF.js","names":[],"sources":["../src/api/ast-schema-editor.ts"],"sourcesContent":["import { Project, SyntaxKind, Node, ObjectLiteralExpression, ObjectLiteralElementLike, PropertyAssignment, SourceFile, IndentationText } from \"ts-morph\";\nimport { nestAdminCollectionKeys, nestAdminPropertyKeys } from \"@rebasepro/types\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\n\n/**\n * The helpers a collection file may be wrapped in.\n *\n * `rebase init` scaffolds every collection as\n * `const postsCollection = defineCollection({ … })` — a call expression, not the\n * bare object literal `rebase introspect` emits. An editor that only understood\n * the bare form found nothing to patch in any stock project, and then rewrote the\n * file from the panel's JSON: no wrapper, no imports, no relation thunks.\n */\nconst COLLECTION_FACTORIES = new Set([\"defineCollection\"]);\n\n/**\n * The only relation-target expression this editor will write through verbatim.\n *\n * A relation's `target` is emitted as SOURCE, not as a string literal, because\n * it has to be `() => otherCollection`. The test for \"is this already a thunk?\"\n * used to be \"does it contain an arrow\", which `() => { require(\"child_process\")\n * .execSync(\"…\") }` also satisfies — and `rebase dev` re-imports the file the\n * moment it changes, so the payload ran without anyone deploying anything.\n *\n * An arrow returning a single identifier is the whole grammar. Anything else is\n * read as a collection name and turned into a thunk by `targetThunk`, which\n * resolves it against the collections directory and refuses a name with no file.\n */\nconst ARROW_TO_IDENTIFIER = /^\\(\\s*\\)\\s*=>\\s*[A-Za-z_$][A-Za-z0-9_$]*$/;\n\n/**\n * A value that must be emitted as source code rather than as JSON.\n *\n * Everything reaching the writer has been through `JSON.stringify` on the wire,\n * so a function-valued key arrives either missing or as a string. A relation's\n * `target` is a thunk in the file and a slug in the payload; this is how the\n * thunk gets written back.\n */\nclass RawExpression {\n constructor(public readonly text: string) {\n }\n}\n\n/**\n * Move presentation keys into the `admin` block.\n *\n * The rule itself lives in `@rebasepro/types`, next to `ADMIN_COLLECTION_KEYS`,\n * because `@rebasepro/cms-types` has to apply the identical one on the panel's\n * side and this package cannot import that one. Two copies used to exist and\n * they disagreed about precedence, which decided whether a presentation edit was\n * saved or silently reverted to the value the user had just changed away from.\n */\nexport function nestAdminKeys(collectionData: Record<string, unknown>): Record<string, unknown> {\n return nestAdminCollectionKeys(collectionData);\n}\n\nexport class AstSchemaEditor {\n private project: Project;\n private collectionsDir: string;\n\n constructor(collectionsDir: string) {\n this.project = new Project({\n manipulationSettings: {\n indentationText: IndentationText.FourSpaces\n }\n });\n if (fs.existsSync(collectionsDir)) {\n this.project.addSourceFilesAtPaths(`${collectionsDir}/**/*.ts`);\n }\n this.collectionsDir = path.resolve(collectionsDir);\n }\n\n /**\n * Sanitize collectionId to prevent path traversal attacks.\n * Only allows alphanumeric characters, underscores, and hyphens.\n */\n /**\n * The variable name the generated file binds the collection to.\n *\n * `sanitizeCollectionId` guards the FILENAME, and permits hyphens and a\n * leading digit — both legal in a filename and neither legal in a\n * JavaScript identifier. So a collection created from the admin panel as\n * `my-notes` (the documented slug shape) or `2024 Archive` (auto-slugged to\n * `2024_archive`) wrote:\n *\n * const my-notesCollection: CollectionConfig = … \"',' expected\"\n * const 2024_archiveCollection: CollectionConfig = … \"Numeric separators\n * are not allowed here\"\n *\n * The panel reported success, and the next boot failed for EVERY collection\n * in the directory — the loader imports all of them — while the editor could\n * no longer parse the file it had just written, so it could not fix itself.\n *\n * Separators camel-case rather than vanish, so `my-notes` and `my_notes`\n * stay distinct; a leading digit is prefixed rather than stripped, so\n * `2024_archive` stays distinct from `archive`. Byte-identical for every\n * slug that already produced a valid identifier.\n */\n private static collectionVarName(safeId: string): string {\n const camel = safeId.replace(/[-_]+([a-zA-Z0-9])/g, (_, char: string) => char.toUpperCase());\n const identifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(camel) ? camel : `c${camel.replace(/^[0-9]/, (d) => d)}`;\n return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier) ? identifier : `c${identifier.replace(/[^A-Za-z0-9_$]/g, \"\")}`;\n }\n\n private sanitizeCollectionId(collectionId: string): string {\n const sanitized = collectionId.replace(/[^a-zA-Z0-9_-]/g, \"\");\n if (!sanitized || sanitized !== collectionId) {\n throw new Error(`Invalid collection ID: \"${collectionId}\". Only alphanumeric characters, underscores, and hyphens are allowed.`);\n }\n return sanitized;\n }\n\n /**\n * Resolve a file path and ensure it falls within the collectionsDir.\n */\n private safePath(filename: string): string {\n const resolved = path.resolve(this.collectionsDir, filename);\n if (!resolved.startsWith(this.collectionsDir + path.sep) && resolved !== this.collectionsDir) {\n throw new Error(\"Path traversal detected: resolved path is outside the collections directory.\");\n }\n return resolved;\n }\n\n private getCollectionFile(collectionId: string) {\n const safeId = this.sanitizeCollectionId(collectionId);\n const filePath = this.safePath(`${safeId}.ts`);\n let file = this.project.getSourceFile(filePath);\n if (!file && fs.existsSync(filePath)) {\n this.project.addSourceFilesAtPaths(`${this.collectionsDir}/**/*.ts`);\n file = this.project.getSourceFile(filePath);\n }\n return file;\n }\n\n /**\n * Find the object literal a collection is declared with, through whatever\n * wraps it.\n *\n * `defineCollection({ … })` is the shape `rebase init` writes and the one the\n * docs recommend; `satisfies` / `as` / parentheses are the other ways an\n * author can dress the same literal. Returning `null` for any of them meant\n * the three callers below each failed differently, and the worst of them\n * overwrote the file.\n */\n private unwrapCollectionObject(node: Node | undefined): ObjectLiteralExpression | null {\n if (!node) return null;\n if (Node.isObjectLiteralExpression(node)) return node;\n if (Node.isParenthesizedExpression(node) ||\n Node.isAsExpression(node) ||\n Node.isSatisfiesExpression(node) ||\n Node.isTypeAssertion(node) ||\n Node.isNonNullExpression(node)) {\n return this.unwrapCollectionObject(node.getExpression());\n }\n if (Node.isCallExpression(node)) {\n // `defineCollection`, `admin.defineCollection`, `defineCollection<Post>`\n const callee = node.getExpression().getText().split(\".\").pop();\n if (callee && COLLECTION_FACTORIES.has(callee)) {\n return this.unwrapCollectionObject(node.getArguments()[0]);\n }\n }\n return null;\n }\n\n private getCollectionObject(collectionId: string): ObjectLiteralExpression | null {\n const file = this.getCollectionFile(collectionId);\n if (!file) return null;\n\n const defaultExport = file.getDefaultExportSymbol();\n if (defaultExport) {\n const declaration = defaultExport.getDeclarations()[0];\n if (declaration && declaration.getKind() === SyntaxKind.ExportAssignment) {\n const expr = declaration.asKind(SyntaxKind.ExportAssignment)?.getExpression();\n if (expr && expr.getKind() === SyntaxKind.Identifier) {\n const varName = expr.getText();\n const varDecl = file.getVariableDeclaration(varName);\n const unwrapped = this.unwrapCollectionObject(varDecl?.getInitializer());\n if (unwrapped) return unwrapped;\n } else {\n // `export default defineCollection({ … })`\n const unwrapped = this.unwrapCollectionObject(expr);\n if (unwrapped) return unwrapped;\n }\n }\n }\n // Fallback: the first VariableDeclaration that holds a collection literal\n for (const varDecl of file.getVariableDeclarations()) {\n const init = this.unwrapCollectionObject(varDecl.getInitializer());\n if (init) return init;\n }\n return null;\n }\n\n /**\n * The collection's object literal, or a refusal that says what to do.\n *\n * Every caller needs this to be all-or-nothing: a missing object literal used\n * to mean \"throw\", \"report success and do nothing\" and \"recreate the file\n * from scratch\" depending on which method you called.\n */\n private requireCollectionObject(collectionId: string): ObjectLiteralExpression {\n const file = this.getCollectionFile(collectionId);\n if (!file) {\n throw new Error(`Collection \"${collectionId}\" has no file at ${path.join(this.collectionsDir, `${collectionId}.ts`)}.`);\n }\n const collectionObj = this.getCollectionObject(collectionId);\n if (!collectionObj) {\n throw new Error(this.unreadableFileMessage(collectionId, file));\n }\n return collectionObj;\n }\n\n private unreadableFileMessage(collectionId: string, file: SourceFile): string {\n return `Could not find the collection object in ${file.getFilePath()}. ` +\n \"The schema editor can only edit a collection declared as `const x = defineCollection({ … })` \" +\n \"or `const x: CollectionConfig = { … }` and exported as the file's default. \" +\n `Edit \"${collectionId}\" in code instead.`;\n }\n\n /** Look a key up on an object literal, quoted or not. */\n private findProperty(obj: ObjectLiteralExpression, name: string): ObjectLiteralElementLike | undefined {\n return obj.getProperty((p: ObjectLiteralElementLike) =>\n \"getName\" in p &&\n typeof (p as PropertyAssignment).getName === \"function\" &&\n ((p as PropertyAssignment).getName() === name ||\n (p as PropertyAssignment).getName() === `\"${name}\"` ||\n (p as PropertyAssignment).getName() === `'${name}'`));\n }\n\n private static quoteKey(key: string): string {\n return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);\n }\n\n private static isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value) && !(value instanceof RawExpression);\n }\n\n private convertJsonToAstString(obj: unknown, indentLevel = 0, oldAstNode?: ObjectLiteralExpression): string {\n // Base TS-morph parses arrays as 2 levels deep from the property key:\n // PropertiesObject = level 1, PropertyConfig = level 2.\n // We calibrate the spacing multiples to keep the items flush with standard TS format.\n const indentStr = \" \";\n const indent = indentStr.repeat(indentLevel);\n const innerIndent = indentStr.repeat(indentLevel + 1);\n\n if (obj instanceof RawExpression) {\n return obj.text;\n }\n if (obj === null || obj === undefined) {\n return \"undefined\";\n }\n if (typeof obj === \"string\") {\n return JSON.stringify(obj);\n }\n if (typeof obj === \"number\" || typeof obj === \"boolean\") {\n return String(obj);\n }\n if (Array.isArray(obj)) {\n if (obj.length === 0) return \"[]\";\n const items = obj.map(item => this.convertJsonToAstString(item, indentLevel + 1));\n return `[\\n${innerIndent}${items.join(`,\\n${innerIndent}`)}\\n${indent}]`;\n }\n if (typeof obj === \"object\") {\n const record = obj as Record<string, unknown>;\n const keys = Object.keys(record);\n\n // Collect preserved AST properties\n const preservedProps: string[] = [];\n if (oldAstNode) {\n const oldProps = oldAstNode.getProperties();\n for (const oldProp of oldProps) {\n if (oldProp.isKind(SyntaxKind.PropertyAssignment)) {\n const nameNode = oldProp.getNameNode();\n let name = nameNode.getText();\n if (name.startsWith('\"') && name.endsWith('\"')) name = name.slice(1, -1);\n if (name.startsWith(\"'\") && name.endsWith(\"'\")) name = name.slice(1, -1);\n\n // If the JSON object doesn't have this key, check if we should preserve it\n if (!(name in record)) {\n const init = oldProp.getInitializer();\n if (init) {\n const kind = init.getKind();\n const isCode = kind === SyntaxKind.ArrowFunction ||\n kind === SyntaxKind.FunctionExpression ||\n kind === SyntaxKind.Identifier ||\n kind === SyntaxKind.CallExpression ||\n kind === SyntaxKind.JsxElement;\n\n if (isCode || name === \"target\" || name === \"callbacks\" || name === \"browserCallbacks\" || name === \"permissions\" || name === \"securityRules\") {\n // Preserve this property exactly as it was\n preservedProps.push(`${AstSchemaEditor.quoteKey(name)}: ${init.getText()}`);\n }\n }\n }\n }\n }\n }\n\n if (keys.length === 0 && preservedProps.length === 0) return \"{}\";\n\n const props = keys.map(key => {\n const keyStr = AstSchemaEditor.quoteKey(key);\n\n // If the value is an object, pass the old AST node to recurse\n let childAstNode: ObjectLiteralExpression | undefined;\n if (oldAstNode && AstSchemaEditor.isPlainObject(record[key])) {\n const oldProp = this.findProperty(oldAstNode, key);\n if (oldProp && oldProp.isKind(SyntaxKind.PropertyAssignment)) {\n childAstNode = oldProp.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);\n }\n }\n\n return `${keyStr}: ${this.convertJsonToAstString(record[key], indentLevel + 1, childAstNode)}`;\n });\n\n const allProps = [...props, ...preservedProps];\n return `{\\n${innerIndent}${allProps.join(`,\\n${innerIndent}`)}\\n${indent}}`;\n }\n return \"undefined\";\n }\n\n /**\n * Write only the keys the patch names, leaving every sibling alone.\n *\n * A patch says what changed, not what the collection is. The panel sends one\n * — `{ propertiesOrder }` is what adding a column posts — and rewriting the\n * `admin` block from it deleted the collection's icon, group, list columns\n * and kanban config in the same write.\n */\n private mergeIntoObjectLiteral(target: ObjectLiteralExpression, data: Record<string, unknown>, indentLevel: number): void {\n for (const [key, value] of Object.entries(data)) {\n const existing = this.findProperty(target, key);\n const existingObj = existing && existing.isKind(SyntaxKind.PropertyAssignment)\n ? existing.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression)\n : undefined;\n\n if (existingObj && AstSchemaEditor.isPlainObject(value)) {\n this.mergeIntoObjectLiteral(existingObj, value, indentLevel + 1);\n continue;\n }\n\n const initializer = this.convertJsonToAstString(value, indentLevel, existingObj);\n if (existing && existing.isKind(SyntaxKind.PropertyAssignment)) {\n existing.setInitializer(initializer);\n } else {\n target.addPropertyAssignment({\n name: AstSchemaEditor.quoteKey(key),\n initializer\n });\n }\n }\n }\n\n public async saveProperty(collectionId: string, propertyKey: string, propertyConfig: Record<string, unknown>) {\n const collectionObj = this.requireCollectionObject(collectionId);\n\n // The panel's property forms bind to the flat names — `readOnly`,\n // `hideFromCollection` — while on disk they belong inside the property's\n // own `admin` block. Written flat they are not merely ignored: the boot\n // validator treats a moved key as fatal.\n const nestedConfig = nestAdminPropertyKeys(propertyConfig);\n\n let propertiesProp = collectionObj.getProperty(\"properties\") as PropertyAssignment;\n if (!propertiesProp) {\n propertiesProp = collectionObj.addPropertyAssignment({\n name: \"properties\",\n initializer: \"{}\"\n });\n }\n\n const propsObj = propertiesProp.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);\n if (propsObj) {\n const existingProp = this.findProperty(propsObj, propertyKey);\n\n let oldPropAstNode: ObjectLiteralExpression | undefined;\n if (existingProp && existingProp.isKind(SyntaxKind.PropertyAssignment)) {\n oldPropAstNode = existingProp.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);\n }\n\n const newInitializer = this.convertJsonToAstString(nestedConfig, 2, oldPropAstNode);\n\n if (existingProp) {\n if (existingProp.isKind(SyntaxKind.PropertyAssignment)) {\n existingProp.setInitializer(newInitializer);\n }\n } else {\n propsObj.addPropertyAssignment({\n name: AstSchemaEditor.quoteKey(propertyKey),\n initializer: newInitializer\n });\n }\n\n const file = this.getCollectionFile(collectionId);\n if (file) {\n file.formatText();\n }\n await this.project.save();\n }\n }\n\n public async deleteProperty(collectionId: string, propertyKey: string) {\n const collectionObj = this.requireCollectionObject(collectionId);\n\n const propertiesProp = collectionObj.getProperty(\"properties\") as PropertyAssignment;\n if (propertiesProp) {\n const propsObj = propertiesProp.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);\n if (propsObj) {\n const existingProp = this.findProperty(propsObj, propertyKey);\n if (existingProp) {\n existingProp.remove();\n const file = this.getCollectionFile(collectionId);\n if (file) {\n file.formatText();\n }\n await this.project.save();\n }\n }\n }\n }\n\n /**\n * Write a collection back to its file.\n *\n * `partial` is the difference between \"this is the collection\" and \"this is\n * what changed about it\". The panel sends both — a full save from the editor\n * dialog, and a one-key patch whenever a property is added, deleted or\n * reordered — and they cannot be told apart by looking at the payload. Read\n * as a full save, a patch deletes everything it does not mention, including\n * `securityRules`; the loader then hands the collection the directory\n * default, which in the scaffold is `access: \"public\"`.\n */\n public async saveCollection(collectionId: string, collectionData: Record<string, unknown>, options: { partial?: boolean } = {}) {\n const partial = options.partial === true;\n let file = this.getCollectionFile(collectionId);\n const collectionObj = file ? this.getCollectionObject(collectionId) : null;\n\n if (file && !collectionObj) {\n // The file is there and we could not read it. Recreating it from the\n // panel's JSON would drop the imports, the callbacks and the relation\n // thunks that JSON cannot carry.\n throw new Error(this.unreadableFileMessage(collectionId, file));\n }\n\n if (!file || !collectionObj) {\n if (partial) {\n throw new Error(`Cannot apply a partial update to \"${collectionId}\": it has no collection file yet.`);\n }\n // Create a new file\n const safeId = this.sanitizeCollectionId(collectionId);\n const newFilePath = this.safePath(`${safeId}.ts`);\n if (fs.existsSync(newFilePath)) {\n throw new Error(`Refusing to overwrite ${newFilePath}: a file for \"${collectionId}\" already exists but could not be parsed.`);\n }\n const varName = `${AstSchemaEditor.collectionVarName(safeId)}Collection`;\n file = this.project.createSourceFile(newFilePath, `import { CollectionConfig } from \"@rebasepro/types\";\\n\\nconst ${varName}: CollectionConfig = ${this.convertJsonToAstString(nestAdminKeys(collectionData))};\\n\\nexport default ${varName};\\n`);\n } else {\n // Update root level properties gracefully\n\n if (!partial) {\n // Force delete securityRules if empty or undefined to handle Formex / serialization stripping\n if (!(\"securityRules\" in collectionData) || collectionData.securityRules === undefined || (Array.isArray(collectionData.securityRules) && collectionData.securityRules.length === 0)) {\n const srProp = collectionObj.getProperty(\"securityRules\");\n if (srProp) {\n srProp.remove();\n }\n\n // If it was in collectionData as an empty array, delete it so the loop below doesn't add it back as \"[]\"\n // Actually, if it's \"[]\", omitting it entirely from the TS file achieves the same logical effect (no RLS rules)\n // and correctly triggers \"unmapped policies\" if the DB still has them.\n delete collectionData[\"securityRules\"];\n }\n }\n\n // The panel works with a flat view model — presentation merged onto the\n // collection — so what arrives here has `icon` and `listProperties` at\n // the top level. On disk they belong inside `admin`. Writing them flat\n // would produce a file the backend loads and ignores and the panel\n // never reads back, which looks exactly like the edit not saving.\n collectionData = nestAdminKeys(collectionData);\n\n for (const key of Object.keys(collectionData)) {\n if (key === \"relations\") {\n this.writeRelations(collectionId, file, collectionObj, collectionData[key]);\n continue;\n }\n\n const prop = collectionObj.getProperty(key) as PropertyAssignment;\n\n let oldAstNode: ObjectLiteralExpression | undefined;\n if (prop && prop.isKind(SyntaxKind.PropertyAssignment)) {\n oldAstNode = prop.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);\n }\n\n if (partial && oldAstNode && AstSchemaEditor.isPlainObject(collectionData[key])) {\n this.mergeIntoObjectLiteral(oldAstNode, collectionData[key] as Record<string, unknown>, 2);\n continue;\n }\n\n const newInit = this.convertJsonToAstString(collectionData[key], 1, oldAstNode);\n if (prop) {\n prop.setInitializer(newInit);\n } else {\n collectionObj.addPropertyAssignment({\n // `quoteKey`, like every other site that emits a key.\n // This one did not, and ts-morph writes a property name\n // out verbatim — so a top-level key of\n // `injected: (() => { … })(), tail` closed the property\n // and opened an expression, which `rebase dev` then\n // re-imported and ran. The rest of this file has always\n // quoted; this was the one that did not.\n name: AstSchemaEditor.quoteKey(key),\n initializer: newInit\n });\n }\n }\n }\n if (file) {\n file.formatText();\n }\n await this.project.save();\n }\n\n /**\n * Write the collection-level `relations` array.\n *\n * Relations are the one key whose values are not all data: `target` is a\n * thunk in the file, and the panel sends the target collection's slug (or,\n * for a relation it did not touch, nothing at all — `JSON.stringify` drops\n * the function). So each entry's target is resolved in that order: a slug\n * becomes `() => xCollection` plus the import it needs, and anything else\n * falls back to the thunk already in the file.\n *\n * This used to be a `continue` with a comment saying relations were handled\n * elsewhere. Nothing handled them; every edit in the Relations tab was\n * dropped without a word.\n */\n private writeRelations(collectionId: string, file: SourceFile, collectionObj: ObjectLiteralExpression, value: unknown): void {\n if (!Array.isArray(value)) return;\n\n const existing = this.findProperty(collectionObj, \"relations\");\n const oldArray = existing && existing.isKind(SyntaxKind.PropertyAssignment)\n ? existing.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression)\n : undefined;\n\n const oldElements = oldArray?.getElements() ?? [];\n const oldTargetsByName = new Map<string, string>();\n // Only for entries the file left unnamed — `relationName` is optional, and\n // those cannot be matched any other way. Positional matching is off the\n // moment the array's length changes, because then a position no longer\n // means the same relation.\n const anonymousTargetsByIndex: (string | undefined)[] = [];\n oldElements.forEach((element, index) => {\n const elementObj = element.asKind(SyntaxKind.ObjectLiteralExpression);\n if (!elementObj) return;\n const targetProp = this.findProperty(elementObj, \"target\");\n const targetInit = targetProp && targetProp.isKind(SyntaxKind.PropertyAssignment)\n ? targetProp.getInitializer()\n : undefined;\n if (!targetInit) return;\n\n const nameProp = this.findProperty(elementObj, \"relationName\");\n const nameInit = nameProp && nameProp.isKind(SyntaxKind.PropertyAssignment)\n ? nameProp.getInitializerIfKind(SyntaxKind.StringLiteral)\n : undefined;\n if (nameInit) oldTargetsByName.set(nameInit.getLiteralValue(), targetInit.getText());\n else anonymousTargetsByIndex[index] = targetInit.getText();\n });\n\n const items = value.map((entry, index) => {\n if (!AstSchemaEditor.isPlainObject(entry)) return entry;\n const relation: Record<string, unknown> = { ...entry };\n const relationName = typeof relation.relationName === \"string\" ? relation.relationName : undefined;\n const rawTarget = relation.target;\n\n let targetText: string | undefined;\n if (typeof rawTarget === \"string\" && rawTarget.trim().length > 0) {\n const trimmed = rawTarget.trim();\n // A target that already looks like a thunk is written into the\n // file as source, so \"contains an arrow\" is not a good enough\n // reason to trust it: `() => { require(\"child_process\")… }` also\n // contains one. Only the exact shape this emits is accepted —\n // an arrow returning one identifier — and anything else is\n // treated as a collection NAME and turned into a thunk here,\n // which is the path that was always safe.\n targetText = ARROW_TO_IDENTIFIER.test(trimmed)\n ? trimmed\n : this.targetThunk(file, trimmed);\n } else if (relationName && oldTargetsByName.has(relationName)) {\n targetText = oldTargetsByName.get(relationName);\n } else if (value.length === oldElements.length) {\n targetText = anonymousTargetsByIndex[index];\n }\n\n if (!targetText) {\n throw new Error(`Relation \"${relationName ?? `#${index}`}\" on collection \"${collectionId}\" has no target collection. Pick one before saving.`);\n }\n\n relation.target = new RawExpression(targetText);\n return relation;\n });\n\n const initializer = this.convertJsonToAstString(items, 1);\n if (existing && existing.isKind(SyntaxKind.PropertyAssignment)) {\n existing.setInitializer(initializer);\n } else {\n collectionObj.addPropertyAssignment({ name: \"relations\", initializer });\n }\n }\n\n /**\n * `() => targetCollection` for a target named by its slug, importing it if\n * the file does not already.\n */\n private targetThunk(file: SourceFile, targetSlug: string): string {\n const targetFile = this.getCollectionFile(targetSlug);\n if (!targetFile) {\n throw new Error(`Cannot link to collection \"${targetSlug}\": no file for it in ${this.collectionsDir}.`);\n }\n\n const identifier = this.getDefaultExportName(targetFile);\n if (!identifier) {\n throw new Error(`Cannot link to collection \"${targetSlug}\": ${targetFile.getFilePath()} has no default export to import.`);\n }\n\n if (targetFile.getFilePath() !== file.getFilePath() && !this.hasDefaultImport(file, identifier)) {\n const relative = path.relative(path.dirname(file.getFilePath()), targetFile.getFilePath())\n .split(path.sep)\n .join(\"/\")\n .replace(/\\.tsx?$/, \".js\");\n file.addImportDeclaration({\n defaultImport: identifier,\n moduleSpecifier: relative.startsWith(\".\") ? relative : `./${relative}`\n });\n }\n\n return `() => ${identifier}`;\n }\n\n private hasDefaultImport(file: SourceFile, identifier: string): boolean {\n return file.getImportDeclarations().some(decl => decl.getDefaultImport()?.getText() === identifier);\n }\n\n private getDefaultExportName(file: SourceFile): string | undefined {\n const declaration = file.getDefaultExportSymbol()?.getDeclarations()[0];\n const expr = declaration?.asKind(SyntaxKind.ExportAssignment)?.getExpression();\n if (expr && expr.getKind() === SyntaxKind.Identifier) return expr.getText();\n\n for (const varDecl of file.getVariableDeclarations()) {\n if (this.unwrapCollectionObject(varDecl.getInitializer())) return varDecl.getName();\n }\n return undefined;\n }\n\n public async deleteCollection(collectionId: string) {\n const file = this.getCollectionFile(collectionId);\n if (file) {\n file.deleteImmediatelySync();\n }\n }\n}\n"],"mappings":"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"}
@@ -13,6 +13,18 @@ import type { ApiKeyStore } from "./api-key-store.js";
13
13
  export interface ApiKeyRouteOptions {
14
14
  store: ApiKeyStore;
15
15
  serviceKey?: string;
16
+ /**
17
+ * Read the caller's roles from the database rather than from their token.
18
+ *
19
+ * Without it this router trusted the `roles` claim, and a key minted here
20
+ * never expires and may carry `admin: true` — so an administrator demoted
21
+ * an hour ago could still mint themselves permanent admin access, and the
22
+ * demotion would not take effect until a token nobody can see had run out.
23
+ * See `createRequireAuth`.
24
+ */
25
+ resolveRoles?: (uid: string) => Promise<string[]>;
26
+ /** Repository for the token-revocation watermark. See `createRequireAuth`. */
27
+ revocationRepo?: Pick<import("../interfaces.js").AuthRepository, "getTokensValidAfter">;
16
28
  }
17
29
  /**
18
30
  * Create admin routes for API key management.
@@ -147,8 +147,19 @@ export interface AuthHooks {
147
147
  /**
148
148
  * Customize JWT access token claims before signing.
149
149
  *
150
- * Return the modified claims object. The returned claims are merged
151
- * into the JWT payload alongside standard claims (uid, roles).
150
+ * Return the modified claims object. The returned claims are merged into
151
+ * the JWT payload BESIDE the identity claims, never over them: `uid`,
152
+ * `roles` and `aal` are written last and a value returned for any of them
153
+ * is discarded.
154
+ *
155
+ * That is not a limitation of the hook, it is what keeps it safe to have.
156
+ * `uid` is who the whole request is — down to the identity the database
157
+ * evaluates its policies against — `roles` is what the admin gate reads,
158
+ * and `aal` is whether a second factor was actually passed. The obvious
159
+ * implementation of this hook spreads the claims it was handed and adds a
160
+ * field, and one that merges a user-controlled profile object returns
161
+ * whatever that object happened to contain. Add facts about a session
162
+ * here; the session's subject is decided by the server.
152
163
  *
153
164
  * @param claims - The default claims that would be included.
154
165
  * @param user - The authenticated user data.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Force `excludeFromApi` onto the auth collection's secret columns.
3
+ *
4
+ * Mutates in place and must therefore run BEFORE the collections are registered:
5
+ * each registry re-normalizes what it is given, and a flag set afterwards
6
+ * reaches whichever copy happened to be mutated and no others.
7
+ *
8
+ * @returns the columns it had to fix, per collection — for the caller to log and
9
+ * for tests to assert on.
10
+ */
11
+ export declare function enforceAuthSecretExclusion(collections: readonly unknown[]): Array<{
12
+ slug: string;
13
+ columns: string[];
14
+ }>;
@@ -33,6 +33,21 @@ export interface AccessTokenPayload {
33
33
  roles: string[];
34
34
  /** Authentication Assurance Level: aal1 = password/oauth, aal2 = MFA verified */
35
35
  aal?: "aal1" | "aal2";
36
+ /**
37
+ * Whether this session is a GUEST: anonymous sign-in rather than an account.
38
+ *
39
+ * An identity claim, written by the server and never taken from a custom
40
+ * claims hook — see {@link generateAccessToken}. It is here rather than
41
+ * looked up per request because everything downstream needs it and nothing
42
+ * downstream has a database: the RLS identity carries it into
43
+ * `rebase.is_anonymous()`, and the WebSocket path has only the token.
44
+ *
45
+ * Absent means "not a guest". A token minted before this claim existed is
46
+ * therefore read as an ordinary account, which is the behaviour that
47
+ * deployment already had — the alternative would reclassify every session
48
+ * in flight at the moment of a deploy.
49
+ */
50
+ isAnonymous?: boolean;
36
51
  /**
37
52
  * When the token was issued, in seconds since the epoch — the standard JWT
38
53
  * `iat` claim, which the signer behind `jwt-crypto.ts` sets on every token
@@ -90,7 +105,13 @@ export declare function isJwtConfigured(): boolean;
90
105
  /**
91
106
  * Generate an access token (short-lived, 1 hour by default)
92
107
  */
93
- export declare function generateAccessToken(uid: string, roles: string[], aal?: "aal1" | "aal2", customClaims?: Record<string, unknown>): Promise<string>;
108
+ export declare function generateAccessToken(uid: string, roles: string[], aal?: "aal1" | "aal2", customClaims?: Record<string, unknown>,
109
+ /**
110
+ * Whether this session is a guest — anonymous sign-in rather than an
111
+ * account. Written into the token so the RLS identity and the WebSocket
112
+ * path can tell the two apart without a database lookup.
113
+ */
114
+ isAnonymous?: boolean): Promise<string>;
94
115
  /**
95
116
  * Get the expiration time of an access token in milliseconds from now
96
117
  */
@@ -1,4 +1,4 @@
1
- import { Hono } from "hono";
1
+ import { Hono, type MiddlewareHandler } from "hono";
2
2
  import { z } from "zod";
3
3
  import { HonoEnv } from "../api/types.js";
4
4
  import type { AuthModuleConfig } from "./routes.js";
@@ -7,6 +7,15 @@ import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasep
7
7
  interface MfaRoutesConfig {
8
8
  router: Hono<HonoEnv>;
9
9
  config: AuthModuleConfig;
10
+ /**
11
+ * The caller's guard for a signed-in user, with the revocation watermark
12
+ * consulted. Enrolling, confirming or removing a factor is a persistence
13
+ * move — the second one the 2026-09-02 audit named — so a token the user
14
+ * has signed out everywhere must not reach it. `routes.ts` builds this
15
+ * from the same repository the refresh path checks; the plain
16
+ * `requireAuth` only verifies the signature.
17
+ */
18
+ requireLiveSession: MiddlewareHandler<HonoEnv>;
10
19
  ops: ReturnType<typeof resolveAuthHooks>;
11
20
  parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T;
12
21
  buildAuthResponse: (user: {
@@ -3,6 +3,7 @@ import type { AuthRepository } from "./interfaces.js";
3
3
  import { DataDriver } from "@rebasepro/types";
4
4
  import { AccessTokenPayload } from "./jwt.js";
5
5
  import type { HonoEnv } from "../api/types.js";
6
+ import { ApiError } from "../api/errors.js";
6
7
  import type { ApiKeyStore } from "./api-keys/api-key-store.js";
7
8
  import { extractBearerToken } from "./bearer-token.js";
8
9
  export { extractBearerToken };
@@ -62,6 +63,27 @@ export interface AuthMiddlewareOptions {
62
63
  */
63
64
  apiKeyStore?: ApiKeyStore;
64
65
  }
66
+ /**
67
+ * Answer a refusal with the canonical envelope, through the same formatter
68
+ * every route uses.
69
+ *
70
+ * These middlewares used to hand-build `c.json({ error: { message, code } },
71
+ * 401)` — seven near-copies of it, which is how they came to omit `requestId`
72
+ * while every other error on the server carried it: the one string a bug report
73
+ * quotes was missing from precisely the errors an app hits most. It also meant
74
+ * a routine 401 (a client refreshing before it knows whether a session exists)
75
+ * logged like an incident, because `ApiError.expected` never reached the
76
+ * logger.
77
+ *
78
+ * `errorHandler` is *called* rather than thrown to. A throw only becomes an
79
+ * envelope where an `onError` is registered, and these middlewares are mounted
80
+ * by callers this package does not control — `defineFunction` hands users a
81
+ * Hono app and invites them to put `requireAuth` on a route of it. Making the
82
+ * status depend on the mount point would turn a 401 into a 500 for exactly
83
+ * those callers. Calling the formatter directly gets the envelope with none of
84
+ * that.
85
+ */
86
+ export declare function refuse(c: Context<HonoEnv>, error: ApiError): Response;
65
87
  /**
66
88
  * Hono middleware that requires a valid JWT token via Authorization header.
67
89
  * Returns 401 if token is missing or invalid.
@@ -94,6 +94,56 @@ export declare const strictAuthLimiter: MiddlewareHandler<HonoEnv>;
94
94
  * useless as a mail bomb. Unauthenticated requests fall back to the IP bucket so
95
95
  * the limiter is never a no-op if it is ever mounted before the auth middleware.
96
96
  */
97
+ /**
98
+ * Read the recipient address out of the body, so a limiter can key on it.
99
+ *
100
+ * A limiter runs before the handler, and Hono caches a parsed body — so this
101
+ * reads a clone, stashes the address, and lets the handler read the same
102
+ * request normally. Without it the limiters below have no account to key on
103
+ * and collapse into one global bucket, which is a limiter in name only.
104
+ *
105
+ * A malformed body is left to the handler, which reports it with the message
106
+ * that names the field; failing here would answer 429 to bad JSON.
107
+ */
108
+ export declare const captureRecipientEmail: MiddlewareHandler<HonoEnv>;
109
+ /**
110
+ * Per-ADDRESS throttle for a route that mails an address the caller named.
111
+ *
112
+ * `strictAuthLimiter` bounds a machine, and a machine is the attacker's to
113
+ * multiply. The mailbox being filled belongs to somebody who cannot rotate
114
+ * anything, and the domain the messages come from is this deployment's
115
+ * reputation — so a distributed run against one address passed the IP limiter
116
+ * untouched and cost the operator their sender score.
117
+ *
118
+ * Five per fifteen minutes: generous for "I didn't get it, send it again",
119
+ * useless as a mail bomb. The same numbers as `verificationEmailLimiter`,
120
+ * which bounds the same act for a signed-in caller.
121
+ *
122
+ * Falls back to the IP bucket for a request whose body named no address, so
123
+ * the limiter is never silently a no-op.
124
+ */
125
+ export declare const recipientEmailLimiter: MiddlewareHandler<HonoEnv>;
126
+ /**
127
+ * How long a route that answers "if an account exists…" takes, whoever asked.
128
+ *
129
+ * These routes deliberately answer the same words for a known and an unknown
130
+ * address. They did not take the same TIME: a known address meant a token
131
+ * insert, a template render and an SMTP round trip, and an unknown one meant
132
+ * returning immediately. That difference is measurable from anywhere, so an
133
+ * endpoint that refuses to say whether an account exists said it anyway, one
134
+ * address at a time.
135
+ *
136
+ * The mail is sent off the response path at each call site, which removes the
137
+ * largest and most variable part; this floor covers the rest without requiring
138
+ * the two branches to be instruction-for-instruction equal, which is not a
139
+ * property anyone can maintain across a refactor.
140
+ *
141
+ * 400ms is chosen to sit above the slow branch rather than to be pleasant: it
142
+ * is a step a person takes once, and the alternative is an enumeration oracle.
143
+ */
144
+ export declare const RECIPIENT_ROUTE_FLOOR_MS = 400;
145
+ /** Hold a result until at least `floorMs` has passed since `startedAt`. */
146
+ export declare function notBefore<T>(startedAt: number, floorMs: number, result: T): Promise<T>;
97
147
  export declare const verificationEmailLimiter: MiddlewareHandler<HonoEnv>;
98
148
  /**
99
149
  * Key generator for API-key-based rate limiting.
@@ -44,7 +44,34 @@ export interface RegistrationPolicy {
44
44
  * first and only pays for the count when that says no.
45
45
  */
46
46
  needsSetup: boolean;
47
+ /**
48
+ * Whether an empty table may still be claimed by whoever registers first.
49
+ * Defaults to {@link isBootstrapWindowOpen}; tests pass it explicitly.
50
+ */
51
+ bootstrapWindowOpen?: boolean;
47
52
  }
53
+ /**
54
+ * Whether the first-come-first-admin window exists at all.
55
+ *
56
+ * On a laptop it should: the person at the keyboard is the operator, and a
57
+ * fresh database that nobody can administer is a dead end. On anything with a
58
+ * public hostname it must not: the shipped artifacts bring DNS and TLS up
59
+ * before the operator has typed anything, `GET /auth/config` advertises
60
+ * `needsSetup`, and whoever reaches `POST /auth/register` first owns the
61
+ * deployment. So the window is open outside production and closed inside it,
62
+ * where the operator names the first admin with `REBASE_ADMIN_EMAIL` /
63
+ * `REBASE_ADMIN_PASSWORD` (see `seed-admin.ts`) or assigns the role with the
64
+ * service key — two ways in, neither of which is a race.
65
+ */
66
+ export declare function isBootstrapWindowOpen(env?: {
67
+ NODE_ENV?: string;
68
+ }): boolean;
69
+ /**
70
+ * What to tell a caller who hit an empty production database with the window
71
+ * shut. Named here so the register route and the bootstrap route say the same
72
+ * thing.
73
+ */
74
+ export declare const SETUP_REQUIRED_MESSAGE: string;
48
75
  /** The full predicate, for callers that already know whether setup is needed. */
49
76
  export declare function isRegistrationOpen(policy: RegistrationPolicy): boolean;
50
77
  /**
@@ -50,7 +50,15 @@ export declare const SERVICE_IDENTITY: {
50
50
  * @param user - The authenticated user identity for RLS.
51
51
  * @returns The RLS-scoped DataDriver (or the original if RLS is unsupported).
52
52
  */
53
- export declare function scopeDataDriver(driver: DataDriver, user: {
53
+ export declare function scopeDataDriver(driver: DataDriver,
54
+ /**
55
+ * `isAnonymous` rides along so a policy can tell a GUEST — an anonymous
56
+ * sign-in — from an account. It is a real user row with a real uid, so
57
+ * without this the database sees the same principal either way. See
58
+ * `rebase.is_anonymous()`.
59
+ */
60
+ user: {
54
61
  uid: string;
55
62
  roles?: string[];
63
+ isAnonymous?: boolean;
56
64
  }): Promise<DataDriver>;
@@ -99,7 +99,14 @@ export interface CookieAuthConfig {
99
99
  path?: string;
100
100
  /** SameSite attribute (default: "Lax"). */
101
101
  sameSite?: "Strict" | "Lax" | "None";
102
- /** Force the Secure flag. Defaults to `true` when SameSite is "None", otherwise auto-detected from the request protocol. */
102
+ /**
103
+ * The Secure flag. Defaults to `true`, and `false` is the only way off —
104
+ * see `AUTH_COOKIE_SECURE`. It used to be inferred from the request
105
+ * protocol, which reads `http` behind any TLS-terminating proxy, so a
106
+ * months-lived credential travelled in cleartext in the commonest
107
+ * production topology. Set it `false` only for a deployment genuinely
108
+ * served over plain http.
109
+ */
103
110
  secure?: boolean;
104
111
  }
105
112
  export declare function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv>;
@@ -0,0 +1,73 @@
1
+ import type { UserManagementAdapter } from "@rebasepro/types";
2
+ /**
3
+ * Create the operator's admin account from the environment, before anyone else
4
+ * can claim it.
5
+ *
6
+ * Every fresh Rebase deployment has a window between "the database is empty"
7
+ * and "the operator has registered", and the registration policy deliberately
8
+ * admits the first registration and promotes it to admin — otherwise an empty
9
+ * database is a dead end, since `POST /admin/bootstrap` needs a caller who is
10
+ * already signed in. That is a reasonable rule for a laptop and a bad one for
11
+ * anything with a public hostname: the shipped artifacts bring DNS and TLS up
12
+ * before their operator has typed anything, so the window is open to the
13
+ * internet, and whoever reaches the form first owns the deployment.
14
+ *
15
+ * A closed window needs a way in that is not a race, which is this: the
16
+ * operator names the first account in the same place they already put the
17
+ * database URL and the JWT secret. Then the artifacts can ship with
18
+ * self-registration off and nothing is left to be claimed.
19
+ *
20
+ * Runs only against an EMPTY user table. Not "no admin exists" — a deployment
21
+ * that has users has already been bootstrapped, and minting an admin into it
22
+ * from an environment variable would be a way to take over a running system by
23
+ * editing a manifest.
24
+ *
25
+ * And runs only where the window it replaces is CLOSED — production. The two are
26
+ * one mechanism with two halves, and running both halves at once is what broke
27
+ * the documented first run: `rebase init` writes `REBASE_ADMIN_EMAIL` and a
28
+ * generated password into `.env` for the compose stack, `rebase dev` reads the
29
+ * same `.env`, and the account appeared before the developer opened the app. The
30
+ * quickstart's first step — register, become the admin — then produced a
31
+ * role-less account, because the table was no longer empty and the window had
32
+ * been spent by a boot nobody watched. Outside production the first registration
33
+ * is the way in, so the variables are announced and ignored.
34
+ */
35
+ export interface SeedAdminEnv {
36
+ REBASE_ADMIN_EMAIL?: string;
37
+ REBASE_ADMIN_PASSWORD?: string;
38
+ /** Decides whether the seed is this deployment's way in at all. */
39
+ NODE_ENV?: string;
40
+ }
41
+ export type SeedAdminOutcome =
42
+ /** Created, with the new user's id. */
43
+ {
44
+ status: "created";
45
+ uid: string;
46
+ }
47
+ /** Nothing was asked for. */
48
+ | {
49
+ status: "not-requested";
50
+ }
51
+ /** Asked for, but the deployment already has users. */
52
+ | {
53
+ status: "already-bootstrapped";
54
+ }
55
+ /**
56
+ * Asked for where the first registration is still the way in — development.
57
+ * Announced at boot and not acted on.
58
+ */
59
+ | {
60
+ status: "window-open";
61
+ }
62
+ /** Asked for incompletely, or the create failed. `reason` has been logged. */
63
+ | {
64
+ status: "skipped";
65
+ reason: string;
66
+ };
67
+ /**
68
+ * @param users The adapter's user management, or undefined when the deployment
69
+ * has no built-in user store to seed into.
70
+ * @param env Where the credentials come from. Injected so the test does not
71
+ * have to mutate `process.env`.
72
+ */
73
+ export declare function seedInitialAdmin(users: UserManagementAdapter | undefined, env?: SeedAdminEnv): Promise<SeedAdminOutcome>;
@@ -1,12 +1,22 @@
1
1
  import { Hono } from "hono";
2
2
  import { z } from "zod";
3
3
  import { HonoEnv } from "../api/types.js";
4
+ import type { MiddlewareHandler } from "hono";
4
5
  import type { AuthModuleConfig } from "./routes.js";
5
6
  import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
6
7
  import type { resolveAuthHooks } from "./auth-hooks.js";
7
8
  interface SessionRoutesConfig {
8
9
  router: Hono<HonoEnv>;
9
10
  config: AuthModuleConfig;
11
+ /**
12
+ * The caller's guard for a signed-in user, with the revocation watermark
13
+ * consulted. Listing and revoking sessions, `/me`, user lookup and linking
14
+ * an anonymous account all describe or change the account, and a token the
15
+ * user has signed out everywhere must not reach them. `routes.ts` builds
16
+ * this from the same repository the refresh path checks; the plain
17
+ * `requireAuth` only verifies the signature.
18
+ */
19
+ requireLiveSession: MiddlewareHandler<HonoEnv>;
10
20
  ops: ReturnType<typeof resolveAuthHooks>;
11
21
  parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T;
12
22
  buildAuthResponse: (user: {