@gmickel/gno 1.32.0 → 1.33.0

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 (33) hide show
  1. package/README.md +11 -2
  2. package/assets/skill/SKILL.md +11 -0
  3. package/assets/skill/cli-reference.md +10 -2
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.33.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.33.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +5 -1
  8. package/spec/cli.md +16 -1
  9. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  10. package/src/cli/commands/publish.ts +43 -7
  11. package/src/ingestion/strip.ts +152 -26
  12. package/src/publish/artifact-asset-codec.ts +75 -0
  13. package/src/publish/artifact-asset-contract.ts +152 -0
  14. package/src/publish/artifact-asset-parse.ts +401 -0
  15. package/src/publish/artifact-asset-sniff.ts +108 -0
  16. package/src/publish/artifact-asset-validate.ts +209 -0
  17. package/src/publish/artifact-assets.ts +58 -0
  18. package/src/publish/artifact-validation.ts +32 -6
  19. package/src/publish/artifact.ts +50 -3
  20. package/src/publish/attachment-bundle.ts +145 -0
  21. package/src/publish/attachment-discover.ts +203 -0
  22. package/src/publish/attachment-load.ts +133 -0
  23. package/src/publish/attachment-obsidian.ts +45 -0
  24. package/src/publish/attachment-path.ts +334 -0
  25. package/src/publish/attachment-raster.ts +852 -0
  26. package/src/publish/attachment-resolver.ts +280 -0
  27. package/src/publish/attachment-types.ts +54 -0
  28. package/src/publish/encrypted-export.ts +121 -44
  29. package/src/publish/export-attachments.ts +224 -0
  30. package/src/publish/export-service.ts +142 -80
  31. package/src/publish/obsidian-sanitize.ts +121 -13
  32. package/src/serve/routes/api.ts +2 -1
  33. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Fail-closed runtime validation for optional publish raster assets.
