@gmickel/gno 1.20.0 → 1.22.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 (55) hide show
  1. package/README.md +29 -5
  2. package/assets/skill/SKILL.md +46 -15
  3. package/package.json +2 -1
  4. package/spec/cli.md +144 -0
  5. package/spec/db/schema.sql +170 -0
  6. package/spec/evals-agentic.md +48 -0
  7. package/spec/mcp.md +22 -0
  8. package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
  9. package/spec/output-schemas/changes.schema.json +280 -0
  10. package/spec/output-schemas/document-diff.schema.json +185 -0
  11. package/spec/output-schemas/impact.schema.json +122 -0
  12. package/spec/output-schemas/publish-artifact.schema.json +284 -0
  13. package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
  14. package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
  15. package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
  16. package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
  17. package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
  18. package/src/cli/commands/changes.ts +160 -0
  19. package/src/cli/commands/context-saved.ts +189 -0
  20. package/src/cli/options.ts +8 -0
  21. package/src/cli/program.ts +195 -0
  22. package/src/core/capsule-registry.ts +279 -0
  23. package/src/core/capsule-reverification-scheduler.ts +218 -0
  24. package/src/core/capsule-reverification.ts +289 -0
  25. package/src/core/change-diff.ts +182 -0
  26. package/src/core/change-journal.ts +228 -0
  27. package/src/core/knowledge-delta.ts +395 -0
  28. package/src/core/knowledge-impact.ts +202 -0
  29. package/src/ingestion/sync.ts +214 -165
  30. package/src/mcp/tools/changes.ts +80 -0
  31. package/src/mcp/tools/index.ts +29 -0
  32. package/src/publish/artifact-validation.ts +259 -0
  33. package/src/publish/artifact.ts +234 -118
  34. package/src/publish/export-service.ts +5 -9
  35. package/src/publish/metadata.ts +195 -0
  36. package/src/sdk/client.ts +42 -0
  37. package/src/sdk/index.ts +7 -0
  38. package/src/sdk/types.ts +22 -0
  39. package/src/serve/doc-events.ts +12 -1
  40. package/src/serve/resident-runtime.ts +22 -0
  41. package/src/serve/routes/api.ts +13 -0
  42. package/src/serve/routes/changes.ts +102 -0
  43. package/src/serve/server.ts +34 -0
  44. package/src/serve/watch-service.ts +9 -0
  45. package/src/store/index.ts +21 -0
  46. package/src/store/migrations/015-document-change-journal.ts +85 -0
  47. package/src/store/migrations/016-saved-capsules.ts +131 -0
  48. package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
  49. package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
  50. package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
  51. package/src/store/migrations/index.ts +10 -0
  52. package/src/store/sqlite/adapter.ts +291 -7
  53. package/src/store/sqlite/capsule-registry-store.ts +534 -0
  54. package/src/store/sqlite/change-journal-store.ts +473 -0
  55. package/src/store/types.ts +262 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Runtime validation and closed projection for publish artifact builders.
