@gmickel/gno 1.32.0 → 1.34.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 (54) hide show
  1. package/README.md +17 -3
  2. package/assets/skill/SKILL.md +30 -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.34.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.34.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 +60 -1
  9. package/spec/mcp.md +21 -0
  10. package/spec/output-schemas/audit-report.schema.json +284 -0
  11. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  12. package/src/cli/commands/audit.ts +231 -0
  13. package/src/cli/commands/publish.ts +43 -7
  14. package/src/cli/errors.ts +9 -2
  15. package/src/cli/program.ts +112 -0
  16. package/src/core/audit-contract.ts +296 -0
  17. package/src/core/audit-freshness.ts +233 -0
  18. package/src/core/audit-links.ts +222 -0
  19. package/src/core/audit-provenance.ts +154 -0
  20. package/src/core/audit-report.ts +318 -0
  21. package/src/core/audit-workspace.ts +678 -0
  22. package/src/core/audit.ts +569 -0
  23. package/src/core/capture.ts +196 -3
  24. package/src/core/document-capabilities.ts +9 -8
  25. package/src/core/record-metadata.ts +33 -0
  26. package/src/ingestion/strip.ts +152 -26
  27. package/src/mcp/http-egress.ts +8 -0
  28. package/src/mcp/tools/audit.ts +97 -0
  29. package/src/mcp/tools/index.ts +13 -0
  30. package/src/publish/artifact-asset-codec.ts +75 -0
  31. package/src/publish/artifact-asset-contract.ts +152 -0
  32. package/src/publish/artifact-asset-parse.ts +401 -0
  33. package/src/publish/artifact-asset-sniff.ts +108 -0
  34. package/src/publish/artifact-asset-validate.ts +209 -0
  35. package/src/publish/artifact-assets.ts +58 -0
  36. package/src/publish/artifact-validation.ts +32 -6
  37. package/src/publish/artifact.ts +50 -3
  38. package/src/publish/attachment-bundle.ts +145 -0
  39. package/src/publish/attachment-discover.ts +203 -0
  40. package/src/publish/attachment-load.ts +133 -0
  41. package/src/publish/attachment-obsidian.ts +45 -0
  42. package/src/publish/attachment-path.ts +334 -0
  43. package/src/publish/attachment-raster.ts +852 -0
  44. package/src/publish/attachment-resolver.ts +280 -0
  45. package/src/publish/attachment-types.ts +54 -0
  46. package/src/publish/encrypted-export.ts +121 -44
  47. package/src/publish/export-attachments.ts +224 -0
  48. package/src/publish/export-service.ts +142 -80
  49. package/src/publish/obsidian-sanitize.ts +121 -13
  50. package/src/serve/routes/api.ts +2 -1
  51. package/src/store/sqlite/adapter.ts +82 -0
  52. package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
  53. package/src/store/sqlite/graph-link-resolver.ts +241 -2
  54. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Synchronous SHA-256 + base64 helpers for publish assets.
