@beyondwork/docx-react-component 1.0.97 → 1.0.98

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.97",
4
+ "version": "1.0.98",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -2640,6 +2640,20 @@ export interface AddScopeResult {
2640
2640
  export interface ExportDocxOptions {
2641
2641
  fileName?: string;
2642
2642
  reason?: string;
2643
+ /**
2644
+ * Optional signer for the workflow-payload `<bw:signature>` block
2645
+ * (coord-06 cleanup §3). When present, `buildWorkflowPayloadParts`
2646
+ * computes the signature over the canonicalized payload XML (bw-
2647
+ * canon/1; `<bw:signature>` excluded from hashing) and injects a
2648
+ * `<bw:signature>` element inside `<bw:workflowPayload>`. Reload
2649
+ * passes the extracted signature to `collabSession.attach({ payload
2650
+ * })` where the tamper-gate transitions to `verified` / `tampered`.
2651
+ *
2652
+ * Hosts that don't pass a signer get the trust-on-first-use
2653
+ * (unsigned) behaviour that shipped before — the payload part is
2654
+ * written without a `<bw:signature>` element.
2655
+ */
2656
+ signer?: import("../io/ooxml/payload-signature.ts").PayloadSigner;
2643
2657
  /**
2644
2658
  * Controls the browser download fallback used by the mounted
2645
2659
  * `WordReviewEditor` ref when no host/datastore `saveExport` adapter is
@@ -49,6 +49,107 @@ export async function verifyWorkflowPayloadXml(
49
49
  return verifier.verify(bytes, sig);
50
50
  }
51
51
 
52
+ /**
53
+ * Coord-06 cleanup §3 (2026-04-24) — sign the workflow payload XML and
54
+ * inject a `<bw:signature>` block into the payload root. The block is
55
+ * excluded from canonicalization before hashing (see
56
+ * `canonicalize-payload.ts::stripSignature`) so the signature is
57
+ * self-consistent: writer and reader both canonicalize-with-signature-
58
+ * stripped, so the same bytes hash.
59
+ *
60
+ * The injection happens inside the root `<bw:workflowPayload>` element,
61
+ * before the closing tag. Idempotent — re-signing on export re-injects
62
+ * a fresh signature block, stripping any prior one first.
63
+ */
64
+ export async function signAndInjectWorkflowPayloadSignature(
65
+ xml: string,
66
+ signer: PayloadSigner,
67
+ now: string = new Date().toISOString(),
68
+ ): Promise<string> {
69
+ const withoutPriorSignature = stripSignatureBlockFromPayload(xml);
70
+ const signature = await signWorkflowPayloadXml(withoutPriorSignature, signer, now);
71
+ return injectSignatureBlock(withoutPriorSignature, signature);
72
+ }
73
+
74
+ /**
75
+ * Parse a `<bw:signature ...>base64...</bw:signature>` block out of a
76
+ * workflow payload XML string. Returns `null` when the payload is
77
+ * unsigned (no `<bw:signature>` found) or when the block is malformed
78
+ * (missing required attributes / unparseable). Malformed returns null
79
+ * rather than throwing — the caller flips the tamper-gate to
80
+ * `"unsigned"` on null and to `"tampered"` only when verification
81
+ * fails on a well-formed signature block.
82
+ */
83
+ export function extractWorkflowPayloadSignature(
84
+ xml: string,
85
+ ): PayloadSignature | null {
86
+ // Match `<bw:signature ...>...</bw:signature>` or self-closing.
87
+ const re =
88
+ /<bw:signature\b([^>]*?)(?:\/>|>([\s\S]*?)<\/bw:signature>)/u;
89
+ const m = re.exec(xml);
90
+ if (!m) return null;
91
+ const attrs = parseAttributes(m[1] ?? "");
92
+ const body = (m[2] ?? "").trim();
93
+ const algorithm = attrs.algorithm;
94
+ const keyId = attrs.keyId;
95
+ const signedAt = attrs.signedAt;
96
+ const canonicalizationProfile = attrs.canonicalizationProfile;
97
+ if (!algorithm || !keyId || !signedAt || !canonicalizationProfile) return null;
98
+ if (algorithm !== "hmac-sha256" && algorithm !== "ed25519") return null;
99
+ if (canonicalizationProfile !== "bw-canon/1") return null;
100
+ if (!body) return null;
101
+ return {
102
+ algorithm,
103
+ keyId,
104
+ signedAt,
105
+ canonicalizationProfile,
106
+ value: body,
107
+ };
108
+ }
109
+
110
+ function stripSignatureBlockFromPayload(xml: string): string {
111
+ return xml.replace(
112
+ /<bw:signature\b[^>]*?(?:\/>|>[\s\S]*?<\/bw:signature>)\s*/u,
113
+ "",
114
+ );
115
+ }
116
+
117
+ function injectSignatureBlock(xml: string, sig: PayloadSignature): string {
118
+ const block =
119
+ `<bw:signature algorithm="${escapeAttr(sig.algorithm)}"` +
120
+ ` keyId="${escapeAttr(sig.keyId)}"` +
121
+ ` signedAt="${escapeAttr(sig.signedAt)}"` +
122
+ ` canonicalizationProfile="${escapeAttr(sig.canonicalizationProfile)}">` +
123
+ `${sig.value}</bw:signature>`;
124
+ // Inject just before the closing `</bw:workflowPayload>` tag so the
125
+ // block is a direct child of the root element. Falls back to a
126
+ // root-tag rewrite if the closing tag isn't the expected shape
127
+ // (e.g. the root is renamed in a future schema bump).
128
+ const closeRe = /<\/bw:workflowPayload>\s*$/u;
129
+ if (closeRe.test(xml)) {
130
+ return xml.replace(closeRe, `${block}</bw:workflowPayload>`);
131
+ }
132
+ return xml.replace(/<\/([^>]+)>\s*$/u, `${block}</$1>`);
133
+ }
134
+
135
+ function parseAttributes(source: string): Record<string, string> {
136
+ const out: Record<string, string> = {};
137
+ const re = /([a-zA-Z_][\w:.-]*)\s*=\s*"([^"]*)"/gu;
138
+ let m: RegExpExecArray | null;
139
+ while ((m = re.exec(source)) !== null) {
140
+ out[m[1]] = m[2];
141
+ }
142
+ return out;
143
+ }
144
+
145
+ function escapeAttr(value: string): string {
146
+ return value
147
+ .replaceAll("&", "&amp;")
148
+ .replaceAll('"', "&quot;")
149
+ .replaceAll("<", "&lt;")
150
+ .replaceAll(">", "&gt;");
151
+ }
152
+
52
153
  // HMAC-SHA256 helpers ----------------------------------------------------
