@rebasepro/server 0.22.0 → 0.23.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 (114) hide show
  1. package/dist/{GCSStorageController-CLIJXwGS.js → GCSStorageController-CjrA4PMo.js} +37 -23
  2. package/dist/GCSStorageController-CjrA4PMo.js.map +1 -0
  3. package/dist/{S3StorageController-Dcuf8lMA.js → S3StorageController-B6pKDNVj.js} +2 -2
  4. package/dist/{S3StorageController-Dcuf8lMA.js.map → S3StorageController-B6pKDNVj.js.map} +1 -1
  5. package/dist/api/ast-schema-editor.d.ts +35 -0
  6. package/dist/api/live-schema-routes.d.ts +14 -0
  7. package/dist/api/rest/api-generator.d.ts +117 -35
  8. package/dist/api/rest/auth-collection-writes.d.ts +85 -0
  9. package/dist/api/rest/field-access-query.d.ts +6 -2
  10. package/dist/api/rest/idempotency.d.ts +7 -1
  11. package/dist/api/rest/nested-write-access.d.ts +46 -0
  12. package/dist/api/rest/write-validation.d.ts +32 -0
  13. package/dist/{ast-schema-editor-CslO8Oje.js → ast-schema-editor-Mvr50v_S.js} +104 -4
  14. package/dist/ast-schema-editor-Mvr50v_S.js.map +1 -0
  15. package/dist/auth/address-ownership.d.ts +53 -0
  16. package/dist/auth/admin-user-ops.d.ts +35 -2
  17. package/dist/auth/auth-hooks.d.ts +4 -0
  18. package/dist/auth/captcha.d.ts +5 -0
  19. package/dist/auth/interfaces.d.ts +36 -6
  20. package/dist/auth/jwt.d.ts +17 -0
  21. package/dist/auth/oauth-signin-policy.d.ts +25 -8
  22. package/dist/auth/rate-limiter.d.ts +31 -1
  23. package/dist/auth/session-routes.d.ts +7 -0
  24. package/dist/auth/token-revocation.d.ts +4 -1
  25. package/dist/{auth-CCDpk2rn.js → auth-B-GIMpDG.js} +639 -228
  26. package/dist/auth-B-GIMpDG.js.map +1 -0
  27. package/dist/backend-DTAOsLQc.js +30 -0
  28. package/dist/backend-DTAOsLQc.js.map +1 -0
  29. package/dist/backup/backup-common.d.ts +19 -0
  30. package/dist/{backup-DzI9jLwc.js → backup-D7YR94N3.js} +69 -8
  31. package/dist/backup-D7YR94N3.js.map +1 -0
  32. package/dist/boot/driver.d.ts +10 -0
  33. package/dist/boot/env.d.ts +2 -2
  34. package/dist/boot/fetch-bundle.d.ts +18 -1
  35. package/dist/boot/rls-audit-option.d.ts +26 -0
  36. package/dist/boot/sources.d.ts +1 -0
  37. package/dist/{contract-routes-eLxV0le1.js → contract-routes-CbFjuBwa.js} +2 -2
  38. package/dist/{contract-routes-eLxV0le1.js.map → contract-routes-CbFjuBwa.js.map} +1 -1
  39. package/dist/cron/cron-routes.d.ts +7 -2
  40. package/dist/cron/cron-scheduler.d.ts +128 -8
  41. package/dist/cron/cron-store.d.ts +72 -8
  42. package/dist/cron/index.d.ts +1 -1
  43. package/dist/{cron-loader-CQjvjpEw.js → cron-loader-DfTj2Hbi.js} +2 -2
  44. package/dist/{cron-loader-CQjvjpEw.js.map → cron-loader-DfTj2Hbi.js.map} +1 -1
  45. package/dist/{cron-routes-B7CRGfiq.js → cron-routes-eE8nif_b.js} +33 -12
  46. package/dist/cron-routes-eE8nif_b.js.map +1 -0
  47. package/dist/{cron-scheduler-COPQxlEq.js → cron-scheduler-B0pLfAix.js} +394 -68
  48. package/dist/cron-scheduler-B0pLfAix.js.map +1 -0
  49. package/dist/{cron-store-BYGZFNWk.js → cron-store-TcoGz-xS.js} +133 -12
  50. package/dist/cron-store-TcoGz-xS.js.map +1 -0
  51. package/dist/{ddl-bootstrap-CfNvxMuK.js → ddl-bootstrap-C6mo0Kmz.js} +2 -25
  52. package/dist/ddl-bootstrap-C6mo0Kmz.js.map +1 -0
  53. package/dist/email/link-base.d.ts +5 -4
  54. package/dist/email/smtp-email-service.d.ts +13 -1
  55. package/dist/email/templates.d.ts +9 -0
  56. package/dist/email/types.d.ts +3 -2
  57. package/dist/env.d.ts +24 -5
  58. package/dist/{history-recorder-BQmB0P_j.js → history-recorder-B4MpJfJK.js} +8 -6
  59. package/dist/history-recorder-B4MpJfJK.js.map +1 -0
  60. package/dist/{history-store-CetkrBBD.js → history-store-BhxWOuz9.js} +2 -2
  61. package/dist/{history-store-CetkrBBD.js.map → history-store-BhxWOuz9.js.map} +1 -1
  62. package/dist/index.d.ts +6 -2
  63. package/dist/index.es.js +2717 -866
  64. package/dist/index.es.js.map +1 -1
  65. package/dist/init/docs.d.ts +5 -2
  66. package/dist/init/shutdown.d.ts +8 -3
  67. package/dist/jobs/index.d.ts +2 -2
  68. package/dist/jobs/job-queue.d.ts +23 -2
  69. package/dist/jobs/job-store.d.ts +37 -5
  70. package/dist/jobs/types.d.ts +8 -6
  71. package/dist/{jobs-Bjr8DZAi.js → jobs-CazMYhyy.js} +305 -166
  72. package/dist/jobs-CazMYhyy.js.map +1 -0
  73. package/dist/{jwt-C4OW-DNq.js → jwt-DnQHNFCl.js} +77 -25
  74. package/dist/{jwt-C4OW-DNq.js.map → jwt-DnQHNFCl.js.map} +1 -1
  75. package/dist/{keys-Qfc4XieN.js → keys-CogCQpxG.js} +18 -3
  76. package/dist/{keys-Qfc4XieN.js.map → keys-CogCQpxG.js.map} +1 -1
  77. package/dist/{logs-routes-3EEzPjhl.js → logs-routes-Bj4TYYUl.js} +7 -4
  78. package/dist/{logs-routes-3EEzPjhl.js.map → logs-routes-Bj4TYYUl.js.map} +1 -1
  79. package/dist/mcp/mcp-routes.d.ts +38 -2
  80. package/dist/mcp/mcp-tools.d.ts +7 -1
  81. package/dist/mcp/oauth-routes.d.ts +27 -0
  82. package/dist/mcp/oauth-store.d.ts +29 -13
  83. package/dist/metrics/history-recorder.d.ts +1 -1
  84. package/dist/{openapi-generator-D8uFz-LW.js → openapi-generator-O_O24MAT.js} +34 -12
  85. package/dist/openapi-generator-O_O24MAT.js.map +1 -0
  86. package/dist/{query-parser-BleZmY18.js → query-parser-DGRVFNM3.js} +39 -30
  87. package/dist/query-parser-DGRVFNM3.js.map +1 -0
  88. package/dist/rls-audit/index.d.ts +4 -0
  89. package/dist/{schema-editor-routes-DdLihzp0.js → schema-editor-routes-C5-lh_jO.js} +2 -2
  90. package/dist/{schema-editor-routes-DdLihzp0.js.map → schema-editor-routes-C5-lh_jO.js.map} +1 -1
  91. package/dist/{src-1vL-I1Po.js → src-pmvW7BFx.js} +122 -14
  92. package/dist/src-pmvW7BFx.js.map +1 -0
  93. package/dist/{src-Br6ARbs6.js → src-vkcwKXbT.js} +2 -27
  94. package/dist/{src-Br6ARbs6.js.map → src-vkcwKXbT.js.map} +1 -1
  95. package/dist/storage/GCSStorageController.d.ts +11 -1
  96. package/dist/storage/keys.d.ts +12 -0
  97. package/dist/storage/rendition-cache.d.ts +11 -1
  98. package/dist/storage/request-keys.d.ts +67 -0
  99. package/dist/storage/types.d.ts +17 -1
  100. package/dist/types-BfKcm9do.js.map +1 -1
  101. package/package.json +9 -9
  102. package/dist/GCSStorageController-CLIJXwGS.js.map +0 -1
  103. package/dist/ast-schema-editor-CslO8Oje.js.map +0 -1
  104. package/dist/auth-CCDpk2rn.js.map +0 -1
  105. package/dist/backup-DzI9jLwc.js.map +0 -1
  106. package/dist/cron-routes-B7CRGfiq.js.map +0 -1
  107. package/dist/cron-scheduler-COPQxlEq.js.map +0 -1
  108. package/dist/cron-store-BYGZFNWk.js.map +0 -1
  109. package/dist/ddl-bootstrap-CfNvxMuK.js.map +0 -1
  110. package/dist/history-recorder-BQmB0P_j.js.map +0 -1
  111. package/dist/jobs-Bjr8DZAi.js.map +0 -1
  112. package/dist/openapi-generator-D8uFz-LW.js.map +0 -1
  113. package/dist/query-parser-BleZmY18.js.map +0 -1
  114. package/dist/src-1vL-I1Po.js.map +0 -1
