@beyondwork/docx-react-component 1.0.41 → 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.
Files changed (88) hide show
  1. package/package.json +13 -1
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. 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
+ }