3
+ *
4
+ * @module src/publish/artifact-asset-validate
5
+ */
6
+
7
+ import { measureArtifactUploadBytes } from "./artifact-asset-codec";
8
+ import {
9
+ BUNDLED_RASTER_ASSETS_CAPABILITY,
10
+ MAX_PUBLISH_UPLOAD_BYTES,
11
+ type PublishArtifactAsset,
12
+ type PublishAssetContractResult,
13
+ type ValidatePublishAssetContractOptions,
14
+ } from "./artifact-asset-contract";
15
+ import {
16
+ collectMarkdownSentinels,
17
+ fail,
18
+ readAssets,
19
+ readRequiredCapabilities,
20
+ type ContractFailure,
21
+ } from "./artifact-asset-parse";
22
+
23
+ type NoteIndex = {
24
+ markdownBySlug: Map<string, string[]>;
25
+ slugs: Set<string>;
26
+ };
27
+
28
+ const collectNoteIndex = (artifact: Record<string, unknown>): NoteIndex => {
29
+ const markdownBySlug = new Map<string, string[]>();
30
+ const slugs = new Set<string>();
31
+ const spaces = Array.isArray(artifact.spaces) ? artifact.spaces : [];
32
+ for (const space of spaces) {
33
+ if (!(space && typeof space === "object" && !Array.isArray(space)))
34
+ continue;
35
+ const record = space as Record<string, unknown>;
36
+ if (!Array.isArray(record.notes)) continue;
37
+ for (const note of record.notes) {
38
+ if (!(note && typeof note === "object" && !Array.isArray(note))) continue;
39
+ const noteRecord = note as Record<string, unknown>;
40
+ if (typeof noteRecord.slug !== "string") continue;
41
+ slugs.add(noteRecord.slug);
42
+ if (typeof noteRecord.markdown === "string") {
43
+ const markdownEntries = markdownBySlug.get(noteRecord.slug) ?? [];
44
+ markdownEntries.push(noteRecord.markdown);
45
+ markdownBySlug.set(noteRecord.slug, markdownEntries);
46
+ }
47
+ }
48
+ }
49
+ return { markdownBySlug, slugs };
50
+ };
51
+
52
+ const collectNoteMarkdown = (notes: NoteIndex): Array<string> =>
53
+ [...notes.markdownBySlug.values()].flat();
54
+
55
+ const validateReferenceOwnership = (
56
+ assets: Array<PublishArtifactAsset>,
57
+ notes: NoteIndex,
58
+ sentinelOwners: Map<string, Set<string>>
59
+ ): ContractFailure | null => {
60
+ for (const [index, asset] of assets.entries()) {
61
+ const field = `assets[${index}]`;
62
+ const owners = sentinelOwners.get(asset.id) ?? new Set<string>();
63
+ if (owners.size === 0) {
64
+ return fail(
65
+ "ASSET_MISSING",
66
+ `${field} has no matching gno-asset sentinel in any artifact note`
67
+ );
68
+ }
69
+ const claimed = new Set<string>();
70
+ for (const [refIndex, reference] of asset.references.entries()) {
71
+ if (!notes.slugs.has(reference.noteSlug)) {
72
+ return fail(
73
+ "ASSET_MISSING",
74
+ `${field}.references[${refIndex}].noteSlug "${reference.noteSlug}" does not own an artifact note`
75
+ );
76
+ }
77
+ claimed.add(reference.noteSlug);
78
+ if (!owners.has(reference.noteSlug)) {
79
+ return fail(
80
+ "ASSET_SENTINEL_UNRESOLVED",
81
+ `${field}.references[${refIndex}] claims note "${reference.noteSlug}" without a matching gno-asset sentinel`
82
+ );
83
+ }
84
+ }
85
+ for (const owner of owners) {
86
+ if (!claimed.has(owner)) {
87
+ return fail(
88
+ "ASSET_SENTINEL_UNRESOLVED",
89
+ `${field} is referenced by note "${owner}" but references[] omits that ownership`
90
+ );
91
+ }
92
+ }
93
+ }
94
+ return null;
95
+ };
96
+
97
+ /**
98
+ * Executable producer/consumer contract for optional raster assets.
99
+ * Legacy asset-free v1/v2 artifacts remain accepted.
100
+ */
101
+ export const validatePublishAssetContract = (
102
+ artifact: unknown,
103
+ options: ValidatePublishAssetContractOptions = {}
104
+ ): PublishAssetContractResult => {
105
+ if (!(artifact && typeof artifact === "object" && !Array.isArray(artifact))) {
106
+ return fail("ASSET_CORRUPT", "Artifact must be an object");
107
+ }
108
+ const record = artifact as Record<string, unknown>;
109
+ const serializedBytes =
110
+ options.serializedUploadBytes ?? measureArtifactUploadBytes(artifact);
111
+ if (serializedBytes > MAX_PUBLISH_UPLOAD_BYTES) {
112
+ return fail(
113
+ "ENVELOPE_OVERSIZE",
114
+ `Final serialized upload is ${serializedBytes} bytes; max is ${MAX_PUBLISH_UPLOAD_BYTES}`
115
+ );
116
+ }
117
+
118
+ const capabilitiesResult = readRequiredCapabilities(
119
+ record.requiredCapabilities
120
+ );
121
+ if (!capabilitiesResult.ok) return capabilitiesResult;
122
+ const assetsResult = readAssets(record.assets);
123
+ if (!assetsResult.ok) return assetsResult;
124
+
125
+ const { capabilities } = capabilitiesResult;
126
+ const { assets } = assetsResult;
127
+ const requiresBundled = capabilities.includes(
128
+ BUNDLED_RASTER_ASSETS_CAPABILITY
129
+ );
130
+ const notes = collectNoteIndex(record);
131
+ const version = record.version;
132
+
133
+ if (version === 2) {
134
+ if (assets.length > 0) {
135
+ return fail(
136
+ "ASSET_CONFLICT",
137
+ "Encrypted v2 artifacts must not carry plaintext assets on the outer envelope"
138
+ );
139
+ }
140
+ for (const markdown of collectNoteMarkdown(notes)) {
141
+ const sentinels = collectMarkdownSentinels(markdown);
142
+ if (!sentinels.ok) return sentinels;
143
+ if (sentinels.ids.size > 0) {
144
+ return fail(
145
+ "ASSET_SENTINEL_RAW",
146
+ "Encrypted v2 outer envelopes must not contain plaintext gno-asset sentinels"
147
+ );
148
+ }
149
+ }
150
+ if (requiresBundled) {
151
+ return { ok: true, classification: "encrypted-client-payload" };
152
+ }
153
+ return { ok: true, classification: "asset-free" };
154
+ }
155
+
156
+ const referencedIds = new Set<string>();
157
+ const sentinelOwners = new Map<string, Set<string>>();
158
+ for (const [slug, markdownEntries] of notes.markdownBySlug.entries()) {
159
+ for (const markdown of markdownEntries) {
160
+ const sentinels = collectMarkdownSentinels(markdown);
161
+ if (!sentinels.ok) return sentinels;
162
+ for (const id of sentinels.ids) {
163
+ referencedIds.add(id);
164
+ const owners = sentinelOwners.get(id) ?? new Set<string>();
165
+ owners.add(slug);
166
+ sentinelOwners.set(id, owners);
167
+ }
168
+ }
169
+ }
170
+
171
+ if (referencedIds.size > 0 && !requiresBundled && assets.length === 0) {
172
+ return fail(
173
+ "ASSET_SENTINEL_RAW",
174
+ "Unresolved gno-asset sentinels must never reach render; declare bundled-raster-assets@1 and include assets"
175
+ );
176
+ }
177
+
178
+ if (assets.length > 0 && !requiresBundled) {
179
+ return fail(
180
+ "CAPABILITY_UNSUPPORTED",
181
+ "assets require requiredCapabilities to include bundled-raster-assets@1"
182
+ );
183
+ }
184
+
185
+ if (assets.length === 0 && referencedIds.size === 0) {
186
+ return { ok: true, classification: "asset-free" };
187
+ }
188
+
189
+ for (const id of referencedIds) {
190
+ if (!assets.some((asset) => asset.id === id)) {
191
+ return fail(
192
+ "ASSET_MISSING",
193
+ `Markdown references gno-asset:${id} but no matching asset descriptor is present`
194
+ );
195
+ }
196
+ }
197
+
198
+ if (referencedIds.size > 0 && assets.length === 0) {
199
+ return fail(
200
+ "ASSET_MISSING",
201
+ "Markdown contains gno-asset sentinels but assets[] is empty"
202
+ );
203
+ }
204
+
205
+ const ownership = validateReferenceOwnership(assets, notes, sentinelOwners);
206
+ if (ownership) return ownership;
207
+
208
+ return { ok: true, classification: "bundled-raster-v1" };
209
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Cross-repo publish artifact raster-asset contract (producer side).
3
+ *
4
+ * Freezes capability negotiation, sentinel grammar, byte accounting,
5
+ * signature sniffing, visibility/lifecycle vocabulary, and fail-closed
6
+ * diagnostics. Does not resolve filesystem attachments or deliver objects.
7
+ *
8
+ * Public import path remains `./artifact-assets` / `src/publish/artifact-assets`.
9
+ *
10
+ * @module src/publish/artifact-assets
11
+ */
12
+
13
+ export type {
14
+ KnownPublishRequiredCapability,
15
+ PublishArtifactAsset,
16
+ PublishArtifactAssetReference,
17
+ PublishAssetClassification,
18
+ PublishAssetContractResult,
19
+ PublishAssetDiagnostic,
20
+ PublishAssetDiagnosticCode,
21
+ PublishAssetLifecycleTerminal,
22
+ SupportedRasterMediaType,
23
+ ValidatePublishAssetContractOptions,
24
+ } from "./artifact-asset-contract";
25
+ export {
26
+ ASSET_DESCRIPTOR_KEYS,
27
+ ASSET_REFERENCE_KEYS,
28
+ BUNDLED_RASTER_ASSETS_CAPABILITY,
29
+ GNO_ASSET_SENTINEL_PATTERN,
30
+ GNO_ASSET_SENTINEL_PREFIX,
31
+ KNOWN_PUBLISH_REQUIRED_CAPABILITIES,
32
+ MAX_PUBLISH_UPLOAD_BYTES,
33
+ MAX_RASTER_DIMENSION_PX,
34
+ MAX_REQUIRED_CAPABILITY_LENGTH,
35
+ MAX_SOURCE_REF_LENGTH,
36
+ MIN_RASTER_DIMENSION_PX,
37
+ MIN_REQUIRED_CAPABILITY_LENGTH,
38
+ MIN_SOURCE_REF_LENGTH,
39
+ PUBLISH_ASSET_DIAGNOSTIC_CODES,
40
+ PUBLISH_ASSET_LIFECYCLE_TERMINALS,
41
+ PUBLISH_ASSET_NOTE_SLUG_PATTERN,
42
+ PUBLISH_ASSET_VISIBILITY,
43
+ SUPPORTED_RASTER_MEDIA_TYPES,
44
+ } from "./artifact-asset-contract";
45
+ export {
46
+ decodeBase64ToBytes,
47
+ measureArtifactUploadBytes,
48
+ measureSerializedUploadBytes,
49
+ serializePublishArtifact,
50
+ sha256BytesHex,
51
+ } from "./artifact-asset-codec";
52
+ export {
53
+ formatGnoAssetSentinel,
54
+ matchGnoAssetTokens,
55
+ parseGnoAssetSentinel,
56
+ sniffRasterMediaType,
57
+ } from "./artifact-asset-sniff";
58
+ export { validatePublishAssetContract } from "./artifact-asset-validate";
@@ -4,11 +4,27 @@
4
4
  * @module src/publish/artifact-validation
5
5
  */
6
6
 
7
+ import { MAX_PUBLISH_UPLOAD_BYTES } from "./artifact-asset-contract";
8
+
7
9
  export const MAX_PUBLISH_SLUG_LENGTH = 80;
8
- export const MAX_ENCRYPTED_CIPHERTEXT_BASE64_LENGTH = 67_108_864;
10
+ /**
11
+ * Ciphertext base64 character ceiling aligned to the 100 MiB final-envelope
12
+ * budget. Exact final serialized UTF-8 measurement remains the authoritative
13
+ * upload gate; this bound only prevents a single field from exceeding what the
14
+ * envelope itself could ever carry.
15
+ */
16
+ export const MAX_ENCRYPTED_CIPHERTEXT_BASE64_LENGTH = MAX_PUBLISH_UPLOAD_BYTES;
9
17
  export const MAX_ENCRYPTED_KEY_MATERIAL_BASE64_LENGTH = 1024;
10
18
  export const MAX_ENCRYPTED_SECRET_TOKEN_LENGTH = 512;
11
19
 
20
+ /** Length-only seam for ciphertext budget tests (avoids allocating huge strings). */
21
+ export const encryptedCiphertextCharLengthAllowed = (
22
+ charLength: number
23
+ ): boolean =>
24
+ Number.isSafeInteger(charLength) &&
25
+ charLength >= 1 &&
26
+ charLength <= MAX_ENCRYPTED_CIPHERTEXT_BASE64_LENGTH;
27
+
12
28
  const PUBLISH_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/u;
13
29
  const BASE64_PATTERN =
14
30
  /^(?:[a-zA-Z0-9+/]{4})*(?:[a-zA-Z0-9+/]{2}==|[a-zA-Z0-9+/]{3}=)?$/u;
@@ -95,6 +111,20 @@ const requireBase64 = (
95
111
  return result;
96
112
  };
97
113
 
114
+ const requireEncryptedCiphertext = (value: unknown): string => {
115
+ const field = "encryptedPayload.ciphertext";
116
+ const result = requireNonblankString(value, field);
117
+ if (!encryptedCiphertextCharLengthAllowed(result.length)) {
118
+ throw new Error(
119
+ `${field} must not exceed ${MAX_ENCRYPTED_CIPHERTEXT_BASE64_LENGTH} characters`
120
+ );
121
+ }
122
+ if (!BASE64_PATTERN.test(result)) {
123
+ throw new Error(`${field} must be valid base64`);
124
+ }
125
+ return result;
126
+ };
127
+
98
128
  const requireSlug = (value: unknown, field: string): string => {
99
129
  const result = requireString(value, field);
100
130
  if (!PUBLISH_SLUG_PATTERN.test(result)) {
@@ -217,11 +247,7 @@ export const validateAndProjectEncryptedPublishInput = (
217
247
 
218
248
  return {
219
249
  encryptedPayload: {
220
- ciphertext: requireBase64(
221
- payload.ciphertext,
222
- "encryptedPayload.ciphertext",
223
- MAX_ENCRYPTED_CIPHERTEXT_BASE64_LENGTH
224
- ),
250
+ ciphertext: requireEncryptedCiphertext(payload.ciphertext),
225
251
  iterations,
226
252
  iv: requireBase64(
227
253
  payload.iv,
@@ -6,6 +6,10 @@
6
6
 
7
7
  import type { EgressLineage } from "../core/egress-provenance";
8
8
  import type { DocumentRow } from "../store/types";
9
+ import type {
10
+ KnownPublishRequiredCapability,
11
+ PublishArtifactAsset,
12
+ } from "./artifact-assets";
9
13
 
10
14
  import { deriveDocid } from "../app/constants";
11
15
  import {
@@ -26,7 +30,37 @@ import {
26
30
 
27
31
  export { MAX_PUBLISH_SLUG_LENGTH } from "./artifact-validation";
28
32
  export { buildExportedMetadata } from "./metadata";
29
-
33
+ export type {
34
+ PublishArtifactAsset,
35
+ PublishArtifactAssetReference,
36
+ SupportedRasterMediaType,
37
+ } from "./artifact-assets";
38
+ export {
39
+ BUNDLED_RASTER_ASSETS_CAPABILITY,
40
+ GNO_ASSET_SENTINEL_PATTERN,
41
+ MAX_PUBLISH_UPLOAD_BYTES,
42
+ PUBLISH_ASSET_DIAGNOSTIC_CODES,
43
+ PUBLISH_ASSET_LIFECYCLE_TERMINALS,
44
+ PUBLISH_ASSET_VISIBILITY,
45
+ SUPPORTED_RASTER_MEDIA_TYPES,
46
+ formatGnoAssetSentinel,
47
+ measureArtifactUploadBytes,
48
+ measureSerializedUploadBytes,
49
+ parseGnoAssetSentinel,
50
+ serializePublishArtifact,
51
+ sniffRasterMediaType,
52
+ validatePublishAssetContract,
53
+ } from "./artifact-assets";
54
+ export {
55
+ attachAssetsToV1Artifact,
56
+ buildDeterministicAssets,
57
+ emptyAssetEgressSummary,
58
+ summarizeAssetEgress,
59
+ } from "./attachment-resolver";
60
+ export type {
61
+ AttachmentDiagnostic,
62
+ PublishAssetEgressSummary,
63
+ } from "./attachment-resolver";
30
64
  export type PublishVisibility =
31
65
  | "encrypted"
32
66
  | "invite-only"
@@ -122,8 +156,10 @@ export interface EncryptedPublishArtifactSpace {
122
156
  }
123
157
 
124
158
  export interface PublishArtifactV1 {
159
+ assets?: PublishArtifactAsset[];
125
160
  egressLineage: EgressLineage;
126
161
  exportedAt: string;
162
+ requiredCapabilities?: KnownPublishRequiredCapability[];
127
163
  source: string;
128
164
  spaces: PublishArtifactSpace[];
129
165
  version: 1;
@@ -132,6 +168,7 @@ export interface PublishArtifactV1 {
132
168
  export interface PublishArtifactV2 {
133
169
  egressLineage: EgressLineage;
134
170
  exportedAt: string;
171
+ requiredCapabilities?: KnownPublishRequiredCapability[];
135
172
  source: string;
136
173
  spaces: EncryptedPublishArtifactSpace[];
137
174
  version: 2;
@@ -406,17 +443,22 @@ export const buildPublishArtifact = (input: {
406
443
  export const buildEncryptedPublishArtifact = (input: {
407
444
  egressLineage?: EgressLineage;
408
445
  encryptedPayload: EncryptedArtifactPayload;
446
+ requiredCapabilities?: KnownPublishRequiredCapability[];
409
447
  routeSlug: string;
410
448
  secretToken: string;
411
449
  sourceType: "note" | "collection";
412
450
  }): PublishArtifactV2 => {
413
- const { egressLineage: providedLineage, ...encryptedInput } = input;
451
+ const {
452
+ egressLineage: providedLineage,
453
+ requiredCapabilities,
454
+ ...encryptedInput
455
+ } = input;
414
456
  const validated = validateAndProjectEncryptedPublishInput(encryptedInput);
415
457
  const egressLineage = egressLineageSchema.parse(
416
458
  providedLineage ?? legacyLocalOnlyEgressLineage("legacy")
417
459
  );
418
460
  const exportedAt = requirePublishDateTime(new Date().toISOString());
419
- return {
461
+ const artifact: PublishArtifactV2 = {
420
462
  egressLineage,
421
463
  exportedAt,
422
464
  source: validated.routeSlug,
@@ -431,6 +473,11 @@ export const buildEncryptedPublishArtifact = (input: {
431
473
  ],
432
474
  version: 2,
433
475
  };
476
+ // Capability is declared on the outer envelope; plaintext assets stay inside ciphertext.
477
+ if (requiredCapabilities && requiredCapabilities.length > 0) {
478
+ artifact.requiredCapabilities = [...requiredCapabilities];
479
+ }
480
+ return artifact;
434
481
  };
435
482
 
436
483
  export const derivePublishArtifactFilename = (artifact: PublishArtifact) => {
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Deterministic asset descriptor assembly and egress size accounting.
3
+ *
4
+ * @module src/publish/attachment-bundle
5
+ */
6
+
7
+ import type { PublishArtifactV1 } from "./artifact";
8
+ import type {
9
+ AttachmentDiagnostic,
10
+ PendingAssetPayload,
11
+ PublishAssetEgressSummary,
12
+ } from "./attachment-types";
13
+
14
+ import { measureArtifactUploadBytes } from "./artifact-asset-codec";
15
+ import {
16
+ BUNDLED_RASTER_ASSETS_CAPABILITY,
17
+ MAX_PUBLISH_UPLOAD_BYTES,
18
+ type PublishArtifactAsset,
19
+ type PublishArtifactAssetReference,
20
+ } from "./artifact-asset-contract";
21
+
22
+ const compareCodeUnits = (left: string, right: string): number => {
23
+ if (left < right) return -1;
24
+ if (left > right) return 1;
25
+ return 0;
26
+ };
27
+
28
+ const sortReferences = (
29
+ references: PublishArtifactAssetReference[]
30
+ ): PublishArtifactAssetReference[] =>
31
+ [...references].sort(
32
+ (a, b) =>
33
+ compareCodeUnits(a.noteSlug, b.noteSlug) ||
34
+ compareCodeUnits(a.sourceRef, b.sourceRef)
35
+ );
36
+
37
+ /** Merge pending payloads across notes into deterministic asset descriptors. */
38
+ export function buildDeterministicAssets(
39
+ payloads: Map<string, PendingAssetPayload>
40
+ ): PublishArtifactAsset[] {
41
+ const assets: PublishArtifactAsset[] = [];
42
+ for (const id of [...payloads.keys()].sort(compareCodeUnits)) {
43
+ const payload = payloads.get(id);
44
+ if (!payload) continue;
45
+ const references = sortReferences(payload.references);
46
+ const seen = new Set<string>();
47
+ const uniqueRefs: PublishArtifactAssetReference[] = [];
48
+ for (const reference of references) {
49
+ const key = `${reference.noteSlug}\0${reference.sourceRef}`;
50
+ if (seen.has(key)) continue;
51
+ seen.add(key);
52
+ uniqueRefs.push(reference);
53
+ }
54
+ assets.push({
55
+ byteLength: payload.byteLength,
56
+ data: payload.data,
57
+ encoding: "base64",
58
+ height: payload.height,
59
+ id: payload.sha256,
60
+ mediaType: payload.mediaType,
61
+ references: uniqueRefs,
62
+ sha256: payload.sha256,
63
+ width: payload.width,
64
+ });
65
+ }
66
+ return assets;
67
+ }
68
+
69
+ export function attachAssetsToV1Artifact(
70
+ artifact: PublishArtifactV1,
71
+ assets: PublishArtifactAsset[]
72
+ ): PublishArtifactV1 {
73
+ if (assets.length === 0) {
74
+ const { assets: _assets, requiredCapabilities: _caps, ...rest } = artifact;
75
+ return rest;
76
+ }
77
+ return {
78
+ ...artifact,
79
+ assets,
80
+ requiredCapabilities: [BUNDLED_RASTER_ASSETS_CAPABILITY],
81
+ };
82
+ }
83
+
84
+ export function summarizeAssetEgress(input: {
85
+ /** Optional override when assets live only inside encrypted plaintext. */
86
+ assets?: PublishArtifactAsset[];
87
+ artifact: unknown;
88
+ diagnostics: AttachmentDiagnostic[];
89
+ externalCount: number;
90
+ preDedupRawBytes: number;
91
+ }): PublishAssetEgressSummary {
92
+ const record =
93
+ input.artifact && typeof input.artifact === "object"
94
+ ? (input.artifact as { assets?: PublishArtifactAsset[] })
95
+ : {};
96
+ const assets =
97
+ input.assets ?? (Array.isArray(record.assets) ? record.assets : []);
98
+ const rawBytes = assets.reduce((sum, asset) => sum + asset.byteLength, 0);
99
+ const encodedBytes = assets.reduce(
100
+ (sum, asset) => sum + asset.data.length,
101
+ 0
102
+ );
103
+ const referenceCount = assets.reduce(
104
+ (sum, asset) => sum + asset.references.length,
105
+ 0
106
+ );
107
+ const finalUploadBytes = measureArtifactUploadBytes(input.artifact);
108
+ if (finalUploadBytes > MAX_PUBLISH_UPLOAD_BYTES) {
109
+ throw new Error(
110
+ `ENVELOPE_OVERSIZE: final serialized upload is ${finalUploadBytes} bytes; max is ${MAX_PUBLISH_UPLOAD_BYTES}`
111
+ );
112
+ }
113
+ const diagnostics = [...input.diagnostics].sort(
114
+ (a, b) =>
115
+ compareCodeUnits(a.code, b.code) ||
116
+ compareCodeUnits(a.noteSlug, b.noteSlug) ||
117
+ compareCodeUnits(a.sourceRef, b.sourceRef) ||
118
+ compareCodeUnits(a.message, b.message)
119
+ );
120
+ return {
121
+ assetCount: assets.length,
122
+ dedupSavedBytes: Math.max(0, input.preDedupRawBytes - rawBytes),
123
+ diagnostics,
124
+ encodedBytes,
125
+ externalCount: input.externalCount,
126
+ finalUploadBytes,
127
+ rawBytes,
128
+ referenceCount,
129
+ };
130
+ }
131
+
132
+ export function emptyAssetEgressSummary(
133
+ finalUploadBytes = 0
134
+ ): PublishAssetEgressSummary {
135
+ return {
136
+ assetCount: 0,
137
+ dedupSavedBytes: 0,
138
+ diagnostics: [],
139
+ encodedBytes: 0,
140
+ externalCount: 0,
141
+ finalUploadBytes,
142
+ rawBytes: 0,
143
+ referenceCount: 0,
144
+ };
145
+ }