3
+ *
4
+ * @module src/publish/artifact-validation
5
+ */
6
+
7
+ export const MAX_PUBLISH_SLUG_LENGTH = 80;
8
+ export const MAX_ENCRYPTED_CIPHERTEXT_BASE64_LENGTH = 67_108_864;
9
+ export const MAX_ENCRYPTED_KEY_MATERIAL_BASE64_LENGTH = 1024;
10
+ export const MAX_ENCRYPTED_SECRET_TOKEN_LENGTH = 512;
11
+
12
+ const PUBLISH_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/u;
13
+ const BASE64_PATTERN =
14
+ /^(?:[a-zA-Z0-9+/]{4})*(?:[a-zA-Z0-9+/]{2}==|[a-zA-Z0-9+/]{3}=)?$/u;
15
+
16
+ const SOURCE_TYPES = new Set(["collection", "note"]);
17
+ const READER_VISIBILITIES = new Set(["invite-only", "public", "secret-link"]);
18
+
19
+ export interface ValidatedPublishNote {
20
+ markdown: string;
21
+ metadata?: Record<string, string | string[]>;
22
+ slug: string;
23
+ summary: string;
24
+ title: string;
25
+ }
26
+
27
+ export interface ValidatedPublishSpaceInput {
28
+ homeNoteSlug?: string;
29
+ notes: ValidatedPublishNote[];
30
+ routeSlug: string;
31
+ sourceType: "note" | "collection";
32
+ summary: string;
33
+ title: string;
34
+ visibility: "invite-only" | "public" | "secret-link";
35
+ }
36
+
37
+ export interface ValidatedEncryptedPublishInput {
38
+ encryptedPayload: {
39
+ ciphertext: string;
40
+ iterations: number;
41
+ iv: string;
42
+ salt: string;
43
+ };
44
+ routeSlug: string;
45
+ secretToken: string;
46
+ sourceType: "note" | "collection";
47
+ }
48
+
49
+ const requireRecord = (
50
+ value: unknown,
51
+ field: string
52
+ ): Record<string, unknown> => {
53
+ if (!(value && typeof value === "object" && !Array.isArray(value))) {
54
+ throw new Error(`${field} must be an object`);
55
+ }
56
+ return value as Record<string, unknown>;
57
+ };
58
+
59
+ const requireString = (value: unknown, field: string): string => {
60
+ if (typeof value !== "string") {
61
+ throw new Error(`${field} must be a string`);
62
+ }
63
+ return value;
64
+ };
65
+
66
+ const requireNonblankString = (value: unknown, field: string): string => {
67
+ const result = requireString(value, field);
68
+ if (result.trim().length === 0) {
69
+ throw new Error(`${field} must not be blank`);
70
+ }
71
+ return result;
72
+ };
73
+
74
+ const requireBoundedNonblankString = (
75
+ value: unknown,
76
+ field: string,
77
+ maxLength: number
78
+ ): string => {
79
+ const result = requireNonblankString(value, field);
80
+ if (result.length > maxLength) {
81
+ throw new Error(`${field} must not exceed ${maxLength} characters`);
82
+ }
83
+ return result;
84
+ };
85
+
86
+ const requireBase64 = (
87
+ value: unknown,
88
+ field: string,
89
+ maxLength: number
90
+ ): string => {
91
+ const result = requireBoundedNonblankString(value, field, maxLength);
92
+ if (!BASE64_PATTERN.test(result)) {
93
+ throw new Error(`${field} must be valid base64`);
94
+ }
95
+ return result;
96
+ };
97
+
98
+ const requireSlug = (value: unknown, field: string): string => {
99
+ const result = requireString(value, field);
100
+ if (!PUBLISH_SLUG_PATTERN.test(result)) {
101
+ throw new Error(`${field} must be a valid publish slug`);
102
+ }
103
+ return result;
104
+ };
105
+
106
+ const projectMetadata = (
107
+ value: unknown,
108
+ field: string
109
+ ): Record<string, string | string[]> | undefined => {
110
+ if (value === undefined) return undefined;
111
+
112
+ const input = requireRecord(value, field);
113
+ const result: Record<string, string | string[]> = {};
114
+ for (const [key, entry] of Object.entries(input)) {
115
+ if (typeof entry === "string") {
116
+ result[key] = entry;
117
+ continue;
118
+ }
119
+ if (
120
+ Array.isArray(entry) &&
121
+ entry.every((item): item is string => typeof item === "string")
122
+ ) {
123
+ result[key] = [...entry];
124
+ continue;
125
+ }
126
+ throw new Error(`${field}.${key} must be a string or string array`);
127
+ }
128
+ return result;
129
+ };
130
+
131
+ const projectNote = (value: unknown, index: number): ValidatedPublishNote => {
132
+ const field = `notes[${index}]`;
133
+ const input = requireRecord(value, field);
134
+ const metadata = projectMetadata(input.metadata, `${field}.metadata`);
135
+ const note: ValidatedPublishNote = {
136
+ markdown: requireString(input.markdown, `${field}.markdown`),
137
+ slug: requireSlug(input.slug, `${field}.slug`),
138
+ summary: requireString(input.summary, `${field}.summary`),
139
+ title: requireNonblankString(input.title, `${field}.title`),
140
+ };
141
+ if (metadata !== undefined) note.metadata = metadata;
142
+ return note;
143
+ };
144
+
145
+ export const validateAndProjectPublishSpaceInput = (
146
+ value: unknown
147
+ ): ValidatedPublishSpaceInput => {
148
+ const input = requireRecord(value, "publish input");
149
+ if (!Array.isArray(input.notes) || input.notes.length === 0) {
150
+ throw new Error("Publish input must contain at least one note");
151
+ }
152
+
153
+ const notes = input.notes.map(projectNote);
154
+ const noteSlugs = new Set<string>();
155
+ for (const note of notes) {
156
+ if (noteSlugs.has(note.slug)) {
157
+ throw new Error(
158
+ `Publish input contains duplicate note slug "${note.slug}"`
159
+ );
160
+ }
161
+ noteSlugs.add(note.slug);
162
+ }
163
+
164
+ const routeSlug = requireSlug(input.routeSlug, "routeSlug");
165
+ const sourceType = requireString(input.sourceType, "sourceType");
166
+ if (!SOURCE_TYPES.has(sourceType)) {
167
+ throw new Error('sourceType must be "note" or "collection"');
168
+ }
169
+
170
+ const visibility = requireString(input.visibility, "visibility");
171
+ if (!READER_VISIBILITIES.has(visibility)) {
172
+ throw new Error(
173
+ 'visibility must be "public", "secret-link", or "invite-only"'
174
+ );
175
+ }
176
+
177
+ const homeNoteSlug =
178
+ input.homeNoteSlug === undefined
179
+ ? undefined
180
+ : requireSlug(input.homeNoteSlug, "homeNoteSlug");
181
+ if (homeNoteSlug !== undefined && !noteSlugs.has(homeNoteSlug)) {
182
+ throw new Error(`homeNoteSlug "${homeNoteSlug}" is not present in notes`);
183
+ }
184
+
185
+ const result: ValidatedPublishSpaceInput = {
186
+ notes,
187
+ routeSlug,
188
+ sourceType: sourceType as "note" | "collection",
189
+ summary: requireString(input.summary, "summary"),
190
+ title: requireNonblankString(input.title, "title"),
191
+ visibility: visibility as ValidatedPublishSpaceInput["visibility"],
192
+ };
193
+ if (homeNoteSlug !== undefined) result.homeNoteSlug = homeNoteSlug;
194
+ return result;
195
+ };
196
+
197
+ export const validateAndProjectEncryptedPublishInput = (
198
+ value: unknown
199
+ ): ValidatedEncryptedPublishInput => {
200
+ const input = requireRecord(value, "encrypted publish input");
201
+ const payload = requireRecord(input.encryptedPayload, "encryptedPayload");
202
+ const iterations = payload.iterations;
203
+ if (
204
+ typeof iterations !== "number" ||
205
+ !Number.isSafeInteger(iterations) ||
206
+ iterations <= 0
207
+ ) {
208
+ throw new Error(
209
+ "encryptedPayload.iterations must be a positive safe integer"
210
+ );
211
+ }
212
+
213
+ const sourceType = requireString(input.sourceType, "sourceType");
214
+ if (!SOURCE_TYPES.has(sourceType)) {
215
+ throw new Error('sourceType must be "note" or "collection"');
216
+ }
217
+
218
+ return {
219
+ encryptedPayload: {
220
+ ciphertext: requireBase64(
221
+ payload.ciphertext,
222
+ "encryptedPayload.ciphertext",
223
+ MAX_ENCRYPTED_CIPHERTEXT_BASE64_LENGTH
224
+ ),
225
+ iterations,
226
+ iv: requireBase64(
227
+ payload.iv,
228
+ "encryptedPayload.iv",
229
+ MAX_ENCRYPTED_KEY_MATERIAL_BASE64_LENGTH
230
+ ),
231
+ salt: requireBase64(
232
+ payload.salt,
233
+ "encryptedPayload.salt",
234
+ MAX_ENCRYPTED_KEY_MATERIAL_BASE64_LENGTH
235
+ ),
236
+ },
237
+ routeSlug: requireSlug(input.routeSlug, "routeSlug"),
238
+ secretToken: requireBoundedNonblankString(
239
+ input.secretToken,
240
+ "secretToken",
241
+ MAX_ENCRYPTED_SECRET_TOKEN_LENGTH
242
+ ),
243
+ sourceType: sourceType as "note" | "collection",
244
+ };
245
+ };
246
+
247
+ export const requirePublishDateTime = (
248
+ value: unknown,
249
+ field = "exportedAt"
250
+ ): string => {
251
+ const result = requireString(value, field);
252
+ if (
253
+ !/^\d{4}-\d{2}-\d{2}T/u.test(result) ||
254
+ Number.isNaN(Date.parse(result))
255
+ ) {
256
+ throw new Error(`${field} must be a valid date-time`);
257
+ }
258
+ return result;
259
+ };
@@ -4,9 +4,23 @@
4
4
  * @module src/publish/artifact
