@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.
- package/README.md +11 -2
- package/assets/skill/SKILL.md +11 -0
- package/assets/skill/cli-reference.md +10 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.33.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.33.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +5 -1
- package/spec/cli.md +16 -1
- package/spec/output-schemas/publish-artifact.schema.json +76 -1
- package/src/cli/commands/publish.ts +43 -7
- package/src/ingestion/strip.ts +152 -26
- package/src/publish/artifact-asset-codec.ts +75 -0
- package/src/publish/artifact-asset-contract.ts +152 -0
- package/src/publish/artifact-asset-parse.ts +401 -0
- package/src/publish/artifact-asset-sniff.ts +108 -0
- package/src/publish/artifact-asset-validate.ts +209 -0
- package/src/publish/artifact-assets.ts +58 -0
- package/src/publish/artifact-validation.ts +32 -6
- package/src/publish/artifact.ts +50 -3
- package/src/publish/attachment-bundle.ts +145 -0
- package/src/publish/attachment-discover.ts +203 -0
- package/src/publish/attachment-load.ts +133 -0
- package/src/publish/attachment-obsidian.ts +45 -0
- package/src/publish/attachment-path.ts +334 -0
- package/src/publish/attachment-raster.ts +852 -0
- package/src/publish/attachment-resolver.ts +280 -0
- package/src/publish/attachment-types.ts +54 -0
- package/src/publish/encrypted-export.ts +121 -44
- package/src/publish/export-attachments.ts +224 -0
- package/src/publish/export-service.ts +142 -80
- package/src/publish/obsidian-sanitize.ts +121 -13
- package/src/serve/routes/api.ts +2 -1
- 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
|
-
|
|
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:
|
|
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,
|
package/src/publish/artifact.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
+
}
|