@beyondwork/docx-react-component 1.0.40 → 1.0.42
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/package.json +13 -1
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +347 -4
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +568 -1
- package/src/index.ts +118 -1
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +271 -0
- package/src/io/ooxml/workflow-payload.ts +146 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +280 -93
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +122 -12
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +230 -34
- package/src/runtime/layout/public-facet.ts +185 -13
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +9 -0
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +587 -0
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui/editor-shell-view.tsx +11 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +32 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CommentAttachment,
|
|
3
|
+
CommentAudience,
|
|
4
|
+
CommentBody,
|
|
5
|
+
CommentLabel,
|
|
6
|
+
CommentMention,
|
|
7
|
+
CommentPresentation,
|
|
8
|
+
CommentPresentationReply,
|
|
9
|
+
CommentPresentationSnapshot,
|
|
10
|
+
CommentReaction,
|
|
11
|
+
} from "../../api/comment-presentation-types.ts";
|
|
12
|
+
import {
|
|
13
|
+
attrNumber,
|
|
14
|
+
childrenOf,
|
|
15
|
+
firstChild,
|
|
16
|
+
parseBwXml,
|
|
17
|
+
renderCdata,
|
|
18
|
+
renderElement,
|
|
19
|
+
renderText,
|
|
20
|
+
stripNs,
|
|
21
|
+
textOf,
|
|
22
|
+
} from "./bw-xml.ts";
|
|
23
|
+
|
|
24
|
+
const NS_URI = "urn:beyondwork:workflow-payload:1";
|
|
25
|
+
|
|
26
|
+
const AUDIENCE_VOCAB = new Set<CommentAudience>([
|
|
27
|
+
"internal",
|
|
28
|
+
"external",
|
|
29
|
+
"shared",
|
|
30
|
+
]);
|
|
31
|
+
const ATTACHMENT_KIND_VOCAB = new Set<CommentAttachment["kind"]>([
|
|
32
|
+
"image",
|
|
33
|
+
"file",
|
|
34
|
+
"link",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export function buildCommentPresentationXml(
|
|
38
|
+
snap: CommentPresentationSnapshot,
|
|
39
|
+
): string {
|
|
40
|
+
const entries = snap.entries.map(buildComment).join("");
|
|
41
|
+
return renderElement(
|
|
42
|
+
"bw:commentPresentation",
|
|
43
|
+
{
|
|
44
|
+
"xmlns:bw": NS_URI,
|
|
45
|
+
schemaVersion: String(snap.schemaVersion),
|
|
46
|
+
},
|
|
47
|
+
[entries],
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildComment(entry: CommentPresentation): string {
|
|
52
|
+
const body = buildBody("bw:body", entry.body);
|
|
53
|
+
const replies = entry.replies.length
|
|
54
|
+
? renderElement("bw:replies", {}, [
|
|
55
|
+
entry.replies.map(buildReply).join(""),
|
|
56
|
+
])
|
|
57
|
+
: "";
|
|
58
|
+
const mentions = entry.mentions.length
|
|
59
|
+
? renderElement("bw:mentions", {}, [
|
|
60
|
+
entry.mentions
|
|
61
|
+
.map((m) =>
|
|
62
|
+
renderElement("bw:mention", {
|
|
63
|
+
userId: m.userId,
|
|
64
|
+
displayName: m.displayName,
|
|
65
|
+
offsetInBody: String(m.offsetInBody),
|
|
66
|
+
entryId: m.entryId,
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
.join(""),
|
|
70
|
+
])
|
|
71
|
+
: "";
|
|
72
|
+
const attachments = entry.attachments.length
|
|
73
|
+
? renderElement("bw:attachments", {}, [
|
|
74
|
+
entry.attachments.map(buildAttachment).join(""),
|
|
75
|
+
])
|
|
76
|
+
: "";
|
|
77
|
+
const reactions = entry.reactions.length
|
|
78
|
+
? renderElement("bw:reactions", {}, [
|
|
79
|
+
entry.reactions
|
|
80
|
+
.map((r) =>
|
|
81
|
+
renderElement("bw:reaction", {
|
|
82
|
+
emoji: r.emoji,
|
|
83
|
+
authorId: r.authorId,
|
|
84
|
+
reactedAt: r.reactedAt,
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
.join(""),
|
|
88
|
+
])
|
|
89
|
+
: "";
|
|
90
|
+
const labels = entry.labels.length
|
|
91
|
+
? renderElement("bw:labels", {}, [
|
|
92
|
+
entry.labels
|
|
93
|
+
.map((l) =>
|
|
94
|
+
renderElement("bw:label", { key: l.key, color: l.color }, [
|
|
95
|
+
renderText(l.text),
|
|
96
|
+
]),
|
|
97
|
+
)
|
|
98
|
+
.join(""),
|
|
99
|
+
])
|
|
100
|
+
: "";
|
|
101
|
+
|
|
102
|
+
return renderElement(
|
|
103
|
+
"bw:comment",
|
|
104
|
+
{
|
|
105
|
+
commentId: entry.commentId,
|
|
106
|
+
audience: entry.audience,
|
|
107
|
+
},
|
|
108
|
+
[body, replies, mentions, attachments, reactions, labels],
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildReply(reply: CommentPresentationReply): string {
|
|
113
|
+
return renderElement("bw:reply", { entryId: reply.entryId }, [
|
|
114
|
+
buildBody("bw:body", reply.body),
|
|
115
|
+
]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildBody(elementName: string, body: CommentBody): string {
|
|
119
|
+
const needsCdata = /[<>&]/.test(body.text);
|
|
120
|
+
return renderElement(
|
|
121
|
+
elementName,
|
|
122
|
+
{
|
|
123
|
+
format: body.format,
|
|
124
|
+
digest: body.digest,
|
|
125
|
+
sanitized: body.sanitized ? "true" : undefined,
|
|
126
|
+
},
|
|
127
|
+
[needsCdata ? renderCdata(body.text) : renderText(body.text)],
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildAttachment(a: CommentAttachment): string {
|
|
132
|
+
return renderElement("bw:attachment", {
|
|
133
|
+
id: a.id,
|
|
134
|
+
kind: a.kind,
|
|
135
|
+
displayName: a.displayName,
|
|
136
|
+
mimeType: a.mimeType,
|
|
137
|
+
relationshipId: a.relationshipId,
|
|
138
|
+
href: a.href,
|
|
139
|
+
byteLength: a.byteLength !== undefined ? String(a.byteLength) : undefined,
|
|
140
|
+
width: a.width !== undefined ? String(a.width) : undefined,
|
|
141
|
+
height: a.height !== undefined ? String(a.height) : undefined,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function parseCommentPresentationXml(
|
|
146
|
+
xml: string,
|
|
147
|
+
): CommentPresentationSnapshot {
|
|
148
|
+
const root = parseBwXml(xml);
|
|
149
|
+
if (stripNs(root.name) !== "commentPresentation") {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`parseCommentPresentationXml: expected <bw:commentPresentation>, got <${root.name}>`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const schemaVersion = attrNumber(root.attributes["schemaVersion"]) ?? 1;
|
|
155
|
+
if (schemaVersion !== 1) {
|
|
156
|
+
return { schemaVersion: 1, entries: [] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const entries: CommentPresentation[] = [];
|
|
160
|
+
for (const commentEl of childrenOf(root, "comment")) {
|
|
161
|
+
const commentId = commentEl.attributes["commentId"] ?? "";
|
|
162
|
+
if (!commentId) continue;
|
|
163
|
+
const audienceAttr = commentEl.attributes["audience"] as CommentAudience;
|
|
164
|
+
const audience: CommentAudience = AUDIENCE_VOCAB.has(audienceAttr)
|
|
165
|
+
? audienceAttr
|
|
166
|
+
: "internal"; // fail-closed per schema
|
|
167
|
+
const bodyEl = firstChild(commentEl, "body");
|
|
168
|
+
const body: CommentBody = bodyEl ? parseBody(bodyEl) : emptyBody();
|
|
169
|
+
|
|
170
|
+
entries.push({
|
|
171
|
+
commentId,
|
|
172
|
+
audience,
|
|
173
|
+
body,
|
|
174
|
+
replies: parseReplies(commentEl),
|
|
175
|
+
mentions: parseMentions(commentEl),
|
|
176
|
+
attachments: parseAttachments(commentEl),
|
|
177
|
+
reactions: parseReactions(commentEl),
|
|
178
|
+
labels: parseLabels(commentEl),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { schemaVersion: 1, entries };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseBody(el: ReturnType<typeof parseBwXml>): CommentBody {
|
|
186
|
+
const body: CommentBody = {
|
|
187
|
+
format: "markdown",
|
|
188
|
+
text: textOf(el),
|
|
189
|
+
digest: el.attributes["digest"] ?? "",
|
|
190
|
+
};
|
|
191
|
+
if (el.attributes["sanitized"] === "true") body.sanitized = true;
|
|
192
|
+
return body;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function emptyBody(): CommentBody {
|
|
196
|
+
return {
|
|
197
|
+
format: "markdown",
|
|
198
|
+
text: "",
|
|
199
|
+
digest: "",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseReplies(
|
|
204
|
+
commentEl: ReturnType<typeof parseBwXml>,
|
|
205
|
+
): CommentPresentationReply[] {
|
|
206
|
+
const container = firstChild(commentEl, "replies");
|
|
207
|
+
if (!container) return [];
|
|
208
|
+
const out: CommentPresentationReply[] = [];
|
|
209
|
+
for (const el of childrenOf(container, "reply")) {
|
|
210
|
+
const entryId = el.attributes["entryId"];
|
|
211
|
+
if (!entryId) continue;
|
|
212
|
+
const bodyEl = firstChild(el, "body");
|
|
213
|
+
out.push({
|
|
214
|
+
entryId,
|
|
215
|
+
body: bodyEl ? parseBody(bodyEl) : emptyBody(),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function parseMentions(
|
|
222
|
+
commentEl: ReturnType<typeof parseBwXml>,
|
|
223
|
+
): CommentMention[] {
|
|
224
|
+
const container = firstChild(commentEl, "mentions");
|
|
225
|
+
if (!container) return [];
|
|
226
|
+
const out: CommentMention[] = [];
|
|
227
|
+
for (const el of childrenOf(container, "mention")) {
|
|
228
|
+
const userId = el.attributes["userId"];
|
|
229
|
+
const displayName = el.attributes["displayName"];
|
|
230
|
+
const offsetInBody = attrNumber(el.attributes["offsetInBody"]);
|
|
231
|
+
if (!userId || !displayName || offsetInBody === undefined) continue;
|
|
232
|
+
const mention: CommentMention = { userId, displayName, offsetInBody };
|
|
233
|
+
if (el.attributes["entryId"] !== undefined) {
|
|
234
|
+
mention.entryId = el.attributes["entryId"];
|
|
235
|
+
}
|
|
236
|
+
out.push(mention);
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parseAttachments(
|
|
242
|
+
commentEl: ReturnType<typeof parseBwXml>,
|
|
243
|
+
): CommentAttachment[] {
|
|
244
|
+
const container = firstChild(commentEl, "attachments");
|
|
245
|
+
if (!container) return [];
|
|
246
|
+
const out: CommentAttachment[] = [];
|
|
247
|
+
for (const el of childrenOf(container, "attachment")) {
|
|
248
|
+
const id = el.attributes["id"];
|
|
249
|
+
const kind = el.attributes["kind"] as CommentAttachment["kind"];
|
|
250
|
+
const displayName = el.attributes["displayName"];
|
|
251
|
+
if (!id || !displayName || !ATTACHMENT_KIND_VOCAB.has(kind)) continue;
|
|
252
|
+
const a: CommentAttachment = { id, kind, displayName };
|
|
253
|
+
if (el.attributes["mimeType"] !== undefined) {
|
|
254
|
+
a.mimeType = el.attributes["mimeType"];
|
|
255
|
+
}
|
|
256
|
+
if (el.attributes["relationshipId"] !== undefined) {
|
|
257
|
+
a.relationshipId = el.attributes["relationshipId"];
|
|
258
|
+
}
|
|
259
|
+
if (el.attributes["href"] !== undefined && isAllowedHref(el.attributes["href"])) {
|
|
260
|
+
a.href = el.attributes["href"];
|
|
261
|
+
}
|
|
262
|
+
const byteLength = attrNumber(el.attributes["byteLength"]);
|
|
263
|
+
if (byteLength !== undefined) a.byteLength = byteLength;
|
|
264
|
+
const width = attrNumber(el.attributes["width"]);
|
|
265
|
+
if (width !== undefined) a.width = width;
|
|
266
|
+
const height = attrNumber(el.attributes["height"]);
|
|
267
|
+
if (height !== undefined) a.height = height;
|
|
268
|
+
out.push(a);
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isAllowedHref(href: string): boolean {
|
|
274
|
+
return (
|
|
275
|
+
href.startsWith("http://") ||
|
|
276
|
+
href.startsWith("https://") ||
|
|
277
|
+
href.startsWith("mailto:")
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parseReactions(
|
|
282
|
+
commentEl: ReturnType<typeof parseBwXml>,
|
|
283
|
+
): CommentReaction[] {
|
|
284
|
+
const container = firstChild(commentEl, "reactions");
|
|
285
|
+
if (!container) return [];
|
|
286
|
+
const out: CommentReaction[] = [];
|
|
287
|
+
for (const el of childrenOf(container, "reaction")) {
|
|
288
|
+
const emoji = el.attributes["emoji"];
|
|
289
|
+
const authorId = el.attributes["authorId"];
|
|
290
|
+
const reactedAt = el.attributes["reactedAt"];
|
|
291
|
+
if (!emoji || !authorId || !reactedAt) continue;
|
|
292
|
+
out.push({ emoji, authorId, reactedAt });
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseLabels(
|
|
298
|
+
commentEl: ReturnType<typeof parseBwXml>,
|
|
299
|
+
): CommentLabel[] {
|
|
300
|
+
const container = firstChild(commentEl, "labels");
|
|
301
|
+
if (!container) return [];
|
|
302
|
+
const out: CommentLabel[] = [];
|
|
303
|
+
for (const el of childrenOf(container, "label")) {
|
|
304
|
+
const key = el.attributes["key"];
|
|
305
|
+
if (!key) continue;
|
|
306
|
+
const label: CommentLabel = { key, text: textOf(el) };
|
|
307
|
+
if (el.attributes["color"] !== undefined) label.color = el.attributes["color"];
|
|
308
|
+
out.push(label);
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ExternalCustody } from "../../api/external-custody-types.ts";
|
|
2
|
+
import {
|
|
3
|
+
attrNumber,
|
|
4
|
+
childrenOf,
|
|
5
|
+
parseBwXml,
|
|
6
|
+
renderElement,
|
|
7
|
+
stripNs,
|
|
8
|
+
} from "./bw-xml.ts";
|
|
9
|
+
|
|
10
|
+
const NS_URI = "urn:beyondwork:workflow-payload:1";
|
|
11
|
+
|
|
12
|
+
export function buildExternalCustodyXml(custody: ExternalCustody): string {
|
|
13
|
+
const stripped = renderElement("bw:strippedComments", {}, [
|
|
14
|
+
custody.strippedCommentIds
|
|
15
|
+
.map((id) => renderElement("bw:commentRef", { commentId: id }))
|
|
16
|
+
.join(""),
|
|
17
|
+
]);
|
|
18
|
+
const participants = custody.strippedParticipantIds.length
|
|
19
|
+
? renderElement("bw:strippedParticipants", {}, [
|
|
20
|
+
custody.strippedParticipantIds
|
|
21
|
+
.map((id) => renderElement("bw:userRef", { id }))
|
|
22
|
+
.join(""),
|
|
23
|
+
])
|
|
24
|
+
: "";
|
|
25
|
+
return renderElement(
|
|
26
|
+
"bw:externalCustody",
|
|
27
|
+
{
|
|
28
|
+
"xmlns:bw": NS_URI,
|
|
29
|
+
schemaVersion: String(custody.schemaVersion),
|
|
30
|
+
custodyId: custody.custodyId,
|
|
31
|
+
originDocumentId: custody.originDocumentId,
|
|
32
|
+
originPayloadId: custody.originPayloadId,
|
|
33
|
+
originContentHash: custody.originContentHash,
|
|
34
|
+
sentAt: custody.sentAt,
|
|
35
|
+
sentBy: custody.sentBy,
|
|
36
|
+
recipient: custody.recipient,
|
|
37
|
+
archiveRef: custody.archiveRef,
|
|
38
|
+
},
|
|
39
|
+
[stripped, participants],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseExternalCustodyXml(xml: string): ExternalCustody {
|
|
44
|
+
const root = parseBwXml(xml);
|
|
45
|
+
if (stripNs(root.name) !== "externalCustody") {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`parseExternalCustodyXml: expected <bw:externalCustody>, got <${root.name}>`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const schemaVersion = attrNumber(root.attributes["schemaVersion"]) ?? 1;
|
|
51
|
+
if (schemaVersion !== 1) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`parseExternalCustodyXml: unsupported schemaVersion=${schemaVersion}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const required = [
|
|
58
|
+
"custodyId",
|
|
59
|
+
"originDocumentId",
|
|
60
|
+
"originPayloadId",
|
|
61
|
+
"originContentHash",
|
|
62
|
+
"sentAt",
|
|
63
|
+
"sentBy",
|
|
64
|
+
"recipient",
|
|
65
|
+
"archiveRef",
|
|
66
|
+
] as const;
|
|
67
|
+
for (const key of required) {
|
|
68
|
+
if (!root.attributes[key]) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`parseExternalCustodyXml: missing required attribute ${key}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const strippedContainer = childrenOf(root, "strippedComments")[0];
|
|
76
|
+
const strippedCommentIds = strippedContainer
|
|
77
|
+
? childrenOf(strippedContainer, "commentRef")
|
|
78
|
+
.map((el) => el.attributes["commentId"])
|
|
79
|
+
.filter((id): id is string => Boolean(id))
|
|
80
|
+
: [];
|
|
81
|
+
|
|
82
|
+
const participantsContainer = childrenOf(root, "strippedParticipants")[0];
|
|
83
|
+
const strippedParticipantIds = participantsContainer
|
|
84
|
+
? childrenOf(participantsContainer, "userRef")
|
|
85
|
+
.map((el) => el.attributes["id"])
|
|
86
|
+
.filter((id): id is string => Boolean(id))
|
|
87
|
+
: [];
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
custodyId: root.attributes["custodyId"]!,
|
|
92
|
+
originDocumentId: root.attributes["originDocumentId"]!,
|
|
93
|
+
originPayloadId: root.attributes["originPayloadId"]!,
|
|
94
|
+
originContentHash: root.attributes["originContentHash"]!,
|
|
95
|
+
sentAt: root.attributes["sentAt"]!,
|
|
96
|
+
sentBy: root.attributes["sentBy"]!,
|
|
97
|
+
recipient: root.attributes["recipient"]!,
|
|
98
|
+
archiveRef: root.attributes["archiveRef"]!,
|
|
99
|
+
strippedCommentIds,
|
|
100
|
+
strippedParticipantIds,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthorKind,
|
|
3
|
+
Participant,
|
|
4
|
+
ParticipantRole,
|
|
5
|
+
ParticipantRoster,
|
|
6
|
+
} from "../../api/participants-types.ts";
|
|
7
|
+
import {
|
|
8
|
+
attrNumber,
|
|
9
|
+
childrenOf,
|
|
10
|
+
parseBwXml,
|
|
11
|
+
renderElement,
|
|
12
|
+
stripNs,
|
|
13
|
+
} from "./bw-xml.ts";
|
|
14
|
+
|
|
15
|
+
const NS_URI = "urn:beyondwork:workflow-payload:1";
|
|
16
|
+
|
|
17
|
+
const ROLE_VOCAB = new Set<ParticipantRole>([
|
|
18
|
+
"author",
|
|
19
|
+
"reviewer",
|
|
20
|
+
"observer",
|
|
21
|
+
]);
|
|
22
|
+
const KIND_VOCAB = new Set<AuthorKind>(["human", "agent", "system"]);
|
|
23
|
+
|
|
24
|
+
export function buildParticipantsXml(roster: ParticipantRoster): string {
|
|
25
|
+
const rows = roster.entries.map(buildRow).join("");
|
|
26
|
+
return renderElement(
|
|
27
|
+
"bw:participants",
|
|
28
|
+
{
|
|
29
|
+
"xmlns:bw": NS_URI,
|
|
30
|
+
schemaVersion: String(roster.schemaVersion),
|
|
31
|
+
},
|
|
32
|
+
[rows],
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildRow(p: Participant): string {
|
|
37
|
+
return renderElement("bw:participant", {
|
|
38
|
+
userId: p.userId,
|
|
39
|
+
email: p.email,
|
|
40
|
+
displayName: p.displayName,
|
|
41
|
+
collabIdentity: p.collabIdentity,
|
|
42
|
+
authorKind: p.authorKind,
|
|
43
|
+
role: p.role,
|
|
44
|
+
organization: p.organization,
|
|
45
|
+
avatarHref: p.avatarHref,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseParticipantsXml(xml: string): ParticipantRoster {
|
|
50
|
+
const root = parseBwXml(xml);
|
|
51
|
+
if (stripNs(root.name) !== "participants") {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`parseParticipantsXml: expected <bw:participants>, got <${root.name}>`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const schemaVersion = attrNumber(root.attributes["schemaVersion"]) ?? 1;
|
|
57
|
+
if (schemaVersion !== 1) {
|
|
58
|
+
return { schemaVersion: 1, entries: [] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const entries: Participant[] = [];
|
|
62
|
+
for (const el of childrenOf(root, "participant")) {
|
|
63
|
+
const userId = el.attributes["userId"];
|
|
64
|
+
const email = el.attributes["email"];
|
|
65
|
+
const displayName = el.attributes["displayName"];
|
|
66
|
+
const collabIdentity = el.attributes["collabIdentity"];
|
|
67
|
+
const authorKind = el.attributes["authorKind"] as AuthorKind;
|
|
68
|
+
if (
|
|
69
|
+
!userId ||
|
|
70
|
+
!email ||
|
|
71
|
+
!displayName ||
|
|
72
|
+
!collabIdentity ||
|
|
73
|
+
!KIND_VOCAB.has(authorKind)
|
|
74
|
+
) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const row: Participant = {
|
|
78
|
+
userId,
|
|
79
|
+
email: email.toLowerCase(),
|
|
80
|
+
displayName,
|
|
81
|
+
collabIdentity,
|
|
82
|
+
authorKind,
|
|
83
|
+
};
|
|
84
|
+
const role = el.attributes["role"] as ParticipantRole | undefined;
|
|
85
|
+
if (role !== undefined && ROLE_VOCAB.has(role)) row.role = role;
|
|
86
|
+
if (el.attributes["organization"] !== undefined) {
|
|
87
|
+
row.organization = el.attributes["organization"];
|
|
88
|
+
}
|
|
89
|
+
const avatarHref = el.attributes["avatarHref"];
|
|
90
|
+
if (avatarHref !== undefined && avatarHref.startsWith("https://")) {
|
|
91
|
+
row.avatarHref = avatarHref;
|
|
92
|
+
}
|
|
93
|
+
entries.push(row);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { schemaVersion: 1, entries };
|
|
97
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { canonicalizePayload } from "./canonicalize-payload.ts";
|
|
2
|
+
|
|
3
|
+
export type SignatureAlgorithm = "hmac-sha256" | "ed25519";
|
|
4
|
+
|
|
5
|
+
export interface PayloadSignature {
|
|
6
|
+
algorithm: SignatureAlgorithm;
|
|
7
|
+
keyId: string;
|
|
8
|
+
signedAt: string;
|
|
9
|
+
canonicalizationProfile: "bw-canon/1";
|
|
10
|
+
value: string; // base64
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PayloadSigner {
|
|
14
|
+
keyId: string;
|
|
15
|
+
algorithm: SignatureAlgorithm;
|
|
16
|
+
sign(canonicalBytes: Uint8Array): Promise<Uint8Array>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PayloadVerifier {
|
|
20
|
+
verify(
|
|
21
|
+
canonicalBytes: Uint8Array,
|
|
22
|
+
signature: PayloadSignature,
|
|
23
|
+
): Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function signWorkflowPayloadXml(
|
|
27
|
+
xml: string,
|
|
28
|
+
signer: PayloadSigner,
|
|
29
|
+
now: string = new Date().toISOString(),
|
|
30
|
+
): Promise<PayloadSignature> {
|
|
31
|
+
const bytes = canonicalizePayload(xml);
|
|
32
|
+
const sig = await signer.sign(bytes);
|
|
33
|
+
return {
|
|
34
|
+
algorithm: signer.algorithm,
|
|
35
|
+
keyId: signer.keyId,
|
|
36
|
+
signedAt: now,
|
|
37
|
+
canonicalizationProfile: "bw-canon/1",
|
|
38
|
+
value: base64Encode(sig),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function verifyWorkflowPayloadXml(
|
|
43
|
+
xml: string,
|
|
44
|
+
sig: PayloadSignature,
|
|
45
|
+
verifier: PayloadVerifier,
|
|
46
|
+
): Promise<boolean> {
|
|
47
|
+
if (sig.canonicalizationProfile !== "bw-canon/1") return false;
|
|
48
|
+
const bytes = canonicalizePayload(xml);
|
|
49
|
+
return verifier.verify(bytes, sig);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// HMAC-SHA256 helpers ----------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Builds a signer + verifier pair backed by WebCrypto HMAC-SHA256.
|
|
56
|
+
* Integration tests use this; production hosts should wire a real key store.
|
|
57
|
+
*/
|
|
58
|
+
export async function createHmacSigner(args: {
|
|
59
|
+
keyId: string;
|
|
60
|
+
secret: Uint8Array;
|
|
61
|
+
}): Promise<PayloadSigner> {
|
|
62
|
+
const key = await importHmacKey(args.secret, ["sign"]);
|
|
63
|
+
return {
|
|
64
|
+
keyId: args.keyId,
|
|
65
|
+
algorithm: "hmac-sha256",
|
|
66
|
+
async sign(bytes) {
|
|
67
|
+
const out = await globalThis.crypto.subtle.sign("HMAC", key, bytes as BufferSource);
|
|
68
|
+
return new Uint8Array(out);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function createHmacVerifier(
|
|
74
|
+
secret: Uint8Array,
|
|
75
|
+
): Promise<PayloadVerifier> {
|
|
76
|
+
const key = await importHmacKey(secret, ["verify"]);
|
|
77
|
+
return {
|
|
78
|
+
async verify(bytes, sig) {
|
|
79
|
+
if (sig.algorithm !== "hmac-sha256") return false;
|
|
80
|
+
const raw = base64Decode(sig.value);
|
|
81
|
+
return globalThis.crypto.subtle.verify("HMAC", key, raw as BufferSource, bytes as BufferSource);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function importHmacKey(
|
|
87
|
+
secret: Uint8Array,
|
|
88
|
+
usages: KeyUsage[],
|
|
89
|
+
): Promise<CryptoKey> {
|
|
90
|
+
return globalThis.crypto.subtle.importKey(
|
|
91
|
+
"raw",
|
|
92
|
+
secret as BufferSource,
|
|
93
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
94
|
+
false,
|
|
95
|
+
usages,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// base64 ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
function base64Encode(bytes: Uint8Array): string {
|
|
102
|
+
let binary = "";
|
|
103
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
104
|
+
return btoa(binary);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function base64Decode(b64: string): Uint8Array {
|
|
108
|
+
const binary = atob(b64);
|
|
109
|
+
const out = new Uint8Array(binary.length);
|
|
110
|
+
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
|
111
|
+
return out;
|
|
112
|
+
}
|