@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.
- package/CHANGELOG.md +11 -0
- package/README.md +14 -8
- package/dist/comments/errors.d.ts +23 -0
- package/dist/comments/errors.js +25 -0
- package/dist/comments/express/index.d.ts +2 -0
- package/dist/comments/express/index.js +2 -0
- package/dist/comments/express/submit-handler.d.ts +30 -0
- package/dist/comments/express/submit-handler.js +85 -0
- package/dist/comments/hooks.d.ts +39 -0
- package/dist/comments/hooks.js +41 -0
- package/dist/comments/index.d.ts +11 -0
- package/dist/comments/index.js +11 -0
- package/dist/comments/ingress.d.ts +57 -0
- package/dist/comments/ingress.js +173 -0
- package/dist/comments/module.d.ts +94 -0
- package/dist/comments/module.js +73 -0
- package/dist/comments/ports.d.ts +184 -0
- package/dist/comments/ports.js +2 -0
- package/dist/comments/repo.memory.d.ts +70 -0
- package/dist/comments/repo.memory.js +120 -0
- package/dist/comments/sanitize.d.ts +15 -0
- package/dist/comments/sanitize.js +31 -0
- package/dist/comments/spam.heuristic.d.ts +8 -0
- package/dist/comments/spam.heuristic.js +54 -0
- package/dist/comments/sql/index.d.ts +3 -0
- package/dist/comments/sql/index.js +3 -0
- package/dist/comments/sql/repo.d.ts +77 -0
- package/dist/comments/sql/repo.js +190 -0
- package/dist/comments/sql/repo.rows.d.ts +49 -0
- package/dist/comments/sql/repo.rows.js +71 -0
- package/dist/comments/types.d.ts +187 -0
- package/dist/comments/types.js +39 -0
- package/dist/comments/write-service.d.ts +121 -0
- package/dist/comments/write-service.js +144 -0
- package/dist/core/commands/command.d.ts +2 -9
- package/dist/core/commands/command.js +2 -9
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +1 -0
- package/dist/core/keyset-cursor.d.ts +25 -0
- package/dist/core/keyset-cursor.js +20 -0
- package/dist/core/ports.d.ts +30 -0
- package/dist/core/ports.js +0 -1
- package/dist/core/tools/args.d.ts +1 -46
- package/dist/core/tools/args.js +1 -44
- package/dist/media/index.d.ts +0 -3
- package/dist/media/index.js +1 -4
- package/dist/media/node.d.ts +5 -0
- package/dist/media/node.js +6 -0
- package/dist/redirects/capture.d.ts +80 -0
- package/dist/redirects/capture.js +66 -0
- package/dist/redirects/express/index.d.ts +27 -0
- package/dist/redirects/express/index.js +40 -0
- package/dist/redirects/hit-sink.d.ts +56 -0
- package/dist/redirects/hit-sink.js +44 -0
- package/dist/redirects/host-config/index.d.ts +40 -0
- package/dist/redirects/host-config/index.js +145 -0
- package/dist/redirects/index.d.ts +14 -0
- package/dist/redirects/index.js +14 -0
- package/dist/redirects/matcher.d.ts +58 -0
- package/dist/redirects/matcher.js +144 -0
- package/dist/redirects/order.d.ts +30 -0
- package/dist/redirects/order.js +26 -0
- package/dist/redirects/ports.d.ts +242 -0
- package/dist/redirects/ports.internal.d.ts +57 -0
- package/dist/redirects/ports.internal.js +14 -0
- package/dist/redirects/ports.js +2 -0
- package/dist/redirects/redirects.d.ts +226 -0
- package/dist/redirects/redirects.js +590 -0
- package/dist/redirects/referrer-alias.d.ts +31 -0
- package/dist/redirects/referrer-alias.js +33 -0
- package/dist/redirects/repo.memory.d.ts +51 -0
- package/dist/redirects/repo.memory.js +98 -0
- package/dist/redirects/reserved-destination.d.ts +58 -0
- package/dist/redirects/reserved-destination.js +59 -0
- package/dist/redirects/resolver.d.ts +100 -0
- package/dist/redirects/resolver.js +87 -0
- package/dist/redirects/spa/index.d.ts +30 -0
- package/dist/redirects/spa/index.js +27 -0
- package/dist/redirects/sql/index.d.ts +4 -0
- package/dist/redirects/sql/index.js +4 -0
- package/dist/redirects/sql/repo.d.ts +82 -0
- package/dist/redirects/sql/repo.js +143 -0
- package/dist/redirects/sql/repo.rows.d.ts +49 -0
- package/dist/redirects/sql/repo.rows.js +79 -0
- package/dist/redirects/sql/row-types.d.ts +34 -0
- package/dist/redirects/sql/row-types.js +2 -0
- package/dist/redirects/target-gate.d.ts +30 -0
- package/dist/redirects/target-gate.js +85 -0
- package/dist/redirects/types.d.ts +213 -0
- package/dist/redirects/types.js +42 -0
- package/dist/seo/absolute-url.d.ts +58 -0
- package/dist/seo/absolute-url.js +117 -0
- package/dist/seo/dom/index.d.ts +14 -0
- package/dist/seo/dom/index.js +52 -0
- package/dist/seo/errors.d.ts +35 -0
- package/dist/seo/errors.js +35 -0
- package/dist/seo/feed.d.ts +49 -0
- package/dist/seo/feed.js +61 -0
- package/dist/seo/index.d.ts +13 -0
- package/dist/seo/index.js +13 -0
- package/dist/seo/media.d.ts +57 -0
- package/dist/seo/media.js +134 -0
- package/dist/seo/page-head.d.ts +18 -0
- package/dist/seo/page-head.js +114 -0
- package/dist/seo/ports.d.ts +221 -0
- package/dist/seo/ports.js +27 -0
- package/dist/seo/seo.d.ts +73 -0
- package/dist/seo/seo.js +230 -0
- package/dist/seo/sitemap.d.ts +139 -0
- package/dist/seo/sitemap.js +217 -0
- package/dist/seo/types.d.ts +278 -0
- package/dist/seo/types.js +2 -0
- package/dist/seo/write-service.d.ts +79 -0
- package/dist/seo/write-service.js +273 -0
- package/dist/seo/xml-escape.d.ts +16 -0
- package/dist/seo/xml-escape.js +19 -0
- package/dist/settings/express/cms-adapter.d.ts +20 -0
- package/dist/settings/express/cms-adapter.js +124 -0
- package/dist/settings/express/contracts.d.ts +135 -0
- package/dist/settings/express/contracts.js +2 -0
- package/dist/settings/express/http.d.ts +10 -0
- package/dist/settings/express/http.js +211 -0
- package/dist/settings/express/index.d.ts +5 -0
- package/dist/settings/express/index.js +5 -0
- package/dist/settings/express/shared.d.ts +68 -0
- package/dist/settings/express/shared.js +54 -0
- package/dist/settings/express/stream.d.ts +24 -0
- package/dist/settings/express/stream.js +147 -0
- package/dist/settings/index.d.ts +3 -38
- package/dist/settings/index.js +3 -35
- package/dist/taxonomy/write-service.d.ts +2 -30
- package/dist/widgets/authorize-helper.d.ts +42 -0
- package/dist/widgets/authorize-helper.js +16 -0
- package/dist/widgets/concurrency.d.ts +12 -0
- package/dist/widgets/concurrency.js +44 -0
- package/dist/widgets/config-validation.d.ts +34 -0
- package/dist/widgets/config-validation.js +125 -0
- package/dist/widgets/embed-service.d.ts +133 -0
- package/dist/widgets/embed-service.js +460 -0
- package/dist/widgets/embed-validation.d.ts +23 -0
- package/dist/widgets/embed-validation.js +38 -0
- package/dist/widgets/entry-payload.d.ts +103 -0
- package/dist/widgets/entry-payload.js +160 -0
- package/dist/widgets/errors.d.ts +136 -0
- package/dist/widgets/errors.js +136 -0
- package/dist/widgets/host-ports.d.ts +149 -0
- package/dist/widgets/host-ports.js +2 -0
- package/dist/widgets/html/html-embeds.d.ts +247 -0
- package/dist/widgets/html/html-embeds.js +408 -0
- package/dist/widgets/html/index.d.ts +5 -0
- package/dist/widgets/html/index.js +4 -0
- package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
- package/dist/widgets/html/legacy-widget-markers.js +45 -0
- package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
- package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +19 -0
- package/dist/widgets/markers/html-attributes.d.ts +98 -0
- package/dist/widgets/markers/html-attributes.js +216 -0
- package/dist/widgets/markers/index.d.ts +3 -0
- package/dist/widgets/markers/index.js +3 -0
- package/dist/widgets/markers/marker.d.ts +496 -0
- package/dist/widgets/markers/marker.js +780 -0
- package/dist/widgets/page-embed-types.d.ts +42 -0
- package/dist/widgets/page-embed-types.js +42 -0
- package/dist/widgets/ports.d.ts +142 -0
- package/dist/widgets/ports.js +2 -0
- package/dist/widgets/read-service.d.ts +81 -0
- package/dist/widgets/read-service.js +79 -0
- package/dist/widgets/region-area-service.d.ts +110 -0
- package/dist/widgets/region-area-service.js +180 -0
- package/dist/widgets/registry.d.ts +217 -0
- package/dist/widgets/registry.js +172 -0
- package/dist/widgets/repo.memory.d.ts +41 -0
- package/dist/widgets/repo.memory.js +48 -0
- package/dist/widgets/resolver-service.d.ts +209 -0
- package/dist/widgets/resolver-service.js +909 -0
- package/dist/widgets/resolvers/contact-form.d.ts +26 -0
- package/dist/widgets/resolvers/contact-form.js +54 -0
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
- package/dist/widgets/resolvers/index.d.ts +65 -0
- package/dist/widgets/resolvers/index.js +155 -0
- package/dist/widgets/resolvers/menu.d.ts +42 -0
- package/dist/widgets/resolvers/menu.js +43 -0
- package/dist/widgets/resolvers/ports.d.ts +82 -0
- package/dist/widgets/resolvers/ports.js +2 -0
- package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
- package/dist/widgets/resolvers/recent-entries.js +165 -0
- package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
- package/dist/widgets/resolvers/taxonomy.js +34 -0
- package/dist/widgets/sql/index.d.ts +3 -0
- package/dist/widgets/sql/index.js +3 -0
- package/dist/widgets/sql/repo.d.ts +41 -0
- package/dist/widgets/sql/repo.js +82 -0
- package/dist/widgets/sql/tables.d.ts +13 -0
- package/dist/widgets/sql/tables.js +2 -0
- package/dist/widgets/types.d.ts +213 -0
- package/dist/widgets/types.js +17 -0
- package/dist/widgets/where-used.d.ts +41 -0
- package/dist/widgets/where-used.js +11 -0
- package/dist/widgets/write-service.d.ts +204 -0
- package/dist/widgets/write-service.js +332 -0
- package/dist/workspace/delete.d.ts +1 -1
- 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
|