5
5
  */
6
6
 
7
- import type { DocumentRow, TagRow } from "../store/types";
7
+ import type { DocumentRow } from "../store/types";
8
8
 
9
+ import { deriveDocid } from "../app/constants";
10
+ import {
11
+ contextCapsuleEvidenceIdentity,
12
+ sha256Text,
13
+ } from "../core/context-capsule-validation";
9
14
  import { stripFrontmatter } from "../ingestion/frontmatter";
15
+ import {
16
+ MAX_PUBLISH_SLUG_LENGTH,
17
+ requirePublishDateTime,
18
+ validateAndProjectEncryptedPublishInput,
19
+ validateAndProjectPublishSpaceInput,
20
+ } from "./artifact-validation";
21
+
22
+ export { MAX_PUBLISH_SLUG_LENGTH } from "./artifact-validation";
23
+ export { buildExportedMetadata } from "./metadata";
10
24
 
11
25
  export type PublishVisibility =
12
26
  | "encrypted"
@@ -22,16 +36,70 @@ export interface PublishArtifactNote {
22
36
  title: string;
23
37
  }
24
38
 
25
- export interface PublishArtifactSpace {
39
+ export const PUBLIC_PUBLISH_MANIFEST_SCHEMA_VERSION = "1.0" as const;
40
+
41
+ export const PUBLIC_PUBLISH_CAPABILITIES = {
42
+ capsuleEvidence: true,
43
+ exactLineCitations: true,
44
+ llmsTxt: true,
45
+ markdownDocuments: true,
46
+ } as const;
47
+
48
+ export interface PublicPublishEvidence {
49
+ docid: string;
50
+ endLine: number;
51
+ evidenceId: string;
52
+ locator: string;
53
+ mirrorHash: string;
54
+ passageHash: string;
55
+ sourceHash: string;
56
+ startLine: number;
57
+ uri: string;
58
+ }
59
+
60
+ export interface PublicPublishDocument {
61
+ byteLength: number;
62
+ contentHash: string;
63
+ evidence: PublicPublishEvidence;
64
+ lineCount: number;
65
+ markdownPath: string;
66
+ slug: string;
67
+ summary: string;
68
+ title: string;
69
+ }
70
+
71
+ export interface PublicPublishManifest {
72
+ capabilities: typeof PUBLIC_PUBLISH_CAPABILITIES;
73
+ documents: PublicPublishDocument[];
74
+ generatedAt: string;
75
+ projectionRevision: string;
76
+ schemaVersion: typeof PUBLIC_PUBLISH_MANIFEST_SCHEMA_VERSION;
77
+ visibility: "public";
78
+ }
79
+
80
+ interface PublishArtifactSpaceBase {
26
81
  homeNoteSlug?: string;
27
82
  notes: PublishArtifactNote[];
28
83
  routeSlug: string;
29
84
  sourceType: "note" | "collection";
30
85
  summary: string;
31
86
  title: string;
32
- visibility: PublishVisibility;
33
87
  }
34
88
 
89
+ export interface PublicPublishArtifactSpace extends PublishArtifactSpaceBase {
90
+ manifest: PublicPublishManifest;
91
+ visibility: "public";
92
+ }
93
+
94
+ export interface RestrictedPublishArtifactSpace extends PublishArtifactSpaceBase {
95
+ manifest?: never;
96
+ visibility: "invite-only" | "secret-link";
97
+ }
98
+
99
+ export type PublishArtifactSpace =
100
+ | PublicPublishArtifactSpace
101
+ | RestrictedPublishArtifactSpace;
102
+
35
103
  export interface EncryptedArtifactPayload {
36
104
  ciphertext: string;
37
105
  iterations: number;
@@ -70,29 +138,6 @@ export const PUBLISH_VISIBILITY_VALUES = [
70
138
  "encrypted",
71
139
  ] as const;
72
140
 
73
- export const MAX_PUBLISH_SLUG_LENGTH = 80;
74
-
75
- const ALLOWED_FRONTMATTER_METADATA_KEYS = new Set([
76
- "audience",
77
- "canonical",
78
- "canonicalUrl",
79
- "canonicalURL",
80
- "coverAlt",
81
- "coverImage",
82
- "icon",
83
- "image",
84
- "layout",
85
- "publishedAt",
86
- "readingTime",
87
- "series",
88
- "seriesOrder",
89
- "status",
90
- "subtitle",
91
- "theme",
92
- "topic",
93
- "topics",
94
- ]);
95
-
96
141
  export const isPublishVisibility = (
97
142
  value: unknown
98
143
  ): value is PublishVisibility =>
@@ -175,119 +220,190 @@ export const deriveExportedSummary = (
175
220
  return plain.slice(0, 200).trim();
176
221
  };
177
222
 
178
- export const buildExportedMetadata = (
179
- doc: Pick<
180
- DocumentRow,
181
- | "author"
182
- | "categories"
183
- | "collection"
184
- | "contentType"
185
- | "frontmatterDate"
186
- | "languageHint"
187
- | "relPath"
188
- >,
189
- parsedFrontmatter: Record<string, unknown>,
190
- tags: TagRow[]
191
- ) => {
192
- const metadata: Record<string, string | string[]> = {};
223
+ const compareCodeUnits = (left: string, right: string): number => {
224
+ if (left < right) return -1;
225
+ if (left > right) return 1;
226
+ return 0;
227
+ };
193
228
 
194
- if (doc.author) {
195
- metadata.author = doc.author;
196
- }
197
- if (doc.contentType) {
198
- metadata.contentType = doc.contentType;
199
- }
200
- if (doc.languageHint) {
201
- metadata.language = doc.languageHint;
202
- }
203
- if (doc.frontmatterDate) {
204
- metadata.date = doc.frontmatterDate;
229
+ const canonicalizeJsonValue = (value: unknown): unknown => {
230
+ if (Array.isArray(value)) {
231
+ return value.map(canonicalizeJsonValue);
205
232
  }
206
- if (doc.categories?.length) {
207
- metadata.categories = doc.categories;
233
+ if (value && typeof value === "object") {
234
+ return Object.fromEntries(
235
+ Object.entries(value)
236
+ .sort(([left], [right]) => compareCodeUnits(left, right))
237
+ .map(([key, entry]) => [key, canonicalizeJsonValue(entry)])
238
+ );
208
239
  }
240
+ return value;
241
+ };
209
242
 
210
- const tagValues = tags.map((tag) => tag.tag);
211
- if (tagValues.length) {
212
- metadata.tags = tagValues;
213
- }
243
+ const canonicalJson = (value: unknown): string =>
244
+ JSON.stringify(canonicalizeJsonValue(value));
245
+
246
+ const buildPublicPublishDocument = (
247
+ routeSlug: string,
248
+ note: PublishArtifactNote
249
+ ): PublicPublishDocument => {
250
+ const contentHash = sha256Text(note.markdown);
251
+ const docid = deriveDocid(contentHash);
252
+ const lineCount = note.markdown.split("\n").length;
253
+ const markdownPath = `./${note.slug}.md`;
254
+ const uri = `gno://public/${routeSlug}/${note.slug}.md`;
255
+ const evidenceBase = {
256
+ uri,
257
+ docid,
258
+ startLine: 1,
259
+ endLine: lineCount,
260
+ sourceHash: contentHash,
261
+ mirrorHash: contentHash,
262
+ passageHash: contentHash,
263
+ };
264
+
265
+ return {
266
+ byteLength: new TextEncoder().encode(note.markdown).byteLength,
267
+ contentHash,
268
+ evidence: {
269
+ ...evidenceBase,
270
+ evidenceId: contextCapsuleEvidenceIdentity(evidenceBase),
271
+ locator: `${markdownPath}#L1-L${lineCount}`,
272
+ },
273
+ lineCount,
274
+ markdownPath,
275
+ slug: note.slug,
276
+ summary: note.summary,
277
+ title: note.title,
278
+ };
279
+ };
214
280
 
215
- metadata.collection = doc.collection;
216
- metadata.sourceRelPath = doc.relPath;
217
-
218
- for (const [key, value] of Object.entries(parsedFrontmatter)) {
219
- if (
220
- key === "tags" ||
221
- key === "title" ||
222
- key === "summary" ||
223
- !ALLOWED_FRONTMATTER_METADATA_KEYS.has(key)
224
- ) {
225
- continue;
226
- }
227
- if (typeof value === "string" && value.trim()) {
228
- metadata[key] = value.trim();
229
- continue;
230
- }
231
- if (Array.isArray(value)) {
232
- const cleaned = value
233
- .filter((entry): entry is string => typeof entry === "string")
234
- .map((entry) => entry.trim())
235
- .filter(Boolean);
236
- if (cleaned.length > 0) {
237
- metadata[key] = cleaned;
238
- }
239
- }
281
+ export const buildPublicPublishManifest = (input: {
282
+ exportedAt: string;
283
+ homeNoteSlug?: string;
284
+ notes: PublishArtifactNote[];
285
+ routeSlug: string;
286
+ sourceType: "note" | "collection";
287
+ summary: string;
288
+ title: string;
289
+ visibility: "public";
290
+ }): PublicPublishManifest => {
291
+ const validated = validateAndProjectPublishSpaceInput(input);
292
+ if (validated.visibility !== "public") {
293
+ throw new Error("Public publish manifests require public visibility");
240
294
  }
241
-
242
- return metadata;
295
+ const exportedAt = requirePublishDateTime(input.exportedAt);
296
+ const documents = validated.notes
297
+ .map((note) => buildPublicPublishDocument(validated.routeSlug, note))
298
+ .sort(
299
+ (left, right) =>
300
+ compareCodeUnits(left.slug, right.slug) ||
301
+ compareCodeUnits(left.title, right.title) ||
302
+ compareCodeUnits(left.contentHash, right.contentHash)
303
+ );
304
+ const revisionProjection = {
305
+ capabilities: PUBLIC_PUBLISH_CAPABILITIES,
306
+ documents,
307
+ homeNoteSlug: validated.homeNoteSlug ?? null,
308
+ notes: validated.notes
309
+ .map((note) => ({
310
+ markdown: note.markdown,
311
+ metadata: note.metadata ?? {},
312
+ slug: note.slug,
313
+ summary: note.summary,
314
+ title: note.title,
315
+ }))
316
+ .sort((left, right) => compareCodeUnits(left.slug, right.slug)),
317
+ routeSlug: validated.routeSlug,
318
+ sourceType: validated.sourceType,
319
+ summary: validated.summary,
320
+ title: validated.title,
321
+ visibility: validated.visibility,
322
+ };
323
+
324
+ return {
325
+ capabilities: { ...PUBLIC_PUBLISH_CAPABILITIES },
326
+ documents,
327
+ generatedAt: exportedAt,
328
+ projectionRevision: sha256Text(canonicalJson(revisionProjection)),
329
+ schemaVersion: PUBLIC_PUBLISH_MANIFEST_SCHEMA_VERSION,
330
+ visibility: validated.visibility,
331
+ };
243
332
  };
244
333
 
245
334
  export const buildPublishArtifact = (input: {
246
335
  homeNoteSlug?: string;
247
336
  notes: PublishArtifactNote[];
248
337
  routeSlug: string;
249
- source: string;
250
338
  sourceType: "note" | "collection";
251
339
  summary: string;
252
340
  title: string;
253
- visibility: PublishVisibility;
254
- }) => ({
255
- exportedAt: new Date().toISOString(),
256
- source: input.source,
257
- spaces: [
258
- {
259
- homeNoteSlug: input.homeNoteSlug,
260
- notes: input.notes,
261
- routeSlug: input.routeSlug,
262
- sourceType: input.sourceType,
263
- summary: input.summary,
264
- title: input.title,
265
- visibility: input.visibility,
266
- },
267
- ],
268
- version: 1 as const,
269
- });
341
+ visibility: Exclude<PublishVisibility, "encrypted">;
342
+ }): PublishArtifactV1 => {
343
+ const validated = validateAndProjectPublishSpaceInput(input);
344
+ const exportedAt = new Date().toISOString();
345
+ const base = {
346
+ ...(validated.homeNoteSlug === undefined
347
+ ? {}
348
+ : { homeNoteSlug: validated.homeNoteSlug }),
349
+ notes: validated.notes,
350
+ routeSlug: validated.routeSlug,
351
+ sourceType: validated.sourceType,
352
+ summary: validated.summary,
353
+ title: validated.title,
354
+ };
355
+ const space: PublishArtifactSpace =
356
+ validated.visibility === "public"
357
+ ? {
358
+ ...base,
359
+ manifest: buildPublicPublishManifest({
360
+ exportedAt,
361
+ homeNoteSlug: validated.homeNoteSlug,
362
+ notes: validated.notes,
363
+ routeSlug: validated.routeSlug,
364
+ sourceType: validated.sourceType,
365
+ summary: validated.summary,
366
+ title: validated.title,
367
+ visibility: validated.visibility,
368
+ }),
369
+ visibility: validated.visibility,
370
+ }
371
+ : {
372
+ ...base,
373
+ visibility: validated.visibility,
374
+ };
375
+
376
+ return {
377
+ exportedAt,
378
+ source: validated.routeSlug,
379
+ spaces: [space],
380
+ version: 1,
381
+ };
382
+ };
270
383
 
271
384
  export const buildEncryptedPublishArtifact = (input: {
272
385
  encryptedPayload: EncryptedArtifactPayload;
273
386
  routeSlug: string;
274
387
  secretToken: string;
275
- source: string;
276
388
  sourceType: "note" | "collection";
277
- }) => ({
278
- exportedAt: new Date().toISOString(),
279
- source: input.source,
280
- spaces: [
281
- {
282
- encryptedPayload: input.encryptedPayload,
283
- routeSlug: input.routeSlug,
284
- secretToken: input.secretToken,
285
- sourceType: input.sourceType,
286
- visibility: "encrypted" as const,
287
- },
288
- ],
289
- version: 2 as const,
290
- });
389
+ }): PublishArtifactV2 => {
390
+ const validated = validateAndProjectEncryptedPublishInput(input);
391
+ const exportedAt = requirePublishDateTime(new Date().toISOString());
392
+ return {
393
+ exportedAt,
394
+ source: validated.routeSlug,
395
+ spaces: [
396
+ {
397
+ encryptedPayload: validated.encryptedPayload,
398
+ routeSlug: validated.routeSlug,
399
+ secretToken: validated.secretToken,
400
+ sourceType: validated.sourceType,
401
+ visibility: "encrypted",
402
+ },
403
+ ],
404
+ version: 2,
405
+ };
406
+ };
291
407
 
292
408
  export const derivePublishArtifactFilename = (artifact: PublishArtifact) => {
293
409
  const routeSlug =