53
154
 
54
155
  /**
@@ -1025,8 +1025,14 @@
1025
1025
  * without an upstream bump. Bump here so persisted cache envelopes
1026
1026
  * re-derive; no algorithm change beyond the floating-drawing clip
1027
1027
  * widening already covered by the resurrected tests.
1028
+ *
1029
+ * 62 — nested break detection inside wrapped surface blocks. Page and
1030
+ * column break detection now walks `sdt_block` children and table-cell
1031
+ * content instead of checking only top-level paragraphs. This honors
1032
+ * `<w:br w:type="page"/>` inside cover-page SDTs such as the CCEP SOW
1033
+ * template, changing page assignment from the v61 cache shape.
1028
1034
  */
1029
- export const LAYOUT_ENGINE_VERSION = 61 as const;
1035
+ export const LAYOUT_ENGINE_VERSION = 62 as const;
1030
1036
 
1031
1037
  /**
1032
1038
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -164,6 +164,17 @@ export function ensureHostMetadataParts(
164
164
  });
165
165
  }
166
166
 
167
+ /**
168
+ * Return shape reports whether a payload part was written AND exposes
169
+ * the built XML + path so the export pipeline can post-process (e.g.
170
+ * coord-06 §3 sign-and-inject `<bw:signature>`). Returns `null` when
171
+ * no payload part was written (no runtime-owned content).
172
+ */
173
+ export interface EnsureWorkflowPayloadPartsResult {
174
+ readonly payloadPartPath: string;
175
+ readonly payloadPartXml: string;
176
+ }
177
+
167
178
  export function ensureWorkflowPayloadParts(
168
179
  exportSession: ExportSession,
169
180
  sessionState: EditorSessionState,
@@ -174,7 +185,7 @@ export function ensureWorkflowPayloadParts(
174
185
  itemPropsPartPath: string;
175
186
  },
176
187
  editorState?: EditorStatePayload,
177
- ): void {
188
+ ): EnsureWorkflowPayloadPartsResult | null {
178
189
  const payloadParts = buildWorkflowPayloadParts({
179
190
  sourcePackage,
180
191
  workflowMetadata: sessionState.workflowMetadata,
@@ -188,7 +199,7 @@ export function ensureWorkflowPayloadParts(
188
199
  producerVersion: sessionState.editorBuild,
189
200
  });
190
201
  if (!payloadParts) {
191
- return;
202
+ return null;
192
203
  }
193
204
  if (
194
205
  payloadParts.payloadPartPath !== resolvedPartPaths.payloadPartPath ||
@@ -228,6 +239,11 @@ export function ensureWorkflowPayloadParts(
228
239
  target: WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
229
240
  preferredId: "rIdBwWorkflowCustomProps",
230
241
  });
242
+
243
+ return {
244
+ payloadPartPath: payloadParts.payloadPartPath,
245
+ payloadPartXml: payloadParts.payloadPartXml,
246
+ };
231
247
  }
232
248
 
233
249
  export function hasHostSafeMetadataPackageStructure(sourcePackage: OpcPackage): boolean {
@@ -93,6 +93,7 @@ import {
93
93
  ensureWorkflowPayloadParts,
94
94
  serializeProtectionRangesIntoDocumentXml,
95
95
  } from "./stateful-export-pipeline.ts";
96
+ import { signAndInjectWorkflowPayloadSignature } from "../../io/ooxml/payload-signature.ts";
96
97
  import {
97
98
  assertExportNotBlockedByCompatibility,
98
99
  assertNoBlockingPreservedComments,
@@ -612,7 +613,7 @@ export async function runStatefulExport(
612
613
 
613
614
  // Schema 1.2: pass through editorState payload collected by the
614
615
  // runtime channel, with any offload entries folded in above.
615
- ensureWorkflowPayloadParts(
616
+ const payloadResult = ensureWorkflowPayloadParts(
616
617
  exportSession,
617
618
  sessionState,
618
619
  currentDocument,
@@ -621,6 +622,25 @@ export async function runStatefulExport(
621
622
  editorStateWithEmbeddings,
622
623
  );
623
624
 
625
+ // coord-06 cleanup §3 — if the host supplied a PayloadSigner, sign
626
+ // the built payload XML and rewrite the customXml/item1.xml part with
627
+ // a `<bw:signature>` block injected inside the root element. The
628
+ // canonicalization pass strips the block before hashing so signer
629
+ // and verifier agree; see `src/io/ooxml/canonicalize-payload.ts`.
630
+ if (options?.signer && payloadResult) {
631
+ const signedXml = await signAndInjectWorkflowPayloadSignature(
632
+ payloadResult.payloadPartXml,
633
+ options.signer,
634
+ );
635
+ const payloadPart = state.sourcePackage.parts.get(payloadResult.payloadPartPath);
636
+ exportSession.replaceOwnedPart({
637
+ path: payloadResult.payloadPartPath,
638
+ bytes: new TextEncoder().encode(signedXml),
639
+ contentType: payloadPart?.contentType ?? "application/xml",
640
+ compression: payloadPart?.compression,
641
+ });
642
+ }
643
+
624
644
  return {
625
645
  bytes: exportSession.serialize(),
626
646
  mimeType: DOCX_MIME_TYPE,
@@ -199,6 +199,14 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
199
199
  * Tests override it to capture the release callback.
200
200
  */
201
201
  scheduleMicrotask?: (callback: () => void) => void;
202
+ /**
203
+ * Synchronous work that must happen after `view.updateState(newState)`
204
+ * but before the optional scroll restore. The ProseMirror surface uses
205
+ * this to dispatch runtime decoration inputs into the PM plugin while
206
+ * the same captured scroll anchor still protects the whole replacement
207
+ * cycle.
208
+ */
209
+ afterUpdateState?: () => void;
202
210
  }
203
211
 
204
212
  /**
@@ -212,8 +220,9 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
212
220
  * `preserveScrollAnchor: true` and a live `geometryFacet`
213
221
  * 2. suppressionRef.current = true
214
222
  * 3. view.updateState(newState) ← PM may fire selection events here
215
- * 4. (optional) restore scroll anchor
216
- * 5. queueMicrotask(() => suppressionRef.current = false)
223
+ * 4. options.afterUpdateState?.() optional decoration/plugin sync
224
+ * 5. (optional) restore scroll anchor
225
+ * 6. queueMicrotask(() => suppressionRef.current = false)
217
226
  *
218
227
  * The microtask release guarantees the flag is still `true` for any
219
228
  * synchronous selection-change handler that fires during (3), and
@@ -242,6 +251,7 @@ export function replaceStatePreservingPosition(
242
251
  : null;
243
252
  options.suppressionRef.current = true;
244
253
  options.view.updateState(newState);
254
+ options.afterUpdateState?.();
245
255
  if (preserved) {
246
256
  const restored = restorePosition(preserved, options);
247
257
  if (!restored) {
@@ -936,6 +936,7 @@ export const TwProseMirrorSurface = forwardRef<
936
936
  recordPerfSample("pm.rebuild");
937
937
  incrementInvalidationCounter("pm.laneA.rebuilds");
938
938
 
939
+ let appliedDecorationProps = false;
939
940
  if (!viewRef.current) {
940
941
  const view = new EditorView(mountRef.current, {
941
942
  state,
@@ -960,32 +961,42 @@ export const TwProseMirrorSurface = forwardRef<
960
961
  // would move by more than the small local-edit budget, the helper
961
962
  // refuses that exact target but restores the captured scrollTop so
962
963
  // a PM/browser-origin top jump is not accepted as the final state.
964
+ // The runtime-decoration plugin is also updated inside the funnel:
965
+ // its meta transaction can rebuild page-break/widget decorations
966
+ // synchronously, so it must be included before the final restore.
963
967
  //
964
968
  // Ordering invariant is regression-guarded by
965
969
  // `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
970
+ const view = viewRef.current;
966
971
  const scrollAnchorPolicy = pendingRebuildScrollAnchorRef.current;
967
972
  pendingRebuildScrollAnchorRef.current = null;
968
973
  const preserveScrollAnchor = shouldPreserveScrollAnchorForRebuild({
969
974
  policy: scrollAnchorPolicy,
970
- view: viewRef.current,
975
+ view,
971
976
  geometryFacet: props.geometryFacet,
972
977
  previousStory: lastBuiltStoryRef.current,
973
978
  nextStory: snapshot.activeStory,
974
979
  });
975
980
  replaceStatePreservingPosition(
976
981
  {
977
- view: viewRef.current,
982
+ view,
978
983
  geometryFacet: props.geometryFacet,
979
984
  suppressionRef: suppressSelectionEchoRef,
980
985
  preserveScrollAnchor,
981
986
  maxScrollDeltaPx: BOUNDED_TYPING_SCROLL_RESTORE_MAX_DELTA_PX,
987
+ afterUpdateState: () => {
988
+ applyDecorationProps(view, positionMap);
989
+ appliedDecorationProps = true;
990
+ },
982
991
  },
983
992
  state,
984
993
  );
985
994
  }
986
995
  documentBuildKeyRef.current = documentBuildKey;
987
996
  lastBuiltStoryRef.current = snapshot.activeStory;
988
- applyDecorationProps(viewRef.current, positionMap);
997
+ if (!appliedDecorationProps) {
998
+ applyDecorationProps(viewRef.current, positionMap);
999
+ }
989
1000
 
990
1001
  if (activeSearchRef.current) {
991
1002
  applySearch(