@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,316 @@
1
+ /**
2
+ * Table row-boundary split math.
3
+ *
4
+ * Pre-P6 the paginated-layout-engine placed every table atomically — a
5
+ * table taller than the remaining page space pushed to the next page as
6
+ * a single block, regardless of how many rows could have fit. That was
7
+ * correctness-first (the `collectTableRowSlices` walk after pagination
8
+ * produces empty slice maps) but visually wrong for CCEP corpus tables
9
+ * like the SOW Milestones table that routinely exceed a page.
10
+ *
11
+ * P6.b is the **pure math** side of the real row-boundary split:
12
+ *
13
+ * - `measureTableRowHeights(...)` computes a per-row twip height
14
+ * vector via the same measurement path the existing
15
+ * `measureTableHeight` walk uses. Each row height already honors
16
+ * `heightRule`, `w:gridBefore` / `w:gridAfter` padding, and real
17
+ * per-cell widths via `resolveCellWidth`.
18
+ *
19
+ * - `findTableRowSplit(...)` takes the per-row heights plus the
20
+ * remaining page space and walks rows greedily until the next row
21
+ * would overflow. It honors two invariants:
22
+ * 1. `cantSplit` rows never straddle a page boundary. If row K
23
+ * is `cantSplit` and overflows, the split moves BEFORE row K
24
+ * (row K becomes the first row of the next page).
25
+ * 2. The header rows (`isHeader: true`) must repeat on every
26
+ * continuation page. The caller passes a
27
+ * `repeatedHeaderHeightTwips` reservation so `findTableRowSplit`
28
+ * accounts for the repeat on subsequent pages.
29
+ *
30
+ * P6.c will wire these helpers into `paginateSectionBlocksWithSplits`
31
+ * so a table overflowing a page emits a `TableRowSlice` pair
32
+ * (rows-on-prev-page, rows-on-next-page) and the node view prepends
33
+ * the header rows on continuation pages. The pure helpers here ship
34
+ * ahead of the wiring so the math is testable in isolation; the
35
+ * pagination engine stays byte-for-byte compatible until P6.c lands.
36
+ */
37
+
38
+ import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
39
+ import type { SurfaceBlockSnapshot } from "../../api/public-types";
40
+
41
+ // Re-export the resolveCellWidth helper from paginated-layout-engine so the
42
+ // math stays single-sourced. The engine module exposes it via the
43
+ // `__resolveCellWidth` test alias already.
44
+ import { __resolveCellWidth } from "./paginated-layout-engine.ts";
45
+
46
+ const MIN_ROW_HEIGHT_TWIPS = 240;
47
+ const TABLE_ROW_PADDING_TWIPS = 120;
48
+
49
+ export interface MeasureTableRowHeightsInput {
50
+ block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
51
+ columnWidth: number;
52
+ measurementProvider?: LayoutMeasurementProvider;
53
+ }
54
+
55
+ /**
56
+ * Per-row twip heights matching the engine's existing
57
+ * `measureTableHeight` walk. Returns an array of length
58
+ * `block.rows.length`; rows with `heightRule === "exact"` are reported
59
+ * at their explicit height, `"atLeast"` at `max(explicit, content)`,
60
+ * and `"auto"` at content height clamped to `MIN_ROW_HEIGHT_TWIPS`.
61
+ *
62
+ * `verticalMerge: "continue"` cells contribute 0 height (the origin
63
+ * cell's height covers the chain), matching pagination semantics.
64
+ */
65
+ export function measureTableRowHeights(
66
+ input: MeasureTableRowHeightsInput,
67
+ ): number[] {
68
+ const { block, columnWidth, measurementProvider } = input;
69
+ const heights: number[] = [];
70
+
71
+ const totalGridTwips = block.gridColumns.reduce((sum, w) => sum + w, 0);
72
+ const gridScale =
73
+ totalGridTwips > 0 && columnWidth > 0 ? columnWidth / totalGridTwips : 1;
74
+
75
+ for (const row of block.rows) {
76
+ const explicitHeight = row.height ?? 0;
77
+ const heightRule = row.heightRule ?? "auto";
78
+ const gridBefore = row.gridBefore ?? 0;
79
+
80
+ let contentHeight = MIN_ROW_HEIGHT_TWIPS;
81
+ let columnCursor = gridBefore;
82
+
83
+ for (const cell of row.cells) {
84
+ const span = Math.max(1, cell.colspan ?? 1);
85
+ const cellWidth = __resolveCellWidth(
86
+ block.gridColumns,
87
+ columnCursor,
88
+ span,
89
+ columnWidth,
90
+ gridScale,
91
+ );
92
+ columnCursor += span;
93
+
94
+ if (cell.verticalMerge === "continue") continue;
95
+
96
+ let cellContentHeight = 0;
97
+ for (const child of cell.content) {
98
+ if (child.kind === "paragraph") {
99
+ cellContentHeight += measureParagraphStandaloneHeight(
100
+ child,
101
+ cellWidth,
102
+ measurementProvider,
103
+ );
104
+ } else {
105
+ cellContentHeight += MIN_ROW_HEIGHT_TWIPS;
106
+ }
107
+ }
108
+ contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
109
+ }
110
+
111
+ let rowHeight: number;
112
+ if (heightRule === "exact" && explicitHeight > 0) {
113
+ rowHeight = explicitHeight;
114
+ } else if (heightRule === "atLeast" && explicitHeight > 0) {
115
+ rowHeight = Math.max(explicitHeight, contentHeight);
116
+ } else if (explicitHeight > 0) {
117
+ rowHeight = Math.max(explicitHeight, contentHeight);
118
+ } else {
119
+ rowHeight = contentHeight;
120
+ }
121
+
122
+ heights.push(Math.max(MIN_ROW_HEIGHT_TWIPS, rowHeight));
123
+ }
124
+
125
+ return heights;
126
+ }
127
+
128
+ /**
129
+ * Lightweight paragraph height estimate used when walking table cell
130
+ * content. Matches the engine's internal measureTableHeight math
131
+ * (MIN_ROW_HEIGHT_TWIPS per paragraph) rather than delegating back to
132
+ * `measureBlockHeight`, which would require threading the
133
+ * per-invocation cache through. Since cell content already re-runs
134
+ * through `measureBlockHeight` on the main pagination path, this
135
+ * helper is only used by `measureTableRowHeights` for the split-math
136
+ * preflight and does not affect canonical pagination.
137
+ */
138
+ function measureParagraphStandaloneHeight(
139
+ _block: SurfaceBlockSnapshot,
140
+ _columnWidth: number,
141
+ _provider: LayoutMeasurementProvider | undefined,
142
+ ): number {
143
+ // P6.b scope: the engine's existing table measurement treats every
144
+ // cell paragraph as MIN_ROW_HEIGHT_TWIPS (see `measureTableHeight`
145
+ // inner loop). P6.b preserves that exact constant so the split
146
+ // math agrees with pagination. P6.c can upgrade this to call
147
+ // `measureBlockHeight` via the cache once the cache is threaded in.
148
+ return MIN_ROW_HEIGHT_TWIPS;
149
+ }
150
+
151
+ export interface FindTableRowSplitInput {
152
+ /** Per-row twip heights from `measureTableRowHeights`. */
153
+ rowHeights: readonly number[];
154
+ /** Parallel vector: true when row `k` has `w:cantSplit`. */
155
+ cantSplitFlags: readonly boolean[];
156
+ /** Parallel vector: true when row `k` has `w:tblHeader`. */
157
+ isHeaderFlags: readonly boolean[];
158
+ /** Twip space available on the current page before the table begins. */
159
+ remainingHeightTwips: number;
160
+ /**
161
+ * Twip height reserved on **continuation** pages for repeated header
162
+ * rows. The engine computes this as the sum of row heights for every
163
+ * row flagged `isHeader` at indices 0..startRow-1. P6.b accepts the
164
+ * value rather than recomputing it so the helper stays pure.
165
+ */
166
+ repeatedHeaderHeightTwips: number;
167
+ /**
168
+ * 0-based index of the first row to consider. When a table has
169
+ * already been sliced onto the previous page, the caller passes the
170
+ * index of the first uncommitted row so split math resumes from the
171
+ * right place. Defaults to 0.
172
+ */
173
+ startRow?: number;
174
+ }
175
+
176
+ export interface TableRowSplitDecision {
177
+ /**
178
+ * Number of rows from `startRow` that fit on the current page. Zero
179
+ * when no row fits (caller should push the whole tail to the next
180
+ * page without splitting mid-table).
181
+ */
182
+ rowsOnCurrentPage: number;
183
+ /**
184
+ * Absolute index (not relative to `startRow`) of the row that begins
185
+ * the continuation page. Equals `startRow + rowsOnCurrentPage`.
186
+ * When `rowsOnCurrentPage === 0`, equals `startRow`.
187
+ */
188
+ splitRowIndex: number;
189
+ /**
190
+ * Whether a continuation page is needed — equivalent to
191
+ * `splitRowIndex < rowHeights.length`. Returned for convenience so
192
+ * callers can branch without recomputing.
193
+ */
194
+ continuationRequired: boolean;
195
+ }
196
+
197
+ /**
198
+ * Walk rows greedily from `startRow` onward, accumulating heights
199
+ * until the next row would exceed `remainingHeightTwips`. Adjusts the
200
+ * split point backward past any `cantSplit` row that would straddle
201
+ * the boundary (so row K is never half on page N and half on page
202
+ * N+1 when K is marked cantSplit).
203
+ *
204
+ * Pure — no DOM, no state. Deterministic.
205
+ */
206
+ export function findTableRowSplit(
207
+ input: FindTableRowSplitInput,
208
+ ): TableRowSplitDecision {
209
+ const {
210
+ rowHeights,
211
+ cantSplitFlags,
212
+ isHeaderFlags,
213
+ remainingHeightTwips,
214
+ repeatedHeaderHeightTwips,
215
+ } = input;
216
+ const startRow = input.startRow ?? 0;
217
+ const totalRows = rowHeights.length;
218
+
219
+ if (startRow >= totalRows) {
220
+ return {
221
+ rowsOnCurrentPage: 0,
222
+ splitRowIndex: totalRows,
223
+ continuationRequired: false,
224
+ };
225
+ }
226
+ if (remainingHeightTwips <= 0) {
227
+ return {
228
+ rowsOnCurrentPage: 0,
229
+ splitRowIndex: startRow,
230
+ continuationRequired: startRow < totalRows,
231
+ };
232
+ }
233
+
234
+ // When resuming from a continuation, the header rows are repeated at
235
+ // the top so they consume the same space BEFORE we start fitting
236
+ // body rows. `startRow === 0` means this is the first page and no
237
+ // headers are repeated yet.
238
+ const headerReservation = startRow > 0 ? repeatedHeaderHeightTwips : 0;
239
+
240
+ let consumed = headerReservation;
241
+ let candidate = startRow;
242
+ for (let k = startRow; k < totalRows; k += 1) {
243
+ const rowHeight = rowHeights[k] ?? MIN_ROW_HEIGHT_TWIPS;
244
+ if (consumed + rowHeight > remainingHeightTwips) {
245
+ break;
246
+ }
247
+ consumed += rowHeight;
248
+ candidate = k + 1;
249
+ }
250
+
251
+ // Honor cantSplit: if the row *before* the split boundary is marked
252
+ // cantSplit, the split index must move back past the entire
253
+ // contiguous run of cantSplit rows ending at `candidate - 1`. The
254
+ // visible effect: a cantSplit row never lands as the last row of a
255
+ // page when the NEXT row overflows (because keeping the row together
256
+ // with the content it should stay with is the whole point of
257
+ // cantSplit).
258
+ //
259
+ // Edge case: if the FIRST row in [startRow..totalRows) is cantSplit
260
+ // and doesn't fit, we can't split at all — return rowsOnCurrentPage
261
+ // = 0 so the caller pushes a clean page break.
262
+ if (candidate > startRow && candidate < totalRows) {
263
+ while (candidate > startRow && cantSplitFlags[candidate - 1] === true) {
264
+ candidate -= 1;
265
+ }
266
+ }
267
+
268
+ // If candidate collapsed back to startRow due to cantSplit, check
269
+ // whether the first row itself fits. If not, rowsOnCurrentPage = 0
270
+ // → push whole tail to next page. If yes, include it.
271
+ if (candidate === startRow) {
272
+ return {
273
+ rowsOnCurrentPage: 0,
274
+ splitRowIndex: startRow,
275
+ continuationRequired: startRow < totalRows,
276
+ };
277
+ }
278
+
279
+ return {
280
+ rowsOnCurrentPage: candidate - startRow,
281
+ splitRowIndex: candidate,
282
+ continuationRequired: candidate < totalRows,
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Convenience: compute the repeated-header-row height sum for a table
288
+ * given the per-row heights and the header flags. This is the value
289
+ * the caller feeds into `findTableRowSplit.repeatedHeaderHeightTwips`.
290
+ */
291
+ export function computeRepeatedHeaderHeight(
292
+ rowHeights: readonly number[],
293
+ isHeaderFlags: readonly boolean[],
294
+ ): number {
295
+ let total = 0;
296
+ for (let k = 0; k < isHeaderFlags.length; k += 1) {
297
+ if (isHeaderFlags[k] === true) {
298
+ total += rowHeights[k] ?? 0;
299
+ }
300
+ }
301
+ return total;
302
+ }
303
+
304
+ /**
305
+ * Extract parallel `cantSplitFlags` + `isHeaderFlags` vectors from a
306
+ * surface table block. Small helper so tests + callers can feed the
307
+ * same representation into `findTableRowSplit` without re-walking the
308
+ * block.
309
+ */
310
+ export function extractRowFlags(
311
+ block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
312
+ ): { cantSplitFlags: boolean[]; isHeaderFlags: boolean[] } {
313
+ const cantSplitFlags = block.rows.map((r) => r.cantSplit === true);
314
+ const isHeaderFlags = block.rows.map((r) => r.isHeader === true);
315
+ return { cantSplitFlags, isHeaderFlags };
316
+ }
@@ -0,0 +1,132 @@
1
+ export interface SanitizeResult {
2
+ text: string;
3
+ sanitized: boolean;
4
+ }
5
+
6
+ /**
7
+ * Minimum-viable sanitizer for the bw:commentPresentation markdown subset.
8
+ *
9
+ * Rejects:
10
+ * - Raw HTML tags (`<script>`, `<img onerror=...>`, …).
11
+ * - Inline-image markdown pointing at anything other than `bw:attachment:` —
12
+ * external image URLs must go through the attachment table so the reader
13
+ * can render them against a known relationship.
14
+ * - Autolinks / link destinations using schemes other than `http:`, `https:`,
15
+ * `mailto:`, `bw:user:`, `bw:attachment:`.
16
+ *
17
+ * Preserves the rest of the text verbatim so the digest remains stable after
18
+ * one sanitize pass. The goal is tamper-evident rendering, not a full
19
+ * CommonMark parser — callers MUST still run a proper renderer with the same
20
+ * whitelist when displaying the text.
21
+ */
22
+ export function sanitizeMarkdown(raw: string): SanitizeResult {
23
+ let sanitized = false;
24
+ let text = raw;
25
+
26
+ // Autolinks (`<javascript:alert(1)>`, `<https://x>`, `<a@b.com>`) must be
27
+ // processed BEFORE the HTML-tag strip, otherwise the HTML regex eats the
28
+ // angle-bracket form as a pseudo-tag and the scheme check never runs.
29
+ text = text.replace(
30
+ /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g,
31
+ (_match, target: string) => {
32
+ if (isAllowedLinkTarget(target)) {
33
+ return `<${target}>`;
34
+ }
35
+ sanitized = true;
36
+ // Wrap in backticks so downstream renderers treat it as inline code
37
+ // instead of a clickable link. Keeping the literal text stabilizes
38
+ // the digest across sanitizer revisions.
39
+ return `\`${target}\``;
40
+ },
41
+ );
42
+ // Email autolinks (no URI scheme) — accept as-is so the HTML strip
43
+ // below treats the angle-bracket form as ordinary prose and we do not
44
+ // flag sanitized unnecessarily.
45
+ text = text.replace(
46
+ /<([A-Za-z0-9][A-Za-z0-9._%+-]*@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/g,
47
+ (_match, addr: string) => `<${addr}>`,
48
+ );
49
+
50
+ const strippedHtml = text.replace(
51
+ // Require a recognisable HTML tag name so we do not strip safe
52
+ // autolinks that survived the pass above (email autolinks, bare URIs).
53
+ /<\/?([A-Za-z][A-Za-z0-9-]*)(\s[^>]*)?\/?>/g,
54
+ (match, tagName: string) => {
55
+ // Skip angle-bracket content that looks like an autolink we just
56
+ // approved (email or scheme URI).
57
+ if (match.includes("://") || match.includes("@") || match.includes(":")) {
58
+ return match;
59
+ }
60
+ // Also skip plain URIs like `<example.com>` — rare, but preserve.
61
+ if (/^[a-z]+\.[a-z]/.test(tagName)) {
62
+ return match;
63
+ }
64
+ sanitized = true;
65
+ return "";
66
+ },
67
+ );
68
+ text = strippedHtml;
69
+
70
+ text = text.replace(
71
+ /!\[([^\]]*)\]\(([^)]+)\)/g,
72
+ (_match, alt: string, target: string) => {
73
+ if (target.startsWith("bw:attachment:")) {
74
+ return `![${alt}](${target})`;
75
+ }
76
+ sanitized = true;
77
+ return `[${alt}]`;
78
+ },
79
+ );
80
+
81
+ text = text.replace(
82
+ /\[([^\]]+)\]\(([^)]+)\)/g,
83
+ (_match, label: string, target: string) => {
84
+ if (isAllowedLinkTarget(target)) {
85
+ return `[${label}](${target})`;
86
+ }
87
+ sanitized = true;
88
+ return label;
89
+ },
90
+ );
91
+
92
+ // Reference-style link definitions: `[label]: target "optional title"`
93
+ // Renderers resolve `[foo][label]` against this table at render time,
94
+ // so a `[x]: javascript:alert(1)` bypasses the inline-link pass above.
95
+ // Strip definitions whose target is not in the scheme allowlist.
96
+ text = text.replace(
97
+ /^[ \t]*\[([^\]\n]+)\]:[ \t]*(\S+)(?:[ \t]+["(][^"\n)]*[")])?[ \t]*$/gm,
98
+ (match, _label: string, target: string) => {
99
+ if (isAllowedLinkTarget(target)) {
100
+ return match;
101
+ }
102
+ sanitized = true;
103
+ return "";
104
+ },
105
+ );
106
+
107
+ return { text, sanitized };
108
+ }
109
+
110
+ function isAllowedLinkTarget(target: string): boolean {
111
+ return (
112
+ target.startsWith("http://") ||
113
+ target.startsWith("https://") ||
114
+ target.startsWith("mailto:") ||
115
+ target.startsWith("bw:user:") ||
116
+ target.startsWith("bw:attachment:")
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Synchronous SHA-256 hex digest used by `CommentBody.digest`. Uses the Node
122
+ * crypto module so the pipeline can hash during import/export without pulling
123
+ * in async. Callers that prefer WebCrypto can build their own.
124
+ */
125
+ export async function sha256Hex(text: string): Promise<string> {
126
+ const { subtle } = globalThis.crypto;
127
+ const bytes = new TextEncoder().encode(text);
128
+ const digest = await subtle.digest("SHA-256", bytes);
129
+ return Array.from(new Uint8Array(digest))
130
+ .map((b) => b.toString(16).padStart(2, "0"))
131
+ .join("");
132
+ }
@@ -0,0 +1,134 @@
1
+ import * as Y from "yjs";
2
+
3
+ import type {
4
+ Participant,
5
+ ParticipantRoster,
6
+ } from "../api/participants-types.ts";
7
+
8
+ const MAP_KEY = "participants";
9
+
10
+ export interface ParticipantRosterStore {
11
+ get(userId: string): Participant | undefined;
12
+ /** Returns a synthetic "Unknown" row when the userId is not in the roster. */
13
+ resolve(userId: string): Participant;
14
+ upsert(entry: Participant): Participant;
15
+ remove(userId: string): void;
16
+ all(): Participant[];
17
+ snapshot(): ParticipantRoster;
18
+ subscribe(fn: (changedIds: string[]) => void): () => void;
19
+ ingestRemote(entry: Participant): void;
20
+ destroy(): void;
21
+ }
22
+
23
+ export function createParticipantRoster(ydoc: Y.Doc): ParticipantRosterStore {
24
+ const yMap = ydoc.getMap<Participant>(MAP_KEY);
25
+ const listeners = new Set<(ids: string[]) => void>();
26
+
27
+ const onChange = (event: Y.YMapEvent<Participant>): void => {
28
+ if (listeners.size === 0) return;
29
+ const ids = Array.from(event.keysChanged);
30
+ for (const fn of listeners) fn(ids);
31
+ };
32
+ yMap.observe(onChange);
33
+
34
+ return {
35
+ get: (id) => cloneMaybe(yMap.get(id)),
36
+ resolve: (id) => yMap.get(id)
37
+ ? clone(yMap.get(id)!)
38
+ : syntheticUnknown(id),
39
+ upsert: (entry) => {
40
+ const prev = yMap.get(entry.userId);
41
+ const normalized = normalize(entry);
42
+ const next: Participant = prev
43
+ ? { ...prev, ...normalized }
44
+ : normalized;
45
+ validate(next);
46
+ yMap.set(entry.userId, next);
47
+ return clone(next);
48
+ },
49
+ remove: (id) => {
50
+ yMap.delete(id);
51
+ },
52
+ all: () => Array.from(yMap.values()).map(clone),
53
+ snapshot: () => ({
54
+ schemaVersion: 1,
55
+ entries: Array.from(yMap.values()).map(clone),
56
+ }),
57
+ subscribe: (fn) => {
58
+ listeners.add(fn);
59
+ return () => {
60
+ listeners.delete(fn);
61
+ };
62
+ },
63
+ ingestRemote: (entry) => {
64
+ validate(entry);
65
+ yMap.set(entry.userId, clone(normalize(entry)));
66
+ },
67
+ destroy: () => {
68
+ yMap.unobserve(onChange);
69
+ listeners.clear();
70
+ },
71
+ };
72
+ }
73
+
74
+ function normalize(p: Participant): Participant {
75
+ const next: Participant = {
76
+ userId: p.userId,
77
+ email: p.email.toLowerCase(),
78
+ displayName: p.displayName,
79
+ collabIdentity: p.collabIdentity,
80
+ authorKind: p.authorKind,
81
+ };
82
+ if (p.role !== undefined) next.role = p.role;
83
+ if (p.organization !== undefined) next.organization = p.organization;
84
+ if (p.avatarHref !== undefined) {
85
+ if (p.avatarHref.startsWith("https://")) {
86
+ next.avatarHref = p.avatarHref;
87
+ }
88
+ // silently drop non-https: schemes per schema rule
89
+ }
90
+ return next;
91
+ }
92
+
93
+ function validate(p: Participant): void {
94
+ if (!p.userId) {
95
+ throw new Error("participant.userId is required");
96
+ }
97
+ if (!p.email) {
98
+ throw new Error(`participant.email is required (userId=${p.userId})`);
99
+ }
100
+ if (!p.displayName) {
101
+ throw new Error(`participant.displayName is required (userId=${p.userId})`);
102
+ }
103
+ if (!p.collabIdentity) {
104
+ throw new Error(`participant.collabIdentity is required (userId=${p.userId})`);
105
+ }
106
+ }
107
+
108
+ function clone(p: Participant): Participant {
109
+ const copy: Participant = {
110
+ userId: p.userId,
111
+ email: p.email,
112
+ displayName: p.displayName,
113
+ collabIdentity: p.collabIdentity,
114
+ authorKind: p.authorKind,
115
+ };
116
+ if (p.role !== undefined) copy.role = p.role;
117
+ if (p.organization !== undefined) copy.organization = p.organization;
118
+ if (p.avatarHref !== undefined) copy.avatarHref = p.avatarHref;
119
+ return copy;
120
+ }
121
+
122
+ function cloneMaybe(p: Participant | undefined): Participant | undefined {
123
+ return p ? clone(p) : undefined;
124
+ }
125
+
126
+ function syntheticUnknown(userId: string): Participant {
127
+ return {
128
+ userId,
129
+ email: "",
130
+ displayName: "Unknown",
131
+ collabIdentity: "",
132
+ authorKind: "human",
133
+ };
134
+ }
@@ -0,0 +1,120 @@
1
+ import {
2
+ signWorkflowPayloadXml,
3
+ type PayloadSignature,
4
+ type PayloadSigner,
5
+ } from "../io/ooxml/payload-signature.ts";
6
+
7
+ /**
8
+ * Central re-sign hook. Every payload mutation — negotiation action,
9
+ * presentation edit, participant upsert, external-custody attach, and
10
+ * (in the P17 parallel lane) metadata-persistence toggle — MUST route
11
+ * through this helper before writing back to the docx. This keeps the
12
+ * tamper-evident invariant un-bypassable: no feature can silently
13
+ * mutate `bw:workflowPayload` without re-signing.
14
+ *
15
+ * The helper is intentionally thin — it does two jobs:
16
+ * 1. Replace (or append, if absent) the root `<bw:signature …/>`
17
+ * element inside the provided payload XML with a new signature
18
+ * over the canonicalized (bw-canon/1) form of the payload minus
19
+ * the signature itself.
20
+ * 2. Return the rewritten XML so the caller can persist it.
21
+ *
22
+ * The canonicalizer already excludes `bw:signature` from its hashing
23
+ * surface; this helper reuses that guarantee instead of reimplementing
24
+ * it.
25
+ */
26
+ export interface ResignPayloadArgs {
27
+ /** The full `<bw:workflowPayload …>…</bw:workflowPayload>` XML. */
28
+ payloadXml: string;
29
+ signer: PayloadSigner;
30
+ /** Optional clock override for deterministic tests. */
31
+ now?: string;
32
+ }
33
+
34
+ export interface ResignPayloadResult {
35
+ payloadXml: string;
36
+ signature: PayloadSignature;
37
+ }
38
+
39
+ export async function resignPayload(
40
+ args: ResignPayloadArgs,
41
+ ): Promise<ResignPayloadResult> {
42
+ const stripped = removeExistingSignature(args.payloadXml);
43
+ const signature = await signWorkflowPayloadXml(stripped, args.signer, args.now);
44
+ const signatureElement = renderSignatureElement(signature);
45
+ const payloadXml = insertSignatureBeforeClose(stripped, signatureElement);
46
+ return { payloadXml, signature };
47
+ }
48
+
49
+ const SIGNATURE_OPEN = /<(?:[A-Za-z_][\w-]*:)?signature\b/;
50
+ const CLOSE_ROOT = /<\/(?:[A-Za-z_][\w-]*:)?workflowPayload\s*>\s*$/;
51
+
52
+ function removeExistingSignature(xml: string): string {
53
+ // The canonicalizer drops bw:signature before hashing, but the stored
54
+ // XML keeps it. For a round-trippable rewrite we strip every
55
+ // self-closing or block-form signature element at the top level. We
56
+ // do this with a tolerant regex instead of a full parse because the
57
+ // host may have normalized the XML (whitespace / attr order) between
58
+ // writes; we just need to locate the element's extent.
59
+ let out = xml;
60
+ while (true) {
61
+ const match = SIGNATURE_OPEN.exec(out);
62
+ if (!match) break;
63
+ const start = match.index;
64
+ const rest = out.slice(start);
65
+ // Self-closing form: `<…signature …/>`
66
+ const selfClose = rest.match(/^<[^>]*?\/>/);
67
+ if (selfClose) {
68
+ out = out.slice(0, start) + out.slice(start + selfClose[0].length);
69
+ continue;
70
+ }
71
+ // Block form: `<…signature …>…</…signature>`
72
+ const openEnd = rest.indexOf(">");
73
+ if (openEnd < 0) break;
74
+ const tagName = match[0].slice(1); // drops leading <
75
+ const closeTag = new RegExp(`</${escapeRegex(tagName)}\\s*>`);
76
+ const closeMatch = closeTag.exec(rest);
77
+ if (!closeMatch) break;
78
+ const end = closeMatch.index + closeMatch[0].length;
79
+ out = out.slice(0, start) + out.slice(start + end);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ function insertSignatureBeforeClose(xml: string, signatureElement: string): string {
85
+ const match = CLOSE_ROOT.exec(xml);
86
+ if (!match) {
87
+ throw new Error(
88
+ "resignPayload: could not locate </bw:workflowPayload> close tag",
89
+ );
90
+ }
91
+ const insertAt = match.index;
92
+ return (
93
+ xml.slice(0, insertAt) +
94
+ signatureElement +
95
+ xml.slice(insertAt)
96
+ );
97
+ }
98
+
99
+ function renderSignatureElement(sig: PayloadSignature): string {
100
+ return (
101
+ `<bw:signature` +
102
+ ` algorithm="${escapeAttr(sig.algorithm)}"` +
103
+ ` keyId="${escapeAttr(sig.keyId)}"` +
104
+ ` signedAt="${escapeAttr(sig.signedAt)}"` +
105
+ ` canonicalizationProfile="${escapeAttr(sig.canonicalizationProfile)}"` +
106
+ ` value="${escapeAttr(sig.value)}"/>`
107
+ );
108
+ }
109
+
110
+ function escapeAttr(v: string): string {
111
+ return v
112
+ .replace(/&/g, "&amp;")
113
+ .replace(/</g, "&lt;")
114
+ .replace(/>/g, "&gt;")
115
+ .replace(/"/g, "&quot;");
116
+ }
117
+
118
+ function escapeRegex(s: string): string {
119
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
+ }