@@ -10,8 +10,14 @@ import { DataDriver } from "@rebasepro/types";
10
10
  * carrying corrected rows from the retry of an unanswered one, which is the
11
11
  * difference between "here is your earlier answer" and silently discarding a
12
12
  * correction.
13
+ *
14
+ * The query string separates instructions sent to one path: `?hard=true` purges
15
+ * where the same `DELETE` without it soft-deletes, and `?on_conflict=` makes a
16
+ * create an upsert. Left out, a purge sent under the soft delete's key was
17
+ * answered with that delete's `204` and never ran. A request with no query
18
+ * string fingerprints exactly as it did before one could enter.
13
19
  */
14
- export declare function requestFingerprint(method: string, path: string, body: unknown): Promise<string>;
20
+ export declare function requestFingerprint(method: string, path: string, body: unknown, query?: URLSearchParams): Promise<string>;
15
21
  /**
16
22
  * What a caller may do with a key.
17
23
  *
@@ -0,0 +1,46 @@
1
+ import { type CollectionConfig, type ResolvedRelation } from "@rebasepro/types";
2
+ import { type FieldViewer } from "@rebasepro/common";
3
+ /** The last hop of a nested path: the parent, the relation it names, and that relation's target. */
4
+ export interface NestedWriteHop {
5
+ parentCollection: CollectionConfig;
6
+ relation: ResolvedRelation;
7
+ targetCollection: CollectionConfig;
8
+ }
9
+ /**
10
+ * What a write through a nested path does to the row the path addresses.
11
+ *
12
+ * - `create`: a new row under the parent (`POST /bands/7/members`);
13
+ * - `update`: an existing row reached through the parent (`PATCH …/members/3`);
14
+ * - `pivot`: the columns of one many-to-many link (`PATCH …/tags/5 { _pivot }`);
15
+ * - `unlink`: a `DELETE` through a nested path. Through a many-to-many one it
16
+ * drops the link; through an owning one it deletes the row, which writes
17
+ * no relation and is the row's own delete rules' to answer.
18
+ */
19
+ export type NestedWriteKind = "create" | "update" | "pivot" | "unlink";
20
+ /**
21
+ * Refuse a write through a parent that sets a relation the caller may not write.
22
+ *
23
+ * A body is checked against the target's field rules, and that is all the
24
+ * nested routes used to check. But the address writes too, and what it writes
25
+ * is not in the body:
26
+ *
27
+ * - A create under a parent whose target carries the parent's key
28
+ * (`hasMany`/`hasOne`) has that key stamped from the URL — `POST
29
+ * /bands/7/members` sets `members.band_id = 7`, which `POST /members
30
+ * { bandId: 7 }` answers `FIELD_NOT_WRITABLE` for when `members.band` is
31
+ * closed to the caller.
32
+ * - A create is also a new member of the parent's relation, and every write
33
+ * through a many-to-many path is set membership: it links on create and on
34
+ * update, edits the link with `_pivot`, and drops it on delete. That is a
35
+ * write of the parent's relation property — `PATCH /posts/1 { tags: [5] }`
36
+ * when `posts.tags` is closed — and its rules hold here.
37
+ *
38
+ * An update through an owning parent changes neither: the driver refuses it
39
+ * unless the row is already the parent's, and does not write the key.
40
+ *
41
+ * One rule for every door that takes a nested path — the REST routes and the
42
+ * realtime socket — asked of the same fields `assertNoClosedFields` judges a
43
+ * body by, so the answer (`FIELD_NOT_WRITABLE`, or `VALIDATION_EXCLUDED_FIELDS`
44
+ * for an `excludeFromApi` relation) is the one the root route gives.
45
+ */
46
+ export declare function assertNestedWriteAllowed(hop: NestedWriteHop, kind: NestedWriteKind, path: string, viewer: FieldViewer | undefined): void;
@@ -1,5 +1,37 @@
1
1
  import { CollectionConfig } from "@rebasepro/types";
