@jini-ai/cms 0.3.7 → 0.3.9
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/content-types/errors.d.ts +41 -12
- package/dist/content-types/errors.js +47 -12
- package/dist/content-types/index.d.ts +1 -1
- package/dist/content-types/index.js +1 -1
- package/dist/content-types/write-service.js +75 -27
- package/dist/core/entity-liveness.d.ts +55 -0
- package/dist/core/entity-liveness.js +38 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/tools/registration-kit.d.ts +55 -17
- package/dist/core/tools/registration-kit.js +49 -20
- package/dist/media/media-service.js +2 -0
- package/dist/navigation/menu-service.d.ts +0 -24
- package/dist/navigation/menu-service.js +27 -0
- package/dist/navigation/tool-registrations.d.ts +0 -11
- package/dist/navigation/tool-registrations.js +15 -0
- package/dist/taxonomy/agent-tools.d.ts +7 -13
- package/dist/taxonomy/agent-tools.js +20 -19
- package/dist/taxonomy/content-lookup.d.ts +6 -0
- package/dist/taxonomy/content-lookup.js +11 -1
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ToolInputError } from "@jini-ai/core";
|
|
1
2
|
/**
|
|
2
3
|
* @file Typed error surface for the `content-types` package.
|
|
3
4
|
*
|
|
@@ -10,26 +11,41 @@
|
|
|
10
11
|
* the constructor sets `this.name` explicitly (confirmed empirically in Session 2 of this
|
|
11
12
|
* workstream) — every class below sets it.
|
|
12
13
|
*
|
|
14
|
+
* Every class the five wired agent tools (`define`/`update_fields`/`deprecate`/`reactivate`/
|
|
15
|
+
* `tombstone`) can actually throw extends `ToolInputError` (`@jini-ai/core`), not plain `Error` —
|
|
16
|
+
* mirroring `core/entity-liveness.ts`'s `EntityNotLiveError` (see its file header for the full
|
|
17
|
+
* rationale). Without that, `write-service.ts`/`lifecycle.ts` return these via a `Result` that
|
|
18
|
+
* `tool-registrations.ts`'s `fromResult` re-throws unwrapped, `@jini-ai/daemon`'s `ToolExecutor`
|
|
19
|
+
* sees a plain `Error` it cannot distinguish from a genuine internal fault, and every HTTP-facing
|
|
20
|
+
* mapping of that failure (`@jini-ai/http-kit`'s `delegated-tools.ts`) redacts it to a 500
|
|
21
|
+
* `INTERNAL_ERROR` — so a model calling `collections_content_type_define` on a key that already
|
|
22
|
+
* exists never sees "use collections_content_type_update_fields", only an opaque failure it cannot
|
|
23
|
+
* act on. `CleanupNotEligibleError` is the one exception: `collections_plan_cleanup`/
|
|
24
|
+
* `collections_execute_cleanup` are declared but not yet wired to any handler
|
|
25
|
+
* (`tool-registrations.ts`'s `UNWIRED_CONTENT_TYPES_TOOL_IDS`), so nothing reaches a model through
|
|
26
|
+
* it today — left as plain `Error` until that wiring exists and it can be verified the same way.
|
|
27
|
+
*
|
|
13
28
|
* Architectural role:
|
|
14
|
-
* `features/content-types` domain logic.
|
|
29
|
+
* `features/content-types` domain logic. Depends on `@jini-ai/core` only for the `ToolInputError`
|
|
30
|
+
* marker above.
|
|
15
31
|
*/
|
|
16
|
-
export declare class ForbiddenError extends
|
|
32
|
+
export declare class ForbiddenError extends ToolInputError {
|
|
17
33
|
constructor(message: string);
|
|
18
34
|
}
|
|
19
35
|
/** CIC U-002-B1 guard 1 — `key`/field-name grammar gate `^[a-z][a-z0-9_]{0,63}$` (U-001-B2). */
|
|
20
|
-
export declare class InvalidKeyGrammarError extends
|
|
36
|
+
export declare class InvalidKeyGrammarError extends ToolInputError {
|
|
21
37
|
constructor(message: string);
|
|
22
38
|
}
|
|
23
39
|
/** CIC U-002-B1 guard 2 — `key` is one of the permanently reserved legacy `posts` keys. */
|
|
24
|
-
export declare class ReservedContentTypeKeyError extends
|
|
40
|
+
export declare class ReservedContentTypeKeyError extends ToolInputError {
|
|
25
41
|
constructor(message: string);
|
|
26
42
|
}
|
|
27
43
|
/** CIC U-002-B1 guard 3 — a field name fails the identifier grammar gate. */
|
|
28
|
-
export declare class InvalidFieldNameGrammarError extends
|
|
44
|
+
export declare class InvalidFieldNameGrammarError extends ToolInputError {
|
|
29
45
|
constructor(message: string);
|
|
30
46
|
}
|
|
31
47
|
/** CIC U-001-B1 / U-002-B1 guard 4 — a field `kind` is not one of the closed field-kind enum. */
|
|
32
|
-
export declare class InvalidFieldKindError extends
|
|
48
|
+
export declare class InvalidFieldKindError extends ToolInputError {
|
|
33
49
|
constructor(message: string);
|
|
34
50
|
}
|
|
35
51
|
/**
|
|
@@ -64,7 +80,7 @@ export declare class StorageOnlyFieldNotQueryableError extends InvalidFieldKindE
|
|
|
64
80
|
* `violation.received` names the offending value's TYPE, never the value — a field payload can
|
|
65
81
|
* carry operator content, and this message reaches both an HTTP client and a model.
|
|
66
82
|
*/
|
|
67
|
-
export declare class InvalidFieldShapeError extends
|
|
83
|
+
export declare class InvalidFieldShapeError extends ToolInputError {
|
|
68
84
|
readonly code: "VALIDATION_ERROR";
|
|
69
85
|
readonly violation: {
|
|
70
86
|
path: string;
|
|
@@ -78,18 +94,31 @@ export declare class InvalidFieldShapeError extends Error {
|
|
|
78
94
|
});
|
|
79
95
|
}
|
|
80
96
|
/** CIC U-002-B1 guard 5 — more than the per-type cap of `queryable` fields were submitted. */
|
|
81
|
-
export declare class QueryableFieldCapExceededError extends
|
|
97
|
+
export declare class QueryableFieldCapExceededError extends ToolInputError {
|
|
82
98
|
constructor(message: string);
|
|
83
99
|
}
|
|
84
100
|
/** CIC U-004-B1 — `expectedVersion` did not match the current row's version (OCC conflict). */
|
|
85
|
-
export declare class VersionConflictError extends
|
|
101
|
+
export declare class VersionConflictError extends ToolInputError {
|
|
86
102
|
constructor(message: string);
|
|
87
103
|
}
|
|
88
|
-
export declare class ContentTypeNotFoundError extends
|
|
104
|
+
export declare class ContentTypeNotFoundError extends ToolInputError {
|
|
89
105
|
constructor(message: string);
|
|
90
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Row 7 (web-high fix plan, 2026-09-24) — `registerContentType` loaded no existing row before
|
|
109
|
+
* saving, so two concurrent first-widget creates (or a caller retrying an existing key) could
|
|
110
|
+
* silently overwrite the current fields, and registering a tombstoned key could resurrect it
|
|
111
|
+
* (INV-06 forbids any transition out of tombstone, including via a fresh register). Thrown when
|
|
112
|
+
* `deps.repo.findByKey` already returns a row for the key, whether that row is active/deprecated
|
|
113
|
+
* or tombstoned — `tombstoned` distinguishes the two so a caller/HTTP route can give the right
|
|
114
|
+
* guidance (retry with update-fields, vs. the key is permanently gone).
|
|
115
|
+
*/
|
|
116
|
+
export declare class ContentTypeAlreadyExistsError extends ToolInputError {
|
|
117
|
+
readonly tombstoned: boolean;
|
|
118
|
+
constructor(key: string, tombstoned: boolean);
|
|
119
|
+
}
|
|
91
120
|
/** A generic validation rejection carrying a stable machine-readable `details.reason`. */
|
|
92
|
-
export declare class ValidationError extends
|
|
121
|
+
export declare class ValidationError extends ToolInputError {
|
|
93
122
|
readonly code: "VALIDATION_ERROR";
|
|
94
123
|
readonly details: {
|
|
95
124
|
reason: string;
|
|
@@ -97,7 +126,7 @@ export declare class ValidationError extends Error {
|
|
|
97
126
|
constructor(message: string, reason: string);
|
|
98
127
|
}
|
|
99
128
|
/** REQ-09..12 lifecycle state-machine guard rejection (INV-06 terminal-tombstone, EC-09 deprecate-first). */
|
|
100
|
-
export declare class ContentTypeLifecycleError extends
|
|
129
|
+
export declare class ContentTypeLifecycleError extends ToolInputError {
|
|
101
130
|
constructor(message: string);
|
|
102
131
|
}
|
|
103
132
|
/** REQ-20 `planCleanup` eligibility-gate rejection; `reason` is one of a closed, ordered set. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ToolInputError } from "@jini-ai/core";
|
|
1
2
|
/**
|
|
2
3
|
* @file Typed error surface for the `content-types` package.
|
|
3
4
|
*
|
|
@@ -10,38 +11,53 @@
|
|
|
10
11
|
* the constructor sets `this.name` explicitly (confirmed empirically in Session 2 of this
|
|
11
12
|
* workstream) — every class below sets it.
|
|
12
13
|
*
|
|
14
|
+
* Every class the five wired agent tools (`define`/`update_fields`/`deprecate`/`reactivate`/
|
|
15
|
+
* `tombstone`) can actually throw extends `ToolInputError` (`@jini-ai/core`), not plain `Error` —
|
|
16
|
+
* mirroring `core/entity-liveness.ts`'s `EntityNotLiveError` (see its file header for the full
|
|
17
|
+
* rationale). Without that, `write-service.ts`/`lifecycle.ts` return these via a `Result` that
|
|
18
|
+
* `tool-registrations.ts`'s `fromResult` re-throws unwrapped, `@jini-ai/daemon`'s `ToolExecutor`
|
|
19
|
+
* sees a plain `Error` it cannot distinguish from a genuine internal fault, and every HTTP-facing
|
|
20
|
+
* mapping of that failure (`@jini-ai/http-kit`'s `delegated-tools.ts`) redacts it to a 500
|
|
21
|
+
* `INTERNAL_ERROR` — so a model calling `collections_content_type_define` on a key that already
|
|
22
|
+
* exists never sees "use collections_content_type_update_fields", only an opaque failure it cannot
|
|
23
|
+
* act on. `CleanupNotEligibleError` is the one exception: `collections_plan_cleanup`/
|
|
24
|
+
* `collections_execute_cleanup` are declared but not yet wired to any handler
|
|
25
|
+
* (`tool-registrations.ts`'s `UNWIRED_CONTENT_TYPES_TOOL_IDS`), so nothing reaches a model through
|
|
26
|
+
* it today — left as plain `Error` until that wiring exists and it can be verified the same way.
|
|
27
|
+
*
|
|
13
28
|
* Architectural role:
|
|
14
|
-
* `features/content-types` domain logic.
|
|
29
|
+
* `features/content-types` domain logic. Depends on `@jini-ai/core` only for the `ToolInputError`
|
|
30
|
+
* marker above.
|
|
15
31
|
*/
|
|
16
|
-
export class ForbiddenError extends
|
|
32
|
+
export class ForbiddenError extends ToolInputError {
|
|
17
33
|
constructor(message) {
|
|
18
34
|
super(message);
|
|
19
35
|
this.name = "ForbiddenError";
|
|
20
36
|
}
|
|
21
37
|
}
|
|
22
38
|
/** CIC U-002-B1 guard 1 — `key`/field-name grammar gate `^[a-z][a-z0-9_]{0,63}$` (U-001-B2). */
|
|
23
|
-
export class InvalidKeyGrammarError extends
|
|
39
|
+
export class InvalidKeyGrammarError extends ToolInputError {
|
|
24
40
|
constructor(message) {
|
|
25
41
|
super(message);
|
|
26
42
|
this.name = "InvalidKeyGrammarError";
|
|
27
43
|
}
|
|
28
44
|
}
|
|
29
45
|
/** CIC U-002-B1 guard 2 — `key` is one of the permanently reserved legacy `posts` keys. */
|
|
30
|
-
export class ReservedContentTypeKeyError extends
|
|
46
|
+
export class ReservedContentTypeKeyError extends ToolInputError {
|
|
31
47
|
constructor(message) {
|
|
32
48
|
super(message);
|
|
33
49
|
this.name = "ReservedContentTypeKeyError";
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
52
|
/** CIC U-002-B1 guard 3 — a field name fails the identifier grammar gate. */
|
|
37
|
-
export class InvalidFieldNameGrammarError extends
|
|
53
|
+
export class InvalidFieldNameGrammarError extends ToolInputError {
|
|
38
54
|
constructor(message) {
|
|
39
55
|
super(message);
|
|
40
56
|
this.name = "InvalidFieldNameGrammarError";
|
|
41
57
|
}
|
|
42
58
|
}
|
|
43
59
|
/** CIC U-001-B1 / U-002-B1 guard 4 — a field `kind` is not one of the closed field-kind enum. */
|
|
44
|
-
export class InvalidFieldKindError extends
|
|
60
|
+
export class InvalidFieldKindError extends ToolInputError {
|
|
45
61
|
constructor(message) {
|
|
46
62
|
super(message);
|
|
47
63
|
this.name = "InvalidFieldKindError";
|
|
@@ -82,7 +98,7 @@ export class StorageOnlyFieldNotQueryableError extends InvalidFieldKindError {
|
|
|
82
98
|
* `violation.received` names the offending value's TYPE, never the value — a field payload can
|
|
83
99
|
* carry operator content, and this message reaches both an HTTP client and a model.
|
|
84
100
|
*/
|
|
85
|
-
export class InvalidFieldShapeError extends
|
|
101
|
+
export class InvalidFieldShapeError extends ToolInputError {
|
|
86
102
|
code = "VALIDATION_ERROR";
|
|
87
103
|
violation;
|
|
88
104
|
constructor(violation) {
|
|
@@ -92,27 +108,46 @@ export class InvalidFieldShapeError extends Error {
|
|
|
92
108
|
}
|
|
93
109
|
}
|
|
94
110
|
/** CIC U-002-B1 guard 5 — more than the per-type cap of `queryable` fields were submitted. */
|
|
95
|
-
export class QueryableFieldCapExceededError extends
|
|
111
|
+
export class QueryableFieldCapExceededError extends ToolInputError {
|
|
96
112
|
constructor(message) {
|
|
97
113
|
super(message);
|
|
98
114
|
this.name = "QueryableFieldCapExceededError";
|
|
99
115
|
}
|
|
100
116
|
}
|
|
101
117
|
/** CIC U-004-B1 — `expectedVersion` did not match the current row's version (OCC conflict). */
|
|
102
|
-
export class VersionConflictError extends
|
|
118
|
+
export class VersionConflictError extends ToolInputError {
|
|
103
119
|
constructor(message) {
|
|
104
120
|
super(message);
|
|
105
121
|
this.name = "VersionConflictError";
|
|
106
122
|
}
|
|
107
123
|
}
|
|
108
|
-
export class ContentTypeNotFoundError extends
|
|
124
|
+
export class ContentTypeNotFoundError extends ToolInputError {
|
|
109
125
|
constructor(message) {
|
|
110
126
|
super(message);
|
|
111
127
|
this.name = "CONTENT_TYPE_NOT_FOUND";
|
|
112
128
|
}
|
|
113
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Row 7 (web-high fix plan, 2026-09-24) — `registerContentType` loaded no existing row before
|
|
132
|
+
* saving, so two concurrent first-widget creates (or a caller retrying an existing key) could
|
|
133
|
+
* silently overwrite the current fields, and registering a tombstoned key could resurrect it
|
|
134
|
+
* (INV-06 forbids any transition out of tombstone, including via a fresh register). Thrown when
|
|
135
|
+
* `deps.repo.findByKey` already returns a row for the key, whether that row is active/deprecated
|
|
136
|
+
* or tombstoned — `tombstoned` distinguishes the two so a caller/HTTP route can give the right
|
|
137
|
+
* guidance (retry with update-fields, vs. the key is permanently gone).
|
|
138
|
+
*/
|
|
139
|
+
export class ContentTypeAlreadyExistsError extends ToolInputError {
|
|
140
|
+
tombstoned;
|
|
141
|
+
constructor(key, tombstoned) {
|
|
142
|
+
super(tombstoned
|
|
143
|
+
? `content type '${key}' was permanently deleted; its key can't be reused (INV-06)`
|
|
144
|
+
: `content type '${key}' already exists; use collections_content_type_update_fields to change its fields`);
|
|
145
|
+
this.name = "ContentTypeAlreadyExistsError";
|
|
146
|
+
this.tombstoned = tombstoned;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
114
149
|
/** A generic validation rejection carrying a stable machine-readable `details.reason`. */
|
|
115
|
-
export class ValidationError extends
|
|
150
|
+
export class ValidationError extends ToolInputError {
|
|
116
151
|
code = "VALIDATION_ERROR";
|
|
117
152
|
details;
|
|
118
153
|
constructor(message, reason) {
|
|
@@ -122,7 +157,7 @@ export class ValidationError extends Error {
|
|
|
122
157
|
}
|
|
123
158
|
}
|
|
124
159
|
/** REQ-09..12 lifecycle state-machine guard rejection (INV-06 terminal-tombstone, EC-09 deprecate-first). */
|
|
125
|
-
export class ContentTypeLifecycleError extends
|
|
160
|
+
export class ContentTypeLifecycleError extends ToolInputError {
|
|
126
161
|
constructor(message) {
|
|
127
162
|
super(message);
|
|
128
163
|
this.name = "ContentTypeLifecycleError";
|
|
@@ -30,7 +30,7 @@ export { ForbiddenError, InvalidKeyGrammarError, ReservedContentTypeKeyError, In
|
|
|
30
30
|
* carries the parent maps this to the same `400 VALIDATION_ERROR` with no change. Catch it by
|
|
31
31
|
* name only if the finer distinction matters.
|
|
32
32
|
*/
|
|
33
|
-
StorageOnlyFieldNotQueryableError, InvalidFieldShapeError, QueryableFieldCapExceededError, VersionConflictError, ContentTypeNotFoundError, ValidationError, ContentTypeLifecycleError, CleanupNotEligibleError, } from "./errors.js";
|
|
33
|
+
StorageOnlyFieldNotQueryableError, InvalidFieldShapeError, QueryableFieldCapExceededError, VersionConflictError, ContentTypeNotFoundError, ContentTypeAlreadyExistsError, ValidationError, ContentTypeLifecycleError, CleanupNotEligibleError, } from "./errors.js";
|
|
34
34
|
/**
|
|
35
35
|
* The queryable-field index machinery. `IDENTIFIER_GRAMMAR_PATTERN` is exported because it is the
|
|
36
36
|
* single definition of the identifier grammar — the published agent-tool schemas reference it
|
|
@@ -20,7 +20,7 @@ export { ForbiddenError, InvalidKeyGrammarError, ReservedContentTypeKeyError, In
|
|
|
20
20
|
* carries the parent maps this to the same `400 VALIDATION_ERROR` with no change. Catch it by
|
|
21
21
|
* name only if the finer distinction matters.
|
|
22
22
|
*/
|
|
23
|
-
StorageOnlyFieldNotQueryableError, InvalidFieldShapeError, QueryableFieldCapExceededError, VersionConflictError, ContentTypeNotFoundError, ValidationError, ContentTypeLifecycleError, CleanupNotEligibleError, } from "./errors.js";
|
|
23
|
+
StorageOnlyFieldNotQueryableError, InvalidFieldShapeError, QueryableFieldCapExceededError, VersionConflictError, ContentTypeNotFoundError, ContentTypeAlreadyExistsError, ValidationError, ContentTypeLifecycleError, CleanupNotEligibleError, } from "./errors.js";
|
|
24
24
|
export { IDENTIFIER_GRAMMAR_PATTERN, validateIdentifierGrammar, mapFieldKindToCast, buildQueryableFieldIndexName, resolveFieldIndexTransition, } from "./index-provisioning.js";
|
|
25
25
|
export { registerContentType, updateContentTypeFields } from "./write-service.js";
|
|
26
26
|
export { deprecateContentType, reactivateContentType, tombstoneContentType, } from "./lifecycle.js";
|
|
@@ -1,7 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EntityNotLiveError } from "../core/entity-liveness.js";
|
|
2
|
+
import { ContentTypeAlreadyExistsError, ContentTypeNotFoundError, ForbiddenError, InvalidFieldKindError, InvalidFieldNameGrammarError, InvalidKeyGrammarError, QueryableFieldCapExceededError, ReservedContentTypeKeyError, StorageOnlyFieldNotQueryableError, ValidationError, VersionConflictError, } from "./errors.js";
|
|
2
3
|
import { resolveFieldIndexTransition, validateIdentifierGrammar } from "./index-provisioning.js";
|
|
3
4
|
import { isContentTypeFieldKind, isIndexableFieldKind, } from "./types.js";
|
|
4
5
|
/** Reserved forever for the legacy `posts` table — an operator Collection can never take these keys. */
|
|
6
|
+
/**
|
|
7
|
+
* Field-name guards shared by register and update, in order: every name passes the identifier
|
|
8
|
+
* grammar (`InvalidFieldNameGrammarError`), THEN no name appears twice
|
|
9
|
+
* (`VALIDATION_ERROR(fields_duplicate_name)`). Duplicates are refused because entry values are
|
|
10
|
+
* stored keyed by field name and the index-transition diff builds a name-keyed map, so two defs
|
|
11
|
+
* sharing a name leave one of them unaddressable (last one wins). Returns `null` when both pass.
|
|
12
|
+
*
|
|
13
|
+
* @complexity O(f) in the number of submitted fields.
|
|
14
|
+
*/
|
|
15
|
+
function fieldNameError(fields) {
|
|
16
|
+
for (const field of fields) {
|
|
17
|
+
if (!validateIdentifierGrammar(field.name)) {
|
|
18
|
+
return new InvalidFieldNameGrammarError(`field name '${field.name}' fails the identifier grammar gate`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
for (const field of fields) {
|
|
23
|
+
if (seen.has(field.name)) {
|
|
24
|
+
return new ValidationError(`field name '${field.name}' appears more than once`, "fields_duplicate_name");
|
|
25
|
+
}
|
|
26
|
+
seen.add(field.name);
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
5
30
|
const RESERVED_CONTENT_TYPE_KEYS = new Set(["post", "page"]);
|
|
6
31
|
/** Per-type cap on `queryable` fields (queryable-index sprawl mitigation). */
|
|
7
32
|
const QUERYABLE_FIELD_CAP = 20;
|
|
@@ -34,12 +59,10 @@ export async function registerContentType(required) {
|
|
|
34
59
|
if (RESERVED_CONTENT_TYPE_KEYS.has(input.key)) {
|
|
35
60
|
return { ok: false, error: new ReservedContentTypeKeyError(`key '${input.key}' is permanently reserved for the legacy 'posts' table`) };
|
|
36
61
|
}
|
|
37
|
-
// Guard 3: field-name grammar (every field, before any kind/cap check).
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
}
|
|
62
|
+
// Guard 3: field-name grammar, then uniqueness (every field, before any kind/cap check).
|
|
63
|
+
const nameErrorOnRegister = fieldNameError(input.fields);
|
|
64
|
+
if (nameErrorOnRegister)
|
|
65
|
+
return { ok: false, error: nameErrorOnRegister };
|
|
43
66
|
// Guard 4: field-kind, closed enum.
|
|
44
67
|
for (const field of input.fields) {
|
|
45
68
|
if (!isContentTypeFieldKind(field.kind)) {
|
|
@@ -62,6 +85,12 @@ export async function registerContentType(required) {
|
|
|
62
85
|
if (countQueryableFields(input.fields) > QUERYABLE_FIELD_CAP) {
|
|
63
86
|
return { ok: false, error: new QueryableFieldCapExceededError(`content type '${input.key}' submits more than ${QUERYABLE_FIELD_CAP} queryable fields`) };
|
|
64
87
|
}
|
|
88
|
+
// Row 7 — a pre-check outside the transaction. Not race-safe by itself (that's the in-tx check
|
|
89
|
+
// below), but it avoids opening a transaction for the overwhelmingly common not-found case.
|
|
90
|
+
const existingBeforeTx = await deps.repo.findByKey({ workspaceId: input.workspaceId, key: input.key });
|
|
91
|
+
if (existingBeforeTx) {
|
|
92
|
+
return { ok: false, error: new ContentTypeAlreadyExistsError(input.key, existingBeforeTx.status === "tombstone") };
|
|
93
|
+
}
|
|
65
94
|
const now = deps.clock.nowIso();
|
|
66
95
|
const contentType = {
|
|
67
96
|
workspaceId: input.workspaceId,
|
|
@@ -72,22 +101,37 @@ export async function registerContentType(required) {
|
|
|
72
101
|
version: 1,
|
|
73
102
|
tombstonedAt: null,
|
|
74
103
|
};
|
|
75
|
-
|
|
76
|
-
await deps.repo.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
workspaceId: input.workspaceId,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
104
|
+
try {
|
|
105
|
+
await deps.repo.transaction(async () => {
|
|
106
|
+
// The check that actually closes the race: two concurrent first-widget creates both see the
|
|
107
|
+
// pre-check above return null, but only one of them can win this in-tx re-check before save.
|
|
108
|
+
const existingInTx = await deps.repo.findByKey({ workspaceId: input.workspaceId, key: input.key });
|
|
109
|
+
if (existingInTx) {
|
|
110
|
+
throw new ContentTypeAlreadyExistsError(input.key, existingInTx.status === "tombstone");
|
|
111
|
+
}
|
|
112
|
+
await deps.repo.save(contentType);
|
|
113
|
+
await deps.repo.appendRevision({
|
|
114
|
+
contentTypeKey: input.key,
|
|
115
|
+
workspaceId: input.workspaceId,
|
|
116
|
+
op: "register",
|
|
117
|
+
stateJson: contentType,
|
|
118
|
+
actorId: input.actorId,
|
|
119
|
+
principalKind: input.principalKind ?? null,
|
|
120
|
+
delegatedByWorkspaceId: input.delegatedByWorkspaceId ?? null,
|
|
121
|
+
delegatedById: input.delegatedById ?? null,
|
|
122
|
+
recordedAt: now,
|
|
123
|
+
});
|
|
124
|
+
if (deps.watermark)
|
|
125
|
+
await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
|
|
87
126
|
});
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (err instanceof ContentTypeAlreadyExistsError)
|
|
130
|
+
return { ok: false, error: err };
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
// Index provisioning runs only once the transaction has committed a new row — never on the
|
|
134
|
+
// already-exists path above.
|
|
91
135
|
await deps.indexProvisioner.provisionIndexesForNewContentType({
|
|
92
136
|
workspaceId: input.workspaceId,
|
|
93
137
|
contentTypeKey: input.key,
|
|
@@ -118,6 +162,12 @@ export async function updateContentTypeFields(required) {
|
|
|
118
162
|
if (!current) {
|
|
119
163
|
return { ok: false, error: new ContentTypeNotFoundError(`content type '${input.key}' was not found in workspace '${input.workspaceId}'`) };
|
|
120
164
|
}
|
|
165
|
+
// Row 15's sibling — a tombstoned type is terminal (INV-06); this check runs before the version
|
|
166
|
+
// check so a stale-version update against a tombstoned row still reports "tombstoned", not
|
|
167
|
+
// "version conflict" (the tombstone tore down every queryable index, so the schema is moot).
|
|
168
|
+
if (current.status === "tombstone") {
|
|
169
|
+
return { ok: false, error: new EntityNotLiveError("content type", input.key, "tombstoned") };
|
|
170
|
+
}
|
|
121
171
|
// U-004-B1: expectedVersion checked FIRST — before fields_empty, before any per-field guard.
|
|
122
172
|
if (input.expectedVersion !== current.version) {
|
|
123
173
|
return { ok: false, error: new VersionConflictError(`expected version ${input.expectedVersion} for content type '${input.key}', found ${current.version}`) };
|
|
@@ -125,11 +175,9 @@ export async function updateContentTypeFields(required) {
|
|
|
125
175
|
if (input.fields.length === 0) {
|
|
126
176
|
return { ok: false, error: new ValidationError(`content type '${input.key}' update submitted an empty fields array`, "fields_empty") };
|
|
127
177
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
}
|
|
178
|
+
const nameErrorOnUpdate = fieldNameError(input.fields);
|
|
179
|
+
if (nameErrorOnUpdate)
|
|
180
|
+
return { ok: false, error: nameErrorOnUpdate };
|
|
133
181
|
for (const field of input.fields) {
|
|
134
182
|
if (!isContentTypeFieldKind(field.kind)) {
|
|
135
183
|
return { ok: false, error: new InvalidFieldKindError(`field '${field.name}' has kind '${field.kind}', not one of the closed field-kind enum`) };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ToolInputError } from "@jini-ai/core";
|
|
2
|
+
/**
|
|
3
|
+
* @file The write-side counterpart to `not-trashed.ts`'s read-side `notTrashed()` filter (Tovu
|
|
4
|
+
* `features/trash/not-trashed.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Purpose:
|
|
7
|
+
* A recurring defect across this codebase's writers: a mutation loads its row with a `findById`
|
|
8
|
+
* (or equivalent) that returns trashed and tombstoned rows just as readily as live ones, and never
|
|
9
|
+
* asks whether the row it found is actually live before saving over it. Fixed per writer, that is
|
|
10
|
+
* an unbounded number of near-identical patches, each one easy to forget on the next new writer.
|
|
11
|
+
* This module is the one shared check every writer calls right after its own load, so the check
|
|
12
|
+
* exists exactly once.
|
|
13
|
+
*
|
|
14
|
+
* Why it lives in `@jini-ai/cms/core` rather than in a Tovu module or a Jini domain package:
|
|
15
|
+
* Tovu's writers (pages, SEO, redirects) and Jini's own domain writers (media, content-types,
|
|
16
|
+
* taxonomy) both need it, Jini cannot import anything Tovu owns, and duplicating the check per side
|
|
17
|
+
* reintroduces the drift this module exists to remove. `core` is the one place both reach.
|
|
18
|
+
*
|
|
19
|
+
* Why `EntityNotLiveError` extends `ToolInputError` rather than a plain `Error`: the daemon's tool
|
|
20
|
+
* executor already treats `ToolInputError` as caller-facing (see `registration-kit.ts`'s doc
|
|
21
|
+
* comment), so every domain's agent tool surfaces this error's message to the caller with no
|
|
22
|
+
* per-domain allowlist entry, and every HTTP route that already maps `ToolInputError`-family errors
|
|
23
|
+
* picks it up for free. A per-domain rule added to `contracts/core/model-facing-tool-errors.ts`
|
|
24
|
+
* instead would be exactly the "correct primitive, unwired call site" defect this codebase keeps
|
|
25
|
+
* hitting — the check would exist but a caller could still fail to wire it in.
|
|
26
|
+
*
|
|
27
|
+
* The message is built only from a fixed string, the entity kind and the caller's own id (never
|
|
28
|
+
* from row content), so it satisfies `contracts/core/model-facing-tool-errors.ts`'s safety rule for
|
|
29
|
+
* a caller-facing message with no redaction step.
|
|
30
|
+
*/
|
|
31
|
+
/** A record's liveness as far as this guard is concerned. `"live"` never throws. */
|
|
32
|
+
export type EntityLiveness = "live" | "trashed" | "tombstoned";
|
|
33
|
+
/**
|
|
34
|
+
* Thrown by {@link assertEntityLive} for a non-live entity. `code` is the stable machine-readable
|
|
35
|
+
* discriminant; `entityType`/`entityId`/`state` are the values the message was built from, kept on
|
|
36
|
+
* the instance so a caller that wants to branch on them (rather than parse the message) can.
|
|
37
|
+
*/
|
|
38
|
+
export declare class EntityNotLiveError extends ToolInputError {
|
|
39
|
+
readonly entityType: string;
|
|
40
|
+
readonly entityId: string;
|
|
41
|
+
readonly state: "trashed" | "tombstoned";
|
|
42
|
+
readonly code: "ENTITY_IN_TRASH" | "ENTITY_TOMBSTONED";
|
|
43
|
+
constructor(entityType: string, entityId: string, state: "trashed" | "tombstoned");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Throws {@link EntityNotLiveError} unless `required.state` is `"live"`. Call this immediately
|
|
47
|
+
* after a writer's own load, before any mutation of the loaded row.
|
|
48
|
+
* @complexity O(1).
|
|
49
|
+
*/
|
|
50
|
+
export declare function assertEntityLive(required: {
|
|
51
|
+
entityType: string;
|
|
52
|
+
entityId: string;
|
|
53
|
+
state: EntityLiveness;
|
|
54
|
+
}): void;
|
|
55
|
+
//# sourceMappingURL=entity-liveness.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ToolInputError } from "@jini-ai/core";
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by {@link assertEntityLive} for a non-live entity. `code` is the stable machine-readable
|
|
4
|
+
* discriminant; `entityType`/`entityId`/`state` are the values the message was built from, kept on
|
|
5
|
+
* the instance so a caller that wants to branch on them (rather than parse the message) can.
|
|
6
|
+
*/
|
|
7
|
+
export class EntityNotLiveError extends ToolInputError {
|
|
8
|
+
entityType;
|
|
9
|
+
entityId;
|
|
10
|
+
state;
|
|
11
|
+
code;
|
|
12
|
+
constructor(entityType, entityId, state) {
|
|
13
|
+
super(buildEntityNotLiveMessage(entityType, entityId, state));
|
|
14
|
+
this.entityType = entityType;
|
|
15
|
+
this.entityId = entityId;
|
|
16
|
+
this.state = state;
|
|
17
|
+
this.name = "EntityNotLiveError";
|
|
18
|
+
this.code = state === "trashed" ? "ENTITY_IN_TRASH" : "ENTITY_TOMBSTONED";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** @complexity O(1). Pure string formatting; the two messages are tested byte-for-byte. */
|
|
22
|
+
function buildEntityNotLiveMessage(entityType, entityId, state) {
|
|
23
|
+
if (state === "trashed") {
|
|
24
|
+
return `ENTITY_IN_TRASH: ${entityType} '${entityId}' is in the Trash. Restore it from the Trash before changing it.`;
|
|
25
|
+
}
|
|
26
|
+
return `ENTITY_TOMBSTONED: ${entityType} '${entityId}' was permanently deleted and can't be changed.`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Throws {@link EntityNotLiveError} unless `required.state` is `"live"`. Call this immediately
|
|
30
|
+
* after a writer's own load, before any mutation of the loaded row.
|
|
31
|
+
* @complexity O(1).
|
|
32
|
+
*/
|
|
33
|
+
export function assertEntityLive(required) {
|
|
34
|
+
if (required.state === "live")
|
|
35
|
+
return;
|
|
36
|
+
throw new EntityNotLiveError(required.entityType, required.entityId, required.state);
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=entity-liveness.js.map
|
package/dist/core/index.d.ts
CHANGED
|
@@ -41,5 +41,6 @@
|
|
|
41
41
|
export type { UUID, ISODateTime, JsonPrimitive, JsonValue, JsonObject, JsonArray, DomainEvent, EventBusPort, OutboxRecord, OutboxPort, ClockPort, IdGeneratorPort, } from './ports.js';
|
|
42
42
|
export type { ChangeSetStatus, ChangeSetOperation, ChangeSetRecord, ChangeSetItemRecord, ChangeSetWithItems, ChangeSetRepoPort, } from './commands/change-set.js';
|
|
43
43
|
export * from './commands/command.js';
|
|
44
|
+
export * from './entity-liveness.js';
|
|
44
45
|
export * from './tools/registration-kit.js';
|
|
45
46
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/core/index.js
CHANGED
|
@@ -91,22 +91,56 @@ export type DerivedRiskByToolId = ReadonlyMap<string, AgentToolSideEffect>;
|
|
|
91
91
|
*/
|
|
92
92
|
export declare const AGENT_TOOL_PRINCIPAL_KIND: "agent";
|
|
93
93
|
/**
|
|
94
|
-
* Actor-class rules that
|
|
94
|
+
* Actor-class rules that need a human confirmer.
|
|
95
95
|
*
|
|
96
96
|
* `confirmer-must-equal-own-delegatedBy` means "a human must confirm this, and the confirmer must
|
|
97
|
-
* be the principal who delegated".
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* confirmation await.
|
|
97
|
+
* be the principal who delegated". The kit never delivers that through `descriptor.requiresConfirmation`:
|
|
98
|
+
* without a host `ExecutionDelegate`, that flag parks the execution on a promise only
|
|
99
|
+
* `resumeConfirmation` can settle, and nothing would call it (unbounded, too, because
|
|
100
|
+
* `descriptor.timeoutMs`'s timer is armed only AFTER the confirmation await).
|
|
101
101
|
*
|
|
102
|
-
* So a tool carrying this rule
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* future edit wiring any of them fails the build instead of silently shipping a tool whose stated
|
|
106
|
-
* human-confirmation requirement is unenforceable. When a confirmation transport does land, this
|
|
107
|
-
* constant is the deliberate place to relax.
|
|
102
|
+
* So a tool carrying this rule is wirable only when its handler was built with
|
|
103
|
+
* {@link humanConfirmedHandler}, which asks the human through the host's own confirmation transport
|
|
104
|
+
* and runs the action only on an explicit yes. Any other handler fails the build.
|
|
108
105
|
*/
|
|
109
106
|
export declare const ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT: Set<string>;
|
|
107
|
+
/**
|
|
108
|
+
* The human who confirmed, as {@link humanConfirmedHandler} hands it to `run`: always the run's own
|
|
109
|
+
* delegating principal (`ctx.principal.id`, see {@link AGENT_TOOL_PRINCIPAL_KIND}), always
|
|
110
|
+
* `kind: "user"`. The only thing a `run` step may pass to a confirm step as its confirmer.
|
|
111
|
+
*/
|
|
112
|
+
export interface HumanConfirmer {
|
|
113
|
+
readonly id: string;
|
|
114
|
+
readonly kind: "user";
|
|
115
|
+
}
|
|
116
|
+
/** What the host's transport reports back. Only exactly `confirmed: true` counts as a yes. */
|
|
117
|
+
export type HumanConfirmationAnswer = {
|
|
118
|
+
confirmed: true;
|
|
119
|
+
} | {
|
|
120
|
+
confirmed: false;
|
|
121
|
+
result: unknown;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Builds the one kind of handler a `confirmer-must-equal-own-delegatedBy` tool may be wired with
|
|
125
|
+
* (see {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}).
|
|
126
|
+
*
|
|
127
|
+
* The order is fixed here, not left to each domain: `prepare` (validate, pre-authorize, derive what
|
|
128
|
+
* the human will see) → `askHuman` (the host's transport, e.g. an MCP-UI dialog) → `run`, only when
|
|
129
|
+
* the answer is exactly `{ confirmed: true }`. The decision never comes from `ctx.input`, so nothing
|
|
130
|
+
* the model sends can stand in for the human. `run` gets the confirmer as the delegating human.
|
|
131
|
+
*
|
|
132
|
+
* @param steps.prepare - Reads input and derives what the human is asked about. May throw.
|
|
133
|
+
* @param steps.askHuman - Asks the human through the host's transport. Throws when no human can be
|
|
134
|
+
* asked; a no-answer returns `{ confirmed: false, result }` and `result` is the tool's result.
|
|
135
|
+
* @param steps.run - Performs the action on a yes.
|
|
136
|
+
* @returns A handler {@link assertToolIsWirable} accepts for a tool carrying the rule.
|
|
137
|
+
* @complexity O(1) plus the three steps.
|
|
138
|
+
*/
|
|
139
|
+
export declare function humanConfirmedHandler<TPrepared>(steps: {
|
|
140
|
+
prepare: (ctx: Parameters<ToolHandler>[0]) => Promise<TPrepared>;
|
|
141
|
+
askHuman: (ctx: Parameters<ToolHandler>[0], prepared: TPrepared) => Promise<HumanConfirmationAnswer>;
|
|
142
|
+
run: (ctx: Parameters<ToolHandler>[0], prepared: TPrepared, confirmer: HumanConfirmer) => Promise<unknown>;
|
|
143
|
+
}): ToolHandler;
|
|
110
144
|
/**
|
|
111
145
|
* Indexes a domain catalog by tool id — the single lookup used for descriptors, risk metadata, and
|
|
112
146
|
* schema-bearing error messages.
|
|
@@ -220,8 +254,8 @@ export declare function decorateWithSchema(params: {
|
|
|
220
254
|
/**
|
|
221
255
|
* Build-time gate on a single tool's risk metadata: refuses to wire a tool whose declared
|
|
222
256
|
* `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
|
|
223
|
-
* whose id that layer has not classified at all, or whose `actorClassRule` needs a
|
|
224
|
-
*
|
|
257
|
+
* whose id that layer has not classified at all, or whose `actorClassRule` needs a human confirmer
|
|
258
|
+
* its handler does not ask for.
|
|
225
259
|
*
|
|
226
260
|
* Throws rather than warning, and at registration time rather than call time: a metadata
|
|
227
261
|
* inconsistency is a developer error that should stop the daemon booting, not a runtime condition
|
|
@@ -230,7 +264,10 @@ export declare function decorateWithSchema(params: {
|
|
|
230
264
|
* @param params.toolId - The wired tool id.
|
|
231
265
|
* @param params.catalogEntry - Its `agent-tools.ts` entry, the declared side of the comparison.
|
|
232
266
|
* @param params.derivedRisk - The wiring layer's independent classification, the derived side.
|
|
233
|
-
* @
|
|
267
|
+
* @param params.handler - The handler being wired; checked only for a tool carrying a rule in
|
|
268
|
+
* {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}.
|
|
269
|
+
* @throws {Error} If the tool is unclassified, misclassified, or needs a human confirmer its handler
|
|
270
|
+
* does not ask for.
|
|
234
271
|
* @complexity O(1) — two map/set lookups.
|
|
235
272
|
* @overallScore 100
|
|
236
273
|
*/
|
|
@@ -238,6 +275,7 @@ export declare function assertToolIsWirable(params: {
|
|
|
238
275
|
toolId: string;
|
|
239
276
|
catalogEntry: WirableToolDefinition;
|
|
240
277
|
derivedRisk: DerivedRiskByToolId;
|
|
278
|
+
handler?: ToolHandler;
|
|
241
279
|
}): void;
|
|
242
280
|
/**
|
|
243
281
|
* Turns one domain's handler map into `ToolRegistration`s, applying every build-time gate the
|
|
@@ -258,9 +296,9 @@ export declare function assertToolIsWirable(params: {
|
|
|
258
296
|
* the same rule, and a `ToolPolicy`-only check would be bypassable by any future non-tool caller of
|
|
259
297
|
* the same domain function, which is precisely why the chokepoint owns the gate.
|
|
260
298
|
*
|
|
261
|
-
* `descriptor.requiresConfirmation` is deliberately never set —
|
|
262
|
-
* {@link
|
|
263
|
-
*
|
|
299
|
+
* `descriptor.requiresConfirmation` is deliberately never set — a tool that needs a human confirm
|
|
300
|
+
* asks inside its own {@link humanConfirmedHandler}; see
|
|
301
|
+
* {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}.
|
|
264
302
|
*
|
|
265
303
|
* @param spec.domain - Human-readable domain name, used only in failure messages.
|
|
266
304
|
* @param spec.catalogModule - Path of the catalog file, named in the drift message so the failure
|
|
@@ -48,22 +48,47 @@ import { ForbiddenError } from "../commands/command.js";
|
|
|
48
48
|
// and Forms' `CommandActor.kind` param without a cast at either call site.
|
|
49
49
|
export const AGENT_TOOL_PRINCIPAL_KIND = "agent";
|
|
50
50
|
/**
|
|
51
|
-
* Actor-class rules that
|
|
51
|
+
* Actor-class rules that need a human confirmer.
|
|
52
52
|
*
|
|
53
53
|
* `confirmer-must-equal-own-delegatedBy` means "a human must confirm this, and the confirmer must
|
|
54
|
-
* be the principal who delegated".
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* confirmation await.
|
|
54
|
+
* be the principal who delegated". The kit never delivers that through `descriptor.requiresConfirmation`:
|
|
55
|
+
* without a host `ExecutionDelegate`, that flag parks the execution on a promise only
|
|
56
|
+
* `resumeConfirmation` can settle, and nothing would call it (unbounded, too, because
|
|
57
|
+
* `descriptor.timeoutMs`'s timer is armed only AFTER the confirmation await).
|
|
58
58
|
*
|
|
59
|
-
* So a tool carrying this rule
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* future edit wiring any of them fails the build instead of silently shipping a tool whose stated
|
|
63
|
-
* human-confirmation requirement is unenforceable. When a confirmation transport does land, this
|
|
64
|
-
* constant is the deliberate place to relax.
|
|
59
|
+
* So a tool carrying this rule is wirable only when its handler was built with
|
|
60
|
+
* {@link humanConfirmedHandler}, which asks the human through the host's own confirmation transport
|
|
61
|
+
* and runs the action only on an explicit yes. Any other handler fails the build.
|
|
65
62
|
*/
|
|
66
63
|
export const ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT = new Set(["confirmer-must-equal-own-delegatedBy"]);
|
|
64
|
+
const HUMAN_CONFIRMED_HANDLERS = new WeakSet();
|
|
65
|
+
/**
|
|
66
|
+
* Builds the one kind of handler a `confirmer-must-equal-own-delegatedBy` tool may be wired with
|
|
67
|
+
* (see {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}).
|
|
68
|
+
*
|
|
69
|
+
* The order is fixed here, not left to each domain: `prepare` (validate, pre-authorize, derive what
|
|
70
|
+
* the human will see) → `askHuman` (the host's transport, e.g. an MCP-UI dialog) → `run`, only when
|
|
71
|
+
* the answer is exactly `{ confirmed: true }`. The decision never comes from `ctx.input`, so nothing
|
|
72
|
+
* the model sends can stand in for the human. `run` gets the confirmer as the delegating human.
|
|
73
|
+
*
|
|
74
|
+
* @param steps.prepare - Reads input and derives what the human is asked about. May throw.
|
|
75
|
+
* @param steps.askHuman - Asks the human through the host's transport. Throws when no human can be
|
|
76
|
+
* asked; a no-answer returns `{ confirmed: false, result }` and `result` is the tool's result.
|
|
77
|
+
* @param steps.run - Performs the action on a yes.
|
|
78
|
+
* @returns A handler {@link assertToolIsWirable} accepts for a tool carrying the rule.
|
|
79
|
+
* @complexity O(1) plus the three steps.
|
|
80
|
+
*/
|
|
81
|
+
export function humanConfirmedHandler(steps) {
|
|
82
|
+
const handler = async (ctx) => {
|
|
83
|
+
const prepared = await steps.prepare(ctx);
|
|
84
|
+
const answer = await steps.askHuman(ctx, prepared);
|
|
85
|
+
if (answer.confirmed !== true)
|
|
86
|
+
return answer.result;
|
|
87
|
+
return steps.run(ctx, prepared, { id: ctx.principal.id, kind: "user" });
|
|
88
|
+
};
|
|
89
|
+
HUMAN_CONFIRMED_HANDLERS.add(handler);
|
|
90
|
+
return handler;
|
|
91
|
+
}
|
|
67
92
|
/**
|
|
68
93
|
* Indexes a domain catalog by tool id — the single lookup used for descriptors, risk metadata, and
|
|
69
94
|
* schema-bearing error messages.
|
|
@@ -256,8 +281,8 @@ export function decorateWithSchema(params) {
|
|
|
256
281
|
/**
|
|
257
282
|
* Build-time gate on a single tool's risk metadata: refuses to wire a tool whose declared
|
|
258
283
|
* `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
|
|
259
|
-
* whose id that layer has not classified at all, or whose `actorClassRule` needs a
|
|
260
|
-
*
|
|
284
|
+
* whose id that layer has not classified at all, or whose `actorClassRule` needs a human confirmer
|
|
285
|
+
* its handler does not ask for.
|
|
261
286
|
*
|
|
262
287
|
* Throws rather than warning, and at registration time rather than call time: a metadata
|
|
263
288
|
* inconsistency is a developer error that should stop the daemon booting, not a runtime condition
|
|
@@ -266,7 +291,10 @@ export function decorateWithSchema(params) {
|
|
|
266
291
|
* @param params.toolId - The wired tool id.
|
|
267
292
|
* @param params.catalogEntry - Its `agent-tools.ts` entry, the declared side of the comparison.
|
|
268
293
|
* @param params.derivedRisk - The wiring layer's independent classification, the derived side.
|
|
269
|
-
* @
|
|
294
|
+
* @param params.handler - The handler being wired; checked only for a tool carrying a rule in
|
|
295
|
+
* {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}.
|
|
296
|
+
* @throws {Error} If the tool is unclassified, misclassified, or needs a human confirmer its handler
|
|
297
|
+
* does not ask for.
|
|
270
298
|
* @complexity O(1) — two map/set lookups.
|
|
271
299
|
* @overallScore 100
|
|
272
300
|
*/
|
|
@@ -279,8 +307,9 @@ export function assertToolIsWirable(params) {
|
|
|
279
307
|
if (derived !== catalogEntry.sideEffects) {
|
|
280
308
|
throw new Error(`tool-registrations: '${toolId}' declares sideEffects '${catalogEntry.sideEffects}' but this layer derives '${derived}' from what its handler calls — reconcile the two rather than trusting the declaration`);
|
|
281
309
|
}
|
|
282
|
-
|
|
283
|
-
|
|
310
|
+
const needsHuman = catalogEntry.actorClassRule && ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT.has(catalogEntry.actorClassRule);
|
|
311
|
+
if (needsHuman && !(params.handler && HUMAN_CONFIRMED_HANDLERS.has(params.handler))) {
|
|
312
|
+
throw new Error(`tool-registrations: '${toolId}' declares actorClassRule '${catalogEntry.actorClassRule}', which needs a human confirmer — build its handler with humanConfirmedHandler so a human answers through the host's confirmation transport (see ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT)`);
|
|
284
313
|
}
|
|
285
314
|
}
|
|
286
315
|
/**
|
|
@@ -302,9 +331,9 @@ export function assertToolIsWirable(params) {
|
|
|
302
331
|
* the same rule, and a `ToolPolicy`-only check would be bypassable by any future non-tool caller of
|
|
303
332
|
* the same domain function, which is precisely why the chokepoint owns the gate.
|
|
304
333
|
*
|
|
305
|
-
* `descriptor.requiresConfirmation` is deliberately never set —
|
|
306
|
-
* {@link
|
|
307
|
-
*
|
|
334
|
+
* `descriptor.requiresConfirmation` is deliberately never set — a tool that needs a human confirm
|
|
335
|
+
* asks inside its own {@link humanConfirmedHandler}; see
|
|
336
|
+
* {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}.
|
|
308
337
|
*
|
|
309
338
|
* @param spec.domain - Human-readable domain name, used only in failure messages.
|
|
310
339
|
* @param spec.catalogModule - Path of the catalog file, named in the drift message so the failure
|
|
@@ -326,7 +355,7 @@ export function buildDomainRegistrations(spec) {
|
|
|
326
355
|
if (!catalogEntry) {
|
|
327
356
|
throw new Error(`tool-registrations: ${spec.domain} catalog has no entry named '${id}' — ${spec.catalogModule} drifted`);
|
|
328
357
|
}
|
|
329
|
-
assertToolIsWirable({ toolId: id, catalogEntry, derivedRisk: spec.derivedRisk });
|
|
358
|
+
assertToolIsWirable({ toolId: id, catalogEntry, derivedRisk: spec.derivedRisk, handler });
|
|
330
359
|
if (!catalogEntry.inputSchema) {
|
|
331
360
|
throw new Error(`tool-registrations: wired tool '${id}' publishes no inputSchema — add one to its entry in ${spec.catalogModule} so the model gets a contract, or leave the tool unwired`);
|
|
332
361
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
* bespoke `MediaRecord` table in place of a generic-entries model.
|
|
52
52
|
*/
|
|
53
53
|
import { createHash } from "node:crypto";
|
|
54
|
+
import { assertEntityLive } from "../core/entity-liveness.js";
|
|
54
55
|
import { MediaConflictError, MediaNotFoundError, MediaSourceImmutableError, MediaStillReferencedError, MediaValidationError, } from "./types.js";
|
|
55
56
|
import { withSha256Lock } from "./blob-gc-lock.js";
|
|
56
57
|
import { tombstoneBlobIfUnreferenced } from "./blob-gc.js";
|
|
@@ -457,6 +458,7 @@ export async function updateMediaMetadata(required, _optional = {}) {
|
|
|
457
458
|
const existing = await deps.mediaRepo.findById({ workspaceId: input.workspaceId, id: input.id });
|
|
458
459
|
if (!existing)
|
|
459
460
|
throw new MediaNotFoundError(`media '${input.id}' was not found`);
|
|
461
|
+
assertEntityLive({ entityType: "media", entityId: input.id, state: existing.status === "trashed" ? "trashed" : "live" });
|
|
460
462
|
if (input.width !== undefined && input.width !== null)
|
|
461
463
|
assertPositiveIntegerOrThrow(input.width, "width");
|
|
462
464
|
if (input.height !== undefined && input.height !== null)
|
|
@@ -1,27 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Write-service for the `navigation` library.
|
|
3
|
-
*
|
|
4
|
-
* Purpose:
|
|
5
|
-
* Implements the menu mutation slice: create, whole-tree update (OCC), soft
|
|
6
|
-
* delete → hard purge, and location assignment. Mirrors the host's `post`
|
|
7
|
-
* feature style (required/optional param objects, typed errors, OCC via
|
|
8
|
-
* `version`).
|
|
9
|
-
*
|
|
10
|
-
* How it relates to the project:
|
|
11
|
-
* - Storage is `MenuRepoPort` (`repo.memory.ts`) — a self-contained repo, not
|
|
12
|
-
* the generic entries repo (see `repo.memory.ts`'s file header for why).
|
|
13
|
-
* - Location assignment additionally writes `NavLocationBindingRepoPort`
|
|
14
|
-
* (`ports.ts`), the one real port this library declares.
|
|
15
|
-
* - In production this whole module runs inside the command
|
|
16
|
-
* gateway/write chokepoint (same-transaction revision, attribution,
|
|
17
|
-
* `entry_refs` extraction). That gateway is not implemented as running code
|
|
18
|
-
* yet, so these functions are the chokepoint's future *contents*, called
|
|
19
|
-
* directly for now.
|
|
20
|
-
*
|
|
21
|
-
* Architectural role:
|
|
22
|
-
* Feature logic only. No Express/route code, no direct SQL — everything goes
|
|
23
|
-
* through the injected repo ports (`deps`).
|
|
24
|
-
*/
|
|
25
1
|
import type { ClockPort, IdGeneratorPort, OutboxPort, UUID } from "../core/ports.js";
|
|
26
2
|
import type { MenuRepoPort } from "./repo.memory.js";
|
|
27
3
|
import type { NavLocationBindingRepoPort } from "./ports.js";
|
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Write-service for the `navigation` library.
|
|
3
|
+
*
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Implements the menu mutation slice: create, whole-tree update (OCC), soft
|
|
6
|
+
* delete → hard purge, and location assignment. Mirrors the host's `post`
|
|
7
|
+
* feature style (required/optional param objects, typed errors, OCC via
|
|
8
|
+
* `version`).
|
|
9
|
+
*
|
|
10
|
+
* How it relates to the project:
|
|
11
|
+
* - Storage is `MenuRepoPort` (`repo.memory.ts`) — a self-contained repo, not
|
|
12
|
+
* the generic entries repo (see `repo.memory.ts`'s file header for why).
|
|
13
|
+
* - Location assignment additionally writes `NavLocationBindingRepoPort`
|
|
14
|
+
* (`ports.ts`), the one real port this library declares.
|
|
15
|
+
* - In production this whole module runs inside the command
|
|
16
|
+
* gateway/write chokepoint (same-transaction revision, attribution,
|
|
17
|
+
* `entry_refs` extraction). That gateway is not implemented as running code
|
|
18
|
+
* yet, so these functions are the chokepoint's future *contents*, called
|
|
19
|
+
* directly for now.
|
|
20
|
+
*
|
|
21
|
+
* Architectural role:
|
|
22
|
+
* Feature logic only. No Express/route code, no direct SQL — everything goes
|
|
23
|
+
* through the injected repo ports (`deps`).
|
|
24
|
+
*/
|
|
25
|
+
import { assertEntityLive } from "../core/entity-liveness.js";
|
|
1
26
|
import { NAV_DOC_TYPE, } from "./types.js";
|
|
2
27
|
// ---------------------------------------------------------------------------
|
|
3
28
|
// Outbox event publication — each mutating function below
|
|
@@ -293,6 +318,7 @@ export async function updateMenuTree(required, optional = {}) {
|
|
|
293
318
|
const existing = await deps.repo.findById({ workspaceId: input.workspaceId, id: input.id });
|
|
294
319
|
if (!existing)
|
|
295
320
|
throw new MenuNotFoundError(`menu '${input.id}' was not found`);
|
|
321
|
+
assertEntityLive({ entityType: "menu", entityId: input.id, state: existing.status === "trash" ? "trashed" : "live" });
|
|
296
322
|
if (existing.version !== input.expectedVersion) {
|
|
297
323
|
throw new MenuConflictError(`menu '${input.id}' was modified concurrently (expected version ${input.expectedVersion}, found ${existing.version})`);
|
|
298
324
|
}
|
|
@@ -360,6 +386,7 @@ export async function assignLocation(required, _optional = {}) {
|
|
|
360
386
|
const menu = await deps.repo.findById({ workspaceId: input.workspaceId, id: input.menuId });
|
|
361
387
|
if (!menu)
|
|
362
388
|
throw new MenuNotFoundError(`menu '${input.menuId}' was not found`);
|
|
389
|
+
assertEntityLive({ entityType: "menu", entityId: input.menuId, state: menu.status === "trash" ? "trashed" : "live" });
|
|
363
390
|
const now = deps.clock.nowIso();
|
|
364
391
|
const existingBinding = await deps.bindingRepo.findByLocation({
|
|
365
392
|
workspaceId: input.workspaceId,
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Navigation's (Menus') half of the agent-tool authorization wiring: maps `agent-tools.ts`'s five
|
|
3
|
-
* catalog entries onto `menu-service.ts`'s read/create/update/assign operations, as
|
|
4
|
-
* `ToolRegistration`s. The entire catalog is wired — `deleteMenu` is deliberately absent from the
|
|
5
|
-
* catalog rather than present-but-unwired; see `navigation/agent-tools.ts`'s own header.
|
|
6
|
-
*
|
|
7
|
-
* Authorization shape: like Media and unlike Forms/Identity/Widgets, `menu-service.ts`'s functions
|
|
8
|
-
* perform NO internal `authorize()` call of their own — every admin HTTP route a host builds gates
|
|
9
|
-
* inline. Every handler here does the same via the kit's `requireToolPermission`, which is the
|
|
10
|
-
* single evaluation for these tools, located where the real route locates it.
|
|
11
|
-
*/
|
|
12
1
|
import type { AuthorizeFn } from "../core/commands/command.js";
|
|
13
2
|
import type { OutboxPort } from "../core/ports.js";
|
|
14
3
|
import { type DerivedRiskByToolId, type ToolRegistration } from "../core/tools/registration-kit.js";
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Navigation's (Menus') half of the agent-tool authorization wiring: maps `agent-tools.ts`'s five
|
|
3
|
+
* catalog entries onto `menu-service.ts`'s read/create/update/assign operations, as
|
|
4
|
+
* `ToolRegistration`s. The entire catalog is wired — `deleteMenu` is deliberately absent from the
|
|
5
|
+
* catalog rather than present-but-unwired; see `navigation/agent-tools.ts`'s own header.
|
|
6
|
+
*
|
|
7
|
+
* Authorization shape: like Media and unlike Forms/Identity/Widgets, `menu-service.ts`'s functions
|
|
8
|
+
* perform NO internal `authorize()` call of their own — every admin HTTP route a host builds gates
|
|
9
|
+
* inline. Every handler here does the same via the kit's `requireToolPermission`, which is the
|
|
10
|
+
* single evaluation for these tools, located where the real route locates it.
|
|
11
|
+
*/
|
|
12
|
+
import { ToolInputError } from "@jini-ai/core";
|
|
1
13
|
import { buildDomainRegistrations, indexCatalogById, requireInputRecord, requireNumber, requireString, requireToolPermission, withSchemaOnRejection, } from "../core/tools/registration-kit.js";
|
|
2
14
|
import { menusAgentToolCatalog } from "./agent-tools.js";
|
|
3
15
|
import { assignLocation, createMenu, MenuNotFoundError, MenuValidationError, updateMenuTree } from "./menu-service.js";
|
|
@@ -65,6 +77,9 @@ export function buildMenusRegistrations(routeDeps) {
|
|
|
65
77
|
menus_create_menu: async (ctx) => {
|
|
66
78
|
const input = requireInputRecord(ctx.input);
|
|
67
79
|
await requireToolPermission(routeDeps, { principalId: ctx.principal.id, permission: "admin.menus.create", entityType: "menu" });
|
|
80
|
+
if (input.items !== undefined && !Array.isArray(input.items)) {
|
|
81
|
+
throw new ToolInputError("'items' must be an array of nav items");
|
|
82
|
+
}
|
|
68
83
|
return withSchemaOnRejection({ toolId: "menus_create_menu", catalog: CATALOG_BY_ID, isShapeRejection: isMenusShapeRejection }, async () => {
|
|
69
84
|
const { menu } = await createMenu({
|
|
70
85
|
deps: menusDeps(routeDeps),
|
|
@@ -26,18 +26,12 @@
|
|
|
26
26
|
* - `confirm()` structurally refuses an agent: `gateway.ts`'s own body — "if (principalKind ===
|
|
27
27
|
* 'agent') throw new ForbiddenError('agent principals may not confirm a gated mutation', ...)"
|
|
28
28
|
* — before any permission check even runs. No `taxonomy_confirm_merge_term`-equivalent tool
|
|
29
|
-
* exists in this catalog
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* kind='user'
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* `confirmer-must-equal-own-delegatedBy` actor-class rule `database_execute_migrate_forward`/
|
|
36
|
-
* `backup_execute_restore` carry, which `assistant/tool-registration-kit.ts`'s
|
|
37
|
-
* `ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT` refuses to build regardless of this
|
|
38
|
-
* catalog's own choice, because the host's `ToolExecutor` has no confirmation transport wired.
|
|
39
|
-
* `taxonomy_execute_merge_term` is declared here (so the exclusion is a structural build-time
|
|
40
|
-
* refusal, not just an absent handler) but is NEVER wired by `tool-registrations.ts`.
|
|
29
|
+
* exists in this catalog.
|
|
30
|
+
* - `taxonomy_execute_merge_term` (2026-09-24) asks the human in chat before it runs: the host
|
|
31
|
+
* wires it with the kit's `humanConfirmedHandler`, which shows a confirm dialog and only on the
|
|
32
|
+
* human's own click confirms as that human (`kind='user'`) and executes as the agent acting for
|
|
33
|
+
* them — the `confirmer-must-equal-own-delegatedBy` rule. The model never sees or supplies a
|
|
34
|
+
* token. A merge deletes the source term's assignments with no Trash or undo, so it is gated.
|
|
41
35
|
*
|
|
42
36
|
* Naming: `taxonomy_*`, matching this package's own name — distinct from `features/content-types`'
|
|
43
37
|
* `collections_*` prefix (a different pairing) and from `features/entries`' own
|
|
@@ -66,7 +60,7 @@ export interface AgentToolDefinition {
|
|
|
66
60
|
/**
|
|
67
61
|
* JSON Schema for this tool's `input`, published to the model via `ToolDescriptor.inputSchema`
|
|
68
62
|
* (`assistant/tool-registration-kit.ts`'s `buildDomainRegistrations`, which refuses to wire any
|
|
69
|
-
* tool lacking one).
|
|
63
|
+
* tool lacking one).
|
|
70
64
|
*/
|
|
71
65
|
inputSchema?: Readonly<Record<string, unknown>>;
|
|
72
66
|
}
|
|
@@ -26,18 +26,12 @@
|
|
|
26
26
|
* - `confirm()` structurally refuses an agent: `gateway.ts`'s own body — "if (principalKind ===
|
|
27
27
|
* 'agent') throw new ForbiddenError('agent principals may not confirm a gated mutation', ...)"
|
|
28
28
|
* — before any permission check even runs. No `taxonomy_confirm_merge_term`-equivalent tool
|
|
29
|
-
* exists in this catalog
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* kind='user'
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* `confirmer-must-equal-own-delegatedBy` actor-class rule `database_execute_migrate_forward`/
|
|
36
|
-
* `backup_execute_restore` carry, which `assistant/tool-registration-kit.ts`'s
|
|
37
|
-
* `ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT` refuses to build regardless of this
|
|
38
|
-
* catalog's own choice, because the host's `ToolExecutor` has no confirmation transport wired.
|
|
39
|
-
* `taxonomy_execute_merge_term` is declared here (so the exclusion is a structural build-time
|
|
40
|
-
* refusal, not just an absent handler) but is NEVER wired by `tool-registrations.ts`.
|
|
29
|
+
* exists in this catalog.
|
|
30
|
+
* - `taxonomy_execute_merge_term` (2026-09-24) asks the human in chat before it runs: the host
|
|
31
|
+
* wires it with the kit's `humanConfirmedHandler`, which shows a confirm dialog and only on the
|
|
32
|
+
* human's own click confirms as that human (`kind='user'`) and executes as the agent acting for
|
|
33
|
+
* them — the `confirmer-must-equal-own-delegatedBy` rule. The model never sees or supplies a
|
|
34
|
+
* token. A merge deletes the source term's assignments with no Trash or undo, so it is gated.
|
|
41
35
|
*
|
|
42
36
|
* Naming: `taxonomy_*`, matching this package's own name — distinct from `features/content-types`'
|
|
43
37
|
* `collections_*` prefix (a different pairing) and from `features/entries`' own
|
|
@@ -172,9 +166,7 @@ export const taxonomyAgentToolCatalog = [
|
|
|
172
166
|
description: "Previews merging one term into another: recomputes and returns the current overlap-loss disclosure (how many pieces of content are " +
|
|
173
167
|
"already assigned to BOTH terms, whose duplicate assignment would be silently lost by the merge's own dedup step) plus a planId/planHash " +
|
|
174
168
|
"a human can use in the admin UI's own merge confirmation ceremony. Read-only — performs no merge. Rejects immediately if fromTermId " +
|
|
175
|
-
"equals intoTermId, before computing anything.
|
|
176
|
-
"through the admin UI — no tool in this catalog can do that step (by design: merges are destructive and irreversible for the merged-away " +
|
|
177
|
-
"term's own identity).",
|
|
169
|
+
"equals intoTermId, before computing anything. To actually merge, call taxonomy_execute_merge_term, which asks the user to confirm first.",
|
|
178
170
|
sideEffects: "none",
|
|
179
171
|
authorization: { permission: "admin.taxonomy.manage" },
|
|
180
172
|
inputSchema: {
|
|
@@ -188,13 +180,22 @@ export const taxonomyAgentToolCatalog = [
|
|
|
188
180
|
},
|
|
189
181
|
},
|
|
190
182
|
{
|
|
191
|
-
//
|
|
192
|
-
// own confirm() step structurally refuses an agent principal, and no confirmation transport
|
|
193
|
-
// exists for an agent to be handed a human-minted token through anyway.
|
|
183
|
+
// Asks the human first — see this file's header.
|
|
194
184
|
name: "taxonomy_execute_merge_term",
|
|
195
|
-
description: "
|
|
185
|
+
description: "Merges one term into another: every piece of content tagged with fromTermId is re-tagged with intoTermId, and fromTermId is " +
|
|
186
|
+
"deprecated. Shows the user a confirm dialog first and only merges if they confirm. The merge can't be undone. Call " +
|
|
187
|
+
"taxonomy_plan_merge_term first to see how many items are affected.",
|
|
196
188
|
sideEffects: "mutates-durable-state",
|
|
197
189
|
authorization: { permission: "admin.taxonomy.manage" },
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: "object",
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
required: ["fromTermId", "intoTermId"],
|
|
194
|
+
properties: {
|
|
195
|
+
fromTermId: { ...TERM_ID_SCHEMA, description: "The term to merge away. It is deprecated after the merge." },
|
|
196
|
+
intoTermId: { ...TERM_ID_SCHEMA, description: "The term that receives every assignment." },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
198
199
|
actorClassRule: "confirmer-must-equal-own-delegatedBy",
|
|
199
200
|
},
|
|
200
201
|
];
|
|
@@ -36,8 +36,14 @@ export interface ContentRecordLookupPort {
|
|
|
36
36
|
}): Promise<{
|
|
37
37
|
workspaceId: string;
|
|
38
38
|
kind: string;
|
|
39
|
+
deletedAt?: string | null;
|
|
39
40
|
} | null>;
|
|
40
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Row 63 (web-high fix plan, 2026-09-24): a trashed post/page must refuse a term assign/unassign,
|
|
44
|
+
* not silently accept one — `assertEntityLive` throws `EntityNotLiveError` the moment `deletedAt`
|
|
45
|
+
* is set, before this ever returns a resolvable target to `write-service.ts`'s join guards.
|
|
46
|
+
*/
|
|
41
47
|
export declare function createPostBackedContentLookup(deps: {
|
|
42
48
|
postRepo: ContentRecordLookupPort;
|
|
43
49
|
workspaceId: string;
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import { assertEntityLive } from "../core/entity-liveness.js";
|
|
1
2
|
import { isContentTypeOnAllowList } from "./write-service.js";
|
|
3
|
+
/**
|
|
4
|
+
* Row 63 (web-high fix plan, 2026-09-24): a trashed post/page must refuse a term assign/unassign,
|
|
5
|
+
* not silently accept one — `assertEntityLive` throws `EntityNotLiveError` the moment `deletedAt`
|
|
6
|
+
* is set, before this ever returns a resolvable target to `write-service.ts`'s join guards.
|
|
7
|
+
*/
|
|
2
8
|
export function createPostBackedContentLookup(deps) {
|
|
3
9
|
return {
|
|
4
10
|
async resolve({ contentId }) {
|
|
5
11
|
const post = await deps.postRepo.findById({ workspaceId: deps.workspaceId, id: contentId });
|
|
6
|
-
|
|
12
|
+
if (!post)
|
|
13
|
+
return null;
|
|
14
|
+
if (post.deletedAt)
|
|
15
|
+
assertEntityLive({ entityType: post.kind, entityId: contentId, state: "trashed" });
|
|
16
|
+
return { workspaceId: post.workspaceId, kind: post.kind };
|
|
7
17
|
},
|
|
8
18
|
};
|
|
9
19
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/cms",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "Content-model capability for Jini-hosted products: framework-free contracts, ports, and domain services in `/core` and per-domain subpaths; Node-bound persistence adapters in `/server`.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|