@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.
- package/README.md +17 -3
- package/assets/skill/SKILL.md +30 -0
- package/assets/skill/cli-reference.md +10 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.34.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.34.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +5 -1
- package/spec/cli.md +60 -1
- package/spec/mcp.md +21 -0
- package/spec/output-schemas/audit-report.schema.json +284 -0
- package/spec/output-schemas/publish-artifact.schema.json +76 -1
- package/src/cli/commands/audit.ts +231 -0
- package/src/cli/commands/publish.ts +43 -7
- package/src/cli/errors.ts +9 -2
- package/src/cli/program.ts +112 -0
- package/src/core/audit-contract.ts +296 -0
- package/src/core/audit-freshness.ts +233 -0
- package/src/core/audit-links.ts +222 -0
- package/src/core/audit-provenance.ts +154 -0
- package/src/core/audit-report.ts +318 -0
- package/src/core/audit-workspace.ts +678 -0
- package/src/core/audit.ts +569 -0
- package/src/core/capture.ts +196 -3
- package/src/core/document-capabilities.ts +9 -8
- package/src/core/record-metadata.ts +33 -0
- package/src/ingestion/strip.ts +152 -26
- package/src/mcp/http-egress.ts +8 -0
- package/src/mcp/tools/audit.ts +97 -0
- package/src/mcp/tools/index.ts +13 -0
- 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/src/store/sqlite/adapter.ts +82 -0
- package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
- package/src/store/sqlite/graph-link-resolver.ts +241 -2
- package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser-aware local attachment discovery, rewrite, and v1 bundling entrypoints.
|
|
3
|
+
*
|
|
4
|
+
* @module src/publish/attachment-resolver
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AttachmentDiagnostic,
|
|
9
|
+
AttachmentResolveContext,
|
|
10
|
+
PendingAssetPayload,
|
|
11
|
+
} from "./attachment-types";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
GNO_ASSET_SENTINEL_PREFIX,
|
|
15
|
+
MAX_PUBLISH_UPLOAD_BYTES,
|
|
16
|
+
} from "./artifact-asset-contract";
|
|
17
|
+
import { formatGnoAssetSentinel } from "./artifact-asset-sniff";
|
|
18
|
+
import {
|
|
19
|
+
attachAssetsToV1Artifact,
|
|
20
|
+
buildDeterministicAssets,
|
|
21
|
+
emptyAssetEgressSummary,
|
|
22
|
+
summarizeAssetEgress,
|
|
23
|
+
} from "./attachment-bundle";
|
|
24
|
+
import {
|
|
25
|
+
discoverImageOccurrences,
|
|
26
|
+
type DiscoveredImageRef,
|
|
27
|
+
} from "./attachment-discover";
|
|
28
|
+
import { readAndValidateAsset } from "./attachment-load";
|
|
29
|
+
import {
|
|
30
|
+
assertContainedFile,
|
|
31
|
+
buildAttachmentBasenameIndex,
|
|
32
|
+
diagnostic,
|
|
33
|
+
resolveCandidateRelPath,
|
|
34
|
+
} from "./attachment-path";
|
|
35
|
+
|
|
36
|
+
export type {
|
|
37
|
+
AttachmentDiagnostic,
|
|
38
|
+
AttachmentDiagnosticCode,
|
|
39
|
+
AttachmentResolveContext,
|
|
40
|
+
PendingAssetPayload,
|
|
41
|
+
PublishAssetEgressSummary,
|
|
42
|
+
} from "./attachment-types";
|
|
43
|
+
export {
|
|
44
|
+
attachAssetsToV1Artifact,
|
|
45
|
+
buildDeterministicAssets,
|
|
46
|
+
emptyAssetEgressSummary,
|
|
47
|
+
summarizeAssetEgress,
|
|
48
|
+
} from "./attachment-bundle";
|
|
49
|
+
export { buildAttachmentBasenameIndex } from "./attachment-path";
|
|
50
|
+
|
|
51
|
+
export interface AttachmentRewriteResult {
|
|
52
|
+
diagnostics: AttachmentDiagnostic[];
|
|
53
|
+
externalCount: number;
|
|
54
|
+
markdown: string;
|
|
55
|
+
payloads: Map<string, PendingAssetPayload>;
|
|
56
|
+
/** Raw bytes summed per successful load before cross-ref dedup. */
|
|
57
|
+
preDedupRawBytes: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type ImageOccurrence = DiscoveredImageRef;
|
|
61
|
+
|
|
62
|
+
const isExternalDestination = (value: string): boolean => {
|
|
63
|
+
const trimmed = value.trim();
|
|
64
|
+
return trimmed.startsWith("//") || /^[a-z][a-z0-9+.-]*:/iu.test(trimmed);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const isDataUrl = (value: string): boolean =>
|
|
68
|
+
value.trim().toLowerCase().startsWith("data:");
|
|
69
|
+
|
|
70
|
+
const escapeMarkdownImageAlt = (value: string): string =>
|
|
71
|
+
value.replace(/[\\[\]]/gu, "\\$&");
|
|
72
|
+
|
|
73
|
+
const formatMarkdownImageTitle = (value: string | null | undefined): string =>
|
|
74
|
+
value === null || value === undefined
|
|
75
|
+
? ""
|
|
76
|
+
: ` "${value.replace(/[\\"]/gu, "\\$&")}"`;
|
|
77
|
+
|
|
78
|
+
const rewriteOccurrence = (
|
|
79
|
+
occurrence: ImageOccurrence,
|
|
80
|
+
assetId: string
|
|
81
|
+
): string => {
|
|
82
|
+
const sentinel = formatGnoAssetSentinel(assetId);
|
|
83
|
+
if (occurrence.kind === "markdown") {
|
|
84
|
+
return `})`;
|
|
85
|
+
}
|
|
86
|
+
const alias = occurrence.alt.trim();
|
|
87
|
+
const display = alias.length > 0 && !/^\d+$/u.test(alias) ? alias : "";
|
|
88
|
+
return ``;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const mergePayload = (
|
|
92
|
+
payloads: Map<string, PendingAssetPayload>,
|
|
93
|
+
loaded: PendingAssetPayload,
|
|
94
|
+
noteSlug: string,
|
|
95
|
+
relPath: string
|
|
96
|
+
): void => {
|
|
97
|
+
const existing = payloads.get(loaded.sha256);
|
|
98
|
+
if (existing) {
|
|
99
|
+
existing.references.push({ noteSlug, sourceRef: relPath });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
payloads.set(loaded.sha256, loaded);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Discover local/external image refs in one parser-aware pass and rewrite
|
|
107
|
+
* successfully bundled destinations to gno-asset:<sha256>.
|
|
108
|
+
*/
|
|
109
|
+
export async function rewriteAttachmentsInMarkdown(
|
|
110
|
+
markdown: string,
|
|
111
|
+
ctx: AttachmentResolveContext
|
|
112
|
+
): Promise<AttachmentRewriteResult> {
|
|
113
|
+
const occurrences = discoverImageOccurrences(markdown);
|
|
114
|
+
const diagnostics: AttachmentDiagnostic[] = [];
|
|
115
|
+
const payloads = new Map<string, PendingAssetPayload>();
|
|
116
|
+
let externalCount = 0;
|
|
117
|
+
let newEncodedAssetBytes = 0;
|
|
118
|
+
let preDedupRawBytes = 0;
|
|
119
|
+
const replacements: Array<{ end: number; start: number; text: string }> = [];
|
|
120
|
+
|
|
121
|
+
for (const occurrence of occurrences) {
|
|
122
|
+
const ref = occurrence.sourceRef.trim();
|
|
123
|
+
if (!ref) {
|
|
124
|
+
diagnostics.push(
|
|
125
|
+
diagnostic(
|
|
126
|
+
"ASSET_CORRUPT",
|
|
127
|
+
"Empty image destination",
|
|
128
|
+
ctx.noteSlug,
|
|
129
|
+
ref
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
replacements.push({
|
|
133
|
+
start: occurrence.start,
|
|
134
|
+
end: occurrence.end,
|
|
135
|
+
text: "",
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (isDataUrl(ref)) {
|
|
140
|
+
diagnostics.push(
|
|
141
|
+
diagnostic(
|
|
142
|
+
"ASSET_UNSUPPORTED_FORMAT",
|
|
143
|
+
"data: image URLs are unsupported",
|
|
144
|
+
ctx.noteSlug,
|
|
145
|
+
ref
|
|
146
|
+
)
|
|
147
|
+
);
|
|
148
|
+
replacements.push({
|
|
149
|
+
start: occurrence.start,
|
|
150
|
+
end: occurrence.end,
|
|
151
|
+
text: "",
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (ref.startsWith(GNO_ASSET_SENTINEL_PREFIX)) {
|
|
156
|
+
diagnostics.push(
|
|
157
|
+
diagnostic(
|
|
158
|
+
"ASSET_CORRUPT",
|
|
159
|
+
"Authored gno-asset references are reserved for publish export",
|
|
160
|
+
ctx.noteSlug,
|
|
161
|
+
ref
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
replacements.push({
|
|
165
|
+
start: occurrence.start,
|
|
166
|
+
end: occurrence.end,
|
|
167
|
+
text: "",
|
|
168
|
+
});
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (isExternalDestination(ref)) {
|
|
172
|
+
if (/^https?:/iu.test(ref) || ref.startsWith("//")) {
|
|
173
|
+
externalCount += 1;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
diagnostics.push(
|
|
177
|
+
diagnostic(
|
|
178
|
+
"ASSET_UNSUPPORTED_FORMAT",
|
|
179
|
+
`Unsupported image protocol in "${ref}"`,
|
|
180
|
+
ctx.noteSlug,
|
|
181
|
+
ref
|
|
182
|
+
)
|
|
183
|
+
);
|
|
184
|
+
replacements.push({
|
|
185
|
+
start: occurrence.start,
|
|
186
|
+
end: occurrence.end,
|
|
187
|
+
text: "",
|
|
188
|
+
});
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const resolved = resolveCandidateRelPath(ref, ctx, occurrence.kind);
|
|
192
|
+
if (!resolved.ok) {
|
|
193
|
+
if (resolved.diagnostic.code === "ASSET_TRAVERSAL") {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`ASSET_TRAVERSAL: ${resolved.diagnostic.message} (${ref})`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
diagnostics.push(resolved.diagnostic);
|
|
199
|
+
replacements.push({
|
|
200
|
+
start: occurrence.start,
|
|
201
|
+
end: occurrence.end,
|
|
202
|
+
text: "",
|
|
203
|
+
});
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const contained = await assertContainedFile(
|
|
208
|
+
ctx.collectionRoot,
|
|
209
|
+
resolved.relPath,
|
|
210
|
+
ctx.noteSlug,
|
|
211
|
+
ref,
|
|
212
|
+
ctx.collectionExcludes
|
|
213
|
+
);
|
|
214
|
+
if ("code" in contained) {
|
|
215
|
+
diagnostics.push(contained);
|
|
216
|
+
replacements.push({
|
|
217
|
+
start: occurrence.start,
|
|
218
|
+
end: occurrence.end,
|
|
219
|
+
text: "",
|
|
220
|
+
});
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const loaded = await readAndValidateAsset(
|
|
225
|
+
contained.absPath,
|
|
226
|
+
ctx.noteSlug,
|
|
227
|
+
ref,
|
|
228
|
+
resolved.relPath
|
|
229
|
+
);
|
|
230
|
+
if ("code" in loaded) {
|
|
231
|
+
diagnostics.push(loaded);
|
|
232
|
+
replacements.push({
|
|
233
|
+
start: occurrence.start,
|
|
234
|
+
end: occurrence.end,
|
|
235
|
+
text: "",
|
|
236
|
+
});
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
preDedupRawBytes += loaded.byteLength;
|
|
241
|
+
const isNewAsset =
|
|
242
|
+
!payloads.has(loaded.sha256) && !ctx.existingAssetIds?.has(loaded.sha256);
|
|
243
|
+
if (isNewAsset) {
|
|
244
|
+
const projectedEncodedBytes =
|
|
245
|
+
(ctx.existingEncodedAssetBytes ?? 0) +
|
|
246
|
+
newEncodedAssetBytes +
|
|
247
|
+
loaded.data.length;
|
|
248
|
+
if (projectedEncodedBytes > MAX_PUBLISH_UPLOAD_BYTES) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`ENVELOPE_OVERSIZE: encoded asset data exceeds ${MAX_PUBLISH_UPLOAD_BYTES} bytes`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
newEncodedAssetBytes += loaded.data.length;
|
|
254
|
+
}
|
|
255
|
+
mergePayload(payloads, loaded, ctx.noteSlug, resolved.relPath);
|
|
256
|
+
replacements.push({
|
|
257
|
+
start: occurrence.start,
|
|
258
|
+
end: occurrence.end,
|
|
259
|
+
text: rewriteOccurrence(occurrence, loaded.sha256),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let output = markdown;
|
|
264
|
+
for (const replacement of [...replacements].sort(
|
|
265
|
+
(a, b) => b.start - a.start
|
|
266
|
+
)) {
|
|
267
|
+
output =
|
|
268
|
+
output.slice(0, replacement.start) +
|
|
269
|
+
replacement.text +
|
|
270
|
+
output.slice(replacement.end);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
diagnostics,
|
|
275
|
+
externalCount,
|
|
276
|
+
markdown: output,
|
|
277
|
+
payloads,
|
|
278
|
+
preDedupRawBytes,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared attachment diagnostic and payload types for publish export.
|
|
3
|
+
*
|
|
4
|
+
* @module src/publish/attachment-types
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
PublishArtifactAsset,
|
|
9
|
+
PublishArtifactAssetReference,
|
|
10
|
+
PublishAssetDiagnosticCode,
|
|
11
|
+
} from "./artifact-asset-contract";
|
|
12
|
+
|
|
13
|
+
export type AttachmentDiagnosticCode =
|
|
14
|
+
| PublishAssetDiagnosticCode
|
|
15
|
+
| "ASSET_AMBIGUOUS"
|
|
16
|
+
| "ASSET_EXTERNAL";
|
|
17
|
+
|
|
18
|
+
export interface AttachmentDiagnostic {
|
|
19
|
+
code: AttachmentDiagnosticCode;
|
|
20
|
+
message: string;
|
|
21
|
+
noteSlug: string;
|
|
22
|
+
sourceRef: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PublishAssetEgressSummary {
|
|
26
|
+
assetCount: number;
|
|
27
|
+
dedupSavedBytes: number;
|
|
28
|
+
diagnostics: AttachmentDiagnostic[];
|
|
29
|
+
encodedBytes: number;
|
|
30
|
+
externalCount: number;
|
|
31
|
+
finalUploadBytes: number;
|
|
32
|
+
rawBytes: number;
|
|
33
|
+
referenceCount: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PendingAssetPayload {
|
|
37
|
+
byteLength: number;
|
|
38
|
+
data: string;
|
|
39
|
+
height: number;
|
|
40
|
+
mediaType: PublishArtifactAsset["mediaType"];
|
|
41
|
+
references: PublishArtifactAssetReference[];
|
|
42
|
+
sha256: string;
|
|
43
|
+
width: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AttachmentResolveContext {
|
|
47
|
+
basenameIndex: Map<string, string[]>;
|
|
48
|
+
collectionExcludes?: readonly string[];
|
|
49
|
+
collectionRoot: string;
|
|
50
|
+
existingAssetIds?: ReadonlySet<string>;
|
|
51
|
+
existingEncodedAssetBytes?: number;
|
|
52
|
+
noteSlug: string;
|
|
53
|
+
sourceRelPath: string;
|
|
54
|
+
}
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Client-encrypted publish payload builder (v2).
|
|
3
|
+
* Raster assets are embedded only inside AES-GCM plaintext ReaderSpaceData.
|
|
4
|
+
*
|
|
5
|
+
* @module src/publish/encrypted-export
|
|
6
|
+
*/
|
|
2
7
|
|
|
3
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
EncryptedArtifactPayload,
|
|
10
|
+
PublishArtifactAsset,
|
|
11
|
+
PublishArtifactNote,
|
|
12
|
+
} from "./artifact";
|
|
4
13
|
|
|
5
|
-
|
|
14
|
+
import { encodeBytesToBase64 } from "./artifact-asset-codec";
|
|
15
|
+
import { BUNDLED_RASTER_ASSETS_CAPABILITY } from "./artifact-asset-contract";
|
|
6
16
|
|
|
7
17
|
const PBKDF2_ITERATIONS = 210_000;
|
|
8
18
|
const IV_BYTES = 12;
|
|
9
19
|
const SALT_BYTES = 16;
|
|
10
20
|
|
|
11
21
|
const encoder = new TextEncoder();
|
|
22
|
+
const decoder = new TextDecoder();
|
|
12
23
|
|
|
13
24
|
type MetadataEntry = {
|
|
14
25
|
label: string;
|
|
@@ -47,14 +58,19 @@ type ReaderNoteCard = {
|
|
|
47
58
|
title: string;
|
|
48
59
|
};
|
|
49
60
|
|
|
50
|
-
|
|
61
|
+
/** AES-GCM plaintext shape for encrypted shares (assets stay client-only). */
|
|
62
|
+
export type EncryptedReaderSpaceData = {
|
|
63
|
+
/** Always empty — encrypted shares never project server asset manifests. */
|
|
51
64
|
assetManifest: [];
|
|
65
|
+
/** Validated descriptors + base64 bytes; omitted when asset-free. */
|
|
66
|
+
assets?: PublishArtifactAsset[];
|
|
52
67
|
currentNote: ReaderNoteCard;
|
|
53
68
|
homeNoteSlug?: string;
|
|
54
69
|
metadataPreview: MetadataEntry[];
|
|
55
70
|
nextNoteSlug?: string;
|
|
56
71
|
noteCards: ReaderNoteCard[];
|
|
57
72
|
previousNoteSlug?: string;
|
|
73
|
+
requiredCapabilities?: Array<typeof BUNDLED_RASTER_ASSETS_CAPABILITY>;
|
|
58
74
|
searchIndex: Array<{
|
|
59
75
|
excerpt: string;
|
|
60
76
|
haystack: string;
|
|
@@ -77,7 +93,28 @@ type ReaderSpaceData = {
|
|
|
77
93
|
visibility: "encrypted";
|
|
78
94
|
};
|
|
79
95
|
|
|
80
|
-
const toBase64 = (value: Uint8Array) =>
|
|
96
|
+
const toBase64 = (value: Uint8Array): string => encodeBytesToBase64(value);
|
|
97
|
+
|
|
98
|
+
const fromBase64 = (value: string): Uint8Array => {
|
|
99
|
+
const binary = atob(value);
|
|
100
|
+
const bytes = new Uint8Array(binary.length);
|
|
101
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
102
|
+
bytes[i] = binary.charCodeAt(i);
|
|
103
|
+
}
|
|
104
|
+
return bytes;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const randomBytes = (size: number): Uint8Array => {
|
|
108
|
+
const bytes = new Uint8Array(size);
|
|
109
|
+
crypto.getRandomValues(bytes);
|
|
110
|
+
return bytes;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const toArrayBuffer = (value: Uint8Array): ArrayBuffer =>
|
|
114
|
+
value.buffer.slice(
|
|
115
|
+
value.byteOffset,
|
|
116
|
+
value.byteOffset + value.byteLength
|
|
117
|
+
) as ArrayBuffer;
|
|
81
118
|
|
|
82
119
|
const stripFrontmatter = (markdown: string) => {
|
|
83
120
|
if (!markdown.startsWith("---\n")) {
|
|
@@ -134,6 +171,7 @@ const deriveExcerpt = (summary: string, blocks: NoteBlock[]) => {
|
|
|
134
171
|
};
|
|
135
172
|
|
|
136
173
|
const deriveReaderPayload = (input: {
|
|
174
|
+
assets: PublishArtifactAsset[];
|
|
137
175
|
exportedAt: string;
|
|
138
176
|
homeNoteSlug?: string;
|
|
139
177
|
notes: PublishArtifactNote[];
|
|
@@ -141,7 +179,7 @@ const deriveReaderPayload = (input: {
|
|
|
141
179
|
sourceType: "note" | "collection";
|
|
142
180
|
summary: string;
|
|
143
181
|
title: string;
|
|
144
|
-
}) => {
|
|
182
|
+
}): { payload: EncryptedReaderSpaceData; secretToken: string } => {
|
|
145
183
|
const noteCards: ReaderNoteCard[] = input.notes.map((note) => {
|
|
146
184
|
const blocks = parseMarkdownBlocks(note.markdown);
|
|
147
185
|
return {
|
|
@@ -173,43 +211,55 @@ const deriveReaderPayload = (input: {
|
|
|
173
211
|
(note) => note.noteId === currentNote.noteId
|
|
174
212
|
);
|
|
175
213
|
const sharePath = `/locked/${makeToken(input.routeSlug)}`;
|
|
214
|
+
const hasAssets = input.assets.length > 0;
|
|
215
|
+
|
|
216
|
+
const payload: EncryptedReaderSpaceData = {
|
|
217
|
+
sharePath,
|
|
218
|
+
shareLabel: "Encrypted share",
|
|
219
|
+
visibility: "encrypted",
|
|
220
|
+
sourceType: input.sourceType,
|
|
221
|
+
title: input.title,
|
|
222
|
+
summary: input.summary,
|
|
223
|
+
snapshot: {
|
|
224
|
+
id: `snapshot-${input.routeSlug}-encrypted-v1`,
|
|
225
|
+
version: 1,
|
|
226
|
+
createdAt: input.exportedAt,
|
|
227
|
+
lastIndexedAt: input.exportedAt,
|
|
228
|
+
searchEnabled: noteCards.length > 1,
|
|
229
|
+
},
|
|
230
|
+
metadataPreview: [],
|
|
231
|
+
assetManifest: [],
|
|
232
|
+
searchIndex: noteCards.map((note) => ({
|
|
233
|
+
noteId: note.noteId,
|
|
234
|
+
slug: note.slug,
|
|
235
|
+
title: note.title,
|
|
236
|
+
excerpt: note.excerpt,
|
|
237
|
+
haystack: `${note.title} ${note.summary}`.toLowerCase(),
|
|
238
|
+
})),
|
|
239
|
+
noteCards,
|
|
240
|
+
currentNote,
|
|
241
|
+
previousNoteSlug: noteCards[currentIndex - 1]?.slug,
|
|
242
|
+
nextNoteSlug: noteCards[currentIndex + 1]?.slug,
|
|
243
|
+
homeNoteSlug: input.homeNoteSlug ?? noteCards[0]?.slug,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
if (hasAssets) {
|
|
247
|
+
payload.assets = input.assets;
|
|
248
|
+
payload.requiredCapabilities = [BUNDLED_RASTER_ASSETS_CAPABILITY];
|
|
249
|
+
}
|
|
176
250
|
|
|
177
251
|
return {
|
|
178
|
-
payload
|
|
179
|
-
sharePath,
|
|
180
|
-
shareLabel: "Encrypted share",
|
|
181
|
-
visibility: "encrypted" as const,
|
|
182
|
-
sourceType: input.sourceType,
|
|
183
|
-
title: input.title,
|
|
184
|
-
summary: input.summary,
|
|
185
|
-
snapshot: {
|
|
186
|
-
id: `snapshot-${input.routeSlug}-encrypted-v1`,
|
|
187
|
-
version: 1,
|
|
188
|
-
createdAt: input.exportedAt,
|
|
189
|
-
lastIndexedAt: input.exportedAt,
|
|
190
|
-
searchEnabled: noteCards.length > 1,
|
|
191
|
-
},
|
|
192
|
-
metadataPreview: [],
|
|
193
|
-
assetManifest: [],
|
|
194
|
-
searchIndex: noteCards.map((note) => ({
|
|
195
|
-
noteId: note.noteId,
|
|
196
|
-
slug: note.slug,
|
|
197
|
-
title: note.title,
|
|
198
|
-
excerpt: note.excerpt,
|
|
199
|
-
haystack: `${note.title} ${note.summary}`.toLowerCase(),
|
|
200
|
-
})),
|
|
201
|
-
noteCards,
|
|
202
|
-
currentNote,
|
|
203
|
-
previousNoteSlug: noteCards[currentIndex - 1]?.slug,
|
|
204
|
-
nextNoteSlug: noteCards[currentIndex + 1]?.slug,
|
|
205
|
-
homeNoteSlug: input.homeNoteSlug ?? noteCards[0]?.slug,
|
|
206
|
-
} satisfies ReaderSpaceData,
|
|
252
|
+
payload,
|
|
207
253
|
secretToken: sharePath.replace("/locked/", ""),
|
|
208
254
|
};
|
|
209
255
|
};
|
|
210
256
|
|
|
211
|
-
const deriveKey = async (
|
|
212
|
-
|
|
257
|
+
const deriveKey = async (
|
|
258
|
+
passphrase: string,
|
|
259
|
+
salt: Uint8Array,
|
|
260
|
+
usages: KeyUsage[]
|
|
261
|
+
) => {
|
|
262
|
+
const material = await crypto.subtle.importKey(
|
|
213
263
|
"raw",
|
|
214
264
|
encoder.encode(passphrase),
|
|
215
265
|
"PBKDF2",
|
|
@@ -217,11 +267,11 @@ const deriveKey = async (passphrase: string, salt: Uint8Array) => {
|
|
|
217
267
|
["deriveKey"]
|
|
218
268
|
);
|
|
219
269
|
|
|
220
|
-
return subtle.deriveKey(
|
|
270
|
+
return crypto.subtle.deriveKey(
|
|
221
271
|
{
|
|
222
272
|
name: "PBKDF2",
|
|
223
273
|
hash: "SHA-256",
|
|
224
|
-
salt:
|
|
274
|
+
salt: toArrayBuffer(salt),
|
|
225
275
|
iterations: PBKDF2_ITERATIONS,
|
|
226
276
|
},
|
|
227
277
|
material,
|
|
@@ -230,7 +280,7 @@ const deriveKey = async (passphrase: string, salt: Uint8Array) => {
|
|
|
230
280
|
length: 256,
|
|
231
281
|
},
|
|
232
282
|
false,
|
|
233
|
-
|
|
283
|
+
usages
|
|
234
284
|
);
|
|
235
285
|
};
|
|
236
286
|
|
|
@@ -240,10 +290,10 @@ const encryptJson = async (
|
|
|
240
290
|
): Promise<EncryptedArtifactPayload> => {
|
|
241
291
|
const salt = randomBytes(SALT_BYTES);
|
|
242
292
|
const iv = randomBytes(IV_BYTES);
|
|
243
|
-
const key = await deriveKey(passphrase, salt);
|
|
293
|
+
const key = await deriveKey(passphrase, salt, ["encrypt"]);
|
|
244
294
|
const plaintext = encoder.encode(JSON.stringify(payload));
|
|
245
|
-
const ciphertext = await subtle.encrypt(
|
|
246
|
-
{ name: "AES-GCM", iv },
|
|
295
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
296
|
+
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
|
|
247
297
|
key,
|
|
248
298
|
plaintext
|
|
249
299
|
);
|
|
@@ -256,7 +306,25 @@ const encryptJson = async (
|
|
|
256
306
|
};
|
|
257
307
|
};
|
|
258
308
|
|
|
309
|
+
export const decryptEncryptedArtifactPayload = async <
|
|
310
|
+
T = EncryptedReaderSpaceData,
|
|
311
|
+
>(
|
|
312
|
+
passphrase: string,
|
|
313
|
+
payload: EncryptedArtifactPayload
|
|
314
|
+
): Promise<T> => {
|
|
315
|
+
const key = await deriveKey(passphrase, fromBase64(payload.salt), [
|
|
316
|
+
"decrypt",
|
|
317
|
+
]);
|
|
318
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
319
|
+
{ name: "AES-GCM", iv: toArrayBuffer(fromBase64(payload.iv)) },
|
|
320
|
+
key,
|
|
321
|
+
toArrayBuffer(fromBase64(payload.ciphertext))
|
|
322
|
+
);
|
|
323
|
+
return JSON.parse(decoder.decode(plaintext)) as T;
|
|
324
|
+
};
|
|
325
|
+
|
|
259
326
|
export const buildEncryptedArtifactPayload = async (input: {
|
|
327
|
+
assets?: PublishArtifactAsset[];
|
|
260
328
|
exportedAt: string;
|
|
261
329
|
homeNoteSlug?: string;
|
|
262
330
|
notes: PublishArtifactNote[];
|
|
@@ -266,7 +334,16 @@ export const buildEncryptedArtifactPayload = async (input: {
|
|
|
266
334
|
summary: string;
|
|
267
335
|
title: string;
|
|
268
336
|
}) => {
|
|
269
|
-
const { payload, secretToken } = deriveReaderPayload(
|
|
337
|
+
const { payload, secretToken } = deriveReaderPayload({
|
|
338
|
+
assets: input.assets ?? [],
|
|
339
|
+
exportedAt: input.exportedAt,
|
|
340
|
+
homeNoteSlug: input.homeNoteSlug,
|
|
341
|
+
notes: input.notes,
|
|
342
|
+
routeSlug: input.routeSlug,
|
|
343
|
+
sourceType: input.sourceType,
|
|
344
|
+
summary: input.summary,
|
|
345
|
+
title: input.title,
|
|
346
|
+
});
|
|
270
347
|
|
|
271
348
|
return {
|
|
272
349
|
encryptedPayload: await encryptJson(input.passphrase, payload),
|