@kungfu-tech/buildchain 2.11.13-alpha.2 → 2.11.13

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.
@@ -273,14 +273,150 @@ function surfaceArtifactRootFor({ artifactRoot, binding }) {
273
273
  return artifactRoot;
274
274
  }
275
275
 
276
- function syncStaticArtifactArgs({ artifactRoot, bucket, objectPrefix }) {
276
+ function syncStaticArtifactArgs({ artifactRoot, bucket, objectPrefix, deleteExcludes = [] }) {
277
277
  const args = ["s3", "sync", artifactRoot, s3Uri(bucket, objectPrefix), "--delete"];
278
278
  if (!objectPrefix) {
279
279
  args.push("--exclude", ".buildchain/*");
280
280
  }
281
+ for (const pattern of deleteExcludes) {
282
+ args.push("--exclude", pattern);
283
+ }
281
284
  return args;
282
285
  }
283
286
 
287
+ const PUBLICATION_ARCHIVE_POLICY_CONTRACT = "kungfu-buildchain-publication-archive-policy";
288
+
289
+ function immutablePrefix(value, label) {
290
+ const raw = String(value || "").trim().replaceAll("\\", "/");
291
+ if (!raw || raw.split("/").includes("..")) {
292
+ throw new Error(`invalid ${label}: ${value}`);
293
+ }
294
+ const normalized = normalizeS3Key(raw);
295
+ if (!normalized || normalized.split("/").length < 2) {
296
+ throw new Error(`${label} must identify a versioned path below an immutable root: ${value}`);
297
+ }
298
+ return normalized;
299
+ }
300
+
301
+ function publicationImmutablePolicy({ artifactRoot, binding }) {
302
+ const surfaceRoot = surfaceArtifactRootFor({ artifactRoot, binding });
303
+ const manifestFile = path.join(surfaceRoot, "manifest.json");
304
+ if (!fs.existsSync(manifestFile)) return null;
305
+ let manifest;
306
+ try {
307
+ manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
308
+ } catch (error) {
309
+ throw new Error(`invalid publication archive manifest ${toPosix(path.relative(artifactRoot, manifestFile))}: ${error.message}`);
310
+ }
311
+ if (manifest?.archivePolicy?.contract !== PUBLICATION_ARCHIVE_POLICY_CONTRACT) return null;
312
+ const declaredPrefixes = [...new Set((manifest.publications || []).flatMap((publication) =>
313
+ (publication.versions || []).map((version) => immutablePrefix(
314
+ version.immutablePath,
315
+ `publication ${publication.id || "unknown"} immutablePath`,
316
+ )),
317
+ ))].sort();
318
+ if (declaredPrefixes.length === 0) {
319
+ throw new Error("publication archive policy must declare at least one immutable version prefix");
320
+ }
321
+ for (const prefix of declaredPrefixes) {
322
+ if (!fs.existsSync(path.join(surfaceRoot, prefix))) {
323
+ throw new Error(`declared immutable publication prefix does not exist in artifact: ${prefix}`);
324
+ }
325
+ }
326
+ const preservedRoots = [...new Set(declaredPrefixes.map((prefix) => prefix.split("/")[0]))].sort();
327
+ const files = preservedRoots
328
+ .flatMap((root) => listFiles(surfaceRoot, root))
329
+ .map((filePath) => ({
330
+ path: toPosix(path.relative(surfaceRoot, filePath)),
331
+ size: fs.statSync(filePath).size,
332
+ sha256: sha256File(filePath),
333
+ }))
334
+ .sort((left, right) => left.path.localeCompare(right.path));
335
+ return {
336
+ contract: "kungfu-buildchain-web-surface-immutable-publication",
337
+ sourceContract: manifest.contract || "",
338
+ archivePolicyContract: PUBLICATION_ARCHIVE_POLICY_CONTRACT,
339
+ manifestPath: toPosix(path.relative(artifactRoot, manifestFile)),
340
+ preservedRoots,
341
+ declaredPrefixes,
342
+ files,
343
+ };
344
+ }
345
+
346
+ function withImmutablePublicationPolicies(bindings, { cwd, artifactPath }) {
347
+ const artifactRoot = path.resolve(cwd, artifactPath);
348
+ const withOwnPolicies = bindings.map((binding) => {
349
+ const immutablePublication = publicationImmutablePolicy({ artifactRoot, binding });
350
+ if (!immutablePublication) return binding;
351
+ return { ...binding, immutablePublication };
352
+ });
353
+ const protectedRoots = withOwnPolicies.flatMap((binding) =>
354
+ (binding.immutablePublication?.preservedRoots || []).map((root) => ({
355
+ owner: binding.surface,
356
+ path: joinS3Key(binding.artifactPathPrefix, root),
357
+ })),
358
+ );
359
+ const withDeleteExcludes = withOwnPolicies.map((binding) => {
360
+ const bindingPrefix = normalizeS3Key(binding.artifactPathPrefix);
361
+ const mutableDeleteExcludes = protectedRoots
362
+ .map((protectedRoot) => {
363
+ if (!bindingPrefix) return `${protectedRoot.path}/*`;
364
+ if (protectedRoot.path === bindingPrefix) return "*";
365
+ if (!protectedRoot.path.startsWith(`${bindingPrefix}/`)) return "";
366
+ return `${protectedRoot.path.slice(bindingPrefix.length + 1)}/*`;
367
+ })
368
+ .filter(Boolean)
369
+ .sort();
370
+ return mutableDeleteExcludes.length > 0
371
+ ? { ...binding, mutableDeleteExcludes }
372
+ : binding;
373
+ });
374
+ return withDeleteExcludes.map((binding) => {
375
+ if (!binding.immutablePublication) return binding;
376
+ const ownedPaths = protectedRoots
377
+ .filter((protectedRoot) => protectedRoot.owner === binding.surface)
378
+ .map((protectedRoot) => protectedRoot.path);
379
+ const coveringBindings = withDeleteExcludes
380
+ .map((candidate) => {
381
+ const candidatePrefix = normalizeS3Key(candidate.artifactPathPrefix);
382
+ const excludes = ownedPaths
383
+ .map((ownedPath) => {
384
+ if (!candidatePrefix) return `${ownedPath}/*`;
385
+ if (ownedPath === candidatePrefix) return "*";
386
+ if (!ownedPath.startsWith(`${candidatePrefix}/`)) return "";
387
+ return `${ownedPath.slice(candidatePrefix.length + 1)}/*`;
388
+ })
389
+ .filter(Boolean)
390
+ .sort();
391
+ return excludes.length > 0
392
+ ? { surface: candidate.surface, mutableDeleteExcludes: excludes }
393
+ : null;
394
+ })
395
+ .filter(Boolean)
396
+ .sort((left, right) => left.surface.localeCompare(right.surface));
397
+ return {
398
+ ...binding,
399
+ immutablePublication: {
400
+ ...binding.immutablePublication,
401
+ coveringBindings,
402
+ },
403
+ };
404
+ });
405
+ }
406
+
407
+ function pathUnderPreservedRoot(relativePath, preservedRoots = []) {
408
+ const normalized = normalizeS3Key(relativePath);
409
+ return preservedRoots.some((root) => normalized === root || normalized.startsWith(`${root}/`));
410
+ }
411
+
412
+ function pathExcludedFromMutableDelete(relativePath, patterns = []) {
413
+ if (patterns.includes("*")) return true;
414
+ return pathUnderPreservedRoot(
415
+ relativePath,
416
+ patterns.map((pattern) => pattern.replace(/\/\*$/, "")),
417
+ );
418
+ }
419
+
284
420
  function directoryIndexAliasKeys({ objectPrefix, relativeIndexPath }) {
285
421
  const normalizedPrefix = normalizeS3Key(objectPrefix);
286
422
  if (!normalizedPrefix) {
@@ -301,6 +437,10 @@ function directoryIndexAliasOperations({ surfaceArtifactRoot, bucket, binding })
301
437
  }
302
438
  return listFiles(surfaceArtifactRoot, ".")
303
439
  .filter((filePath) => path.basename(filePath) === (binding.directoryIndex || "index.html"))
440
+ .filter((filePath) => !pathExcludedFromMutableDelete(
441
+ toPosix(path.relative(surfaceArtifactRoot, filePath)),
442
+ binding.mutableDeleteExcludes || [],
443
+ ))
304
444
  .flatMap((filePath) => {
305
445
  const relativeIndexPath = toPosix(path.relative(surfaceArtifactRoot, filePath));
306
446
  return directoryIndexAliasKeys({ objectPrefix: binding.objectPrefix, relativeIndexPath }).map((key) => ({
@@ -931,9 +1071,12 @@ export function planWebSurfaceDeploy({
931
1071
  rollbackLimitations,
932
1072
  deployedAt,
933
1073
  });
934
- const surfaceBindings = withSurfaceRoutingEvidence(manifest.surfaceBindings, {
1074
+ const surfaceBindings = withImmutablePublicationPolicies(withSurfaceRoutingEvidence(manifest.surfaceBindings, {
935
1075
  artifactPath: artifactPath || deployConfig.artifactPath || ".",
936
1076
  files: resolvedArtifact.files,
1077
+ }), {
1078
+ cwd,
1079
+ artifactPath: artifactPath || deployConfig.artifactPath || ".",
937
1080
  });
938
1081
  manifest.surfaceBindings = surfaceBindings;
939
1082
  return {
@@ -993,9 +1136,12 @@ export function applyWebSurfaceDeploy({
993
1136
  }
994
1137
  const bucket = deployConfig.bucket || deployConfig.target || "";
995
1138
  const artifactRoot = path.resolve(cwd, resolvedPlan.artifact.path);
996
- const bindings = withSurfaceRoutingEvidence(resolvedPlan.manifest.surfaceBindings || [], {
1139
+ const bindings = withImmutablePublicationPolicies(withSurfaceRoutingEvidence(resolvedPlan.manifest.surfaceBindings || [], {
997
1140
  artifactPath: resolvedPlan.artifact.path,
998
1141
  files: resolvedPlan.artifact.files || [],
1142
+ }), {
1143
+ cwd,
1144
+ artifactPath: resolvedPlan.artifact.path,
999
1145
  });
1000
1146
  resolvedPlan.manifest.surfaceBindings = bindings;
1001
1147
  if (!dryRun) {
@@ -1007,20 +1153,56 @@ export function applyWebSurfaceDeploy({
1007
1153
  });
1008
1154
  }
1009
1155
  }
1010
- const operations = [
1011
- ...cloudFrontDirectoryIndexRewriteOperations(bindings),
1012
- ...bindings.flatMap((binding) => deployBindingOperations({
1156
+ const bindingOperations = bindings.flatMap((binding) => deployBindingOperations({
1013
1157
  artifactRoot,
1014
1158
  deployConfig,
1015
1159
  manifest: resolvedPlan.manifest,
1016
1160
  binding,
1017
- })),
1161
+ }));
1162
+ const operations = [
1163
+ ...bindingOperations.filter((operation) => operation.action === "verify-immutable-artifact-before-upload"),
1164
+ ...bindingOperations.filter((operation) => operation.action === "sync-immutable-artifact"),
1165
+ ...bindingOperations.filter((operation) => operation.action === "verify-immutable-artifact-after-upload"),
1166
+ ...cloudFrontDirectoryIndexRewriteOperations(bindings),
1167
+ ...bindingOperations.filter((operation) => ![
1168
+ "verify-immutable-artifact-before-upload",
1169
+ "sync-immutable-artifact",
1170
+ "verify-immutable-artifact-after-upload",
1171
+ ].includes(operation.action)),
1018
1172
  ];
1019
1173
  const primaryBinding = bindings[0] || {};
1020
1174
  const objectPrefix = primaryBinding.objectPrefix || objectPrefixFor(deployConfig, resolvedPlan.manifest.alias || resolvedPlan.manifest.channel);
1021
1175
  const manifestKey = primaryBinding.manifestKey || deployManifestKey(deployConfig, resolvedPlan.manifest);
1022
1176
  const invalidationPaths = bindings.flatMap((binding) => [viewerWildcardPath(binding), cdnPath(binding.manifestKey)]);
1023
1177
  const operationResults = runAdapterOperations({ operations, dryRun, commandRunner });
1178
+ const immutablePreservation = bindings
1179
+ .filter((binding) => binding.immutablePublication)
1180
+ .map((binding) => {
1181
+ const relevant = operationResults.filter((operation) =>
1182
+ operation.surface === binding.surface &&
1183
+ [
1184
+ "verify-immutable-artifact-before-upload",
1185
+ "sync-immutable-artifact",
1186
+ "verify-immutable-artifact-after-upload",
1187
+ "sync-static-artifact",
1188
+ ].includes(operation.action),
1189
+ );
1190
+ const expectedOperationCount = (binding.immutablePublication.files.length * 2) +
1191
+ binding.immutablePublication.preservedRoots.length + 1;
1192
+ const complete = relevant.length === expectedOperationCount;
1193
+ return {
1194
+ surface: binding.surface,
1195
+ manifestPath: binding.immutablePublication.manifestPath,
1196
+ preservedRoots: binding.immutablePublication.preservedRoots,
1197
+ declaredPrefixes: binding.immutablePublication.declaredPrefixes,
1198
+ fileCount: binding.immutablePublication.files.length,
1199
+ mutableDeleteExcludes: binding.mutableDeleteExcludes || [],
1200
+ coveringBindings: binding.immutablePublication.coveringBindings,
1201
+ status: !complete || relevant.some((operation) => operation.status === "failed")
1202
+ ? "failed"
1203
+ : dryRun ? "planned" : "applied",
1204
+ };
1205
+ });
1024
1206
  return {
1025
1207
  schemaVersion: 1,
1026
1208
  contract: "kungfu-buildchain-web-surface-deploy-apply",
@@ -1043,6 +1225,7 @@ export function applyWebSurfaceDeploy({
1043
1225
  invalidationPaths,
1044
1226
  manifest: resolvedPlan.manifest,
1045
1227
  surfaceBindings: bindings,
1228
+ immutablePreservation,
1046
1229
  operations: operationResults,
1047
1230
  };
1048
1231
  }
@@ -1622,6 +1805,64 @@ export async function checkWebSurfaceHealth({
1622
1805
  }
1623
1806
  }
1624
1807
 
1808
+ const immutableBindings = bindings.filter((binding) => binding.immutablePublication);
1809
+ if (immutableBindings.length > 0) {
1810
+ const operationSource = Array.isArray(result?.operations) && result.operations.length > 0
1811
+ ? result.operations
1812
+ : (Array.isArray(plan?.steps) ? plan.steps : []);
1813
+ const requiredActions = [
1814
+ "verify-immutable-artifact-before-upload",
1815
+ "sync-immutable-artifact",
1816
+ "verify-immutable-artifact-after-upload",
1817
+ "sync-static-artifact",
1818
+ ];
1819
+ const preservationBindings = immutableBindings.map((binding) => {
1820
+ const actions = new Set(operationSource
1821
+ .filter((operation) => operation.surface === binding.surface)
1822
+ .filter((operation) => operationEvidenceStatus(operation, { plannedEvidence: !result }))
1823
+ .map((operation) => operation.action));
1824
+ const missingActions = requiredActions.filter((action) => !actions.has(action));
1825
+ const coveringBindings = (binding.immutablePublication.coveringBindings || []).map((covering) => {
1826
+ const sync = operationSource.find((operation) =>
1827
+ operation.surface === covering.surface &&
1828
+ operation.action === "sync-static-artifact" &&
1829
+ operationEvidenceStatus(operation, { plannedEvidence: !result }),
1830
+ );
1831
+ const actualExcludes = sync?.preservation?.mutableDeleteExcludes || sync?.deleteExcludes || [];
1832
+ return {
1833
+ ...covering,
1834
+ status: sync && covering.mutableDeleteExcludes.every((pattern) => actualExcludes.includes(pattern))
1835
+ ? "pass"
1836
+ : "fail",
1837
+ };
1838
+ });
1839
+ return {
1840
+ surface: binding.surface,
1841
+ manifestPath: binding.immutablePublication.manifestPath,
1842
+ preservedRoots: binding.immutablePublication.preservedRoots,
1843
+ declaredPrefixes: binding.immutablePublication.declaredPrefixes,
1844
+ fileCount: binding.immutablePublication.files.length,
1845
+ mutableDeleteExcludes: binding.mutableDeleteExcludes || [],
1846
+ coveringBindings,
1847
+ actions: [...actions].sort(),
1848
+ requiredActions,
1849
+ missingActions,
1850
+ status: missingActions.length === 0 && coveringBindings.every((covering) => covering.status === "pass")
1851
+ ? "pass"
1852
+ : "fail",
1853
+ };
1854
+ });
1855
+ checks.push({
1856
+ surface: "__immutable__",
1857
+ url: "",
1858
+ status: preservationBindings.every((binding) => binding.status === "pass") ? "pass" : "fail",
1859
+ bindings: preservationBindings,
1860
+ message: preservationBindings.every((binding) => binding.status === "pass")
1861
+ ? "immutable publication roots were excluded from mutable deletion and verified around no-overwrite upload"
1862
+ : "immutable publication preservation evidence is incomplete",
1863
+ });
1864
+ }
1865
+
1625
1866
  const manifestChecks = bindings.map((binding) => ({
1626
1867
  surface: binding.surface,
1627
1868
  manifestKey: binding.manifestKey,
@@ -1655,13 +1896,66 @@ function deployBindingOperations({ artifactRoot, deployConfig, manifest, binding
1655
1896
  const bucket = binding.bucket || effectiveDeploy.bucket || effectiveDeploy.target || "";
1656
1897
  const distribution = binding.distributionId || effectiveDeploy.cloudfront_distribution || effectiveDeploy.distribution || "";
1657
1898
  const surfaceArtifactRoot = surfaceArtifactRootFor({ artifactRoot, binding });
1899
+ const immutable = binding.immutablePublication;
1900
+ const immutableVerifier = path.join(moduleDir, "web-surface-immutable-object.mjs");
1901
+ const verifyImmutable = (phase) => (immutable?.files || []).map((file) => ({
1902
+ action: `verify-immutable-artifact-${phase}`,
1903
+ surface: binding.surface,
1904
+ command: "node",
1905
+ args: [
1906
+ immutableVerifier,
1907
+ "--bucket",
1908
+ bucket,
1909
+ "--key",
1910
+ joinS3Key(binding.objectPrefix, file.path),
1911
+ "--sha256",
1912
+ file.sha256,
1913
+ ],
1914
+ immutable: {
1915
+ phase,
1916
+ path: file.path,
1917
+ sha256: file.sha256,
1918
+ },
1919
+ }));
1920
+ const syncImmutable = (immutable?.preservedRoots || []).map((root) => ({
1921
+ action: "sync-immutable-artifact",
1922
+ surface: binding.surface,
1923
+ command: "aws",
1924
+ args: [
1925
+ "s3",
1926
+ "sync",
1927
+ path.join(surfaceArtifactRoot, root),
1928
+ s3Uri(bucket, joinS3Key(binding.objectPrefix, root)),
1929
+ "--no-overwrite",
1930
+ "--checksum-algorithm",
1931
+ "SHA256",
1932
+ ],
1933
+ immutable: {
1934
+ preservedRoot: root,
1935
+ overwrite: false,
1936
+ },
1937
+ }));
1658
1938
  const operations = [
1939
+ ...verifyImmutable("before-upload"),
1940
+ ...syncImmutable,
1941
+ ...verifyImmutable("after-upload"),
1659
1942
  {
1660
1943
  action: "sync-static-artifact",
1661
1944
  surface: binding.surface,
1662
1945
  command: "aws",
1663
- args: syncStaticArtifactArgs({ artifactRoot: surfaceArtifactRoot, bucket, objectPrefix: binding.objectPrefix }),
1946
+ args: syncStaticArtifactArgs({
1947
+ artifactRoot: surfaceArtifactRoot,
1948
+ bucket,
1949
+ objectPrefix: binding.objectPrefix,
1950
+ deleteExcludes: binding.mutableDeleteExcludes || [],
1951
+ }),
1664
1952
  routing: binding.routing,
1953
+ preservation: binding.mutableDeleteExcludes?.length > 0
1954
+ ? {
1955
+ contract: "kungfu-buildchain-web-surface-immutable-delete-exclusion",
1956
+ mutableDeleteExcludes: binding.mutableDeleteExcludes,
1957
+ }
1958
+ : undefined,
1665
1959
  },
1666
1960
  ...directoryIndexAliasOperations({ surfaceArtifactRoot, bucket, binding }),
1667
1961
  {
@@ -1698,32 +1992,61 @@ function deployBindingOperations({ artifactRoot, deployConfig, manifest, binding
1698
1992
 
1699
1993
  function planAdapterSteps(adapter, deployConfig, manifest) {
1700
1994
  if (adapter === "aws-s3-cloudfront") {
1995
+ const bindingSteps = manifest.surfaceBindings.flatMap((binding) => [
1996
+ ...(binding.immutablePublication
1997
+ ? [
1998
+ {
1999
+ action: "verify-immutable-artifact-before-upload",
2000
+ surface: binding.surface,
2001
+ fileCount: binding.immutablePublication.files.length,
2002
+ },
2003
+ {
2004
+ action: "sync-immutable-artifact",
2005
+ surface: binding.surface,
2006
+ roots: binding.immutablePublication.preservedRoots,
2007
+ overwrite: false,
2008
+ },
2009
+ {
2010
+ action: "verify-immutable-artifact-after-upload",
2011
+ surface: binding.surface,
2012
+ fileCount: binding.immutablePublication.files.length,
2013
+ },
2014
+ ]
2015
+ : []),
2016
+ {
2017
+ action: "sync-static-artifact",
2018
+ surface: binding.surface,
2019
+ target: binding.bucket,
2020
+ prefix: binding.objectPrefix,
2021
+ deleteExcludes: binding.mutableDeleteExcludes || [],
2022
+ },
2023
+ {
2024
+ action: "write-deployment-manifest",
2025
+ surface: binding.surface,
2026
+ target: manifestPrefixFor(deployConfig),
2027
+ key: binding.manifestKey,
2028
+ },
2029
+ {
2030
+ action: "invalidate-cdn",
2031
+ surface: binding.surface,
2032
+ distribution: binding.distributionId,
2033
+ },
2034
+ ]);
1701
2035
  return [
2036
+ ...bindingSteps.filter((step) => step.action === "verify-immutable-artifact-before-upload"),
2037
+ ...bindingSteps.filter((step) => step.action === "sync-immutable-artifact"),
2038
+ ...bindingSteps.filter((step) => step.action === "verify-immutable-artifact-after-upload"),
1702
2039
  ...cloudFrontDirectoryIndexRewriteOperations(manifest.surfaceBindings || []).map((operation) => ({
1703
2040
  action: operation.action,
1704
2041
  distribution: operation.routing.distributionId,
1705
2042
  functionName: operation.routing.functionName,
1706
2043
  strategy: operation.routing.strategy,
1707
2044
  })),
1708
- ...manifest.surfaceBindings.flatMap((binding) => [
1709
- {
1710
- action: "sync-static-artifact",
1711
- surface: binding.surface,
1712
- target: binding.bucket,
1713
- prefix: binding.objectPrefix,
1714
- },
1715
- {
1716
- action: "write-deployment-manifest",
1717
- surface: binding.surface,
1718
- target: manifestPrefixFor(deployConfig),
1719
- key: binding.manifestKey,
1720
- },
1721
- {
1722
- action: "invalidate-cdn",
1723
- surface: binding.surface,
1724
- distribution: binding.distributionId,
1725
- },
1726
- ]),
2045
+ ...bindingSteps.filter((step) => ![
2046
+ "verify-immutable-artifact-before-upload",
2047
+ "sync-immutable-artifact",
2048
+ "verify-immutable-artifact-after-upload",
2049
+ ].includes(step.action)),
1727
2050
  ];
1728
2051
  }
1729
2052
  return [
@@ -0,0 +1,123 @@
1
+ import crypto from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ function defaultCommandRunner(command, args) {
6
+ return spawnSync(command, args, {
7
+ encoding: null,
8
+ maxBuffer: 256 * 1024 * 1024,
9
+ });
10
+ }
11
+
12
+ function resultStatus(result) {
13
+ return result?.status ?? result?.exitCode ?? 0;
14
+ }
15
+
16
+ function resultText(value) {
17
+ return Buffer.isBuffer(value) ? value.toString("utf8") : String(value || "");
18
+ }
19
+
20
+ function assertSha256(value) {
21
+ const digest = String(value || "").trim().toLowerCase();
22
+ if (!/^[a-f0-9]{64}$/.test(digest)) {
23
+ throw new Error("expected SHA256 must be a 64-character hexadecimal digest");
24
+ }
25
+ return digest;
26
+ }
27
+
28
+ function isMissingHeadObject(result) {
29
+ if (resultStatus(result) === 0) return false;
30
+ return /(?:404|not found|nosuchkey)/i.test(resultText(result?.stderr));
31
+ }
32
+
33
+ function assertCommandSucceeded(result, label) {
34
+ if (result?.error) throw result.error;
35
+ if (resultStatus(result) !== 0) {
36
+ const stderr = resultText(result?.stderr).trim();
37
+ throw new Error(`${label} failed with exit code ${resultStatus(result)}${stderr ? `: ${stderr}` : ""}`);
38
+ }
39
+ }
40
+
41
+ function checksumHexFromHead(head) {
42
+ const encoded = String(head?.ChecksumSHA256 || "").trim();
43
+ if (!encoded) return "";
44
+ const decoded = Buffer.from(encoded, "base64");
45
+ return decoded.length === 32 ? decoded.toString("hex") : "";
46
+ }
47
+
48
+ export function verifyImmutableS3Object({
49
+ bucket,
50
+ key,
51
+ expectedSha256,
52
+ commandRunner = defaultCommandRunner,
53
+ } = {}) {
54
+ const expected = assertSha256(expectedSha256);
55
+ if (!String(bucket || "").trim() || !String(key || "").trim()) {
56
+ throw new Error("immutable S3 verification requires bucket and key");
57
+ }
58
+ const head = commandRunner("aws", [
59
+ "s3api",
60
+ "head-object",
61
+ "--bucket",
62
+ bucket,
63
+ "--key",
64
+ key,
65
+ "--checksum-mode",
66
+ "ENABLED",
67
+ ]);
68
+ if (isMissingHeadObject(head)) {
69
+ return { status: "missing", bucket, key, expectedSha256: expected };
70
+ }
71
+ assertCommandSucceeded(head, `head immutable object s3://${bucket}/${key}`);
72
+
73
+ let stored = "";
74
+ let source = "";
75
+ try {
76
+ stored = checksumHexFromHead(JSON.parse(resultText(head.stdout) || "{}"));
77
+ } catch {
78
+ stored = "";
79
+ }
80
+ if (stored) {
81
+ source = "s3-checksum-sha256";
82
+ } else {
83
+ const download = commandRunner("aws", ["s3", "cp", `s3://${bucket}/${key}`, "-", "--only-show-errors"]);
84
+ assertCommandSucceeded(download, `read immutable object s3://${bucket}/${key}`);
85
+ const body = Buffer.isBuffer(download.stdout) ? download.stdout : Buffer.from(download.stdout || "");
86
+ stored = crypto.createHash("sha256").update(body).digest("hex");
87
+ source = "downloaded-object";
88
+ }
89
+ if (stored !== expected) {
90
+ throw new Error(
91
+ `immutable object digest mismatch for s3://${bucket}/${key}: expected ${expected}, got ${stored}`,
92
+ );
93
+ }
94
+ return { status: "verified", bucket, key, sha256: stored, source };
95
+ }
96
+
97
+ function cliArgs(argv) {
98
+ const values = {};
99
+ for (let index = 0; index < argv.length; index += 2) {
100
+ const flag = argv[index];
101
+ const value = argv[index + 1];
102
+ if (!flag?.startsWith("--") || value === undefined) {
103
+ throw new Error("usage: web-surface-immutable-object --bucket NAME --key KEY --sha256 DIGEST");
104
+ }
105
+ values[flag.slice(2)] = value;
106
+ }
107
+ return values;
108
+ }
109
+
110
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
111
+ try {
112
+ const args = cliArgs(process.argv.slice(2));
113
+ const result = verifyImmutableS3Object({
114
+ bucket: args.bucket,
115
+ key: args.key,
116
+ expectedSha256: args.sha256,
117
+ });
118
+ process.stdout.write(`${JSON.stringify(result)}\n`);
119
+ } catch (error) {
120
+ process.stderr.write(`${String(error.message || error)}\n`);
121
+ process.exitCode = 1;
122
+ }
123
+ }
@@ -207,6 +207,9 @@ export function compactWebSurfaceApplyResult(result = {}) {
207
207
  objectPrefix: result.objectPrefix || "",
208
208
  manifestKey: result.manifestKey || "",
209
209
  invalidationPaths: Array.isArray(result.invalidationPaths) ? result.invalidationPaths : [],
210
+ immutablePreservation: Array.isArray(result.immutablePreservation)
211
+ ? result.immutablePreservation
212
+ : [],
210
213
  surfaceBindings: Array.isArray(result.surfaceBindings)
211
214
  ? result.surfaceBindings.map((binding) => ({
212
215
  surface: binding.surface || "",
@@ -216,6 +219,7 @@ export function compactWebSurfaceApplyResult(result = {}) {
216
219
  manifestKey: binding.manifestKey || "",
217
220
  accessControl: binding.accessControl || "",
218
221
  healthStrategy: binding.healthStrategy || "",
222
+ mutableDeleteExcludes: binding.mutableDeleteExcludes || [],
219
223
  }))
220
224
  : [],
221
225
  };