@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.
- package/dist/{GCSStorageController-CLIJXwGS.js → GCSStorageController-CjrA4PMo.js} +37 -23
- package/dist/GCSStorageController-CjrA4PMo.js.map +1 -0
- package/dist/{S3StorageController-Dcuf8lMA.js → S3StorageController-B6pKDNVj.js} +2 -2
- package/dist/{S3StorageController-Dcuf8lMA.js.map → S3StorageController-B6pKDNVj.js.map} +1 -1
- package/dist/api/ast-schema-editor.d.ts +35 -0
- package/dist/api/live-schema-routes.d.ts +14 -0
- package/dist/api/rest/api-generator.d.ts +117 -35
- package/dist/api/rest/auth-collection-writes.d.ts +85 -0
- package/dist/api/rest/field-access-query.d.ts +6 -2
- package/dist/api/rest/idempotency.d.ts +7 -1
- package/dist/api/rest/nested-write-access.d.ts +46 -0
- package/dist/api/rest/write-validation.d.ts +32 -0
- package/dist/{ast-schema-editor-CslO8Oje.js → ast-schema-editor-Mvr50v_S.js} +104 -4
- package/dist/ast-schema-editor-Mvr50v_S.js.map +1 -0
- package/dist/auth/address-ownership.d.ts +53 -0
- package/dist/auth/admin-user-ops.d.ts +35 -2
- package/dist/auth/auth-hooks.d.ts +4 -0
- package/dist/auth/captcha.d.ts +5 -0
- package/dist/auth/interfaces.d.ts +36 -6
- package/dist/auth/jwt.d.ts +17 -0
- package/dist/auth/oauth-signin-policy.d.ts +25 -8
- package/dist/auth/rate-limiter.d.ts +31 -1
- package/dist/auth/session-routes.d.ts +7 -0
- package/dist/auth/token-revocation.d.ts +4 -1
- package/dist/{auth-CCDpk2rn.js → auth-B-GIMpDG.js} +639 -228
- package/dist/auth-B-GIMpDG.js.map +1 -0
- package/dist/backend-DTAOsLQc.js +30 -0
- package/dist/backend-DTAOsLQc.js.map +1 -0
- package/dist/backup/backup-common.d.ts +19 -0
- package/dist/{backup-DzI9jLwc.js → backup-D7YR94N3.js} +69 -8
- package/dist/backup-D7YR94N3.js.map +1 -0
- package/dist/boot/driver.d.ts +10 -0
- package/dist/boot/env.d.ts +2 -2
- package/dist/boot/fetch-bundle.d.ts +18 -1
- package/dist/boot/rls-audit-option.d.ts +26 -0
- package/dist/boot/sources.d.ts +1 -0
- package/dist/{contract-routes-eLxV0le1.js → contract-routes-CbFjuBwa.js} +2 -2
- package/dist/{contract-routes-eLxV0le1.js.map → contract-routes-CbFjuBwa.js.map} +1 -1
- package/dist/cron/cron-routes.d.ts +7 -2
- package/dist/cron/cron-scheduler.d.ts +128 -8
- package/dist/cron/cron-store.d.ts +72 -8
- package/dist/cron/index.d.ts +1 -1
- package/dist/{cron-loader-CQjvjpEw.js → cron-loader-DfTj2Hbi.js} +2 -2
- package/dist/{cron-loader-CQjvjpEw.js.map → cron-loader-DfTj2Hbi.js.map} +1 -1
- package/dist/{cron-routes-B7CRGfiq.js → cron-routes-eE8nif_b.js} +33 -12
- package/dist/cron-routes-eE8nif_b.js.map +1 -0
- package/dist/{cron-scheduler-COPQxlEq.js → cron-scheduler-B0pLfAix.js} +394 -68
- package/dist/cron-scheduler-B0pLfAix.js.map +1 -0
- package/dist/{cron-store-BYGZFNWk.js → cron-store-TcoGz-xS.js} +133 -12
- package/dist/cron-store-TcoGz-xS.js.map +1 -0
- package/dist/{ddl-bootstrap-CfNvxMuK.js → ddl-bootstrap-C6mo0Kmz.js} +2 -25
- package/dist/ddl-bootstrap-C6mo0Kmz.js.map +1 -0
- package/dist/email/link-base.d.ts +5 -4
- package/dist/email/smtp-email-service.d.ts +13 -1
- package/dist/email/templates.d.ts +9 -0
- package/dist/email/types.d.ts +3 -2
- package/dist/env.d.ts +24 -5
- package/dist/{history-recorder-BQmB0P_j.js → history-recorder-B4MpJfJK.js} +8 -6
- package/dist/history-recorder-B4MpJfJK.js.map +1 -0
- package/dist/{history-store-CetkrBBD.js → history-store-BhxWOuz9.js} +2 -2
- package/dist/{history-store-CetkrBBD.js.map → history-store-BhxWOuz9.js.map} +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.es.js +2717 -866
- package/dist/index.es.js.map +1 -1
- package/dist/init/docs.d.ts +5 -2
- package/dist/init/shutdown.d.ts +8 -3
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/job-queue.d.ts +23 -2
- package/dist/jobs/job-store.d.ts +37 -5
- package/dist/jobs/types.d.ts +8 -6
- package/dist/{jobs-Bjr8DZAi.js → jobs-CazMYhyy.js} +305 -166
- package/dist/jobs-CazMYhyy.js.map +1 -0
- package/dist/{jwt-C4OW-DNq.js → jwt-DnQHNFCl.js} +77 -25
- package/dist/{jwt-C4OW-DNq.js.map → jwt-DnQHNFCl.js.map} +1 -1
- package/dist/{keys-Qfc4XieN.js → keys-CogCQpxG.js} +18 -3
- package/dist/{keys-Qfc4XieN.js.map → keys-CogCQpxG.js.map} +1 -1
- package/dist/{logs-routes-3EEzPjhl.js → logs-routes-Bj4TYYUl.js} +7 -4
- package/dist/{logs-routes-3EEzPjhl.js.map → logs-routes-Bj4TYYUl.js.map} +1 -1
- package/dist/mcp/mcp-routes.d.ts +38 -2
- package/dist/mcp/mcp-tools.d.ts +7 -1
- package/dist/mcp/oauth-routes.d.ts +27 -0
- package/dist/mcp/oauth-store.d.ts +29 -13
- package/dist/metrics/history-recorder.d.ts +1 -1
- package/dist/{openapi-generator-D8uFz-LW.js → openapi-generator-O_O24MAT.js} +34 -12
- package/dist/openapi-generator-O_O24MAT.js.map +1 -0
- package/dist/{query-parser-BleZmY18.js → query-parser-DGRVFNM3.js} +39 -30
- package/dist/query-parser-DGRVFNM3.js.map +1 -0
- package/dist/rls-audit/index.d.ts +4 -0
- package/dist/{schema-editor-routes-DdLihzp0.js → schema-editor-routes-C5-lh_jO.js} +2 -2
- package/dist/{schema-editor-routes-DdLihzp0.js.map → schema-editor-routes-C5-lh_jO.js.map} +1 -1
- package/dist/{src-1vL-I1Po.js → src-pmvW7BFx.js} +122 -14
- package/dist/src-pmvW7BFx.js.map +1 -0
- package/dist/{src-Br6ARbs6.js → src-vkcwKXbT.js} +2 -27
- package/dist/{src-Br6ARbs6.js.map → src-vkcwKXbT.js.map} +1 -1
- package/dist/storage/GCSStorageController.d.ts +11 -1
- package/dist/storage/keys.d.ts +12 -0
- package/dist/storage/rendition-cache.d.ts +11 -1
- package/dist/storage/request-keys.d.ts +67 -0
- package/dist/storage/types.d.ts +17 -1
- package/dist/types-BfKcm9do.js.map +1 -1
- package/package.json +9 -9
- package/dist/GCSStorageController-CLIJXwGS.js.map +0 -1
- package/dist/ast-schema-editor-CslO8Oje.js.map +0 -1
- package/dist/auth-CCDpk2rn.js.map +0 -1
- package/dist/backup-DzI9jLwc.js.map +0 -1
- package/dist/cron-routes-B7CRGfiq.js.map +0 -1
- package/dist/cron-scheduler-COPQxlEq.js.map +0 -1
- package/dist/cron-store-BYGZFNWk.js.map +0 -1
- package/dist/ddl-bootstrap-CfNvxMuK.js.map +0 -1
- package/dist/history-recorder-BQmB0P_j.js.map +0 -1
- package/dist/jobs-Bjr8DZAi.js.map +0 -1
- package/dist/openapi-generator-D8uFz-LW.js.map +0 -1
- package/dist/query-parser-BleZmY18.js.map +0 -1
- 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-
|
|
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
|
|
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 =
|
|
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-
|
|
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
|
*
|
package/dist/auth/captcha.d.ts
CHANGED
|
@@ -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
|
|
575
|
-
* total.
|
|
576
|
-
* so
|
|
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.
|