3
+ * Bun.CryptoHasher for digests; browser-safe atob for base64 decode parity with gno.sh.
4
+ *
5
+ * @module src/publish/artifact-asset-codec
6
+ */
7
+
8
+ /** Synchronous SHA-256 hex digest over raw bytes (Bun-native). */
9
+ export const sha256BytesHex = (bytes: Uint8Array): string => {
10
+ const hasher = new Bun.CryptoHasher("sha256");
11
+ hasher.update(bytes);
12
+ return hasher.digest("hex");
13
+ };
14
+
15
+ const BASE64_PATTERN =
16
+ /^(?:[a-zA-Z0-9+/]{4})*(?:[a-zA-Z0-9+/]{2}==|[a-zA-Z0-9+/]{3}=)?$/u;
17
+
18
+ /**
19
+ * Encode raw bytes to standard base64 without Node Buffer.
20
+ * Prefers Bun/Web Uint8Array.toBase64(); falls back to btoa for parity with decode.
21
+ */
22
+ export const encodeBytesToBase64 = (bytes: Uint8Array): string => {
23
+ if (typeof bytes.toBase64 === "function") {
24
+ return bytes.toBase64();
25
+ }
26
+ const CHUNK = 0x8000;
27
+ let binary = "";
28
+ for (let offset = 0; offset < bytes.length; offset += CHUNK) {
29
+ const slice = bytes.subarray(offset, offset + CHUNK);
30
+ binary += String.fromCharCode(...slice);
31
+ }
32
+ return btoa(binary);
33
+ };
34
+
35
+ /** Decode standard base64 without Buffer (uses atob). */
36
+ export const decodeBase64ToBytes = (
37
+ data: string
38
+ ): { ok: true; bytes: Uint8Array } | { ok: false } => {
39
+ if (!BASE64_PATTERN.test(data) || data.length === 0) return { ok: false };
40
+ try {
41
+ const binary = atob(data);
42
+ if (binary.length === 0 && data.replace(/=+$/u, "").length > 0) {
43
+ return { ok: false };
44
+ }
45
+ const bytes = new Uint8Array(binary.length);
46
+ for (let i = 0; i < binary.length; i += 1) {
47
+ bytes[i] = binary.charCodeAt(i);
48
+ }
49
+ return { ok: true, bytes };
50
+ } catch {
51
+ return { ok: false };
52
+ }
53
+ };
54
+
55
+ const UTF8 = new TextEncoder();
56
+
57
+ export const measureSerializedUploadBytes = (serializedBody: string): number =>
58
+ UTF8.encode(serializedBody).byteLength;
59
+
60
+ /**
61
+ * Canonical on-disk/upload serialization for publish artifacts.
62
+ *
63
+ * Byte-budget enforcement and every artifact writer must use this exact
64
+ * representation so `finalUploadBytes` describes the bytes handed to gno.sh.
65
+ */
66
+ export const serializePublishArtifact = (artifact: unknown): string => {
67
+ const serialized = JSON.stringify(artifact, null, 2);
68
+ if (serialized === undefined) {
69
+ throw new TypeError("Publish artifact is not JSON-serializable");
70
+ }
71
+ return serialized;
72
+ };
73
+
74
+ export const measureArtifactUploadBytes = (artifact: unknown): number =>
75
+ measureSerializedUploadBytes(serializePublishArtifact(artifact));
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Shared publish-asset contract vocabulary (producer/consumer).
3
+ *
4
+ * @module src/publish/artifact-asset-contract
5
+ */
6
+
7
+ export const MAX_PUBLISH_UPLOAD_BYTES = 100 * 1024 * 1024;
8
+
9
+ /** Schema bounds for asset reference sourceRef (publish-artifact.schema.json). */
10
+ export const MIN_SOURCE_REF_LENGTH = 1;
11
+ export const MAX_SOURCE_REF_LENGTH = 1024;
12
+
13
+ export const BUNDLED_RASTER_ASSETS_CAPABILITY =
14
+ "bundled-raster-assets@1" as const;
15
+
16
+ export const KNOWN_PUBLISH_REQUIRED_CAPABILITIES = [
17
+ BUNDLED_RASTER_ASSETS_CAPABILITY,
18
+ ] as const;
19
+
20
+ /** Schema bounds for requiredCapabilities entries (publish-artifact.schema.json). */
21
+ export const MIN_REQUIRED_CAPABILITY_LENGTH = 1;
22
+ export const MAX_REQUIRED_CAPABILITY_LENGTH = 128;
23
+
24
+ export type KnownPublishRequiredCapability =
25
+ (typeof KNOWN_PUBLISH_REQUIRED_CAPABILITIES)[number];
26
+
27
+ export const SUPPORTED_RASTER_MEDIA_TYPES = [
28
+ "image/png",
29
+ "image/jpeg",
30
+ "image/gif",
31
+ "image/webp",
32
+ "image/avif",
33
+ ] as const;
34
+
35
+ export type SupportedRasterMediaType =
36
+ (typeof SUPPORTED_RASTER_MEDIA_TYPES)[number];
37
+
38
+ export const MAX_RASTER_DIMENSION_PX = 16_384;
39
+ export const MIN_RASTER_DIMENSION_PX = 1;
40
+
41
+ /** Strict sentinel: scheme + lowercase SHA-256 hex asset id. */
42
+ export const GNO_ASSET_SENTINEL_PATTERN = /^gno-asset:([a-f0-9]{64})$/u;
43
+ export const GNO_ASSET_SENTINEL_PREFIX = "gno-asset:";
44
+
45
+ export const PUBLISH_ASSET_NOTE_SLUG_PATTERN =
46
+ /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/u;
47
+
48
+ export const PUBLISH_ASSET_VISIBILITY = {
49
+ public: {
50
+ class: "public",
51
+ delivery: "immutable-public-url",
52
+ storage: "private-until-public-commit",
53
+ notes:
54
+ "Validated assets may use immutable public URLs only after the share is classified public.",
55
+ },
56
+ "secret-link": {
57
+ class: "secret",
58
+ delivery: "capability-authorized",
59
+ storage: "private",
60
+ forbids: ["public-object-url", "presigned-url-as-sole-authorization"],
61
+ notes:
62
+ "Secret is an authorization boundary; private storage delivery requires the secret-share capability.",
63
+ },
64
+ encrypted: {
65
+ class: "encrypted",
66
+ delivery: "client-blob-url",
67
+ storage: "none-plaintext",
68
+ assetPlacement: "encrypted-client-payload",
69
+ notes:
70
+ "Plaintext image bytes remain inside the encrypted client payload; Blob URLs are revoked on replacement/unmount.",
71
+ },
72
+ } as const;
73
+
74
+ export const PUBLISH_ASSET_LIFECYCLE_TERMINALS = [
75
+ "committed",
76
+ "rolled_back",
77
+ "deleted",
78
+ "orphan_cleaned",
79
+ "idempotent_noop",
80
+ ] as const;
81
+
82
+ export type PublishAssetLifecycleTerminal =
83
+ (typeof PUBLISH_ASSET_LIFECYCLE_TERMINALS)[number];
84
+
85
+ export const PUBLISH_ASSET_DIAGNOSTIC_CODES = [
86
+ "ASSET_MISSING",
87
+ "ASSET_CONFLICT",
88
+ "ASSET_SENTINEL_RAW",
89
+ "ASSET_SENTINEL_UNRESOLVED",
90
+ "ASSET_SENTINEL_INVALID",
91
+ "ASSET_TRAVERSAL",
92
+ "ASSET_MIME_SPOOF",
93
+ "ASSET_OVERSIZE",
94
+ "ASSET_CORRUPT",
95
+ "ASSET_UNSUPPORTED_FORMAT",
96
+ "ASSET_DIMENSION_INVALID",
97
+ "CAPABILITY_UNSUPPORTED",
98
+ "ENVELOPE_OVERSIZE",
99
+ ] as const;
100
+
101
+ export type PublishAssetDiagnosticCode =
102
+ (typeof PUBLISH_ASSET_DIAGNOSTIC_CODES)[number];
103
+
104
+ export interface PublishArtifactAssetReference {
105
+ noteSlug: string;
106
+ sourceRef: string;
107
+ }
108
+
109
+ export interface PublishArtifactAsset {
110
+ byteLength: number;
111
+ data: string;
112
+ encoding: "base64";
113
+ height: number;
114
+ id: string;
115
+ mediaType: string;
116
+ references: Array<PublishArtifactAssetReference>;
117
+ sha256: string;
118
+ width: number;
119
+ }
120
+
121
+ export interface PublishAssetDiagnostic {
122
+ code: PublishAssetDiagnosticCode;
123
+ message: string;
124
+ }
125
+
126
+ export type PublishAssetClassification =
127
+ | "asset-free"
128
+ | "bundled-raster-v1"
129
+ | "encrypted-client-payload";
130
+
131
+ export type PublishAssetContractResult =
132
+ | { ok: true; classification: PublishAssetClassification }
133
+ | { ok: false; diagnostic: PublishAssetDiagnostic };
134
+
135
+ export interface ValidatePublishAssetContractOptions {
136
+ /** Exact UTF-8 byte length of the final upload body when already serialized. */
137
+ serializedUploadBytes?: number;
138
+ }
139
+
140
+ export const ASSET_DESCRIPTOR_KEYS = [
141
+ "byteLength",
142
+ "data",
143
+ "encoding",
144
+ "height",
145
+ "id",
146
+ "mediaType",
147
+ "references",
148
+ "sha256",
149
+ "width",
150
+ ] as const;
151
+
152
+ export const ASSET_REFERENCE_KEYS = ["noteSlug", "sourceRef"] as const;
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Closed-object parsing and sourceRef threat checks for publish assets.
3
+ *
4
+ * @module src/publish/artifact-asset-parse
5
+ */
6
+
7
+ import { decodeBase64ToBytes, sha256BytesHex } from "./artifact-asset-codec";
8
+ import {
9
+ ASSET_DESCRIPTOR_KEYS,
10
+ ASSET_REFERENCE_KEYS,
11
+ KNOWN_PUBLISH_REQUIRED_CAPABILITIES,
12
+ MAX_PUBLISH_UPLOAD_BYTES,
13
+ MAX_RASTER_DIMENSION_PX,
14
+ MAX_REQUIRED_CAPABILITY_LENGTH,
15
+ MAX_SOURCE_REF_LENGTH,
16
+ MIN_RASTER_DIMENSION_PX,
17
+ MIN_REQUIRED_CAPABILITY_LENGTH,
18
+ MIN_SOURCE_REF_LENGTH,
19
+ PUBLISH_ASSET_NOTE_SLUG_PATTERN,
20
+ SUPPORTED_RASTER_MEDIA_TYPES,
21
+ type PublishArtifactAsset,
22
+ type PublishArtifactAssetReference,
23
+ type PublishAssetDiagnostic,
24
+ type PublishAssetDiagnosticCode,
25
+ type SupportedRasterMediaType,
26
+ } from "./artifact-asset-contract";
27
+ import { parseGnoAssetSentinel } from "./artifact-asset-sniff";
28
+ import { discoverImageOccurrences } from "./attachment-discover";
29
+ import { validateRasterBytesStructural } from "./attachment-raster";
30
+
31
+ const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/u;
32
+ const TRAVERSAL_PATTERN = /(?:^|[\\/])\.\.(?:[\\/]|$)|^\/|^[a-zA-Z]:[\\/]/u;
33
+ const ABSOLUTE_PATTERN = /^(?:\/|\\|[a-zA-Z]:[\\/])/u;
34
+
35
+ export type ContractFailure = {
36
+ ok: false;
37
+ diagnostic: PublishAssetDiagnostic;
38
+ };
39
+
40
+ export const fail = (
41
+ code: PublishAssetDiagnosticCode,
42
+ message: string
43
+ ): ContractFailure => ({
44
+ ok: false,
45
+ diagnostic: { code, message },
46
+ });
47
+
48
+ const isSupportedMediaType = (
49
+ value: string
50
+ ): value is SupportedRasterMediaType =>
51
+ (SUPPORTED_RASTER_MEDIA_TYPES as ReadonlyArray<string>).includes(value);
52
+
53
+ const sortedKeysEqual = (
54
+ keys: Array<string>,
55
+ expected: ReadonlyArray<string>
56
+ ): boolean =>
57
+ JSON.stringify([...keys].sort()) === JSON.stringify([...expected].sort());
58
+
59
+ export const collectMarkdownSentinels = (
60
+ markdown: string
61
+ ): ContractFailure | { ok: true; ids: Set<string> } => {
62
+ const ids = new Set<string>();
63
+ for (const occurrence of discoverImageOccurrences(markdown, {
64
+ excludeFrontmatter: false,
65
+ })) {
66
+ const sourceRef = occurrence.sourceRef;
67
+ if (!sourceRef.startsWith("gno-asset:")) continue;
68
+ const parsed = parseGnoAssetSentinel(sourceRef);
69
+ if (!parsed.ok) {
70
+ return fail(
71
+ "ASSET_SENTINEL_INVALID",
72
+ `Invalid gno-asset sentinel grammar: "${sourceRef}"`
73
+ );
74
+ }
75
+ ids.add(parsed.assetId);
76
+ }
77
+ return { ok: true, ids };
78
+ };
79
+
80
+ const expandSourceRefForms = (sourceRef: string): Array<string> => {
81
+ const forms = [sourceRef];
82
+ let current = sourceRef;
83
+ for (let round = 0; round < 4; round += 1) {
84
+ if (!/%[0-9a-fA-F]{2}/u.test(current)) break;
85
+ try {
86
+ const decoded = decodeURIComponent(current);
87
+ if (decoded === current) break;
88
+ forms.push(decoded);
89
+ current = decoded;
90
+ } catch {
91
+ break;
92
+ }
93
+ }
94
+ return forms;
95
+ };
96
+
97
+ const validateSourceRef = (
98
+ sourceRef: string,
99
+ field: string
100
+ ): ContractFailure | null => {
101
+ if (typeof sourceRef !== "string") {
102
+ return fail("ASSET_CORRUPT", `${field} must be a string`);
103
+ }
104
+ // Enforce schema 1..1024 bound before percent-decode / traversal normalization.
105
+ // JSON Schema maxLength counts Unicode code points, not UTF-16 code units.
106
+ const sourceRefLength = Array.from(sourceRef).length;
107
+ if (
108
+ sourceRefLength < MIN_SOURCE_REF_LENGTH ||
109
+ sourceRefLength > MAX_SOURCE_REF_LENGTH
110
+ ) {
111
+ return fail(
112
+ "ASSET_CORRUPT",
113
+ `${field} must be ${MIN_SOURCE_REF_LENGTH}..${MAX_SOURCE_REF_LENGTH} characters`
114
+ );
115
+ }
116
+ if (sourceRef.trim().length === 0) {
117
+ return fail("ASSET_TRAVERSAL", `${field} must not be blank`);
118
+ }
119
+ for (const form of expandSourceRefForms(sourceRef)) {
120
+ if (
121
+ form.includes("\0") ||
122
+ TRAVERSAL_PATTERN.test(form) ||
123
+ ABSOLUTE_PATTERN.test(form)
124
+ ) {
125
+ return fail(
126
+ "ASSET_TRAVERSAL",
127
+ `${field} escapes the approved collection root`
128
+ );
129
+ }
130
+ }
131
+ return null;
132
+ };
133
+
134
+ const readClosedReference = (
135
+ value: unknown,
136
+ field: string
137
+ ): ContractFailure | { ok: true; reference: PublishArtifactAssetReference } => {
138
+ if (!(value && typeof value === "object" && !Array.isArray(value))) {
139
+ return fail("ASSET_CORRUPT", `${field} must be a closed object`);
140
+ }
141
+ const record = value as Record<string, unknown>;
142
+ if (!sortedKeysEqual(Object.keys(record), ASSET_REFERENCE_KEYS)) {
143
+ return fail("ASSET_CORRUPT", `${field} contains unknown or missing fields`);
144
+ }
145
+ if (typeof record.noteSlug !== "string" || record.noteSlug.trim() === "") {
146
+ return fail(
147
+ "ASSET_CORRUPT",
148
+ `${field}.noteSlug must be a non-empty string`
149
+ );
150
+ }
151
+ if (!PUBLISH_ASSET_NOTE_SLUG_PATTERN.test(record.noteSlug)) {
152
+ return fail(
153
+ "ASSET_CORRUPT",
154
+ `${field}.noteSlug must match publish note slug syntax`
155
+ );
156
+ }
157
+ if (typeof record.sourceRef !== "string") {
158
+ return fail("ASSET_CORRUPT", `${field}.sourceRef must be a string`);
159
+ }
160
+ const traversal = validateSourceRef(record.sourceRef, `${field}.sourceRef`);
161
+ if (traversal) return traversal;
162
+ return {
163
+ ok: true,
164
+ reference: {
165
+ noteSlug: record.noteSlug,
166
+ sourceRef: record.sourceRef,
167
+ },
168
+ };
169
+ };
170
+
171
+ const readClosedAsset = (
172
+ value: unknown,
173
+ index: number
174
+ ): ContractFailure | { ok: true; asset: PublishArtifactAsset } => {
175
+ const field = `assets[${index}]`;
176
+ if (!(value && typeof value === "object" && !Array.isArray(value))) {
177
+ return fail("ASSET_CORRUPT", `${field} must be a closed object`);
178
+ }
179
+ const record = value as Record<string, unknown>;
180
+ if (!sortedKeysEqual(Object.keys(record), ASSET_DESCRIPTOR_KEYS)) {
181
+ return fail("ASSET_CORRUPT", `${field} contains unknown or missing fields`);
182
+ }
183
+
184
+ if (typeof record.id !== "string" || typeof record.sha256 !== "string") {
185
+ return fail(
186
+ "ASSET_CORRUPT",
187
+ `${field} id/sha256 must be lowercase SHA-256 hex`
188
+ );
189
+ }
190
+ if (
191
+ !SHA256_HEX_PATTERN.test(record.id) ||
192
+ !SHA256_HEX_PATTERN.test(record.sha256)
193
+ ) {
194
+ return fail(
195
+ "ASSET_CORRUPT",
196
+ `${field} id/sha256 must be lowercase SHA-256 hex`
197
+ );
198
+ }
199
+ if (record.id !== record.sha256) {
200
+ return fail(
201
+ "ASSET_CONFLICT",
202
+ `${field} id must equal sha256 of payload bytes`
203
+ );
204
+ }
205
+ if (record.encoding !== "base64") {
206
+ return fail("ASSET_CORRUPT", `${field}.encoding must be "base64"`);
207
+ }
208
+ if (
209
+ typeof record.byteLength !== "number" ||
210
+ !Number.isSafeInteger(record.byteLength) ||
211
+ record.byteLength < 1
212
+ ) {
213
+ return fail(
214
+ "ASSET_CORRUPT",
215
+ `${field}.byteLength must be a positive integer`
216
+ );
217
+ }
218
+ if (record.byteLength > MAX_PUBLISH_UPLOAD_BYTES) {
219
+ return fail(
220
+ "ASSET_OVERSIZE",
221
+ `${field}.byteLength exceeds the ${MAX_PUBLISH_UPLOAD_BYTES} byte upload ceiling`
222
+ );
223
+ }
224
+ if (
225
+ typeof record.width !== "number" ||
226
+ typeof record.height !== "number" ||
227
+ !Number.isSafeInteger(record.width) ||
228
+ !Number.isSafeInteger(record.height) ||
229
+ record.width < MIN_RASTER_DIMENSION_PX ||
230
+ record.height < MIN_RASTER_DIMENSION_PX ||
231
+ record.width > MAX_RASTER_DIMENSION_PX ||
232
+ record.height > MAX_RASTER_DIMENSION_PX
233
+ ) {
234
+ return fail(
235
+ "ASSET_DIMENSION_INVALID",
236
+ `${field} dimensions must be integers in ${MIN_RASTER_DIMENSION_PX}..${MAX_RASTER_DIMENSION_PX}`
237
+ );
238
+ }
239
+ if (!Array.isArray(record.references) || record.references.length === 0) {
240
+ return fail(
241
+ "ASSET_CORRUPT",
242
+ `${field}.references must be a non-empty array`
243
+ );
244
+ }
245
+
246
+ const references: Array<PublishArtifactAssetReference> = [];
247
+ for (const [refIndex, entry] of record.references.entries()) {
248
+ const parsed = readClosedReference(
249
+ entry,
250
+ `${field}.references[${refIndex}]`
251
+ );
252
+ if (!parsed.ok) return parsed;
253
+ references.push(parsed.reference);
254
+ }
255
+
256
+ if (typeof record.mediaType !== "string") {
257
+ return fail(
258
+ "ASSET_UNSUPPORTED_FORMAT",
259
+ `${field}.mediaType "${String(record.mediaType)}" is unsupported (SVG excluded)`
260
+ );
261
+ }
262
+ if (record.mediaType === "image/svg+xml") {
263
+ return fail(
264
+ "ASSET_UNSUPPORTED_FORMAT",
265
+ `${field}.mediaType "image/svg+xml" is unsupported (SVG excluded)`
266
+ );
267
+ }
268
+ if (!isSupportedMediaType(record.mediaType)) {
269
+ return fail(
270
+ "ASSET_UNSUPPORTED_FORMAT",
271
+ `${field}.mediaType "${record.mediaType}" is not a supported raster type`
272
+ );
273
+ }
274
+ if (typeof record.data !== "string") {
275
+ return fail("ASSET_CORRUPT", `${field}.data must be valid base64`);
276
+ }
277
+
278
+ const decoded = decodeBase64ToBytes(record.data);
279
+ if (!decoded.ok) {
280
+ return fail("ASSET_CORRUPT", `${field}.data must be valid base64`);
281
+ }
282
+ if (decoded.bytes.byteLength !== record.byteLength) {
283
+ return fail(
284
+ "ASSET_CORRUPT",
285
+ `${field}.byteLength does not match decoded payload length`
286
+ );
287
+ }
288
+ const digest = sha256BytesHex(decoded.bytes);
289
+ if (digest !== record.sha256) {
290
+ return fail(
291
+ "ASSET_CORRUPT",
292
+ `${field}.sha256 does not match payload bytes`
293
+ );
294
+ }
295
+ // Closed-object parse stays synchronous: structural validation only.
296
+ // Producer/file-ingress paths must await validateRasterDecodable separately.
297
+ const validatedRaster = validateRasterBytesStructural(decoded.bytes);
298
+ if (!validatedRaster.ok) {
299
+ return fail(validatedRaster.code, `${field} ${validatedRaster.message}`);
300
+ }
301
+ if (validatedRaster.mediaType !== record.mediaType) {
302
+ return fail(
303
+ "ASSET_MIME_SPOOF",
304
+ `${field} declared ${record.mediaType} but bytes sniff as ${validatedRaster.mediaType}`
305
+ );
306
+ }
307
+ if (
308
+ validatedRaster.width !== record.width ||
309
+ validatedRaster.height !== record.height
310
+ ) {
311
+ return fail(
312
+ "ASSET_DIMENSION_INVALID",
313
+ `${field} declared ${record.width}x${record.height} but bytes are ${validatedRaster.width}x${validatedRaster.height}`
314
+ );
315
+ }
316
+
317
+ return {
318
+ ok: true,
319
+ asset: {
320
+ byteLength: record.byteLength,
321
+ data: record.data,
322
+ encoding: "base64",
323
+ height: record.height,
324
+ id: record.id,
325
+ mediaType: record.mediaType,
326
+ references,
327
+ sha256: record.sha256,
328
+ width: record.width,
329
+ },
330
+ };
331
+ };
332
+
333
+ export const readRequiredCapabilities = (
334
+ value: unknown
335
+ ): ContractFailure | { ok: true; capabilities: Array<string> } => {
336
+ if (value === undefined) return { ok: true, capabilities: [] };
337
+ if (
338
+ !Array.isArray(value) ||
339
+ value.some((entry) => typeof entry !== "string")
340
+ ) {
341
+ return fail(
342
+ "CAPABILITY_UNSUPPORTED",
343
+ "requiredCapabilities must be an array of strings when present"
344
+ );
345
+ }
346
+ const capabilities = value as Array<string>;
347
+ const seen = new Set<string>();
348
+ for (const capability of capabilities) {
349
+ if (
350
+ capability.length < MIN_REQUIRED_CAPABILITY_LENGTH ||
351
+ capability.length > MAX_REQUIRED_CAPABILITY_LENGTH
352
+ ) {
353
+ return fail(
354
+ "CAPABILITY_UNSUPPORTED",
355
+ `requiredCapabilities entries must be non-empty strings of at most ${MAX_REQUIRED_CAPABILITY_LENGTH} characters`
356
+ );
357
+ }
358
+ if (seen.has(capability)) {
359
+ return fail(
360
+ "CAPABILITY_UNSUPPORTED",
361
+ `requiredCapabilities duplicates capability "${capability}"`
362
+ );
363
+ }
364
+ seen.add(capability);
365
+ if (
366
+ !(KNOWN_PUBLISH_REQUIRED_CAPABILITIES as ReadonlyArray<string>).includes(
367
+ capability
368
+ )
369
+ ) {
370
+ return fail(
371
+ "CAPABILITY_UNSUPPORTED",
372
+ `Unsupported required capability "${capability}"`
373
+ );
374
+ }
375
+ }
376
+ return { ok: true, capabilities };
377
+ };
378
+
379
+ export const readAssets = (
380
+ value: unknown
381
+ ): ContractFailure | { ok: true; assets: Array<PublishArtifactAsset> } => {
382
+ if (value === undefined) return { ok: true, assets: [] };
383
+ if (!Array.isArray(value)) {
384
+ return fail("ASSET_CORRUPT", "assets must be an array when present");
385
+ }
386
+ const assets: Array<PublishArtifactAsset> = [];
387
+ const seenIds = new Set<string>();
388
+ for (const [index, entry] of value.entries()) {
389
+ const parsed = readClosedAsset(entry, index);
390
+ if (!parsed.ok) return parsed;
391
+ if (seenIds.has(parsed.asset.id)) {
392
+ return fail(
393
+ "ASSET_CONFLICT",
394
+ `assets[${index}] duplicates asset id ${parsed.asset.id}; dedup requires one descriptor per content id`
395
+ );
396
+ }
397
+ seenIds.add(parsed.asset.id);
398
+ assets.push(parsed.asset);
399
+ }
400
+ return { ok: true, assets };
401
+ };