2
2
  import { type FieldViewer } from "@rebasepro/common";
3
+ /**
4
+ * Refuse a write naming a field this caller may not set.
5
+ *
6
+ * `excludeFromApi` means what the generated SDK already says it means: "absent
7
+ * from Row, Insert and Update — the API surface does not mention it, in either
8
+ * direction". The server only ever enforced the read half, so the column a
9
+ * collection had declared unservable was still writable by anyone whose
10
+ * policies let them write the row.
11
+ *
12
+ * On the user store that is the sharp one. `users_write_own` lets you update
13
+ * your own row, `password_hash` is a column on it, and setting it directly is a
14
+ * password change that does not need the old password — so a stolen access
15
+ * token, which expires within the hour, becomes a password the attacker chose.
16
+ * Everything else the flag protects is the same shape: a verification token, a
17
+ * rotation counter, a secret the server owns.
18
+ *
19
+ * `access.write: ["editor"]` is the same rule with a role list instead of an
20
+ * empty one, and answers `FIELD_NOT_WRITABLE` rather than
21
+ * `VALIDATION_EXCLUDED_FIELDS`. Both refuse rather than dropping the key: a
22
+ * write that silently discards a field reports success for an edit that did not
23
+ * happen, which is the one outcome a form cannot recover from.
24
+ *
25
+ * The empty-list half is refused for every caller, not only unprivileged ones,
26
+ * and regardless of `strictWrites`. The framework's own auth paths do not come
27
+ * through here — `prepareUserCreation` builds the row itself — so what is left
28
+ * is callers the rule was written to exclude.
29
+ *
30
+ * Exported for the history revert, which writes a stored version rather than a
31
+ * body and so asks this question of the fields the revert would change, not of
32
+ * every key the snapshot holds. `where` prefixes the message.
33
+ */
34
+ export declare function assertNoClosedFields(values: Record<string, unknown>, collection: CollectionConfig, where: string, viewer: FieldViewer | undefined): void;
3
35
  /**
4
36
  * Reject a write naming a field the collection does not have.
5
37
  *
@@ -2,7 +2,7 @@ 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-Br6ARbs6.js";
5
+ import "./src-vkcwKXbT.js";
6
6
  import { i as nestAdminPropertyKeys, r as nestAdminCollectionKeys } from "./admin_block-DxKLmdiv.js";
7
7
  import * as fs$1 from "fs";
8
8
  import * as path$1 from "path";
@@ -280,7 +280,30 @@ var AstSchemaEditor = class AstSchemaEditor {
280
280
  });
281
281
  }
282
282
  }
283
+ /**
284
+ * Drop whatever a refused edit left in memory.
285
+ *
286
+ * One editor serves every request, and `project.save()` writes every file
287
+ * with unsaved changes. So an edit refused halfway through — a relation with
288
+ * no target, found after the name was already rewritten and an import
289
+ * added — reached the disk with the next save of some other collection, and
290
+ * a file refused as it was being created appeared anyway.
291
+ */
292
+ forgetUnsaved(collectionId) {
293
+ const file = this.project.getSourceFile(path$1.resolve(this.collectionsDir, `${collectionId}.ts`));
294
+ if (!file || file.isSaved()) return;
295
+ if (fs$1.existsSync(file.getFilePath())) file.refreshFromFileSystemSync();
296
+ else this.project.removeSourceFile(file);
297
+ }
283
298
  async saveProperty(collectionId, propertyKey, propertyConfig) {
299
+ try {
300
+ await this.writeProperty(collectionId, propertyKey, propertyConfig);
301
+ } catch (err) {
302
+ this.forgetUnsaved(collectionId);
303
+ throw err;
304
+ }
305
+ }
306
+ async writeProperty(collectionId, propertyKey, propertyConfig) {
284
307
  const collectionObj = this.requireCollectionObject(collectionId);
285
308
  const nestedConfig = nestAdminPropertyKeys(propertyConfig);
286
309
  let propertiesProp = collectionObj.getProperty("properties");
@@ -293,7 +316,8 @@ var AstSchemaEditor = class AstSchemaEditor {
293
316
  const existingProp = this.findProperty(propsObj, propertyKey);
294
317
  let oldPropAstNode;
295
318
  if (existingProp && existingProp.isKind(SyntaxKind.PropertyAssignment)) oldPropAstNode = existingProp.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
296
- const newInitializer = this.convertJsonToAstString(nestedConfig, 2, oldPropAstNode);
319
+ const withTargets = this.relationTargetsAsSource(collectionId, this.getCollectionFile(collectionId), { [propertyKey]: nestedConfig }, propsObj)[propertyKey];
320
+ const newInitializer = this.convertJsonToAstString(withTargets, 2, oldPropAstNode);
297
321
  if (existingProp) {
298
322
  if (existingProp.isKind(SyntaxKind.PropertyAssignment)) existingProp.setInitializer(newInitializer);
299
323
  } else propsObj.addPropertyAssignment({
@@ -332,6 +356,14 @@ var AstSchemaEditor = class AstSchemaEditor {
332
356
  * default, which in the scaffold is `access: "public"`.
333
357
  */
334
358
  async saveCollection(collectionId, collectionData, options = {}) {
359
+ try {
360
+ await this.writeCollection(collectionId, collectionData, options);
361
+ } catch (err) {
362
+ this.forgetUnsaved(collectionId);
363
+ throw err;
364
+ }
365
+ }
366
+ async writeCollection(collectionId, collectionData, options) {
335
367
  const partial = options.partial === true;
336
368
  let file = this.getCollectionFile(collectionId);
337
369
  const collectionObj = file ? this.getCollectionObject(collectionId) : null;
@@ -342,7 +374,14 @@ var AstSchemaEditor = class AstSchemaEditor {
342
374
  const newFilePath = this.safePath(`${safeId}.ts`);
343
375
  if (fs$1.existsSync(newFilePath)) throw new Error(`Refusing to overwrite ${newFilePath}: a file for "${collectionId}" already exists but could not be parsed.`);
344
376
  const varName = `${AstSchemaEditor.collectionVarName(safeId)}Collection`;
345
- file = this.project.createSourceFile(newFilePath, `import { CollectionConfig } from "@rebasepro/types";\n\nconst ${varName}: CollectionConfig = ${this.convertJsonToAstString(nestAdminKeysDeep(collectionData))};\n\nexport default ${varName};\n`);
377
+ file = this.project.createSourceFile(newFilePath, `import { CollectionConfig } from "@rebasepro/types";\n\nconst ${varName}: CollectionConfig = {};\n\nexport default ${varName};\n`);
378
+ const { relations, ...rest } = nestAdminKeysDeep(collectionData);
379
+ const data = "properties" in rest ? {
380
+ ...rest,
381
+ properties: this.relationTargetsAsSource(collectionId, file, rest.properties, void 0)
382
+ } : rest;
383
+ file.getVariableDeclarationOrThrow(varName).setInitializer(this.convertJsonToAstString(data));
384
+ if (relations !== void 0) this.writeRelations(collectionId, file, this.requireCollectionObject(collectionId), relations);
346
385
  } else {
347
386
  if (!partial) {
348
387
  if (!("securityRules" in collectionData) || collectionData.securityRules === void 0 || Array.isArray(collectionData.securityRules) && collectionData.securityRules.length === 0) {
@@ -360,6 +399,7 @@ var AstSchemaEditor = class AstSchemaEditor {
360
399
  const prop = collectionObj.getProperty(key);
361
400
  let oldAstNode;
362
401
  if (prop && prop.isKind(SyntaxKind.PropertyAssignment)) oldAstNode = prop.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
402
+ if (key === "properties") collectionData[key] = this.relationTargetsAsSource(collectionId, file, collectionData[key], oldAstNode);
363
403
  if (partial && oldAstNode && AstSchemaEditor.isPlainObject(collectionData[key])) {
364
404
  this.mergeIntoObjectLiteral(oldAstNode, collectionData[key], 2);
365
405
  continue;
@@ -376,6 +416,66 @@ var AstSchemaEditor = class AstSchemaEditor {
376
416
  await this.project.save();
377
417
  }
378
418
  /**
419
+ * Every property relation's `target`, as source.
420
+ *
421
+ * The collection-level `relations` array has {@link writeRelations}. A
422
+ * relation declared on a property went through `convertJsonToAstString`
423
+ * like any other value, so a target the property form had picked — a slug,
424
+ * which is all JSON can carry — was written as the string literal
425
+ * `target: "authors"`, and the next boot threw in `resolveRelation` for every
426
+ * collection in the directory. The same rule applies here as there: a slug
427
+ * becomes `() => xCollection` plus its import, an arrow returning one
428
+ * identifier is written through, and a relation sent without a target keeps
429
+ * the one already in the file — or is refused, when there is none.
430
+ *
431
+ * Walks into `map` properties, an array's `of` and a block's `oneOf`, which
432
+ * the registry resolves relations in too. `oldProperties` is the same
433
+ * properties object in the file, when there is one.
434
+ */
435
+ relationTargetsAsSource(collectionId, file, properties, oldProperties, path = "properties") {
436
+ if (!AstSchemaEditor.isPlainObject(properties)) return {};
437
+ return Object.fromEntries(Object.entries(properties).map(([key, property]) => [key, this.propertyRelationTargetsAsSource(collectionId, file, property, oldProperties ? this.objectAt(oldProperties, key) : void 0, `${path}.${key}`)]));
438
+ }
439
+ propertyRelationTargetsAsSource(collectionId, file, property, oldProperty, path) {
440
+ if (!AstSchemaEditor.isPlainObject(property)) return property;
441
+ const next = { ...property };
442
+ if (property.type === "relation" && AstSchemaEditor.isPlainObject(property.relation)) {
443
+ const relation = { ...property.relation };
444
+ const rawTarget = relation.target;
445
+ if (typeof rawTarget === "string" && rawTarget.trim().length > 0) {
446
+ const trimmed = rawTarget.trim();
447
+ relation.target = new RawExpression(ARROW_TO_IDENTIFIER.test(trimmed) ? trimmed : this.targetThunk(file, trimmed));
448
+ } else {
449
+ const oldRelation = oldProperty ? this.objectAt(oldProperty, "relation") : void 0;
450
+ if (!oldRelation || !this.findProperty(oldRelation, "target")) throw new Error(`Relation property "${path}" on collection "${collectionId}" has no target collection. Pick one before saving.`);
451
+ delete relation.target;
452
+ }
453
+ next.relation = relation;
454
+ }
455
+ if (AstSchemaEditor.isPlainObject(property.properties)) next.properties = this.relationTargetsAsSource(collectionId, file, property.properties, oldProperty ? this.objectAt(oldProperty, "properties") : void 0, path);
456
+ if (Array.isArray(property.of)) {
457
+ const oldOf = oldProperty ? this.initializerOf(oldProperty, "of")?.asKind(SyntaxKind.ArrayLiteralExpression) : void 0;
458
+ next.of = property.of.map((item, index) => this.propertyRelationTargetsAsSource(collectionId, file, item, oldOf?.getElements()[index]?.asKind(SyntaxKind.ObjectLiteralExpression), `${path}.of[${index}]`));
459
+ } else if (AstSchemaEditor.isPlainObject(property.of)) next.of = this.propertyRelationTargetsAsSource(collectionId, file, property.of, oldProperty ? this.objectAt(oldProperty, "of") : void 0, `${path}.of`);
460
+ if (AstSchemaEditor.isPlainObject(property.oneOf) && AstSchemaEditor.isPlainObject(property.oneOf.properties)) {
461
+ const oldOneOf = oldProperty ? this.objectAt(oldProperty, "oneOf") : void 0;
462
+ next.oneOf = {
463
+ ...property.oneOf,
464
+ properties: this.relationTargetsAsSource(collectionId, file, property.oneOf.properties, oldOneOf ? this.objectAt(oldOneOf, "properties") : void 0, `${path}.oneOf`)
465
+ };
466
+ }
467
+ return next;
468
+ }
469
+ /** The initializer of `obj[key]` in the file, when it is written as a property assignment. */
470
+ initializerOf(obj, key) {
471
+ const prop = this.findProperty(obj, key);
472
+ return prop && prop.isKind(SyntaxKind.PropertyAssignment) ? prop.getInitializer() : void 0;
473
+ }
474
+ /** `obj[key]` in the file, when it is an object literal. */
475
+ objectAt(obj, key) {
476
+ return this.initializerOf(obj, key)?.asKind(SyntaxKind.ObjectLiteralExpression);
477
+ }
478
+ /**
379
479
  * Write the collection-level `relations` array.
380
480
  *
381
481
  * Relations are the one key whose values are not all data: `target` is a
@@ -462,4 +562,4 @@ var AstSchemaEditor = class AstSchemaEditor {
462
562
  //#endregion
463
563
  export { AstSchemaEditor };
464
564
 
465
- //# sourceMappingURL=ast-schema-editor-CslO8Oje.js.map
565
+ //# sourceMappingURL=ast-schema-editor-Mvr50v_S.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ast-schema-editor-Mvr50v_S.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\n/**\n * {@link nestAdminKeys} for a whole collection — its properties included.\n *\n * `nestAdminKeys` only ever moved the COLLECTION's presentation keys. A\n * property's — `markdown`, `multiline`, `previewAsTag` — went to disk exactly as\n * the panel sent them, at the top level of the property, which the boot\n * validator treats as fatal. `saveProperty` nests them; `saveCollection` did\n * not, and *creating* a collection is a `saveCollection`.\n *\n * So a collection created from a template wrote a file that could not boot, and\n * took the whole project down with it — the loader imports every collection in\n * the directory, so one bad file stops `rebase dev` from starting at all. The\n * panel reported success.\n */\nexport function nestAdminKeysDeep(collectionData: Record<string, unknown>): Record<string, unknown> {\n const nested = nestAdminKeys(collectionData);\n const properties = nested.properties;\n if (properties && typeof properties === \"object\" && !Array.isArray(properties)) {\n nested.properties = Object.fromEntries(\n Object.entries(properties as Record<string, unknown>).map(([key, property]) => [\n key,\n property && typeof property === \"object\" && !Array.isArray(property)\n ? nestAdminPropertyKeys(property as Record<string, unknown>)\n : property\n ])\n );\n }\n return nested;\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 /**\n * Drop whatever a refused edit left in memory.\n *\n * One editor serves every request, and `project.save()` writes every file\n * with unsaved changes. So an edit refused halfway through — a relation with\n * no target, found after the name was already rewritten and an import\n * added — reached the disk with the next save of some other collection, and\n * a file refused as it was being created appeared anyway.\n */\n private forgetUnsaved(collectionId: string): void {\n const file = this.project.getSourceFile(path.resolve(this.collectionsDir, `${collectionId}.ts`));\n if (!file || file.isSaved()) return;\n if (fs.existsSync(file.getFilePath())) {\n file.refreshFromFileSystemSync();\n } else {\n this.project.removeSourceFile(file);\n }\n }\n\n public async saveProperty(collectionId: string, propertyKey: string, propertyConfig: Record<string, unknown>) {\n try {\n await this.writeProperty(collectionId, propertyKey, propertyConfig);\n } catch (err) {\n this.forgetUnsaved(collectionId);\n throw err;\n }\n }\n\n private async writeProperty(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 withTargets = this.relationTargetsAsSource(\n collectionId,\n this.getCollectionFile(collectionId)!,\n { [propertyKey]: nestedConfig },\n propsObj\n )[propertyKey];\n const newInitializer = this.convertJsonToAstString(withTargets, 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 try {\n await this.writeCollection(collectionId, collectionData, options);\n } catch (err) {\n this.forgetUnsaved(collectionId);\n throw err;\n }\n }\n\n private async writeCollection(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 // Created empty and filled in after: a relation's target becomes an\n // import, and an import needs a file to be added to.\n file = this.project.createSourceFile(newFilePath, `import { CollectionConfig } from \"@rebasepro/types\";\\n\\nconst ${varName}: CollectionConfig = {};\\n\\nexport default ${varName};\\n`);\n const { relations, ...rest } = nestAdminKeysDeep(collectionData);\n const data = \"properties\" in rest\n ? { ...rest, properties: this.relationTargetsAsSource(collectionId, file, rest.properties, undefined) }\n : rest;\n file.getVariableDeclarationOrThrow(varName).setInitializer(this.convertJsonToAstString(data));\n if (relations !== undefined) {\n this.writeRelations(collectionId, file, this.requireCollectionObject(collectionId), relations);\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. A\n // property's presentation keys are worse than ignored: they are fatal\n // at the next boot, which is why this goes all the way down.\n collectionData = nestAdminKeysDeep(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 (key === \"properties\") {\n collectionData[key] = this.relationTargetsAsSource(collectionId, file, collectionData[key], oldAstNode);\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 * Every property relation's `target`, as source.\n *\n * The collection-level `relations` array has {@link writeRelations}. A\n * relation declared on a property went through `convertJsonToAstString`\n * like any other value, so a target the property form had picked — a slug,\n * which is all JSON can carry — was written as the string literal\n * `target: \"authors\"`, and the next boot threw in `resolveRelation` for every\n * collection in the directory. The same rule applies here as there: a slug\n * becomes `() => xCollection` plus its import, an arrow returning one\n * identifier is written through, and a relation sent without a target keeps\n * the one already in the file — or is refused, when there is none.\n *\n * Walks into `map` properties, an array's `of` and a block's `oneOf`, which\n * the registry resolves relations in too. `oldProperties` is the same\n * properties object in the file, when there is one.\n */\n private relationTargetsAsSource(\n collectionId: string,\n file: SourceFile,\n properties: unknown,\n oldProperties: ObjectLiteralExpression | undefined,\n path = \"properties\"\n ): Record<string, unknown> {\n if (!AstSchemaEditor.isPlainObject(properties)) return {};\n return Object.fromEntries(Object.entries(properties).map(([key, property]) => [\n key,\n this.propertyRelationTargetsAsSource(\n collectionId,\n file,\n property,\n oldProperties ? this.objectAt(oldProperties, key) : undefined,\n `${path}.${key}`\n )\n ]));\n }\n\n private propertyRelationTargetsAsSource(\n collectionId: string,\n file: SourceFile,\n property: unknown,\n oldProperty: ObjectLiteralExpression | undefined,\n path: string\n ): unknown {\n if (!AstSchemaEditor.isPlainObject(property)) return property;\n const next: Record<string, unknown> = { ...property };\n\n if (property.type === \"relation\" && AstSchemaEditor.isPlainObject(property.relation)) {\n const relation: Record<string, unknown> = { ...property.relation };\n const rawTarget = relation.target;\n if (typeof rawTarget === \"string\" && rawTarget.trim().length > 0) {\n const trimmed = rawTarget.trim();\n // See `writeRelations` for why the arrow is matched this narrowly.\n relation.target = new RawExpression(ARROW_TO_IDENTIFIER.test(trimmed)\n ? trimmed\n : this.targetThunk(file, trimmed));\n } else {\n const oldRelation = oldProperty ? this.objectAt(oldProperty, \"relation\") : undefined;\n if (!oldRelation || !this.findProperty(oldRelation, \"target\")) {\n throw new Error(`Relation property \"${path}\" on collection \"${collectionId}\" has no target collection. Pick one before saving.`);\n }\n // Absent, so `convertJsonToAstString` keeps the thunk in the file.\n delete relation.target;\n }\n next.relation = relation;\n }\n\n if (AstSchemaEditor.isPlainObject(property.properties)) {\n next.properties = this.relationTargetsAsSource(\n collectionId, file, property.properties,\n oldProperty ? this.objectAt(oldProperty, \"properties\") : undefined, path);\n }\n if (Array.isArray(property.of)) {\n const oldOf = oldProperty ? this.initializerOf(oldProperty, \"of\")?.asKind(SyntaxKind.ArrayLiteralExpression) : undefined;\n next.of = property.of.map((item, index) => this.propertyRelationTargetsAsSource(\n collectionId, file, item,\n oldOf?.getElements()[index]?.asKind(SyntaxKind.ObjectLiteralExpression), `${path}.of[${index}]`));\n } else if (AstSchemaEditor.isPlainObject(property.of)) {\n next.of = this.propertyRelationTargetsAsSource(\n collectionId, file, property.of,\n oldProperty ? this.objectAt(oldProperty, \"of\") : undefined, `${path}.of`);\n }\n if (AstSchemaEditor.isPlainObject(property.oneOf) && AstSchemaEditor.isPlainObject(property.oneOf.properties)) {\n const oldOneOf = oldProperty ? this.objectAt(oldProperty, \"oneOf\") : undefined;\n next.oneOf = {\n ...property.oneOf,\n properties: this.relationTargetsAsSource(\n collectionId, file, property.oneOf.properties,\n oldOneOf ? this.objectAt(oldOneOf, \"properties\") : undefined, `${path}.oneOf`)\n };\n }\n return next;\n }\n\n /** The initializer of `obj[key]` in the file, when it is written as a property assignment. */\n private initializerOf(obj: ObjectLiteralExpression, key: string): Node | undefined {\n const prop = this.findProperty(obj, key);\n return prop && prop.isKind(SyntaxKind.PropertyAssignment) ? prop.getInitializer() : undefined;\n }\n\n /** `obj[key]` in the file, when it is an object literal. */\n private objectAt(obj: ObjectLiteralExpression, key: string): ObjectLiteralExpression | undefined {\n return this.initializerOf(obj, key)?.asKind(SyntaxKind.ObjectLiteralExpression);\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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"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * What happens when the owner of an address proves it for the first time.
3
+ *
4
+ * Nothing verifies the address an account is made with: `POST /auth/register`
5
+ * takes whatever it is given, and so does a sign-in through a provider that
6
+ * does not vouch for the address (Spotify and Facebook never do; Discord and
7
+ * multi-tenant Microsoft sometimes do not). So anyone can make an account for
8
+ * someone else's address and leave a way in on it — a password, or the
9
+ * provider identity they signed up with — then wait for the owner.
10
+ *
11
+ * The owner turns up by proving the address: a magic link, an email code or a
12
+ * password reset, each read out of their inbox. That proof used to mark the
13
+ * account verified and leave the attacker's way in where it was, so the owner
14
+ * and the attacker shared the account from then on. Now the proof also removes
15
+ * everything on the account that nobody proved: the password, every identity
16
+ * whose provider did not vouch for this address, and every session.
17
+ *
18
+ * @module
19
+ */
20
+ import type { AuthRepository, OAuthProviderProfile, UserData, UserIdentityData } from "./interfaces.js";
21
+ /**
22
+ * The profile stored with a linked OAuth identity.
23
+ *
24
+ * `emailVerified` is recorded so that a later proof of the address can tell an
25
+ * identity whose provider vouched for it from one whose provider did not.
26
+ */
27
+ export declare function identityProfileData(profile: OAuthProviderProfile): Record<string, unknown>;
28
+ /**
29
+ * Did this identity's provider vouch for `email` when it was linked?
30
+ *
31
+ * Only whoever controls the inbox can hold a provider account that vouches for
32
+ * the address, so such an identity is the owner's. An identity linked before
33
+ * the flag was recorded reads as not vouching: it is detached, and if its
34
+ * provider does vouch, the next sign-in with it links it again.
35
+ */
36
+ export declare function identityVouchesForAddress(identity: UserIdentityData, email: string): boolean;
37
+ /**
38
+ * Settle an account whose address its owner has just proven, and mark it
39
+ * verified.
40
+ *
41
+ * Call it only for an account that is not yet verified, from a route that has
42
+ * just consumed something mailed to the address. It detaches every identity
43
+ * whose provider did not vouch for the address, sets the password to
44
+ * `passwordHash` — the one the owner just chose on a reset, or `null` to remove
45
+ * the one somebody else may have registered with — and ends every session, in
46
+ * that order and before the account is marked verified.
47
+ *
48
+ * A repository that cannot detach an identity makes this refuse with 409 when
49
+ * there is one to detach. Marking the account verified with the identity still
50
+ * on it is the takeover itself, and leaving the account unverified while
51
+ * signing the owner in would share the account all the same.
52
+ */
53
+ export declare function confirmAddressOwnership(authRepo: AuthRepository, user: Pick<UserData, "id" | "email" | "passwordHash">, passwordHash: string | null): Promise<void>;
@@ -2,7 +2,9 @@
2
2
  * Admin User Operations
3
3
  *
4
4
  * Shared utilities and orchestration for admin-initiated user management
5
- * (user creation via REST API, password reset via admin panel).
5
+ * (user creation via REST API, password reset via admin panel), and the rules
6
+ * user administration holds to whichever door it arrives by — the admin user
7
+ * routes, or the auth collection through the data API.
6
8
  *
7
9
  * Hook resolution order:
8
10
  * 1. Collection-level hook (`auth.onCreateUser` on the collection) — closest to the data
@@ -12,7 +14,7 @@
12
14
  import type { AuthRepository } from "./interfaces.js";
13
15
  import type { EmailService, EmailConfig } from "../email/index.js";
14
16
  import type { ResolvedAuthHooks } from "./auth-hooks.js";
15
- import type { AuthCollectionConfig, UserCreationFinalizeResult, UserCreationPrepareResult } from "@rebasepro/types";
17
+ import type { AuthAdapter, AuthCollectionConfig, UserCreationFinalizeResult, UserCreationPrepareResult } from "@rebasepro/types";
16
18
  /**
17
19
  * Generate a cryptographically secure random password that meets strength requirements.
18
20
  *
@@ -100,3 +102,34 @@ export declare function finalizeAdminUserCreation(entity: {
100
102
  * ones left out.
101
103
  */
102
104
  export declare function completeUserCreation(prepared: UserCreationPrepareResult, finalize?: (clearPassword: string | undefined) => Promise<UserCreationFinalizeResult>): Promise<UserCreationFinalizeResult>;
105
+ /** Refuse deleting these users when that would leave the project with no administrator. */
106
+ export declare function assertUserDeletionsAllowed(authRepo: AuthRepository, uids: readonly string[]): Promise<void>;
107
+ /** Refuse setting these users' roles when that would leave the project with no administrator. */
108
+ export declare function assertRoleChangesAllowed(authRepo: AuthRepository, changes: ReadonlyArray<{
109
+ uid: string;
110
+ roles: readonly unknown[];
111
+ }>): Promise<void>;
112
+ /**
113
+ * A user's new email, in the form sign-in looks it up by — or a 409 when
114
+ * another account holds it.
115
+ *
116
+ * Sign-in, password reset and magic links all find the account by
117
+ * `normalizeEmail(input)`. An address stored as typed is one those lookups
118
+ * never match: its owner is locked out, and registering the address again hits
119
+ * the case-insensitive unique index.
120
+ */
121
+ export declare function normalizeUserEmailChange(authRepo: AuthRepository, uid: string, email: string): Promise<string>;
122
+ /**
123
+ * Run `afterUserDelete` without letting it fail the deletion it follows: the
124
+ * user is already gone, so a failing listener is logged, not answered.
125
+ */
126
+ export declare function runAfterUserDelete(resolvedHooks: ResolvedAuthHooks, uid: string): void;
127
+ /**
128
+ * The auth adapter's side of user administration through the auth collection
129
+ * (`AuthAdapter.prepareUserUpdates` and the deletion pair): the admin user
130
+ * routes' rules and hooks, for the data API's writes to the same rows.
131
+ */
132
+ export declare function authCollectionUserAdmin(ctx: {
133
+ authRepo: AuthRepository;
134
+ resolvedHooks: ResolvedAuthHooks;
135
+ }): Required<Pick<AuthAdapter, "prepareUserUpdates" | "prepareUserDeletions" | "finalizeUserDeletions">>;
@@ -105,6 +105,10 @@ export interface AuthHooks {
105
105
  /**
106
106
  * Called before a new user is created (registration or admin creation).
107
107
  *
108
+ * Also called when a guest becomes an account through
109
+ * `POST /auth/anonymous/link`, with the email and password hash it is
110
+ * getting; the guest itself was created with `isAnonymous: true`.
111
+ *
108
112
  * Return modified data to alter what gets stored, or throw an error
109
113
  * to reject the creation entirely.
110
114
  *
@@ -39,6 +39,11 @@ export interface CaptchaConfig {
39
39
  /** Milliseconds before a verification attempt is abandoned. Default 5000. */
40
40
  timeoutMs?: number;
41
41
  }
42
+ /**
43
+ * A protectable auth route. `register` guards both ways an account is made
44
+ * with a password: `POST /auth/register`, and `POST /auth/anonymous/link`,
45
+ * which turns a guest into one.
46
+ */
42
47
  export type CaptchaRoute = "register" | "login" | "forgotPassword" | "magicLink" | "emailOtp";
43
48
  export declare const DEFAULT_CAPTCHA_ROUTES: CaptchaRoute[];
44
49
  /**
@@ -179,6 +179,20 @@ export interface RefreshTokenSession {
179
179
  * assurance level is a property of the *sign-in*, not of the account.
180
180
  */
181
181
  aal?: "aal1" | "aal2";
182
+ /**
183
+ * The hash of the token this one replaces, when it is minted by rotating
184
+ * one. A repository that honours it writes the new token only while that
185
+ * token is still live — present and not revoked — decided in the same
186
+ * transaction as the write, and answers `false` from
187
+ * {@link TokenRepository.createRefreshToken} when it is not.
188
+ *
189
+ * That is the sign-out that landed while the rotation was in flight: the
190
+ * refresh read a live token, the logout revoked every row of the session,
191
+ * and the refresh then wrote a new, unrevoked row into it — in cookie mode
192
+ * re-setting the cookie, so the sign-out silently did not happen. A
193
+ * repository that ignores this keeps that race.
194
+ */
195
+ rotatedFrom?: string;
182
196
  }
183
197
  /**
184
198
  * Password reset token info
@@ -263,6 +277,15 @@ export interface UserRepository {
263
277
  * Link a new OAuth identity to a user
264
278
  */
265
279
  linkUserIdentity(uid: string, provider: string, providerId: string, profileData?: Record<string, unknown>): Promise<void>;
280
+ /**
281
+ * Detach one OAuth identity from a user.
282
+ *
283
+ * Used when the owner of an address proves it for the first time, to
284
+ * remove the identities someone attached to the account before anyone had
285
+ * proven the address. Optional: a repository without it cannot do that,
286
+ * and such a proof is refused rather than leaving the identities in place.
287
+ */
288
+ unlinkUserIdentity?(uid: string, provider: string, providerId: string): Promise<void>;
266
289
  /**
267
290
  * Update a user
268
291
  */
@@ -280,9 +303,10 @@ export interface UserRepository {
280
303
  */
281
304
  listUsersPaginated(options?: ListUsersOptions): Promise<PaginatedUsersResult>;
282
305
  /**
283
- * Update user's password hash
306
+ * Update user's password hash. `null` removes the password: the account
307
+ * then signs in only by link, code or provider.
284
308
  */
285
- updatePassword(id: string, passwordHash: string): Promise<void>;
309
+ updatePassword(id: string, passwordHash: string | null): Promise<void>;
286
310
  /**
287
311
  * Set email verification status
288
312
  */
@@ -357,8 +381,12 @@ export interface TokenRepository {
357
381
  * optional so that repositories written against an older release keep
358
382
  * satisfying this interface; implementations that ignore it degrade to one
359
383
  * session per token.
384
+ *
385
+ * Resolves `false` when `session.rotatedFrom` names a token that is no
386
+ * longer live, in which case nothing was written. Anything else means the
387
+ * token was written.
360
388
  */
361
- createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<void>;
389
+ createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<boolean | void>;
362
390
  /**
363
391
  * Mark a token as superseded by a rotation, WITHOUT making it unusable.
364
392
  *
@@ -571,9 +599,11 @@ export interface MfaRepository {
571
599
  */
572
600
  claimMfaFactorCounter?(factorId: string, counter: number): Promise<boolean>;
573
601
  /**
574
- * Record a failed verification against a challenge and return the new
575
- * total. Atomic (`UPDATE … SET attempts = attempts + 1 RETURNING attempts`)
576
- * so concurrent guesses cannot share one increment.
602
+ * Record a verification attempt against a challenge and return the new
603
+ * total. The route claims it before judging the code and refuses a claim
604
+ * past the cap, so this count is the cap. Atomic (`UPDATE … SET attempts =
605
+ * attempts + 1 RETURNING attempts`) so concurrent guesses cannot share
606
+ * one increment.
577
607
  *
578
608
  * Optional; where it is absent the route falls back to the rate limiters
579
609
  * alone.