@jini-ai/cms 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +14 -8
  3. package/dist/comments/errors.d.ts +23 -0
  4. package/dist/comments/errors.js +25 -0
  5. package/dist/comments/express/index.d.ts +2 -0
  6. package/dist/comments/express/index.js +2 -0
  7. package/dist/comments/express/submit-handler.d.ts +30 -0
  8. package/dist/comments/express/submit-handler.js +85 -0
  9. package/dist/comments/hooks.d.ts +39 -0
  10. package/dist/comments/hooks.js +41 -0
  11. package/dist/comments/index.d.ts +11 -0
  12. package/dist/comments/index.js +11 -0
  13. package/dist/comments/ingress.d.ts +57 -0
  14. package/dist/comments/ingress.js +173 -0
  15. package/dist/comments/module.d.ts +94 -0
  16. package/dist/comments/module.js +73 -0
  17. package/dist/comments/ports.d.ts +184 -0
  18. package/dist/comments/ports.js +2 -0
  19. package/dist/comments/repo.memory.d.ts +70 -0
  20. package/dist/comments/repo.memory.js +120 -0
  21. package/dist/comments/sanitize.d.ts +15 -0
  22. package/dist/comments/sanitize.js +31 -0
  23. package/dist/comments/spam.heuristic.d.ts +8 -0
  24. package/dist/comments/spam.heuristic.js +54 -0
  25. package/dist/comments/sql/index.d.ts +3 -0
  26. package/dist/comments/sql/index.js +3 -0
  27. package/dist/comments/sql/repo.d.ts +77 -0
  28. package/dist/comments/sql/repo.js +190 -0
  29. package/dist/comments/sql/repo.rows.d.ts +49 -0
  30. package/dist/comments/sql/repo.rows.js +71 -0
  31. package/dist/comments/types.d.ts +187 -0
  32. package/dist/comments/types.js +39 -0
  33. package/dist/comments/write-service.d.ts +121 -0
  34. package/dist/comments/write-service.js +144 -0
  35. package/dist/core/commands/command.d.ts +2 -9
  36. package/dist/core/commands/command.js +2 -9
  37. package/dist/core/index.d.ts +2 -1
  38. package/dist/core/index.js +1 -0
  39. package/dist/core/keyset-cursor.d.ts +25 -0
  40. package/dist/core/keyset-cursor.js +20 -0
  41. package/dist/core/ports.d.ts +30 -0
  42. package/dist/core/ports.js +0 -1
  43. package/dist/core/tools/args.d.ts +1 -46
  44. package/dist/core/tools/args.js +1 -44
  45. package/dist/media/index.d.ts +0 -3
  46. package/dist/media/index.js +1 -4
  47. package/dist/media/node.d.ts +5 -0
  48. package/dist/media/node.js +6 -0
  49. package/dist/redirects/capture.d.ts +80 -0
  50. package/dist/redirects/capture.js +66 -0
  51. package/dist/redirects/express/index.d.ts +27 -0
  52. package/dist/redirects/express/index.js +40 -0
  53. package/dist/redirects/hit-sink.d.ts +56 -0
  54. package/dist/redirects/hit-sink.js +44 -0
  55. package/dist/redirects/host-config/index.d.ts +40 -0
  56. package/dist/redirects/host-config/index.js +145 -0
  57. package/dist/redirects/index.d.ts +14 -0
  58. package/dist/redirects/index.js +14 -0
  59. package/dist/redirects/matcher.d.ts +58 -0
  60. package/dist/redirects/matcher.js +144 -0
  61. package/dist/redirects/order.d.ts +30 -0
  62. package/dist/redirects/order.js +26 -0
  63. package/dist/redirects/ports.d.ts +242 -0
  64. package/dist/redirects/ports.internal.d.ts +57 -0
  65. package/dist/redirects/ports.internal.js +14 -0
  66. package/dist/redirects/ports.js +2 -0
  67. package/dist/redirects/redirects.d.ts +226 -0
  68. package/dist/redirects/redirects.js +590 -0
  69. package/dist/redirects/referrer-alias.d.ts +31 -0
  70. package/dist/redirects/referrer-alias.js +33 -0
  71. package/dist/redirects/repo.memory.d.ts +51 -0
  72. package/dist/redirects/repo.memory.js +98 -0
  73. package/dist/redirects/reserved-destination.d.ts +58 -0
  74. package/dist/redirects/reserved-destination.js +59 -0
  75. package/dist/redirects/resolver.d.ts +100 -0
  76. package/dist/redirects/resolver.js +87 -0
  77. package/dist/redirects/spa/index.d.ts +30 -0
  78. package/dist/redirects/spa/index.js +27 -0
  79. package/dist/redirects/sql/index.d.ts +4 -0
  80. package/dist/redirects/sql/index.js +4 -0
  81. package/dist/redirects/sql/repo.d.ts +82 -0
  82. package/dist/redirects/sql/repo.js +143 -0
  83. package/dist/redirects/sql/repo.rows.d.ts +49 -0
  84. package/dist/redirects/sql/repo.rows.js +79 -0
  85. package/dist/redirects/sql/row-types.d.ts +34 -0
  86. package/dist/redirects/sql/row-types.js +2 -0
  87. package/dist/redirects/target-gate.d.ts +30 -0
  88. package/dist/redirects/target-gate.js +85 -0
  89. package/dist/redirects/types.d.ts +213 -0
  90. package/dist/redirects/types.js +42 -0
  91. package/dist/seo/absolute-url.d.ts +58 -0
  92. package/dist/seo/absolute-url.js +117 -0
  93. package/dist/seo/dom/index.d.ts +14 -0
  94. package/dist/seo/dom/index.js +52 -0
  95. package/dist/seo/errors.d.ts +35 -0
  96. package/dist/seo/errors.js +35 -0
  97. package/dist/seo/feed.d.ts +49 -0
  98. package/dist/seo/feed.js +61 -0
  99. package/dist/seo/index.d.ts +13 -0
  100. package/dist/seo/index.js +13 -0
  101. package/dist/seo/media.d.ts +57 -0
  102. package/dist/seo/media.js +134 -0
  103. package/dist/seo/page-head.d.ts +18 -0
  104. package/dist/seo/page-head.js +114 -0
  105. package/dist/seo/ports.d.ts +221 -0
  106. package/dist/seo/ports.js +27 -0
  107. package/dist/seo/seo.d.ts +73 -0
  108. package/dist/seo/seo.js +230 -0
  109. package/dist/seo/sitemap.d.ts +139 -0
  110. package/dist/seo/sitemap.js +217 -0
  111. package/dist/seo/types.d.ts +278 -0
  112. package/dist/seo/types.js +2 -0
  113. package/dist/seo/write-service.d.ts +79 -0
  114. package/dist/seo/write-service.js +273 -0
  115. package/dist/seo/xml-escape.d.ts +16 -0
  116. package/dist/seo/xml-escape.js +19 -0
  117. package/dist/settings/express/cms-adapter.d.ts +20 -0
  118. package/dist/settings/express/cms-adapter.js +124 -0
  119. package/dist/settings/express/contracts.d.ts +135 -0
  120. package/dist/settings/express/contracts.js +2 -0
  121. package/dist/settings/express/http.d.ts +10 -0
  122. package/dist/settings/express/http.js +211 -0
  123. package/dist/settings/express/index.d.ts +5 -0
  124. package/dist/settings/express/index.js +5 -0
  125. package/dist/settings/express/shared.d.ts +68 -0
  126. package/dist/settings/express/shared.js +54 -0
  127. package/dist/settings/express/stream.d.ts +24 -0
  128. package/dist/settings/express/stream.js +147 -0
  129. package/dist/settings/index.d.ts +3 -38
  130. package/dist/settings/index.js +3 -35
  131. package/dist/taxonomy/write-service.d.ts +2 -30
  132. package/dist/widgets/authorize-helper.d.ts +42 -0
  133. package/dist/widgets/authorize-helper.js +16 -0
  134. package/dist/widgets/concurrency.d.ts +12 -0
  135. package/dist/widgets/concurrency.js +44 -0
  136. package/dist/widgets/config-validation.d.ts +34 -0
  137. package/dist/widgets/config-validation.js +125 -0
  138. package/dist/widgets/embed-service.d.ts +133 -0
  139. package/dist/widgets/embed-service.js +460 -0
  140. package/dist/widgets/embed-validation.d.ts +23 -0
  141. package/dist/widgets/embed-validation.js +38 -0
  142. package/dist/widgets/entry-payload.d.ts +103 -0
  143. package/dist/widgets/entry-payload.js +160 -0
  144. package/dist/widgets/errors.d.ts +136 -0
  145. package/dist/widgets/errors.js +136 -0
  146. package/dist/widgets/host-ports.d.ts +149 -0
  147. package/dist/widgets/host-ports.js +2 -0
  148. package/dist/widgets/html/html-embeds.d.ts +247 -0
  149. package/dist/widgets/html/html-embeds.js +408 -0
  150. package/dist/widgets/html/index.d.ts +5 -0
  151. package/dist/widgets/html/index.js +4 -0
  152. package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
  153. package/dist/widgets/html/legacy-widget-markers.js +45 -0
  154. package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
  155. package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
  156. package/dist/widgets/index.d.ts +20 -0
  157. package/dist/widgets/index.js +19 -0
  158. package/dist/widgets/markers/html-attributes.d.ts +98 -0
  159. package/dist/widgets/markers/html-attributes.js +216 -0
  160. package/dist/widgets/markers/index.d.ts +3 -0
  161. package/dist/widgets/markers/index.js +3 -0
  162. package/dist/widgets/markers/marker.d.ts +496 -0
  163. package/dist/widgets/markers/marker.js +780 -0
  164. package/dist/widgets/page-embed-types.d.ts +42 -0
  165. package/dist/widgets/page-embed-types.js +42 -0
  166. package/dist/widgets/ports.d.ts +142 -0
  167. package/dist/widgets/ports.js +2 -0
  168. package/dist/widgets/read-service.d.ts +81 -0
  169. package/dist/widgets/read-service.js +79 -0
  170. package/dist/widgets/region-area-service.d.ts +110 -0
  171. package/dist/widgets/region-area-service.js +180 -0
  172. package/dist/widgets/registry.d.ts +217 -0
  173. package/dist/widgets/registry.js +172 -0
  174. package/dist/widgets/repo.memory.d.ts +41 -0
  175. package/dist/widgets/repo.memory.js +48 -0
  176. package/dist/widgets/resolver-service.d.ts +209 -0
  177. package/dist/widgets/resolver-service.js +909 -0
  178. package/dist/widgets/resolvers/contact-form.d.ts +26 -0
  179. package/dist/widgets/resolvers/contact-form.js +54 -0
  180. package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
  181. package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
  182. package/dist/widgets/resolvers/index.d.ts +65 -0
  183. package/dist/widgets/resolvers/index.js +155 -0
  184. package/dist/widgets/resolvers/menu.d.ts +42 -0
  185. package/dist/widgets/resolvers/menu.js +43 -0
  186. package/dist/widgets/resolvers/ports.d.ts +82 -0
  187. package/dist/widgets/resolvers/ports.js +2 -0
  188. package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
  189. package/dist/widgets/resolvers/recent-entries.js +165 -0
  190. package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
  191. package/dist/widgets/resolvers/taxonomy.js +34 -0
  192. package/dist/widgets/sql/index.d.ts +3 -0
  193. package/dist/widgets/sql/index.js +3 -0
  194. package/dist/widgets/sql/repo.d.ts +41 -0
  195. package/dist/widgets/sql/repo.js +82 -0
  196. package/dist/widgets/sql/tables.d.ts +13 -0
  197. package/dist/widgets/sql/tables.js +2 -0
  198. package/dist/widgets/types.d.ts +213 -0
  199. package/dist/widgets/types.js +17 -0
  200. package/dist/widgets/where-used.d.ts +41 -0
  201. package/dist/widgets/where-used.js +11 -0
  202. package/dist/widgets/write-service.d.ts +204 -0
  203. package/dist/widgets/write-service.js +332 -0
  204. package/dist/workspace/delete.d.ts +1 -1
  205. package/package.json +220 -11
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @file Server-side, versioned document-mutation command for `widgetEmbed` nodes (SPEC-043
3
+ * REQ-44/45, ADR-047 Debate Fold-In Amendment 6).
4
+ *
5
+ * Purpose:
6
+ * The second entry point into the SAME document-mutation contract the live TipTap editor's own
7
+ * Save path uses — both ultimately write through `features/entries/write-service.ts`'s
8
+ * `updateEntry` chokepoint, the same `expectedVersion` OCC guard, and the same revision machinery.
9
+ * Usable with no browser editor session open (an AI agent, or a server-side script). Enforces
10
+ * REQ-19/20's guardrails (no widget-in-widget recursion, per-document embed-count clamp) via
11
+ * `embed-validation.ts`'s `validateWidgetEmbedMutation` — the ONE validator both this path and the
12
+ * live editor's own chokepoint-side hook must call, so the two paths can never silently diverge
13
+ * (INV-04).
14
+ *
15
+ * Closes a real, previously-disclosed gap this file's own domain depends on: `updateEntry` had no
16
+ * parameter to change `bodyJson` after creation (only `createEntry` did) — fixed additively in
17
+ * `features/entries/write-service.ts` as part of this same dispatch (see that file's `bodyJson`
18
+ * doc comment) rather than worked around here.
19
+ *
20
+ * Architectural role:
21
+ * `widgets` domain logic (implementation outline C-007), landing in this later routes/UI/AI-tools
22
+ * dispatch rather than the earlier domain-layer session, since it composes `updateEntry`'s new
23
+ * `bodyJson` capability which did not exist yet at that time.
24
+ */
25
+ import type { Clock as ClockPort, UUID } from "@jini-ai/core/primitives";
26
+ import type { ChangeSetRepoPort, OutboxPort } from "../core/index.js";
27
+ import type { EntryRefsRepoPort } from "./ports.js";
28
+ import type { ContentTypeRepoPort } from "../content-types/index.js";
29
+ import type { EntryRecord, EntryRepoPort } from "../entries/index.js";
30
+ import type { WidgetHostPorts, BeforeSaveHookPort, PostRecord, ForgetRemovedPostFn, PostRepoPort } from "./host-ports.js";
31
+ import { type WidgetsAuthorizeFn } from "./authorize-helper.js";
32
+ export interface EmbedServiceDeps {
33
+ host: Pick<WidgetHostPorts, "entries" | "entryRefs" | "posts">;
34
+ entryRepo: EntryRepoPort;
35
+ contentTypeRepo: ContentTypeRepoPort;
36
+ entryRefsRepo: EntryRefsRepoPort;
37
+ /** REQ-44 — a post/page host is a second, separate table (see `loadEmbedHost`'s doc). */
38
+ postRepo: PostRepoPort;
39
+ /**
40
+ * Required by `restorePostForward`, which this service's `rollback` calls. Nothing here ever
41
+ * trashes a post, so it can never actually fire — but the dependency is required rather than
42
+ * optional on purpose: an optional compensation port is how one gets wired at three call sites
43
+ * out of four. See `post.ts`'s {@link ForgetRemovedPostFn}.
44
+ */
45
+ forgetRemovedPost: ForgetRemovedPostFn;
46
+ /** The same command-gateway change-set store the live editor's own post-save route uses, so a
47
+ * post/page embed write records an auditable, revertible change set identically (ADR-047
48
+ * Amendment 6). */
49
+ changeSets: ChangeSetRepoPort;
50
+ clock: ClockPort;
51
+ ids: {
52
+ newId: () => string;
53
+ };
54
+ authorize: WidgetsAuthorizeFn;
55
+ outbox: OutboxPort;
56
+ /** Same optional "absent = zero-plugin behavior unchanged" contract as `UpdatePostDeps.beforeSaveHook`. */
57
+ beforeSaveHook?: BeforeSaveHookPort;
58
+ /** REQ-20 — policy-bounded, not hardcoded; defaults to `DEFAULT_MAX_EMBEDS_PER_DOCUMENT`. */
59
+ maxEmbedsPerDocument?: number;
60
+ }
61
+ export interface InsertWidgetEmbedInput {
62
+ readonly workspaceId: UUID;
63
+ readonly actor: {
64
+ readonly principalId: UUID;
65
+ readonly kind: string;
66
+ };
67
+ readonly hostEntryId: UUID;
68
+ readonly baseVersion: number;
69
+ readonly widgetEntryId: UUID;
70
+ }
71
+ export interface InsertWidgetEmbedRequired {
72
+ deps: EmbedServiceDeps;
73
+ input: InsertWidgetEmbedInput;
74
+ }
75
+ /** REQ-44/45: inserts a new `widgetEmbed` node referencing `widgetEntryId`, appended to the host
76
+ * entry's body — one atomic, version-guarded write through the same chokepoint the live editor's
77
+ * Save uses. Returns the newly-minted `placementId` so the caller (an agent tool, or the
78
+ * server-side embed-mutation routes) can address this exact placement afterward. */
79
+ export declare function insertWidgetEmbed(required: InsertWidgetEmbedRequired, _optional?: Record<string, never>): Promise<{
80
+ entry: EntryRecord | PostRecord;
81
+ placementId: UUID;
82
+ }>;
83
+ export interface RemoveWidgetEmbedInput {
84
+ readonly workspaceId: UUID;
85
+ readonly actor: {
86
+ readonly principalId: UUID;
87
+ readonly kind: string;
88
+ };
89
+ readonly hostEntryId: UUID;
90
+ readonly baseVersion: number;
91
+ readonly placementId: UUID;
92
+ }
93
+ export interface RemoveWidgetEmbedRequired {
94
+ deps: EmbedServiceDeps;
95
+ input: RemoveWidgetEmbedInput;
96
+ }
97
+ /** REQ-44/45: removes the `widgetEmbed` node matching `placementId` from the host entry's body —
98
+ * the placement itself is removed (the widget instance it referenced is untouched). */
99
+ export declare function removeWidgetEmbed(required: RemoveWidgetEmbedRequired, _optional?: Record<string, never>): Promise<{
100
+ entry: EntryRecord | PostRecord;
101
+ }>;
102
+ export interface ReorderWidgetEmbedsInput {
103
+ readonly workspaceId: UUID;
104
+ readonly actor: {
105
+ readonly principalId: UUID;
106
+ readonly kind: string;
107
+ };
108
+ readonly hostEntryId: UUID;
109
+ readonly baseVersion: number;
110
+ /** The new widget-entry order, one id per EXISTING embed slot in the host's body, in document
111
+ * order — must be the same length as the number of `widgetEmbed` nodes currently present. */
112
+ readonly orderedWidgetEntryIds: readonly UUID[];
113
+ }
114
+ export interface ReorderWidgetEmbedsRequired {
115
+ deps: EmbedServiceDeps;
116
+ input: ReorderWidgetEmbedsInput;
117
+ }
118
+ export declare class WidgetEmbedReorderCountMismatchError extends Error {
119
+ readonly expectedCount: number;
120
+ readonly actualCount: number;
121
+ constructor({ message, expectedCount, actualCount }: {
122
+ message: string;
123
+ expectedCount: number;
124
+ actualCount: number;
125
+ }, _optional?: Record<string, never>);
126
+ }
127
+ /** REQ-44/45: reassigns which widget occupies which existing embed slot, in document order — the
128
+ * document's own shape (slot count/position, surrounding content) is unchanged. Rejects a count
129
+ * mismatch before writing anything (the caller must supply exactly one id per existing slot). */
130
+ export declare function reorderWidgetEmbeds(required: ReorderWidgetEmbedsRequired, _optional?: Record<string, never>): Promise<{
131
+ entry: EntryRecord | PostRecord;
132
+ }>;
133
+ //# sourceMappingURL=embed-service.d.ts.map
@@ -0,0 +1,460 @@
1
+ import { isNonArrayRecord } from "@jini-ai/core";
2
+ import { ForbiddenError, executeCommand } from "../core/index.js";
3
+ import { PRE_AUTHORIZED, requireWidgetPermission, } from "./authorize-helper.js";
4
+ import { withEntryLock } from "./concurrency.js";
5
+ import { validateWidgetEmbedMutation } from "./embed-validation.js";
6
+ import { parseWidgetInstancePayload } from "./entry-payload.js";
7
+ import { WidgetEmbedGuardrailError, WidgetEmbedHostNotFoundError, WidgetEmbedHostUnsupportedError, WidgetEmbedPlacementNotFoundError, WidgetForbiddenError, WidgetInstanceNotFoundError, WidgetVersionConflictError, } from "./errors.js";
8
+ import { WIDGET_AREA_CONTENT_TYPE, WIDGET_CONTENT_TYPE } from "./types.js";
9
+ /** Matches the certified `embed-validation.unit.test.ts` suite's own value — no separate policy
10
+ * config surface exists yet for this (OQ-level detail, same disclosure posture as OQ-01's resolver
11
+ * timeout). */
12
+ const DEFAULT_MAX_EMBEDS_PER_DOCUMENT = 50;
13
+ /** Walks a TipTap-shaped `bodyJson` tree collecting every `widgetEmbed` node, in document order. */
14
+ function collectEmbeds(root, out) {
15
+ // An explicit stack, not recursion: a host body's nesting depth is caller/import-controlled and a
16
+ // 5000-deep body overflowed the call stack here on every embed save (same class as f646ba397's
17
+ // GET-path walkers). Children are pushed in reverse so the walk stays pre-order — document order,
18
+ // which `reorderWidgetEmbeds` relies on.
19
+ const stack = [root];
20
+ while (stack.length > 0) {
21
+ const node = stack.pop();
22
+ const candidate = { value: node };
23
+ const attrs = { value: isNonArrayRecord(candidate) && candidate.value.type === "widgetEmbed" ? candidate.value.attrs : undefined };
24
+ if (Array.isArray(node)) {
25
+ for (let i = node.length - 1; i >= 0; i--)
26
+ stack.push(node[i]);
27
+ continue;
28
+ }
29
+ if (!isNonArrayRecord(candidate))
30
+ continue;
31
+ if (isNonArrayRecord(attrs) &&
32
+ typeof attrs.value.placementId === "string" &&
33
+ typeof attrs.value.widgetEntryId === "string") {
34
+ out.push({ type: "widgetEmbed", placementId: attrs.value.placementId, widgetEntryId: attrs.value.widgetEntryId });
35
+ }
36
+ if (Array.isArray(candidate.value.content))
37
+ stack.push(candidate.value.content);
38
+ }
39
+ }
40
+ function widgetEmbedNode(placementId, widgetEntryId) {
41
+ return { type: "widgetEmbed", attrs: { placementId, widgetEntryId } };
42
+ }
43
+ /** Appends one new `widgetEmbed` node to the end of the document's top-level `content` array — the
44
+ * simplest well-defined "insert" position for a server-side/no-editor caller (an arbitrary cursor
45
+ * position is a live-editor-only concept; REQ-44 requires the mutation exist, not that it target an
46
+ * arbitrary offset). Falls back to a fresh empty doc if `bodyJson` isn't already a TipTap-shaped
47
+ * doc node (e.g. a still-empty entry). */
48
+ function appendEmbed(bodyJson, placementId, widgetEntryId) {
49
+ const body = { value: bodyJson };
50
+ const doc = isNonArrayRecord(body) && body.value.type === "doc" ? body.value : { type: "doc", content: [] };
51
+ const content = Array.isArray(doc.content) ? doc.content : [];
52
+ return { ...doc, content: [...content, widgetEmbedNode(placementId, widgetEntryId)] };
53
+ }
54
+ /** Removes every `widgetEmbed` node matching `placementId`, at any depth, preserving every other
55
+ * node's position — mirrors the tree-shape-preserving discipline `reorderEmbeds` below also uses. */
56
+ function isEmbedWithPlacementId(node, placementId) {
57
+ const candidate = { value: node };
58
+ const attrs = { value: isNonArrayRecord(candidate) && candidate.value.type === "widgetEmbed" ? candidate.value.attrs : undefined };
59
+ return isNonArrayRecord(candidate) && isNonArrayRecord(attrs) && attrs.value.placementId === placementId;
60
+ }
61
+ /** Whether `removeEmbedByPlacementId` would remove anything — same match, so the two never disagree. */
62
+ function containsEmbedWithPlacementId({ node: root, placementId }, _optional = {}) {
63
+ // Stored/imported nesting is unbounded; keep the complete walk off the JavaScript call stack.
64
+ const stack = [root];
65
+ while (stack.length > 0) {
66
+ const node = stack.pop();
67
+ const candidate = { value: node };
68
+ if (Array.isArray(node)) {
69
+ for (let i = node.length - 1; i >= 0; i--)
70
+ stack.push(node[i]);
71
+ }
72
+ else if (isEmbedWithPlacementId(node, placementId)) {
73
+ return true;
74
+ }
75
+ else if (isNonArrayRecord(candidate) && Array.isArray(candidate.value.content)) {
76
+ stack.push(candidate.value.content);
77
+ }
78
+ }
79
+ return false;
80
+ }
81
+ function removeEmbedByPlacementId({ node: root, placementId }, _optional = {}) {
82
+ let result;
83
+ // Each task assigns one transformed child to its copied parent; no recursive reconstruction
84
+ // or mutation of the original document (which is also the write's inverse/revision state).
85
+ const stack = [
86
+ { node: root, assign: (value) => { result = value; } },
87
+ ];
88
+ while (stack.length > 0) {
89
+ const { node, assign } = stack.pop();
90
+ const candidate = { value: node };
91
+ if (Array.isArray(node)) {
92
+ const children = node.filter((child) => !isEmbedWithPlacementId(child, placementId));
93
+ const copy = new Array(children.length);
94
+ assign(copy);
95
+ for (let i = children.length - 1; i >= 0; i--) {
96
+ stack.push({ node: children[i], assign: (value) => { copy[i] = value; } });
97
+ }
98
+ }
99
+ else if (isNonArrayRecord(candidate) && Array.isArray(candidate.value.content)) {
100
+ const copy = { ...candidate.value };
101
+ assign(copy);
102
+ stack.push({ node: candidate.value.content, assign: (value) => { copy.content = value; } });
103
+ }
104
+ else {
105
+ assign(node);
106
+ }
107
+ }
108
+ return result;
109
+ }
110
+ /** Reassigns which widget occupies which EXISTING embed slot, in document order, per
111
+ * `orderedWidgetEntryIds` — the document's shape (surrounding paragraphs/headings, slot count and
112
+ * position) is unchanged; only each slot's `widgetEntryId` is swapped. `placementId`s move with
113
+ * their new `widgetEntryId` (a fresh placement identity per REQ-44's "reorder" being a distinct
114
+ * operation from "insert") so `entry_refs` re-extraction after the write reflects the new mapping
115
+ * cleanly rather than aliasing stale target ids to a slot that no longer represents them. */
116
+ function reorderEmbedSlots({ node: root, newIds, orderedWidgetEntryIds }, _optional = {}) {
117
+ let result;
118
+ let index = 0;
119
+ const stack = [
120
+ { node: root, assign: (value) => { result = value; } },
121
+ ];
122
+ while (stack.length > 0) {
123
+ const { node, assign } = stack.pop();
124
+ const candidate = { value: node };
125
+ const attrs = { value: isNonArrayRecord(candidate) && candidate.value.type === "widgetEmbed" ? candidate.value.attrs : undefined };
126
+ if (Array.isArray(node)) {
127
+ const copy = new Array(node.length);
128
+ assign(copy);
129
+ // Reverse push preserves pre-order, including new placement-id allocation order.
130
+ for (let i = node.length - 1; i >= 0; i--) {
131
+ stack.push({ node: node[i], assign: (value) => { copy[i] = value; } });
132
+ }
133
+ }
134
+ else if (isNonArrayRecord(candidate) && isNonArrayRecord(attrs)) {
135
+ // reorderWidgetEmbeds validates one supplied id per existing embed slot.
136
+ assign(widgetEmbedNode(newIds(), orderedWidgetEntryIds[index++]));
137
+ }
138
+ else if (isNonArrayRecord(candidate) && Array.isArray(candidate.value.content)) {
139
+ const copy = { ...candidate.value };
140
+ assign(copy);
141
+ stack.push({ node: candidate.value.content, assign: (value) => { copy.content = value; } });
142
+ }
143
+ else {
144
+ assign(node);
145
+ }
146
+ }
147
+ return result;
148
+ }
149
+ async function extractAndStoreEmbedRefs(deps, workspaceId, host) {
150
+ const refs = deps.host.entryRefs.extract({
151
+ workspaceId,
152
+ sourceEntryId: host.id,
153
+ sourceEntryType: host.type,
154
+ bodyJson: host.bodyJson,
155
+ fieldsExt: {},
156
+ });
157
+ await deps.entryRefsRepo.replaceForSource({ workspaceId, sourceEntryId: host.id, refs });
158
+ }
159
+ async function loadEmbedHost(deps, workspaceId, hostEntryId) {
160
+ const entry = await deps.entryRepo.findById({ workspaceId, id: hostEntryId });
161
+ if (entry) {
162
+ if (entry.type === WIDGET_AREA_CONTENT_TYPE) {
163
+ // REQ-17 (D4) — a widget_area entry is itself a region's composition document; it must never
164
+ // also be a widgetEmbed host (no recursion into a region from inside a region).
165
+ throw new WidgetEmbedHostUnsupportedError({ message: `host '${hostEntryId}' is a widget_area entry, which can never host a widgetEmbed node (REQ-17)`, reason: "widget-area" });
166
+ }
167
+ return { kind: "entry", record: entry };
168
+ }
169
+ const post = await deps.postRepo.findById({ workspaceId, id: hostEntryId });
170
+ if (!post || deps.host.posts.isTrashed(post)) {
171
+ throw new WidgetEmbedHostNotFoundError({ message: `host entry '${hostEntryId}' was not found in workspace '${workspaceId}' (it must be the id of an existing, non-trashed post, page, or content entry)` });
172
+ }
173
+ if (post.bodyFormat === "html") {
174
+ // An html-format Page (SPEC-047) has no Tiptap body — `data-embed-config` markers are its embed
175
+ // mechanism instead (`html-embeds.ts`), not a `widgetEmbed` node.
176
+ throw new WidgetEmbedHostUnsupportedError({ message: `host '${hostEntryId}' is an HTML-format page, which has no rich-text body for widgetEmbed nodes; embed widgets in it with a data-embed-config marker of type "widget" via pages_write_region or pages_write_html instead`, reason: "html-page" });
177
+ }
178
+ return { kind: "post", record: post };
179
+ }
180
+ /** The content/entity type `assertGuardrails` validates against — an entry's registered content
181
+ * type for an entries host, or the post/page `kind` for a posts-table host. */
182
+ function hostBodyType(host) {
183
+ return host.kind === "entry" ? host.record.type : host.record.kind;
184
+ }
185
+ /**
186
+ * REQ-16/17 (Fable adversarial-review fix, 2026-07-21, Finding E): mirrors
187
+ * `region-area-service.ts`'s identical REQ-16 placement check, previously asymmetric — a region
188
+ * placement referencing a nonexistent/trashed/wrong-type widget was rejected before any write, but
189
+ * `insertWidgetEmbed` inserted the embed node regardless, silently degrading to the render-time
190
+ * placeholder instead of failing at write time the way region placement does. Also enforces REQ-17
191
+ * (a `widget_area` entry must never itself be an embed target) via the same `type !==
192
+ * WIDGET_CONTENT_TYPE` check region placement already uses to exclude it.
193
+ */
194
+ async function assertEmbedTargetIsLiveWidget(deps, workspaceId, widgetEntryId) {
195
+ const widget = await deps.entryRepo.findById({ workspaceId, id: widgetEntryId });
196
+ if (!widget || widget.type !== WIDGET_CONTENT_TYPE) {
197
+ throw new WidgetInstanceNotFoundError({ message: `embed references widget '${widgetEntryId}', which does not exist in workspace '${workspaceId}' (REQ-16/17)` });
198
+ }
199
+ const payload = parseWidgetInstancePayload({ fieldsJson: widget.fieldsJson });
200
+ if (payload.status === "trash" || payload.status === "purged") {
201
+ throw new WidgetInstanceNotFoundError({ message: `embed references widget '${widgetEntryId}', which is trashed (REQ-16)` });
202
+ }
203
+ }
204
+ /** REQ-19/20: validates the resulting embed set BEFORE writing — the same guardrail the live
205
+ * editor's own chokepoint-side hook must call (INV-04), never bypassable from this path. */
206
+ function assertGuardrails(deps, hostEntryType, resultingBodyJson) {
207
+ const resultingEmbeds = [];
208
+ collectEmbeds(resultingBodyJson, resultingEmbeds);
209
+ const result = validateWidgetEmbedMutation({
210
+ hostEntryType,
211
+ resultingEmbeds,
212
+ maxEmbedsPerDocument: deps.maxEmbedsPerDocument ?? DEFAULT_MAX_EMBEDS_PER_DOCUMENT,
213
+ });
214
+ if (!result.valid) {
215
+ throw new WidgetEmbedGuardrailError({ message: `widgetEmbed mutation on host entry rejected: ${result.reason} (REQ-19/20)`, reason: result.reason });
216
+ }
217
+ }
218
+ async function writeHostBody(deps, workspaceId, actor, current, baseVersion, nextBodyJson) {
219
+ const result = await deps.host.entries.updateEntry({
220
+ deps: {
221
+ entryRepo: deps.entryRepo,
222
+ contentTypeRepo: deps.contentTypeRepo,
223
+ clock: deps.clock,
224
+ authorize: PRE_AUTHORIZED,
225
+ // Bridges the raw, full-`DomainEvent` infra port (`EmbedServiceDeps.outbox`) into the
226
+ // narrower `{enqueue({name,payload})}` shape `updateEntry` declares locally — same fix,
227
+ // same reasoning as `write-service.ts`'s `entriesWriteDeps` helper (only one call site
228
+ // here, so inlined rather than factored out). Previously `deps.outbox` was forwarded
229
+ // unwrapped, which compiled but threw `NOT NULL constraint failed: outbox_events.id`
230
+ // against the real SQLite outbox on every embed insert/remove/reorder.
231
+ outbox: deps.host.entries.toEntryOutbox({ outbox: deps.outbox, clock: deps.clock, idGen: deps.ids, workspaceId }),
232
+ onWritten: (entry) => extractAndStoreEmbedRefs(deps, workspaceId, entry),
233
+ },
234
+ input: {
235
+ actorId: actor.principalId,
236
+ workspaceId,
237
+ id: current.id,
238
+ bodyJson: nextBodyJson,
239
+ expectedVersion: baseVersion,
240
+ },
241
+ });
242
+ if (!result.ok) {
243
+ if (deps.host.entries.isVersionConflict(result.error)) {
244
+ const latest = await deps.entryRepo.findById({ workspaceId, id: current.id });
245
+ throw new WidgetVersionConflictError({ message: result.error.message, currentVersion: latest?.version ?? current.version });
246
+ }
247
+ throw result.error;
248
+ }
249
+ return result.value.entry;
250
+ }
251
+ /** REQ-44's post/page host arm — writes through the SAME command-gateway + `updatePost` chokepoint
252
+ * the live TipTap editor's own Save path uses (`routes/posts/update.ts`), so a post/page embed
253
+ * mutation gets an identical change-set/rollback unit of work (ADR-047 Amendment 6, D2). Only
254
+ * `bodyJson` actually changes; every other field travels through unchanged from `current`.
255
+ *
256
+ * Unlike the entries arm above — which re-extracts `entry_refs` inside `updateEntry`'s `onWritten`,
257
+ * within that chokepoint's own transaction — the refs refresh here runs AFTER `executeCommand` has
258
+ * committed, because the command gateway exposes no in-transaction hook. A refs write that fails
259
+ * therefore leaves the post saved with stale refs (where-used over-reports, and the non-force purge
260
+ * guard fails safe by blocking). Accepted, not overlooked. */
261
+ async function writePostHostBody(deps, workspaceId, actor, current, baseVersion, nextBodyJson) {
262
+ let priorPost = null;
263
+ try {
264
+ const { result } = await executeCommand({
265
+ deps: {
266
+ clock: deps.clock,
267
+ idGen: deps.ids,
268
+ changeSets: deps.changeSets,
269
+ outbox: deps.outbox,
270
+ authorize: deps.authorize,
271
+ },
272
+ command: {
273
+ workspaceId,
274
+ actor: { id: actor.principalId, kind: actor.kind },
275
+ summary: `Update widgetEmbed placements on post ${current.id}`,
276
+ permission: "content.write",
277
+ },
278
+ mutation: {
279
+ entityType: "post",
280
+ entityId: current.id,
281
+ operation: "update",
282
+ captureInverse: async () => {
283
+ priorPost = await deps.postRepo.findById({ workspaceId, id: current.id });
284
+ if (!priorPost)
285
+ return null;
286
+ return {
287
+ title: priorPost.title,
288
+ slug: priorPost.slug,
289
+ bodyJson: priorPost.bodyJson,
290
+ status: priorPost.status,
291
+ templateChoice: priorPost.templateChoice ?? null,
292
+ overridesThemePage: priorPost.overridesThemePage ?? null,
293
+ ...(priorPost.ext !== undefined ? { ext: priorPost.ext } : {}),
294
+ };
295
+ },
296
+ execute: () => deps.host.posts.updatePost({
297
+ deps: {
298
+ repo: deps.postRepo,
299
+ clock: deps.clock,
300
+ outbox: deps.outbox,
301
+ beforeSaveHook: deps.beforeSaveHook,
302
+ },
303
+ input: {
304
+ workspaceId,
305
+ id: current.id,
306
+ title: current.title,
307
+ slug: current.slug,
308
+ status: current.status,
309
+ bodyJson: nextBodyJson,
310
+ expectedVersion: baseVersion,
311
+ actorId: actor.principalId,
312
+ // `actor.kind` is this call's real provenance ("user" for the admin HTTP routes,
313
+ // "agent" for the widget tool surface — see `InsertWidgetEmbedInput.actor`'s own
314
+ // doc). Only a non-"user" kind is delegated — mirrors `tool-registrations.ts`'s
315
+ // identical delegatedBy* convention for `post_revisions` attribution.
316
+ delegatedByWorkspaceId: actor.kind === "user" ? undefined : workspaceId,
317
+ delegatedById: actor.kind === "user" ? undefined : actor.principalId,
318
+ },
319
+ }),
320
+ captureEntityVersion: ({ result }) => result.post.version,
321
+ rollback: async () => {
322
+ if (!priorPost)
323
+ return;
324
+ await deps.host.posts.restorePostForward({
325
+ deps: {
326
+ repo: deps.postRepo,
327
+ clock: deps.clock,
328
+ outbox: deps.outbox,
329
+ forgetRemoved: deps.forgetRemovedPost,
330
+ },
331
+ input: {
332
+ prior: priorPost,
333
+ actorId: actor.principalId,
334
+ // Mirrors the forward write's own delegatedBy* convention just above.
335
+ delegatedByWorkspaceId: actor.kind === "user" ? undefined : workspaceId,
336
+ delegatedById: actor.kind === "user" ? undefined : actor.principalId,
337
+ },
338
+ });
339
+ },
340
+ },
341
+ });
342
+ await extractAndStoreEmbedRefs(deps, workspaceId, { id: result.post.id, type: result.post.kind, bodyJson: result.post.bodyJson });
343
+ return result.post;
344
+ }
345
+ catch (err) {
346
+ if (deps.host.posts.isVersionConflict(err)) {
347
+ throw new WidgetVersionConflictError({ message: err.message, currentVersion: err.currentVersion });
348
+ }
349
+ if (deps.host.posts.isNotFound(err)) {
350
+ // Trash race between `loadEmbedHost`'s check and this write — same not-found shape either way.
351
+ throw new WidgetEmbedHostNotFoundError({ message: `host entry '${current.id}' was not found in workspace '${workspaceId}' (it must be the id of an existing, non-trashed post, page, or content entry)` });
352
+ }
353
+ if (err instanceof ForbiddenError) {
354
+ // Reformatted to match `authorize-helper.ts`'s `requireWidgetPermission` wording (not
355
+ // `executeCommand`'s own message verbatim) so `widgetForbiddenToResponse`'s regex extracts
356
+ // `permission`/`reason` identically regardless of which of the two gates rejected the caller.
357
+ throw new WidgetForbiddenError({ message: `principal '${actor.principalId}' lacks permission 'content.write' (${err.reason})` });
358
+ }
359
+ throw err;
360
+ }
361
+ }
362
+ /** REQ-44/45: inserts a new `widgetEmbed` node referencing `widgetEntryId`, appended to the host
363
+ * entry's body — one atomic, version-guarded write through the same chokepoint the live editor's
364
+ * Save uses. Returns the newly-minted `placementId` so the caller (an agent tool, or the
365
+ * server-side embed-mutation routes) can address this exact placement afterward. */
366
+ export async function insertWidgetEmbed(required, _optional = {}) {
367
+ const { deps, input } = required;
368
+ await requireWidgetPermission({
369
+ authorize: deps.authorize,
370
+ actor: input.actor,
371
+ workspaceId: input.workspaceId,
372
+ permission: "widgets.place",
373
+ });
374
+ return withEntryLock({ key: `${input.workspaceId}::${input.hostEntryId}`, fn: async () => {
375
+ const host = await loadEmbedHost(deps, input.workspaceId, input.hostEntryId);
376
+ await assertEmbedTargetIsLiveWidget(deps, input.workspaceId, input.widgetEntryId);
377
+ const placementId = deps.ids.newId();
378
+ const nextBodyJson = appendEmbed(host.record.bodyJson, placementId, input.widgetEntryId);
379
+ assertGuardrails(deps, hostBodyType(host), nextBodyJson);
380
+ const entry = host.kind === "entry"
381
+ ? await writeHostBody(deps, input.workspaceId, input.actor, host.record, input.baseVersion, nextBodyJson)
382
+ : await writePostHostBody(deps, input.workspaceId, input.actor, host.record, input.baseVersion, nextBodyJson);
383
+ return { entry, placementId };
384
+ } });
385
+ }
386
+ /** REQ-44/45: removes the `widgetEmbed` node matching `placementId` from the host entry's body —
387
+ * the placement itself is removed (the widget instance it referenced is untouched). */
388
+ export async function removeWidgetEmbed(required, _optional = {}) {
389
+ const { deps, input } = required;
390
+ await requireWidgetPermission({
391
+ authorize: deps.authorize,
392
+ actor: input.actor,
393
+ workspaceId: input.workspaceId,
394
+ permission: "widgets.place",
395
+ });
396
+ return withEntryLock({ key: `${input.workspaceId}::${input.hostEntryId}`, fn: async () => {
397
+ const host = await loadEmbedHost(deps, input.workspaceId, input.hostEntryId);
398
+ if (!containsEmbedWithPlacementId({ node: host.record.bodyJson, placementId: input.placementId })) {
399
+ // C4d fix: `removeEmbedByPlacementId` is a no-op filter — without this check a stale/bogus
400
+ // placementId fell through to `writeHostBody` unchanged and reported success while writing
401
+ // nothing.
402
+ throw new WidgetEmbedPlacementNotFoundError({ message: `no widget embed with placementId '${input.placementId}' was found in host '${input.hostEntryId}'` });
403
+ }
404
+ const nextBodyJson = removeEmbedByPlacementId({ node: host.record.bodyJson, placementId: input.placementId });
405
+ assertGuardrails(deps, hostBodyType(host), nextBodyJson);
406
+ const entry = host.kind === "entry"
407
+ ? await writeHostBody(deps, input.workspaceId, input.actor, host.record, input.baseVersion, nextBodyJson)
408
+ : await writePostHostBody(deps, input.workspaceId, input.actor, host.record, input.baseVersion, nextBodyJson);
409
+ return { entry };
410
+ } });
411
+ }
412
+ export class WidgetEmbedReorderCountMismatchError extends Error {
413
+ expectedCount;
414
+ actualCount;
415
+ constructor({ message, expectedCount, actualCount }, _optional = {}) {
416
+ super(message);
417
+ this.expectedCount = expectedCount;
418
+ this.actualCount = actualCount;
419
+ this.name = "WidgetEmbedReorderCountMismatchError";
420
+ }
421
+ }
422
+ /** REQ-44/45: reassigns which widget occupies which existing embed slot, in document order — the
423
+ * document's own shape (slot count/position, surrounding content) is unchanged. Rejects a count
424
+ * mismatch before writing anything (the caller must supply exactly one id per existing slot). */
425
+ export async function reorderWidgetEmbeds(required, _optional = {}) {
426
+ const { deps, input } = required;
427
+ await requireWidgetPermission({
428
+ authorize: deps.authorize,
429
+ actor: input.actor,
430
+ workspaceId: input.workspaceId,
431
+ permission: "widgets.place",
432
+ });
433
+ return withEntryLock({ key: `${input.workspaceId}::${input.hostEntryId}`, fn: async () => {
434
+ const host = await loadEmbedHost(deps, input.workspaceId, input.hostEntryId);
435
+ const existing = [];
436
+ collectEmbeds(host.record.bodyJson, existing);
437
+ if (existing.length !== input.orderedWidgetEntryIds.length) {
438
+ throw new WidgetEmbedReorderCountMismatchError({ message: `reorder must supply exactly one widgetEntryId per existing embed slot (${existing.length} present, ${input.orderedWidgetEntryIds.length} supplied)`, expectedCount: existing.length, actualCount: input.orderedWidgetEntryIds.length });
439
+ }
440
+ // Round-2 external-audit fix (2026-07-21, codex WIDGETS-R2-001/blocker): reorder assigns NEW
441
+ // widgetEntryId values to slots (see reorderEmbedSlots below) exactly like insertWidgetEmbed
442
+ // does, but never got the same REQ-16/17 target-existence/liveness/type check — an authorized
443
+ // widgets.place caller could persist a nonexistent, wrong-type, trashed, or cross-workspace id
444
+ // (codex reproduced both a numeric id and a dangling entry_refs row for "does-not-exist").
445
+ // Every distinct replacement target is validated before the host document or entry_refs change.
446
+ for (const widgetEntryId of new Set(input.orderedWidgetEntryIds)) {
447
+ if (typeof widgetEntryId !== "string" || widgetEntryId.length === 0) {
448
+ throw new WidgetInstanceNotFoundError({ message: "reorder contains an invalid widgetEntryId" });
449
+ }
450
+ await assertEmbedTargetIsLiveWidget(deps, input.workspaceId, widgetEntryId);
451
+ }
452
+ const nextBodyJson = reorderEmbedSlots({ node: host.record.bodyJson, newIds: () => deps.ids.newId(), orderedWidgetEntryIds: input.orderedWidgetEntryIds });
453
+ assertGuardrails(deps, hostBodyType(host), nextBodyJson);
454
+ const entry = host.kind === "entry"
455
+ ? await writeHostBody(deps, input.workspaceId, input.actor, host.record, input.baseVersion, nextBodyJson)
456
+ : await writePostHostBody(deps, input.workspaceId, input.actor, host.record, input.baseVersion, nextBodyJson);
457
+ return { entry };
458
+ } });
459
+ }
460
+ //# sourceMappingURL=embed-service.js.map
@@ -0,0 +1,23 @@
1
+ import type { WidgetEmbedNode } from "./types.js";
2
+ export interface ValidateWidgetEmbedMutationInput {
3
+ /** The content type of the entry the mutation would apply to (e.g. `page`, `post`, `widget`). */
4
+ readonly hostEntryType: string;
5
+ /** Every `widgetEmbed` node the resulting `bodyJson` would contain after the mutation applies. */
6
+ readonly resultingEmbeds: readonly WidgetEmbedNode[];
7
+ /** Configured per-document maximum (feature.spec.md REQ-20 — policy-bounded, not hardcoded). */
8
+ readonly maxEmbedsPerDocument: number;
9
+ }
10
+ export type ValidateWidgetEmbedMutationResult = {
11
+ readonly valid: true;
12
+ } | {
13
+ readonly valid: false;
14
+ readonly reason: "recursion" | "count-exceeded";
15
+ };
16
+ /**
17
+ * Rejects a `widgetEmbed` mutation that would (a) place any `widgetEmbed` node
18
+ * inside a `widget`-type entry's own `bodyJson` (REQ-19, INV-04 — no
19
+ * widget-in-widget recursion, regardless of nesting depth), or (b) exceed
20
+ * `maxEmbedsPerDocument` (REQ-20).
21
+ */
22
+ export declare function validateWidgetEmbedMutation(input: ValidateWidgetEmbedMutationInput, _optional?: Record<string, never>): ValidateWidgetEmbedMutationResult;
23
+ //# sourceMappingURL=embed-validation.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file `validateWidgetEmbedMutation` — the shared `widgetEmbed` guardrail validator
3
+ * (SPEC-043 REQ-19/20, INV-04; ADR-047 Debate Fold-In Amendment 5/6).
4
+ *
5
+ * Purpose:
6
+ * A pure decision function, no I/O. This is the ONE validator both the live-editor mutation path
7
+ * (ADR-016 §1 CopilotKit frontend action, via the existing change-set review layer) and the
8
+ * server-side/AI mutation path (`embed-service.ts`'s `insertWidgetEmbed` et al., Amendment 6) must
9
+ * call — enforcement lives here, not duplicated at each call site, so the two paths can never
10
+ * silently diverge (the exact gap Amendment 6 exists to close).
11
+ *
12
+ * Architectural role:
13
+ * TDD-certified implementation (SPEC-043 / implementation outline C-008). Signature and JSDoc are
14
+ * design-frozen; `validateWidgetEmbedMutation()` is a pure decision function — no I/O — rejecting a
15
+ * mutation that would place a `widgetEmbed` inside a widget's own body (REQ-19/INV-04) or exceed
16
+ * `maxEmbedsPerDocument` (REQ-20). Verified against `__tests__/unit/embed-validation.unit.test.ts`.
17
+ */
18
+ import { WIDGET_CONTENT_TYPE } from "./types.js";
19
+ /**
20
+ * Rejects a `widgetEmbed` mutation that would (a) place any `widgetEmbed` node
21
+ * inside a `widget`-type entry's own `bodyJson` (REQ-19, INV-04 — no
22
+ * widget-in-widget recursion, regardless of nesting depth), or (b) exceed
23
+ * `maxEmbedsPerDocument` (REQ-20).
24
+ */
25
+ export function validateWidgetEmbedMutation(input, _optional = {}) {
26
+ // REQ-19/INV-04: a widget instance's own bodyJson must never carry a widgetEmbed node at rest,
27
+ // regardless of nesting depth — the caller passes the flattened resultingEmbeds list, so depth
28
+ // is already erased; only "host is a widget, and at least one embed would result" matters.
29
+ if (input.hostEntryType === WIDGET_CONTENT_TYPE && input.resultingEmbeds.length > 0) {
30
+ return { valid: false, reason: "recursion" };
31
+ }
32
+ // REQ-20: a configured, policy-bounded per-document embed-count clamp.
33
+ if (input.resultingEmbeds.length > input.maxEmbedsPerDocument) {
34
+ return { valid: false, reason: "count-exceeded" };
35
+ }
36
+ return { valid: true };
37
+ }
38
+ //# sourceMappingURL=embed-validation.js.map