@jini-ai/cms 0.3.5 → 0.3.7
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/README.md +22 -17
- package/dist/content-types/agent-tools.js +1 -1
- package/dist/content-types/errors.d.ts +19 -1
- package/dist/content-types/errors.js +22 -1
- package/dist/content-types/index-provisioning.d.ts +56 -10
- package/dist/content-types/index-provisioning.js +86 -13
- package/dist/content-types/index.d.ts +15 -3
- package/dist/content-types/index.js +14 -2
- package/dist/content-types/lifecycle.js +3 -3
- package/dist/content-types/types.d.ts +64 -3
- package/dist/content-types/types.js +67 -3
- package/dist/content-types/write-service.d.ts +18 -3
- package/dist/content-types/write-service.js +28 -2
- package/dist/core/ports.d.ts +12 -2
- package/dist/entries/field-validation.js +96 -1
- package/dist/entries/write-service.d.ts +14 -0
- package/dist/entries/write-service.js +2 -2
- package/dist/identity/admin-crud-service.d.ts +28 -0
- package/dist/identity/admin-crud-service.js +30 -0
- package/dist/identity/auth-service.d.ts +32 -1
- package/dist/identity/auth-service.js +44 -15
- package/dist/identity/index.d.ts +4 -1
- package/dist/identity/index.js +4 -1
- package/dist/identity/seed.d.ts +14 -2
- package/dist/identity/seed.js +197 -45
- package/dist/media/agent-tools.d.ts +13 -3
- package/dist/media/agent-tools.js +45 -10
- package/dist/media/content-type-sniffer.d.ts +1 -1
- package/dist/media/content-type-sniffer.js +52 -4
- package/dist/media/html-attributes.d.ts +90 -0
- package/dist/media/html-attributes.js +161 -0
- package/dist/media/image-transformer.sharp.d.ts +35 -1
- package/dist/media/image-transformer.sharp.js +44 -2
- package/dist/media/index.d.ts +2 -1
- package/dist/media/index.js +2 -1
- package/dist/media/media-service.d.ts +62 -2
- package/dist/media/media-service.js +230 -7
- package/dist/media/ports.d.ts +6 -0
- package/dist/media/rendition-service.d.ts +7 -1
- package/dist/media/rendition-service.js +10 -4
- package/dist/media/repo.memory.d.ts +4 -0
- package/dist/media/repo.memory.js +3 -0
- package/dist/media/tool-registrations.d.ts +12 -0
- package/dist/media/tool-registrations.js +20 -12
- package/dist/media/types.d.ts +26 -0
- package/dist/navigation/agent-tools.js +8 -1
- package/dist/navigation/index.d.ts +10 -1
- package/dist/navigation/index.js +10 -1
- package/dist/navigation/menu-service.d.ts +60 -4
- package/dist/navigation/menu-service.js +106 -8
- package/dist/settings/dictionaries/index.d.ts +3 -3
- package/dist/settings/dictionaries/index.js +3 -3
- package/dist/taxonomy/agent-tools.d.ts +5 -4
- package/dist/taxonomy/agent-tools.js +33 -10
- package/dist/taxonomy/content-lookup.d.ts +35 -0
- package/dist/taxonomy/content-lookup.js +28 -0
- package/dist/taxonomy/index.d.ts +2 -2
- package/dist/taxonomy/index.js +2 -2
- package/dist/taxonomy/repo.memory.d.ts +11 -2
- package/dist/taxonomy/repo.memory.js +11 -0
- package/dist/taxonomy/write-service.d.ts +44 -15
- package/dist/taxonomy/write-service.js +60 -8
- package/package.json +3 -2
- package/dist/content-types/agent-tools.d.ts.map +0 -1
- package/dist/content-types/agent-tools.js.map +0 -1
- package/dist/content-types/cleanup.d.ts.map +0 -1
- package/dist/content-types/cleanup.js.map +0 -1
- package/dist/content-types/errors.d.ts.map +0 -1
- package/dist/content-types/errors.js.map +0 -1
- package/dist/content-types/field-defs.d.ts.map +0 -1
- package/dist/content-types/field-defs.js.map +0 -1
- package/dist/content-types/index-provisioning.d.ts.map +0 -1
- package/dist/content-types/index-provisioning.js.map +0 -1
- package/dist/content-types/index.d.ts.map +0 -1
- package/dist/content-types/index.js.map +0 -1
- package/dist/content-types/lifecycle-dispatch.d.ts.map +0 -1
- package/dist/content-types/lifecycle-dispatch.js.map +0 -1
- package/dist/content-types/lifecycle.d.ts.map +0 -1
- package/dist/content-types/lifecycle.js.map +0 -1
- package/dist/content-types/list.d.ts.map +0 -1
- package/dist/content-types/list.js.map +0 -1
- package/dist/content-types/repo.memory.d.ts.map +0 -1
- package/dist/content-types/repo.memory.js.map +0 -1
- package/dist/content-types/tool-registrations.d.ts.map +0 -1
- package/dist/content-types/tool-registrations.js.map +0 -1
- package/dist/content-types/types.d.ts.map +0 -1
- package/dist/content-types/types.js.map +0 -1
- package/dist/content-types/write-service.d.ts.map +0 -1
- package/dist/content-types/write-service.js.map +0 -1
- package/dist/core/commands/change-set.d.ts.map +0 -1
- package/dist/core/commands/change-set.js.map +0 -1
- package/dist/core/commands/command.d.ts.map +0 -1
- package/dist/core/commands/command.js.map +0 -1
- package/dist/core/index.d.ts.map +0 -1
- package/dist/core/index.js.map +0 -1
- package/dist/core/ports.d.ts.map +0 -1
- package/dist/core/ports.js.map +0 -1
- package/dist/core/tools/registration-kit.d.ts.map +0 -1
- package/dist/core/tools/registration-kit.js.map +0 -1
- package/dist/entries/agent-tools.d.ts.map +0 -1
- package/dist/entries/agent-tools.js.map +0 -1
- package/dist/entries/errors.d.ts.map +0 -1
- package/dist/entries/errors.js.map +0 -1
- package/dist/entries/field-validation.d.ts.map +0 -1
- package/dist/entries/field-validation.js.map +0 -1
- package/dist/entries/index.d.ts.map +0 -1
- package/dist/entries/index.js.map +0 -1
- package/dist/entries/lifecycle-dispatch.d.ts.map +0 -1
- package/dist/entries/lifecycle-dispatch.js.map +0 -1
- package/dist/entries/list.d.ts.map +0 -1
- package/dist/entries/list.js.map +0 -1
- package/dist/entries/repo.memory.d.ts.map +0 -1
- package/dist/entries/repo.memory.js.map +0 -1
- package/dist/entries/tool-registrations.d.ts.map +0 -1
- package/dist/entries/tool-registrations.js.map +0 -1
- package/dist/entries/types.d.ts.map +0 -1
- package/dist/entries/types.js.map +0 -1
- package/dist/entries/write-service.d.ts.map +0 -1
- package/dist/entries/write-service.js.map +0 -1
- package/dist/identity/admin-crud-service.d.ts.map +0 -1
- package/dist/identity/admin-crud-service.js.map +0 -1
- package/dist/identity/agent-tool-input.d.ts.map +0 -1
- package/dist/identity/agent-tool-input.js.map +0 -1
- package/dist/identity/agent-tools.d.ts.map +0 -1
- package/dist/identity/agent-tools.js.map +0 -1
- package/dist/identity/auth-service.d.ts.map +0 -1
- package/dist/identity/auth-service.js.map +0 -1
- package/dist/identity/authorize.d.ts.map +0 -1
- package/dist/identity/authorize.js.map +0 -1
- package/dist/identity/grant-service.d.ts.map +0 -1
- package/dist/identity/grant-service.js.map +0 -1
- package/dist/identity/hasher.d.ts.map +0 -1
- package/dist/identity/hasher.js.map +0 -1
- package/dist/identity/index.d.ts.map +0 -1
- package/dist/identity/index.js.map +0 -1
- package/dist/identity/password-policy.d.ts.map +0 -1
- package/dist/identity/password-policy.js.map +0 -1
- package/dist/identity/permission-migrations.d.ts.map +0 -1
- package/dist/identity/permission-migrations.js.map +0 -1
- package/dist/identity/permissions.d.ts.map +0 -1
- package/dist/identity/permissions.js.map +0 -1
- package/dist/identity/ports.d.ts.map +0 -1
- package/dist/identity/ports.js.map +0 -1
- package/dist/identity/repo.memory.d.ts.map +0 -1
- package/dist/identity/repo.memory.js.map +0 -1
- package/dist/identity/seed.d.ts.map +0 -1
- package/dist/identity/seed.js.map +0 -1
- package/dist/identity/tool-registrations.d.ts.map +0 -1
- package/dist/identity/tool-registrations.js.map +0 -1
- package/dist/identity/types.d.ts.map +0 -1
- package/dist/identity/types.js.map +0 -1
- package/dist/identity/username.d.ts.map +0 -1
- package/dist/identity/username.js.map +0 -1
- package/dist/media/agent-tools.d.ts.map +0 -1
- package/dist/media/agent-tools.js.map +0 -1
- package/dist/media/blob-gc-lock.d.ts.map +0 -1
- package/dist/media/blob-gc-lock.js.map +0 -1
- package/dist/media/blob-gc.d.ts.map +0 -1
- package/dist/media/blob-gc.js.map +0 -1
- package/dist/media/blob-key.d.ts.map +0 -1
- package/dist/media/blob-key.js.map +0 -1
- package/dist/media/blob-store.fs.d.ts.map +0 -1
- package/dist/media/blob-store.fs.js.map +0 -1
- package/dist/media/blob-store.memory.d.ts.map +0 -1
- package/dist/media/blob-store.memory.js.map +0 -1
- package/dist/media/content-type-sniffer.d.ts.map +0 -1
- package/dist/media/content-type-sniffer.js.map +0 -1
- package/dist/media/image-transformer.d.ts.map +0 -1
- package/dist/media/image-transformer.js.map +0 -1
- package/dist/media/image-transformer.sharp.d.ts.map +0 -1
- package/dist/media/image-transformer.sharp.js.map +0 -1
- package/dist/media/index.d.ts.map +0 -1
- package/dist/media/index.js.map +0 -1
- package/dist/media/media-service.d.ts.map +0 -1
- package/dist/media/media-service.js.map +0 -1
- package/dist/media/ports.d.ts.map +0 -1
- package/dist/media/ports.js.map +0 -1
- package/dist/media/rendition-service.d.ts.map +0 -1
- package/dist/media/rendition-service.js.map +0 -1
- package/dist/media/repo.memory.d.ts.map +0 -1
- package/dist/media/repo.memory.js.map +0 -1
- package/dist/media/tool-registrations.d.ts.map +0 -1
- package/dist/media/tool-registrations.js.map +0 -1
- package/dist/media/transform-lock.d.ts.map +0 -1
- package/dist/media/transform-lock.js.map +0 -1
- package/dist/media/transform-registry.d.ts.map +0 -1
- package/dist/media/transform-registry.js.map +0 -1
- package/dist/media/transform-types.d.ts.map +0 -1
- package/dist/media/transform-types.js.map +0 -1
- package/dist/media/types.d.ts.map +0 -1
- package/dist/media/types.js.map +0 -1
- package/dist/navigation/agent-tools.d.ts.map +0 -1
- package/dist/navigation/agent-tools.js.map +0 -1
- package/dist/navigation/contracts.d.ts.map +0 -1
- package/dist/navigation/contracts.js.map +0 -1
- package/dist/navigation/index.d.ts.map +0 -1
- package/dist/navigation/index.js.map +0 -1
- package/dist/navigation/menu-service.d.ts.map +0 -1
- package/dist/navigation/menu-service.js.map +0 -1
- package/dist/navigation/ports.d.ts.map +0 -1
- package/dist/navigation/ports.js.map +0 -1
- package/dist/navigation/read-model.d.ts.map +0 -1
- package/dist/navigation/read-model.js.map +0 -1
- package/dist/navigation/reconcile.d.ts.map +0 -1
- package/dist/navigation/reconcile.js.map +0 -1
- package/dist/navigation/repo.memory.d.ts.map +0 -1
- package/dist/navigation/repo.memory.js.map +0 -1
- package/dist/navigation/resolver.d.ts.map +0 -1
- package/dist/navigation/resolver.js.map +0 -1
- package/dist/navigation/tool-registrations.d.ts.map +0 -1
- package/dist/navigation/tool-registrations.js.map +0 -1
- package/dist/navigation/types.d.ts.map +0 -1
- package/dist/navigation/types.js.map +0 -1
- package/dist/presentation/index.d.ts.map +0 -1
- package/dist/presentation/index.js.map +0 -1
- package/dist/presentation/presentation.d.ts.map +0 -1
- package/dist/presentation/presentation.js.map +0 -1
- package/dist/presentation/repo.memory.d.ts.map +0 -1
- package/dist/presentation/repo.memory.js.map +0 -1
- package/dist/server/index.d.ts.map +0 -1
- package/dist/server/index.js.map +0 -1
- package/dist/settings/agent-tools.d.ts.map +0 -1
- package/dist/settings/agent-tools.js.map +0 -1
- package/dist/settings/agent-writable-preferences.d.ts.map +0 -1
- package/dist/settings/agent-writable-preferences.js.map +0 -1
- package/dist/settings/change-feed.d.ts.map +0 -1
- package/dist/settings/change-feed.js.map +0 -1
- package/dist/settings/definitions-dispatch.d.ts.map +0 -1
- package/dist/settings/definitions-dispatch.js.map +0 -1
- package/dist/settings/dictionaries/index.d.ts.map +0 -1
- package/dist/settings/dictionaries/index.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ar.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ar.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.bn.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.bn.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.de.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.de.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.es.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.es.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.fa.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.fa.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.fr.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.fr.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.hi.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.hi.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.hu.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.hu.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.id.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.id.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.it.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.it.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ja.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ja.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ko.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ko.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.pl.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.pl.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.pt-BR.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.pt-BR.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ru.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ru.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.th.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.th.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.tr.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.tr.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.uk.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.uk.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ur.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ur.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-CN.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-CN.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-TW.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-TW.js.map +0 -1
- package/dist/settings/ensure-definitions.d.ts.map +0 -1
- package/dist/settings/ensure-definitions.js.map +0 -1
- package/dist/settings/errors.d.ts.map +0 -1
- package/dist/settings/errors.js.map +0 -1
- package/dist/settings/index.d.ts.map +0 -1
- package/dist/settings/index.js.map +0 -1
- package/dist/settings/ports.d.ts.map +0 -1
- package/dist/settings/ports.js.map +0 -1
- package/dist/settings/purge-service.d.ts.map +0 -1
- package/dist/settings/purge-service.js.map +0 -1
- package/dist/settings/repo.memory.d.ts.map +0 -1
- package/dist/settings/repo.memory.js.map +0 -1
- package/dist/settings/settings.d.ts.map +0 -1
- package/dist/settings/settings.js.map +0 -1
- package/dist/settings/tool-registrations.d.ts.map +0 -1
- package/dist/settings/tool-registrations.js.map +0 -1
- package/dist/settings/types.d.ts.map +0 -1
- package/dist/settings/types.js.map +0 -1
- package/dist/settings/ui-tab-definitions.d.ts.map +0 -1
- package/dist/settings/ui-tab-definitions.js.map +0 -1
- package/dist/settings/write-service.d.ts.map +0 -1
- package/dist/settings/write-service.js.map +0 -1
- package/dist/taxonomy/agent-tools.d.ts.map +0 -1
- package/dist/taxonomy/agent-tools.js.map +0 -1
- package/dist/taxonomy/content-lookup.d.ts.map +0 -1
- package/dist/taxonomy/content-lookup.js.map +0 -1
- package/dist/taxonomy/index.d.ts.map +0 -1
- package/dist/taxonomy/index.js.map +0 -1
- package/dist/taxonomy/list.d.ts.map +0 -1
- package/dist/taxonomy/list.js.map +0 -1
- package/dist/taxonomy/merge-term.d.ts.map +0 -1
- package/dist/taxonomy/merge-term.js.map +0 -1
- package/dist/taxonomy/repo.memory.d.ts.map +0 -1
- package/dist/taxonomy/repo.memory.js.map +0 -1
- package/dist/taxonomy/validation-chain.d.ts.map +0 -1
- package/dist/taxonomy/validation-chain.js.map +0 -1
- package/dist/taxonomy/write-service.d.ts.map +0 -1
- package/dist/taxonomy/write-service.js.map +0 -1
- package/dist/workspace/agent-tools.d.ts.map +0 -1
- package/dist/workspace/agent-tools.js.map +0 -1
- package/dist/workspace/create.d.ts.map +0 -1
- package/dist/workspace/create.js.map +0 -1
- package/dist/workspace/delete.d.ts.map +0 -1
- package/dist/workspace/delete.js.map +0 -1
- package/dist/workspace/index.d.ts.map +0 -1
- package/dist/workspace/index.js.map +0 -1
- package/dist/workspace/repo.memory.d.ts.map +0 -1
- package/dist/workspace/repo.memory.js.map +0 -1
- package/dist/workspace/tool-registrations.d.ts.map +0 -1
- package/dist/workspace/tool-registrations.js.map +0 -1
- package/dist/workspace/update.d.ts.map +0 -1
- package/dist/workspace/update.js.map +0 -1
|
@@ -14,19 +14,83 @@
|
|
|
14
14
|
* is that enum's single source of truth so the write-service's validation and the index
|
|
15
15
|
* provisioner's CAST-mapping can never drift apart.
|
|
16
16
|
*
|
|
17
|
+
* The enum is SPLIT rather than flat (2026-09-05): a kind is either INDEXABLE — it may carry
|
|
18
|
+
* `queryable: true` and therefore may reach `mapFieldKindToCast` — or STORAGE-ONLY, stored
|
|
19
|
+
* verbatim in `fieldsJson` and never indexed, sorted, or filtered on. The split is what keeps the
|
|
20
|
+
* CAST table's exhaustiveness compiler-enforced without forcing a CAST target to be invented for a
|
|
21
|
+
* kind that has none; see `index-provisioning.ts`'s `KIND_TO_CAST_LITERAL`.
|
|
22
|
+
*
|
|
17
23
|
* Architectural role:
|
|
18
24
|
* `features/content-types` domain vocabulary. No dependencies.
|
|
19
25
|
*/
|
|
20
|
-
/**
|
|
21
|
-
|
|
26
|
+
/**
|
|
27
|
+
* CIC U-001-B1 — the closed, 5-entry INDEXABLE SCALAR set. Never extend ad hoc.
|
|
28
|
+
*
|
|
29
|
+
* Unchanged since the original enum: every value that has ever reached a `CAST` literal is still
|
|
30
|
+
* here, in this order. Kept as its own constant so the "these five are the original, untouched
|
|
31
|
+
* scalars" claim is expressible in code rather than only in a comment.
|
|
32
|
+
*/
|
|
33
|
+
export const CONTENT_TYPE_SCALAR_KINDS = ["text", "integer", "real", "boolean", "datetime"];
|
|
34
|
+
/**
|
|
35
|
+
* CIC U-001-B1 — kinds that MAY carry `queryable: true` and therefore MAY reach
|
|
36
|
+
* `mapFieldKindToCast`. Every member MUST have an entry in `index-provisioning.ts`'s
|
|
37
|
+
* `KIND_TO_CAST_LITERAL`; the compiler enforces this via `Record<IndexableFieldKind, string>`, so
|
|
38
|
+
* adding a member here fails the build until a CAST literal is supplied for it.
|
|
39
|
+
*
|
|
40
|
+
* `relation` stores a foreign entity id and shares `text`'s storage class, so its CAST target is
|
|
41
|
+
* the already-present literal `"TEXT"` — it adds a KEY to that table, never a new VALUE to the
|
|
42
|
+
* DDL alphabet.
|
|
43
|
+
*/
|
|
44
|
+
export const INDEXABLE_FIELD_KINDS = [...CONTENT_TYPE_SCALAR_KINDS, "relation"];
|
|
45
|
+
/**
|
|
46
|
+
* Kinds stored verbatim inside `fieldsJson` and NEVER indexed, sorted, or filtered on. A
|
|
47
|
+
* storage-only kind has no CAST target — it is not that one is hard to choose, it is that a JSON
|
|
48
|
+
* document has no scalar storage class to cast to — so `write-service.ts` rejects
|
|
49
|
+
* `queryable: true` on one before any index transition can be resolved.
|
|
50
|
+
*/
|
|
51
|
+
export const STORAGE_ONLY_FIELD_KINDS = ["json"];
|
|
52
|
+
/**
|
|
53
|
+
* CIC U-001-B1 — the closed field-kind enum. UNCHANGED NAME AND MEANING for every existing
|
|
54
|
+
* consumer: it is still the complete, closed set of legal `kind` values, and
|
|
55
|
+
* {@link isContentTypeFieldKind} is still the gate for it.
|
|
56
|
+
*
|
|
57
|
+
* Member order is load-bearing, not cosmetic: the five scalars must stay first and in their
|
|
58
|
+
* original order because this array's `join("|")` is interpolated into an operator-facing error
|
|
59
|
+
* message. The spread order below preserves that; the published agent-tool schema
|
|
60
|
+
* (`agent-tools.ts`) also spreads this constant, so both widen in lockstep with no hand-copied
|
|
61
|
+
* second list to drift.
|
|
62
|
+
*/
|
|
63
|
+
export const CONTENT_TYPE_FIELD_KINDS = [
|
|
64
|
+
...INDEXABLE_FIELD_KINDS,
|
|
65
|
+
...STORAGE_ONLY_FIELD_KINDS,
|
|
66
|
+
];
|
|
22
67
|
/**
|
|
23
68
|
* Type guard for {@link ContentTypeFieldKind}. The single gate every kind value must pass before
|
|
24
69
|
* it is trusted anywhere near DDL construction (`index-provisioning.ts`) or write validation.
|
|
25
70
|
*
|
|
26
|
-
* @
|
|
71
|
+
* Note the division of labour with {@link isIndexableFieldKind}: passing THIS gate means the value
|
|
72
|
+
* is a legal kind to DECLARE and STORE. It does NOT license a CAST — `mapFieldKindToCast` applies
|
|
73
|
+
* the narrower gate, because "storable" and "indexable" are no longer the same set.
|
|
74
|
+
*
|
|
75
|
+
* @complexity O(1) — a fixed-size array membership test.
|
|
27
76
|
* @overallScore 100
|
|
28
77
|
*/
|
|
29
78
|
export function isContentTypeFieldKind(value) {
|
|
30
79
|
return typeof value === "string" && CONTENT_TYPE_FIELD_KINDS.includes(value);
|
|
31
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Type guard for {@link IndexableFieldKind} — the gate `mapFieldKindToCast` applies instead of
|
|
83
|
+
* {@link isContentTypeFieldKind}, and the gate `write-service.ts` applies to reject
|
|
84
|
+
* `queryable: true` on a storage-only kind.
|
|
85
|
+
*
|
|
86
|
+
* For all five original scalars this predicate and {@link isContentTypeFieldKind} agree exactly,
|
|
87
|
+
* which is what makes narrowing the CAST gate behavior-preserving for every input that could
|
|
88
|
+
* exist before `relation`/`json` were introduced.
|
|
89
|
+
*
|
|
90
|
+
* @complexity O(1) — a fixed-size array membership test.
|
|
91
|
+
* @overallScore 100
|
|
92
|
+
*/
|
|
93
|
+
export function isIndexableFieldKind(value) {
|
|
94
|
+
return typeof value === "string" && INDEXABLE_FIELD_KINDS.includes(value);
|
|
95
|
+
}
|
|
32
96
|
//# sourceMappingURL=types.js.map
|
|
@@ -10,8 +10,12 @@ import { type ActorIdentityInput, type ActorPrincipalKind, type ContentTypeField
|
|
|
10
10
|
* same-transaction row + revision write (+ watermark stamp when supplied) -> index provisioning.
|
|
11
11
|
*
|
|
12
12
|
* `registerContentType`'s CIC U-002-B1 guard order is binding, not incidental: key grammar ->
|
|
13
|
-
* reserved-key -> field-name grammar -> field-kind -> queryable-cap,
|
|
14
|
-
* first failure.
|
|
13
|
+
* reserved-key -> field-name grammar -> field-kind -> storage-only-not-queryable -> queryable-cap,
|
|
14
|
+
* evaluation stops at the first failure. The storage-only check is numbered 4b rather than
|
|
15
|
+
* renumbering 5, because the fixed order is pinned by name in the certified suite and the existing
|
|
16
|
+
* five guards keep both their relative order and their identity: 4b sits strictly between the
|
|
17
|
+
* field-kind check and the queryable-cap check, and can only fire on a field whose kind already
|
|
18
|
+
* passed guard 4. `updateContentTypeFields`'s CIC U-004-B1 additionally requires `expectedVersion`
|
|
15
19
|
* to be checked BEFORE the `fields_empty` floor and before any per-field guard, so a stale
|
|
16
20
|
* `expectedVersion` combined with `fields: []` reports `VERSION_CONFLICT`, never
|
|
17
21
|
* `VALIDATION_ERROR(fields_empty)`.
|
|
@@ -26,11 +30,22 @@ import { type ActorIdentityInput, type ActorPrincipalKind, type ContentTypeField
|
|
|
26
30
|
* `features/content-types` domain logic. Depends only on `core/ports` and this package's own
|
|
27
31
|
* `errors.ts`/`index-provisioning.ts`/`types.ts` — no adapter, no other feature.
|
|
28
32
|
*/
|
|
29
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Matches every other feature's chokepoint `AuthorizeFn` shape structurally — no shared import,
|
|
35
|
+
* kept decoupled. `entityType`/`entityId` let `registerContentType`/`updateContentTypeFields`
|
|
36
|
+
* (and `lifecycle.ts`'s three transitions, which import this same type) pass
|
|
37
|
+
* `entityType: "content-type"` — matching every fronting HTTP route's own
|
|
38
|
+
* `entityType: "content-type"` pre-check. See `entries/write-service.ts`'s identical `AuthorizeFn`
|
|
39
|
+
* doc for why an omitted `entityType` here previously denied a content-type-scoped-only grant with
|
|
40
|
+
* `resource_scope_mismatch` even though the route's pre-check allowed it, and why this chokepoint —
|
|
41
|
+
* not the route — is the one that must stay at least as expressive.
|
|
42
|
+
*/
|
|
30
43
|
export type AuthorizeFn = (params: {
|
|
31
44
|
principalId: string;
|
|
32
45
|
permission: string;
|
|
33
46
|
workspaceId: string;
|
|
47
|
+
entityType?: string | undefined;
|
|
48
|
+
entityId?: string | undefined;
|
|
34
49
|
}) => Promise<{
|
|
35
50
|
allowed: boolean;
|
|
36
51
|
reason: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ContentTypeNotFoundError, ForbiddenError, InvalidFieldKindError, InvalidFieldNameGrammarError, InvalidKeyGrammarError, QueryableFieldCapExceededError, ReservedContentTypeKeyError, ValidationError, VersionConflictError, } from "./errors.js";
|
|
1
|
+
import { ContentTypeNotFoundError, ForbiddenError, InvalidFieldKindError, InvalidFieldNameGrammarError, InvalidKeyGrammarError, QueryableFieldCapExceededError, ReservedContentTypeKeyError, StorageOnlyFieldNotQueryableError, ValidationError, VersionConflictError, } from "./errors.js";
|
|
2
2
|
import { resolveFieldIndexTransition, validateIdentifierGrammar } from "./index-provisioning.js";
|
|
3
|
-
import { isContentTypeFieldKind, } from "./types.js";
|
|
3
|
+
import { isContentTypeFieldKind, isIndexableFieldKind, } from "./types.js";
|
|
4
4
|
/** Reserved forever for the legacy `posts` table — an operator Collection can never take these keys. */
|
|
5
5
|
const RESERVED_CONTENT_TYPE_KEYS = new Set(["post", "page"]);
|
|
6
6
|
/** Per-type cap on `queryable` fields (queryable-index sprawl mitigation). */
|
|
@@ -21,6 +21,7 @@ export async function registerContentType(required) {
|
|
|
21
21
|
principalId: input.actorId,
|
|
22
22
|
permission: "admin.collections.manage",
|
|
23
23
|
workspaceId: input.workspaceId,
|
|
24
|
+
entityType: "content-type",
|
|
24
25
|
});
|
|
25
26
|
if (!authResult.allowed) {
|
|
26
27
|
return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot register a content type (${authResult.reason})`) };
|
|
@@ -45,6 +46,18 @@ export async function registerContentType(required) {
|
|
|
45
46
|
return { ok: false, error: new InvalidFieldKindError(`field '${field.name}' has kind '${field.kind}', not one of the closed field-kind enum`) };
|
|
46
47
|
}
|
|
47
48
|
}
|
|
49
|
+
// Guard 4b: a storage-only kind cannot be queryable. Placed strictly between guard 4 and guard 5
|
|
50
|
+
// so a bad kind still reports as a kind error, and a legal-but-unindexable kind is rejected
|
|
51
|
+
// BEFORE the cap count and long before `resolveFieldIndexTransition` could hand it to the index
|
|
52
|
+
// provisioner. This is the check that keeps a kind with no CAST target off the DDL path.
|
|
53
|
+
for (const field of input.fields) {
|
|
54
|
+
if (field.queryable && !isIndexableFieldKind(field.kind)) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: new StorageOnlyFieldNotQueryableError(`field '${field.name}' has storage-only kind '${field.kind}' and cannot be queryable`),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
48
61
|
// Guard 5: queryable-field cap, submitted array alone.
|
|
49
62
|
if (countQueryableFields(input.fields) > QUERYABLE_FIELD_CAP) {
|
|
50
63
|
return { ok: false, error: new QueryableFieldCapExceededError(`content type '${input.key}' submits more than ${QUERYABLE_FIELD_CAP} queryable fields`) };
|
|
@@ -96,6 +109,7 @@ export async function updateContentTypeFields(required) {
|
|
|
96
109
|
principalId: input.actorId,
|
|
97
110
|
permission: "admin.collections.manage",
|
|
98
111
|
workspaceId: input.workspaceId,
|
|
112
|
+
entityType: "content-type",
|
|
99
113
|
});
|
|
100
114
|
if (!authResult.allowed) {
|
|
101
115
|
return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot update content type '${input.key}' (${authResult.reason})`) };
|
|
@@ -121,6 +135,18 @@ export async function updateContentTypeFields(required) {
|
|
|
121
135
|
return { ok: false, error: new InvalidFieldKindError(`field '${field.name}' has kind '${field.kind}', not one of the closed field-kind enum`) };
|
|
122
136
|
}
|
|
123
137
|
}
|
|
138
|
+
// Guard 4b: a storage-only kind cannot be queryable. Placed strictly between guard 4 and guard 5
|
|
139
|
+
// so a bad kind still reports as a kind error, and a legal-but-unindexable kind is rejected
|
|
140
|
+
// BEFORE the cap count and long before `resolveFieldIndexTransition` could hand it to the index
|
|
141
|
+
// provisioner. This is the check that keeps a kind with no CAST target off the DDL path.
|
|
142
|
+
for (const field of input.fields) {
|
|
143
|
+
if (field.queryable && !isIndexableFieldKind(field.kind)) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
error: new StorageOnlyFieldNotQueryableError(`field '${field.name}' has storage-only kind '${field.kind}' and cannot be queryable`),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
124
150
|
if (countQueryableFields(input.fields) > QUERYABLE_FIELD_CAP) {
|
|
125
151
|
return { ok: false, error: new QueryableFieldCapExceededError(`content type '${input.key}' submits more than ${QUERYABLE_FIELD_CAP} queryable fields`) };
|
|
126
152
|
}
|
package/dist/core/ports.d.ts
CHANGED
|
@@ -123,8 +123,18 @@ export interface OutboxPort {
|
|
|
123
123
|
claimPending(batchSize: number, nowIso: ISODateTime): Promise<OutboxRecord[]>;
|
|
124
124
|
/** Mark a row as delivered. */
|
|
125
125
|
markDelivered(id: UUID): Promise<void>;
|
|
126
|
-
/**
|
|
127
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Mark a row as failed and set its next retry time.
|
|
128
|
+
*
|
|
129
|
+
* `nextStatus` is decided by the CALLER (the retry-policy owner, e.g. a worker that tracks an
|
|
130
|
+
* attempt cap) — an implementation must persist exactly what it is told, not re-derive the
|
|
131
|
+
* decision from the row's own stored `attempts` (2026-09-06: this parameter replaced that
|
|
132
|
+
* adapter-side re-derivation, mirroring `WebhookDeliveryRepoPort.markFailed`'s `nextStatus`
|
|
133
|
+
* shape in the host). `"pending"` re-enters the retry queue at `nextAttemptAt`; `"failed"`
|
|
134
|
+
* is terminal — `claimPending` only ever selects `"pending"` rows, so a `"failed"` row is
|
|
135
|
+
* permanently excluded from retry regardless of `nextAttemptAt`.
|
|
136
|
+
*/
|
|
137
|
+
markFailed(id: UUID, error: string, nextAttemptAt: ISODateTime, nextStatus: Extract<OutboxRecord["status"], "pending" | "failed">): Promise<void>;
|
|
128
138
|
}
|
|
129
139
|
/** Clock abstraction to make time deterministic in tests. */
|
|
130
140
|
export interface ClockPort {
|
|
@@ -1,18 +1,109 @@
|
|
|
1
1
|
function isPlainObject(value) {
|
|
2
2
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
3
|
}
|
|
4
|
+
/**
|
|
5
|
+
* Maximum nesting depth a `json` field's value may reach.
|
|
6
|
+
*
|
|
7
|
+
* A resource bound, not a product rule — the same role `MAX_FIELD_DEFS` plays in
|
|
8
|
+
* `content-types/field-defs.ts`. It is also what makes this check total: a self-referential value
|
|
9
|
+
* (`a.self = a`) is a legal JS object that `JSON.stringify` throws on, and it is the depth cap,
|
|
10
|
+
* not a visited-set, that guarantees this function terminates on one. Deliberately far above any
|
|
11
|
+
* plausible content payload so it can never act as a product constraint.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_JSON_DEPTH = 32;
|
|
14
|
+
/**
|
|
15
|
+
* Maximum number of values (scalars, arrays and objects, counted together) one `json` field may
|
|
16
|
+
* contain. The second half of the termination guarantee above, and the bound that stops a
|
|
17
|
+
* pathological wide-but-shallow payload from making validation the expensive part of a write.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_JSON_NODES = 10_000;
|
|
20
|
+
/**
|
|
21
|
+
* True only for objects that are structurally plain — prototype `Object.prototype` or `null`.
|
|
22
|
+
*
|
|
23
|
+
* Stricter than this module's {@link isPlainObject}, and deliberately so: a `Map`, `Set`, or class
|
|
24
|
+
* instance passes a `typeof === "object"` test and then `JSON.stringify`s to `{}`, silently
|
|
25
|
+
* discarding every value it held. A `Date` survives, but as a string — its type changes at the
|
|
26
|
+
* storage boundary, so a later read gets a different kind of value than the one written. Both are
|
|
27
|
+
* exactly the "accepted here, mangled downstream" class this check exists to reject.
|
|
28
|
+
*/
|
|
29
|
+
function isStructurallyPlainObject(value) {
|
|
30
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
31
|
+
return false;
|
|
32
|
+
const proto = Object.getPrototypeOf(value);
|
|
33
|
+
return proto === Object.prototype || proto === null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Verifies that a value is a JSON value — `null`, a boolean, a FINITE number, a string, an array
|
|
37
|
+
* of JSON values, or a plain object whose values are JSON values — within a bounded depth and node
|
|
38
|
+
* budget.
|
|
39
|
+
*
|
|
40
|
+
* This exists because "storage-only" means "not indexed", never "not checked". The obvious
|
|
41
|
+
* alternative — accepting every value for the `json` kind — is not a validator: it admits
|
|
42
|
+
* functions, `symbol`, `bigint`, `undefined`, `NaN`/`Infinity`, and circular references, each of
|
|
43
|
+
* which either throws inside `JSON.stringify` or is silently dropped/coerced by it, downstream of
|
|
44
|
+
* the validator whose entire job was to catch them. Rejecting them here turns a serialization
|
|
45
|
+
* crash (or a silent data loss) at the storage boundary into a named per-field validation error.
|
|
46
|
+
*
|
|
47
|
+
* Symbol-keyed properties are rejected rather than ignored for the same reason `field-defs.ts`
|
|
48
|
+
* rejects an unrecognized key instead of dropping it: `JSON.stringify` discards them, so accepting
|
|
49
|
+
* one would persist a value the caller did not get told was thrown away.
|
|
50
|
+
*
|
|
51
|
+
* @complexity O(n) in the number of contained values, hard-bounded by {@link MAX_JSON_NODES}, and
|
|
52
|
+
* O(d) stack depth bounded by {@link MAX_JSON_DEPTH}. Total on every input, including cyclic ones.
|
|
53
|
+
* @overallScore 100
|
|
54
|
+
*/
|
|
55
|
+
function isBoundedJsonValue(value, budget, depth = 0) {
|
|
56
|
+
if (depth > MAX_JSON_DEPTH)
|
|
57
|
+
return false;
|
|
58
|
+
if (budget.nodes <= 0)
|
|
59
|
+
return false;
|
|
60
|
+
budget.nodes -= 1;
|
|
61
|
+
if (value === null)
|
|
62
|
+
return true;
|
|
63
|
+
switch (typeof value) {
|
|
64
|
+
case "boolean":
|
|
65
|
+
case "string":
|
|
66
|
+
return true;
|
|
67
|
+
case "number":
|
|
68
|
+
// `NaN`/`Infinity` are numbers that `JSON.stringify` silently rewrites to `null` — a value
|
|
69
|
+
// that reads back as a different type than the one written.
|
|
70
|
+
return Number.isFinite(value);
|
|
71
|
+
case "object":
|
|
72
|
+
break;
|
|
73
|
+
default:
|
|
74
|
+
// `undefined`, `function`, `symbol`, `bigint`: dropped, dropped, dropped, and throws.
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
return value.every((element) => isBoundedJsonValue(element, budget, depth + 1));
|
|
79
|
+
}
|
|
80
|
+
if (!isStructurallyPlainObject(value))
|
|
81
|
+
return false;
|
|
82
|
+
if (Object.getOwnPropertySymbols(value).length > 0)
|
|
83
|
+
return false;
|
|
84
|
+
return Object.values(value).every((entry) => isBoundedJsonValue(entry, budget, depth + 1));
|
|
85
|
+
}
|
|
4
86
|
/**
|
|
5
87
|
* Runtime-conformance check for one field's value against its declared kind. Loose by design
|
|
6
88
|
* (e.g. `datetime` accepts any string) — kind-conformance here is about JS runtime shape, not
|
|
7
89
|
* full ISO-8601/format validation, which is out of this package's scope.
|
|
8
90
|
*
|
|
9
|
-
*
|
|
91
|
+
* The `default: return false` arm is load-bearing: a kind added to `ContentTypeFieldKind` without
|
|
92
|
+
* an arm here does not fail the build, it produces a field that can be DECLARED but whose every
|
|
93
|
+
* value is rejected on write. Any future kind must be added here in the same change.
|
|
94
|
+
*
|
|
95
|
+
* @complexity O(1) for every scalar kind; O(n) bounded by `MAX_JSON_NODES` for `json`.
|
|
10
96
|
* @overallScore 100
|
|
11
97
|
*/
|
|
12
98
|
function conformsToKind(value, kind) {
|
|
13
99
|
switch (kind) {
|
|
14
100
|
case "text":
|
|
15
101
|
case "datetime":
|
|
102
|
+
// A foreign entity id. String-shaped exactly like `text`; referential integrity is NOT checked
|
|
103
|
+
// here and is not checked anywhere — this function is a pure runtime-shape check with no repo
|
|
104
|
+
// access, and giving it one would put an I/O dependency into a module whose header commits to
|
|
105
|
+
// having none. The gap is real and belongs to the application layer.
|
|
106
|
+
case "relation":
|
|
16
107
|
return typeof value === "string";
|
|
17
108
|
case "integer":
|
|
18
109
|
return typeof value === "number" && Number.isInteger(value);
|
|
@@ -20,6 +111,10 @@ function conformsToKind(value, kind) {
|
|
|
20
111
|
return typeof value === "number";
|
|
21
112
|
case "boolean":
|
|
22
113
|
return typeof value === "boolean";
|
|
114
|
+
// Storage-only: no scalar shape is imposed, but the value must still be something that
|
|
115
|
+
// survives the `JSON.stringify` boundary intact. See `isBoundedJsonValue`.
|
|
116
|
+
case "json":
|
|
117
|
+
return isBoundedJsonValue(value, { nodes: MAX_JSON_NODES });
|
|
23
118
|
default:
|
|
24
119
|
return false;
|
|
25
120
|
}
|
|
@@ -26,10 +26,24 @@ import type { ActorIdentityInput, EntryRecord, OwningContentType, Result } from
|
|
|
26
26
|
* `errors.ts`/`field-validation.ts`/`types.ts` — never on `features/content-types`' write path,
|
|
27
27
|
* only its read-only `OwningContentType` type shape.
|
|
28
28
|
*/
|
|
29
|
+
/**
|
|
30
|
+
* `entityType`/`entityId` are threaded through so `createEntry`/`resolveExistingEntryForTransition`
|
|
31
|
+
* below can pass `entityType: "entry"` to `deps.authorize()` — matching every fronting HTTP route's
|
|
32
|
+
* own `entityType: "entry"` pre-check exactly. Before this, this chokepoint's calls omitted
|
|
33
|
+
* `entityType` entirely, so `identity/authorize.ts`'s evaluator (`resourceType != null &&
|
|
34
|
+
* resourceType !== context.entityType` never matches a missing `entityType`) denied a principal
|
|
35
|
+
* holding ONLY an entry-scoped grant with `resource_scope_mismatch`, even though the route's own
|
|
36
|
+
* pre-check — and any owner/unscoped grant — allowed the identical request. This chokepoint is also
|
|
37
|
+
* the ONLY authorization check the agent-tool surface ever reaches (see `agent-tools.ts`'s header:
|
|
38
|
+
* tools call these functions directly, never through an HTTP route), so it must be at least as
|
|
39
|
+
* expressive as the route, not merely consistent with it.
|
|
40
|
+
*/
|
|
29
41
|
export type AuthorizeFn = (params: {
|
|
30
42
|
principalId: string;
|
|
31
43
|
permission: string;
|
|
32
44
|
workspaceId: string;
|
|
45
|
+
entityType?: string | undefined;
|
|
46
|
+
entityId?: string | undefined;
|
|
33
47
|
}) => Promise<{
|
|
34
48
|
allowed: boolean;
|
|
35
49
|
reason: string;
|
|
@@ -19,7 +19,7 @@ function delegationFields(input) {
|
|
|
19
19
|
*/
|
|
20
20
|
export async function createEntry(required) {
|
|
21
21
|
const { deps, input } = required;
|
|
22
|
-
const authResult = await deps.authorize({ principalId: input.actorId, permission: "admin.collections.manage", workspaceId: input.workspaceId });
|
|
22
|
+
const authResult = await deps.authorize({ principalId: input.actorId, permission: "admin.collections.manage", workspaceId: input.workspaceId, entityType: "entry" });
|
|
23
23
|
if (!authResult.allowed) {
|
|
24
24
|
return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot create an entry (${authResult.reason})`) };
|
|
25
25
|
}
|
|
@@ -82,7 +82,7 @@ export async function createEntry(required) {
|
|
|
82
82
|
* @overallScore 100
|
|
83
83
|
*/
|
|
84
84
|
async function resolveExistingEntryForTransition(deps, input) {
|
|
85
|
-
const authResult = await deps.authorize({ principalId: input.actorId, permission: "admin.collections.manage", workspaceId: input.workspaceId });
|
|
85
|
+
const authResult = await deps.authorize({ principalId: input.actorId, permission: "admin.collections.manage", workspaceId: input.workspaceId, entityType: "entry" });
|
|
86
86
|
if (!authResult.allowed) {
|
|
87
87
|
return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot modify entry '${input.id}' (${authResult.reason})`) };
|
|
88
88
|
}
|
|
@@ -97,6 +97,33 @@ export declare function updateUser(required: {
|
|
|
97
97
|
* INV-05), then revokes every one of the target's active sessions (idempotent no-op if it has none,
|
|
98
98
|
* EC-16) — a reset that left old sessions alive would not actually contain a compromised account.
|
|
99
99
|
*
|
|
100
|
+
* Refuses a THIRD PARTY resetting the seeded owner's password (REQ-11/REQ-13's reasoning, same
|
|
101
|
+
* `OwnerRequiredError` `disablePrincipal` uses): `user.manage` is independently grantable and not
|
|
102
|
+
* owner-exclusive (`permissions.ts` — "Create/disable operator users and principals"), so without
|
|
103
|
+
* this check a caller holding only that one delegated permission could set a password of their own
|
|
104
|
+
* choosing on the seeded owner's account and log in as owner — a full takeover from a routine
|
|
105
|
+
* delegation. Checked before the password-policy validation so the refusal doesn't depend on the
|
|
106
|
+
* caller supplying a well-formed password first.
|
|
107
|
+
*
|
|
108
|
+
* Deliberately NOT unconditional the way `disablePrincipal`'s owner check is: the owner resetting
|
|
109
|
+
* ITS OWN password (`callerPrincipalId === principalId === seededOwnerPrincipalId`) is refused
|
|
110
|
+
* nothing — that self-service path is real, shipped infrastructure
|
|
111
|
+
* (`reset-admin-password-self-verified.ts`, driven by both the `TOVU_ADMIN_RESET_PASSWORD`
|
|
112
|
+
* boot-time incident-recovery hook and the `backfill-reset-admin-password.ts` CLI script), added
|
|
113
|
+
* specifically to let an operator recover a locked-out owner account out-of-band. Disabling has no
|
|
114
|
+
* legitimate self-case (an owner disabling itself only ever stands the workspace down), so
|
|
115
|
+
* `disablePrincipal` can refuse unconditionally; a credential rotation the owner performs on itself
|
|
116
|
+
* is ordinary hygiene and the documented recovery path, so only a MISMATCHED caller — the actual
|
|
117
|
+
* escalation shape — is refused here.
|
|
118
|
+
*
|
|
119
|
+
* Deliberately NOT INV-08-clamped (unlike `disablePrincipal`'s second guard): a reset changes a
|
|
120
|
+
* credential, it never changes `status`, so it can never drop the workspace's active owner-`*`
|
|
121
|
+
* count — INV-08's headcount guard has nothing to protect here.
|
|
122
|
+
*
|
|
123
|
+
* `input.seededOwnerPrincipalId` is the caller's job to resolve (`await deps.ownerPrincipalId` at
|
|
124
|
+
* the route layer, mirroring `disablePrincipal`'s own contract above) — this function takes the
|
|
125
|
+
* already-resolved id so it stays a plain, directly-testable function.
|
|
126
|
+
*
|
|
100
127
|
* @complexity O(s) in the target's session count, s = active session rows to revoke — bounded by
|
|
101
128
|
* the same operator-managed-roster scale assumption every other identity list call here makes.
|
|
102
129
|
* @overallScore 100
|
|
@@ -108,6 +135,7 @@ export declare function resetUserPassword(required: {
|
|
|
108
135
|
callerPrincipalId: UUID;
|
|
109
136
|
principalId: UUID;
|
|
110
137
|
password: string;
|
|
138
|
+
seededOwnerPrincipalId: UUID;
|
|
111
139
|
};
|
|
112
140
|
}): Promise<{
|
|
113
141
|
user: UserRecord;
|
|
@@ -139,6 +139,33 @@ export async function updateUser(required) {
|
|
|
139
139
|
* INV-05), then revokes every one of the target's active sessions (idempotent no-op if it has none,
|
|
140
140
|
* EC-16) — a reset that left old sessions alive would not actually contain a compromised account.
|
|
141
141
|
*
|
|
142
|
+
* Refuses a THIRD PARTY resetting the seeded owner's password (REQ-11/REQ-13's reasoning, same
|
|
143
|
+
* `OwnerRequiredError` `disablePrincipal` uses): `user.manage` is independently grantable and not
|
|
144
|
+
* owner-exclusive (`permissions.ts` — "Create/disable operator users and principals"), so without
|
|
145
|
+
* this check a caller holding only that one delegated permission could set a password of their own
|
|
146
|
+
* choosing on the seeded owner's account and log in as owner — a full takeover from a routine
|
|
147
|
+
* delegation. Checked before the password-policy validation so the refusal doesn't depend on the
|
|
148
|
+
* caller supplying a well-formed password first.
|
|
149
|
+
*
|
|
150
|
+
* Deliberately NOT unconditional the way `disablePrincipal`'s owner check is: the owner resetting
|
|
151
|
+
* ITS OWN password (`callerPrincipalId === principalId === seededOwnerPrincipalId`) is refused
|
|
152
|
+
* nothing — that self-service path is real, shipped infrastructure
|
|
153
|
+
* (`reset-admin-password-self-verified.ts`, driven by both the `TOVU_ADMIN_RESET_PASSWORD`
|
|
154
|
+
* boot-time incident-recovery hook and the `backfill-reset-admin-password.ts` CLI script), added
|
|
155
|
+
* specifically to let an operator recover a locked-out owner account out-of-band. Disabling has no
|
|
156
|
+
* legitimate self-case (an owner disabling itself only ever stands the workspace down), so
|
|
157
|
+
* `disablePrincipal` can refuse unconditionally; a credential rotation the owner performs on itself
|
|
158
|
+
* is ordinary hygiene and the documented recovery path, so only a MISMATCHED caller — the actual
|
|
159
|
+
* escalation shape — is refused here.
|
|
160
|
+
*
|
|
161
|
+
* Deliberately NOT INV-08-clamped (unlike `disablePrincipal`'s second guard): a reset changes a
|
|
162
|
+
* credential, it never changes `status`, so it can never drop the workspace's active owner-`*`
|
|
163
|
+
* count — INV-08's headcount guard has nothing to protect here.
|
|
164
|
+
*
|
|
165
|
+
* `input.seededOwnerPrincipalId` is the caller's job to resolve (`await deps.ownerPrincipalId` at
|
|
166
|
+
* the route layer, mirroring `disablePrincipal`'s own contract above) — this function takes the
|
|
167
|
+
* already-resolved id so it stays a plain, directly-testable function.
|
|
168
|
+
*
|
|
142
169
|
* @complexity O(s) in the target's session count, s = active session rows to revoke — bounded by
|
|
143
170
|
* the same operator-managed-roster scale assumption every other identity list call here makes.
|
|
144
171
|
* @overallScore 100
|
|
@@ -157,6 +184,9 @@ export async function resetUserPassword(required) {
|
|
|
157
184
|
});
|
|
158
185
|
if (!target)
|
|
159
186
|
throw new IdentityNotFoundError(`user '${input.principalId}' was not found`);
|
|
187
|
+
if (target.principalId === input.seededOwnerPrincipalId && input.callerPrincipalId !== input.seededOwnerPrincipalId) {
|
|
188
|
+
throw new OwnerRequiredError("the seeded owner's password can only be reset by the owner itself, never by another caller (REQ-11/REQ-13)");
|
|
189
|
+
}
|
|
160
190
|
if (!input.password) {
|
|
161
191
|
throw new IdentityValidationError("password is required");
|
|
162
192
|
}
|
|
@@ -11,6 +11,13 @@ import { type PrincipalRecord, type SessionRecord } from "./types.js";
|
|
|
11
11
|
* tokens (INV-05: the raw token is never persisted). Password verification is
|
|
12
12
|
* argon2id (`hasher.ts`), never a raw comparison.
|
|
13
13
|
*
|
|
14
|
+
* `createSessionForPrincipal` (2026-09-06) is the one session minter; `login()` calls it after
|
|
15
|
+
* verifying credentials, and a host that identifies a principal by some other means (e.g. a
|
|
16
|
+
* loopback-only single-use boot token, which has nothing to verify a password against) calls it
|
|
17
|
+
* directly instead of re-implementing session construction. Before this export existed, a
|
|
18
|
+
* downstream host had duplicated this file's private `hashToken` byte-for-byte because
|
|
19
|
+
* `login()` was the only minter and hard-requires a password — this closes that fork.
|
|
20
|
+
*
|
|
14
21
|
* Architectural role:
|
|
15
22
|
* Ordinary core functions (like `updatePost`/`membersWriteService`), not a
|
|
16
23
|
* port — session/credential logic has one implementation.
|
|
@@ -23,6 +30,30 @@ export interface AuthServiceDeps {
|
|
|
23
30
|
clock: ClockPort;
|
|
24
31
|
idGen: IdGeneratorPort;
|
|
25
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Mint and persist a session for a principal the caller has ALREADY identified by some means
|
|
35
|
+
* other than this function (password verification in `login()` below, or a caller-specific proof
|
|
36
|
+
* such as a loopback-only single-use boot token). Factored out of `login()`'s former inline
|
|
37
|
+
* session-construction block (2026-09-06) so both paths share one minter — a host with no
|
|
38
|
+
* password to verify no longer has to hand-roll its own token hashing (and risk drifting from
|
|
39
|
+
* this file's private `hashToken`) just to mint a session for a principal it has already proven
|
|
40
|
+
* by other means.
|
|
41
|
+
*
|
|
42
|
+
* @complexity O(1) — one session write.
|
|
43
|
+
* @overallScore 100
|
|
44
|
+
*/
|
|
45
|
+
export declare function createSessionForPrincipal(required: {
|
|
46
|
+
deps: AuthServiceDeps;
|
|
47
|
+
input: {
|
|
48
|
+
workspaceId: UUID;
|
|
49
|
+
principalId: UUID;
|
|
50
|
+
ip?: string | undefined;
|
|
51
|
+
userAgent?: string | undefined;
|
|
52
|
+
};
|
|
53
|
+
}): Promise<{
|
|
54
|
+
session: SessionRecord;
|
|
55
|
+
rawToken: string;
|
|
56
|
+
}>;
|
|
26
57
|
/**
|
|
27
58
|
* Verify `username`+`password` for an `active` principal and mint a session
|
|
28
59
|
* (AC-02, REQ-06). Constant-shaped failure: a nonexistent username, a
|
|
@@ -30,7 +61,7 @@ export interface AuthServiceDeps {
|
|
|
30
61
|
* with the same message — no user-enumeration signal.
|
|
31
62
|
*
|
|
32
63
|
* @complexity O(1) — one username lookup, one principal lookup, one hash
|
|
33
|
-
* verify, one session write, one `lastLoginAt` write.
|
|
64
|
+
* verify, one session write (via `createSessionForPrincipal`), one `lastLoginAt` write.
|
|
34
65
|
* @overallScore 100
|
|
35
66
|
*/
|
|
36
67
|
export declare function login(required: {
|
|
@@ -12,6 +12,13 @@ import { normalizeUsername } from "./username.js";
|
|
|
12
12
|
* tokens (INV-05: the raw token is never persisted). Password verification is
|
|
13
13
|
* argon2id (`hasher.ts`), never a raw comparison.
|
|
14
14
|
*
|
|
15
|
+
* `createSessionForPrincipal` (2026-09-06) is the one session minter; `login()` calls it after
|
|
16
|
+
* verifying credentials, and a host that identifies a principal by some other means (e.g. a
|
|
17
|
+
* loopback-only single-use boot token, which has nothing to verify a password against) calls it
|
|
18
|
+
* directly instead of re-implementing session construction. Before this export existed, a
|
|
19
|
+
* downstream host had duplicated this file's private `hashToken` byte-for-byte because
|
|
20
|
+
* `login()` was the only minter and hard-requires a password — this closes that fork.
|
|
21
|
+
*
|
|
15
22
|
* Architectural role:
|
|
16
23
|
* Ordinary core functions (like `updatePost`/`membersWriteService`), not a
|
|
17
24
|
* port — session/credential logic has one implementation.
|
|
@@ -28,6 +35,35 @@ function newRawToken() {
|
|
|
28
35
|
function isoPlusMs(nowIso, ms) {
|
|
29
36
|
return new Date(new Date(nowIso).getTime() + ms).toISOString();
|
|
30
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Mint and persist a session for a principal the caller has ALREADY identified by some means
|
|
40
|
+
* other than this function (password verification in `login()` below, or a caller-specific proof
|
|
41
|
+
* such as a loopback-only single-use boot token). Factored out of `login()`'s former inline
|
|
42
|
+
* session-construction block (2026-09-06) so both paths share one minter — a host with no
|
|
43
|
+
* password to verify no longer has to hand-roll its own token hashing (and risk drifting from
|
|
44
|
+
* this file's private `hashToken`) just to mint a session for a principal it has already proven
|
|
45
|
+
* by other means.
|
|
46
|
+
*
|
|
47
|
+
* @complexity O(1) — one session write.
|
|
48
|
+
* @overallScore 100
|
|
49
|
+
*/
|
|
50
|
+
export async function createSessionForPrincipal(required) {
|
|
51
|
+
const { deps, input } = required;
|
|
52
|
+
const nowIso = deps.clock.nowIso();
|
|
53
|
+
const rawToken = newRawToken();
|
|
54
|
+
const session = {
|
|
55
|
+
id: deps.idGen.newId(),
|
|
56
|
+
workspaceId: input.workspaceId,
|
|
57
|
+
principalId: input.principalId,
|
|
58
|
+
tokenHash: hashToken(rawToken),
|
|
59
|
+
createdAt: nowIso,
|
|
60
|
+
expiresAt: isoPlusMs(nowIso, SESSION_TTL_MS),
|
|
61
|
+
ip: input.ip,
|
|
62
|
+
userAgent: input.userAgent,
|
|
63
|
+
};
|
|
64
|
+
await deps.repos.sessions.save(session);
|
|
65
|
+
return { session, rawToken };
|
|
66
|
+
}
|
|
31
67
|
/**
|
|
32
68
|
* Verify `username`+`password` for an `active` principal and mint a session
|
|
33
69
|
* (AC-02, REQ-06). Constant-shaped failure: a nonexistent username, a
|
|
@@ -35,7 +71,7 @@ function isoPlusMs(nowIso, ms) {
|
|
|
35
71
|
* with the same message — no user-enumeration signal.
|
|
36
72
|
*
|
|
37
73
|
* @complexity O(1) — one username lookup, one principal lookup, one hash
|
|
38
|
-
* verify, one session write, one `lastLoginAt` write.
|
|
74
|
+
* verify, one session write (via `createSessionForPrincipal`), one `lastLoginAt` write.
|
|
39
75
|
* @overallScore 100
|
|
40
76
|
*/
|
|
41
77
|
export async function login(required) {
|
|
@@ -59,20 +95,13 @@ export async function login(required) {
|
|
|
59
95
|
if (!passwordOk) {
|
|
60
96
|
throw new AuthInvalidCredentialsError("invalid username or password");
|
|
61
97
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
createdAt: nowIso,
|
|
70
|
-
expiresAt: isoPlusMs(nowIso, SESSION_TTL_MS),
|
|
71
|
-
ip: input.ip,
|
|
72
|
-
userAgent: input.userAgent,
|
|
73
|
-
};
|
|
74
|
-
await deps.repos.sessions.save(session);
|
|
75
|
-
await deps.repos.users.save({ ...userRow, lastLoginAt: nowIso });
|
|
98
|
+
const { session, rawToken } = await createSessionForPrincipal({
|
|
99
|
+
deps,
|
|
100
|
+
input: { workspaceId: input.workspaceId, principalId: principal.id, ip: input.ip, userAgent: input.userAgent },
|
|
101
|
+
});
|
|
102
|
+
// Reuses the session's own `createdAt` rather than a second `clock.nowIso()` call — both are
|
|
103
|
+
// meant to be the same instant, and reading one avoids two clock reads ever disagreeing.
|
|
104
|
+
await deps.repos.users.save({ ...userRow, lastLoginAt: session.createdAt });
|
|
76
105
|
return { principal, session, rawToken };
|
|
77
106
|
}
|
|
78
107
|
/**
|
package/dist/identity/index.d.ts
CHANGED
|
@@ -17,7 +17,10 @@ export { InMemoryPrincipalRepo, InMemoryUserRepo, InMemorySessionRepo, InMemoryR
|
|
|
17
17
|
* argon2 adapter belongs in `../server` once that layer exists.
|
|
18
18
|
*/
|
|
19
19
|
export { authorize, resolveEffectivePermissions, type AuthorizeContext, type AuthorizeResult, type AuthorizeDeps, } from "./authorize.js";
|
|
20
|
-
export { login, logout, validateSession, getEffectivePermissions,
|
|
20
|
+
export { login, logout, validateSession, getEffectivePermissions,
|
|
21
|
+
/** The password-free session minter `login()` itself is now built on — see its own doc for who
|
|
22
|
+
* else should call it directly. */
|
|
23
|
+
createSessionForPrincipal, SESSION_TTL_MS, type AuthServiceDeps, } from "./auth-service.js";
|
|
21
24
|
export { seedIdentity, type SeedIdentityDeps, type SeedIdentityInput, type SeedIdentityResult } from "./seed.js";
|
|
22
25
|
export { createUser, createRole, createPolicy, assignRole, attachPolicy,
|
|
23
26
|
/** Exported so the assistant's read-only identity tools gate on the SAME caller-permission
|
package/dist/identity/index.js
CHANGED
|
@@ -9,7 +9,10 @@ export { InMemoryPrincipalRepo, InMemoryUserRepo, InMemorySessionRepo, InMemoryR
|
|
|
9
9
|
* argon2 adapter belongs in `../server` once that layer exists.
|
|
10
10
|
*/
|
|
11
11
|
export { authorize, resolveEffectivePermissions, } from "./authorize.js";
|
|
12
|
-
export { login, logout, validateSession, getEffectivePermissions,
|
|
12
|
+
export { login, logout, validateSession, getEffectivePermissions,
|
|
13
|
+
/** The password-free session minter `login()` itself is now built on — see its own doc for who
|
|
14
|
+
* else should call it directly. */
|
|
15
|
+
createSessionForPrincipal, SESSION_TTL_MS, } from "./auth-service.js";
|
|
13
16
|
export { seedIdentity } from "./seed.js";
|
|
14
17
|
export { createUser, createRole, createPolicy, assignRole, attachPolicy,
|
|
15
18
|
/** Exported so the assistant's read-only identity tools gate on the SAME caller-permission
|