@hraness/sponge 0.2.4 → 0.2.5

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 (85) hide show
  1. package/README.md +53 -0
  2. package/dist/migrations/005.d.ts +3 -0
  3. package/dist/migrations/005.js +19 -0
  4. package/dist/migrations/006.d.ts +3 -0
  5. package/dist/migrations/006.js +9 -0
  6. package/dist/migrations/007.d.ts +3 -0
  7. package/dist/migrations/007.js +10 -0
  8. package/dist/src/annotations/quote.d.ts +32 -0
  9. package/dist/src/annotations/quote.js +135 -0
  10. package/dist/src/annotations/store.d.ts +50 -0
  11. package/dist/src/annotations/store.js +89 -0
  12. package/dist/src/app/source.d.ts +2 -0
  13. package/dist/src/app/source.js +13 -17
  14. package/dist/src/cli/arguments.js +24 -7
  15. package/dist/src/cli/digest.d.ts +10 -0
  16. package/dist/src/cli/digest.js +63 -0
  17. package/dist/src/cli/import.d.ts +11 -0
  18. package/dist/src/cli/import.js +10 -0
  19. package/dist/src/cli/indexing.d.ts +3 -0
  20. package/dist/src/cli/indexing.js +5 -2
  21. package/dist/src/cli/main.js +94 -8
  22. package/dist/src/cli/runtimes.d.ts +10 -0
  23. package/dist/src/cli/runtimes.js +32 -0
  24. package/dist/src/digest/editorial.d.ts +53 -0
  25. package/dist/src/digest/editorial.js +81 -0
  26. package/dist/src/digest/endpoint.d.ts +20 -0
  27. package/dist/src/digest/endpoint.js +103 -0
  28. package/dist/src/digest/pipeline.d.ts +148 -0
  29. package/dist/src/digest/pipeline.js +410 -0
  30. package/dist/src/digest/schema.d.ts +85 -0
  31. package/dist/src/digest/schema.js +118 -0
  32. package/dist/src/digest/store.d.ts +57 -0
  33. package/dist/src/digest/store.js +77 -0
  34. package/dist/src/identity/query-policy.d.ts +21 -0
  35. package/dist/src/identity/query-policy.js +55 -0
  36. package/dist/src/identity/uri.d.ts +30 -0
  37. package/dist/src/identity/uri.js +94 -0
  38. package/dist/src/identity/visibility.d.ts +24 -0
  39. package/dist/src/identity/visibility.js +75 -0
  40. package/dist/src/importers/apply.d.ts +10 -0
  41. package/dist/src/importers/apply.js +25 -22
  42. package/dist/src/importers/jungle-digest.d.ts +97 -0
  43. package/dist/src/importers/jungle-digest.js +221 -0
  44. package/dist/src/importers/jungle-store.d.ts +45 -0
  45. package/dist/src/importers/jungle-store.js +95 -0
  46. package/dist/src/importers/jungle.d.ts +54 -0
  47. package/dist/src/importers/jungle.js +134 -0
  48. package/dist/src/indexing/index.d.ts +7 -0
  49. package/dist/src/indexing/index.js +31 -8
  50. package/dist/src/indexing/prune-run.d.ts +22 -0
  51. package/dist/src/indexing/prune-run.js +67 -0
  52. package/dist/src/indexing/prune.d.ts +30 -0
  53. package/dist/src/indexing/prune.js +95 -0
  54. package/dist/src/indexing/runner.js +5 -5
  55. package/dist/src/inference/embedding/index.d.ts +25 -0
  56. package/dist/src/inference/embedding/index.js +11 -0
  57. package/dist/src/models/artifacts.js +175 -33
  58. package/dist/src/portability/database.js +3 -0
  59. package/dist/src/publish/component.d.ts +8 -0
  60. package/dist/src/publish/component.js +114 -0
  61. package/dist/src/publish/feed.d.ts +24 -0
  62. package/dist/src/publish/feed.js +77 -0
  63. package/dist/src/publish/identity.d.ts +6 -0
  64. package/dist/src/publish/identity.js +68 -0
  65. package/dist/src/publish/index.d.ts +34 -0
  66. package/dist/src/publish/index.js +137 -0
  67. package/dist/src/publish/projection.d.ts +29 -0
  68. package/dist/src/publish/projection.js +43 -0
  69. package/dist/src/search/hybrid.js +2 -2
  70. package/dist/src/search/lexical.d.ts +11 -0
  71. package/dist/src/search/lexical.js +6 -0
  72. package/dist/src/search/query.d.ts +7 -3
  73. package/dist/src/search/query.js +52 -33
  74. package/dist/src/search/vector.d.ts +1 -1
  75. package/dist/src/search/vector.js +6 -5
  76. package/dist/src/store/changes.d.ts +42 -0
  77. package/dist/src/store/changes.js +178 -0
  78. package/dist/src/store/foundation.d.ts +7 -0
  79. package/dist/src/store/foundation.js +7 -0
  80. package/dist/src/store/index.d.ts +69 -0
  81. package/dist/src/store/index.js +134 -51
  82. package/dist/src/worker/runtime-gc.d.ts +30 -0
  83. package/dist/src/worker/runtime-gc.js +296 -0
  84. package/native/runtime-manifest.json +4 -4
  85. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { chmod, lstat, mkdir, open, rename, rm, statfs, link, unlink, readdir, realpath } from 'node:fs/promises';
3
- import { join } from 'node:path';
3
+ import { dirname, join } from 'node:path';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { processIdentity, identityAlive } from '../worker/identity.js';
6
6
  import { readJournal, writeJournal } from '../worker/journal.js';
@@ -48,11 +48,64 @@ async function syncDirectory(path) { const file = await open(path, constants.O_R
48
48
  finally {
49
49
  await file.close();
50
50
  } }
51
- async function verifyArtifact(path, artifact) {
51
+ const uid = () => process.getuid?.();
52
+ const packageName = /^[a-f0-9]{64}$/, linkHolder = /^(?:[a-f0-9]{64}|\.(?:model-install|gc)-[-a-f0-9]{36})$/;
53
+ const missing = (error) => error.code === 'ENOENT';
54
+ async function exists(path) { try {
55
+ await lstat(path);
56
+ return true;
57
+ }
58
+ catch (error) {
59
+ if (missing(error))
60
+ return false;
61
+ throw error;
62
+ } }
63
+ async function privateDirectory(path) { const stat = await lstat(path); return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === uid() && (stat.mode & 0o077) === 0; }
64
+ /** A shared artifact inode is trusted only when every one of its links is a top-level file of a private package,
65
+ * stage or GC directory of this cache: nothing outside the private cache can reach (or later rewrite) it. */
66
+ async function linksAccounted(root, stat) {
67
+ if (stat.nlink > 64)
68
+ return false;
69
+ const holders = (await readdir(root)).filter(name => linkHolder.test(name));
70
+ if (holders.length > 256)
71
+ return false;
72
+ let count = 0;
73
+ for (const name of holders) {
74
+ const dir = join(root, name);
75
+ let entries;
76
+ try {
77
+ if (!await privateDirectory(dir))
78
+ continue;
79
+ entries = await readdir(dir);
80
+ }
81
+ catch (error) {
82
+ if (missing(error))
83
+ continue;
84
+ throw error;
85
+ }
86
+ if (entries.length > 4096)
87
+ return false;
88
+ for (const entry of entries) {
89
+ let s;
90
+ try {
91
+ s = await lstat(join(dir, entry));
92
+ }
93
+ catch (error) {
94
+ if (missing(error))
95
+ continue;
96
+ throw error;
97
+ }
98
+ if (s.isFile() && s.ino === stat.ino && s.dev === stat.dev)
99
+ count++;
100
+ }
101
+ }
102
+ return count === stat.nlink;
103
+ }
104
+ async function verifyArtifact(path, artifact, root) {
52
105
  const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
53
106
  try {
54
107
  const before = await file.stat();
55
- if (!before.isFile() || before.nlink !== 1 || before.size !== artifact.bytes || (before.mode & 0o077) !== 0 || (artifact.executable && (before.mode & 0o100) === 0))
108
+ if (!before.isFile() || before.nlink < 1 || before.nlink !== 1 && !(root && await linksAccounted(root, before)) || before.size !== artifact.bytes || (before.mode & 0o077) !== 0 || (artifact.executable && (before.mode & 0o100) === 0))
56
109
  throw new ModelSetupError('integrity');
57
110
  const hash = createHash('sha256'), buffer = Buffer.alloc(65536);
58
111
  let bytes = 0;
@@ -66,20 +119,128 @@ async function verifyArtifact(path, artifact) {
66
119
  hash.update(buffer.subarray(0, read.bytesRead));
67
120
  }
68
121
  const after = await file.stat();
69
- if (bytes !== artifact.bytes || hash.digest('hex') !== artifact.sha256 || before.mtimeMs !== after.mtimeMs)
122
+ if (bytes !== artifact.bytes || hash.digest('hex') !== artifact.sha256 || before.mtimeMs !== after.mtimeMs || before.nlink !== after.nlink)
123
+ throw new ModelSetupError('integrity');
124
+ }
125
+ finally {
126
+ await file.close();
127
+ }
128
+ }
129
+ /** Write one stream into a fresh staged file while hashing; the caller verifies against the manifest. */
130
+ async function writeStaged(path, artifact, chunks, signal) {
131
+ const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
132
+ const hash = createHash('sha256');
133
+ let bytes = 0;
134
+ try {
135
+ for await (const chunk of chunks) {
136
+ if (signal?.aborted)
137
+ throw new ModelSetupError('cancelled');
138
+ bytes += chunk.length;
139
+ if (bytes > artifact.bytes)
140
+ throw new ModelSetupError('integrity');
141
+ hash.update(chunk);
142
+ let offset = 0;
143
+ while (offset < chunk.length) {
144
+ const result = await file.write(chunk, offset, chunk.length - offset);
145
+ if (!result.bytesWritten)
146
+ throw new ModelSetupError('unavailable');
147
+ offset += result.bytesWritten;
148
+ }
149
+ }
150
+ if (bytes !== artifact.bytes || hash.digest('hex') !== artifact.sha256)
70
151
  throw new ModelSetupError('integrity');
152
+ await file.chmod(artifact.executable ? 0o700 : 0o600);
153
+ await file.sync();
71
154
  }
72
155
  finally {
73
156
  await file.close();
74
157
  }
75
158
  }
159
+ /** Same name, size and mode class in another installed package that no setup or recovery currently holds. */
160
+ async function reuseCandidates(root, artifact, exclude) {
161
+ const result = [];
162
+ for (const name of (await readdir(root)).filter(n => packageName.test(n) && n !== exclude).sort()) {
163
+ try {
164
+ if (await exists(join(root, `.${name}.lock`)) || await exists(join(root, `.${name}.recovery-lock`)) || !await privateDirectory(join(root, name)))
165
+ continue;
166
+ const s = await lstat(join(root, name, artifact.name));
167
+ if (s.isFile() && !s.isSymbolicLink() && s.uid === uid() && s.size === artifact.bytes && (s.mode & 0o077) === 0 && !!artifact.executable === ((s.mode & 0o100) !== 0))
168
+ result.push(join(root, name, artifact.name));
169
+ }
170
+ catch (error) {
171
+ if (!missing(error))
172
+ throw error;
173
+ }
174
+ }
175
+ return result;
176
+ }
177
+ /** Upgrade reuse: a package whose fingerprint differs only by release URLs carries byte-identical artifacts. A candidate
178
+ * is never trusted: it is opened without following links, hard-linked (same inode as the opened file, or undone) or
179
+ * else copied from that descriptor, and the staged file is re-hashed against THIS manifest. Any doubt downloads. */
180
+ async function reuseArtifact(root, stage, artifact, exclude, signal) {
181
+ const target = join(stage, artifact.name), mode = artifact.executable ? 0o700 : 0o600;
182
+ for (const candidate of await reuseCandidates(root, artifact, exclude)) {
183
+ if (signal?.aborted)
184
+ throw new ModelSetupError('cancelled');
185
+ let source;
186
+ try {
187
+ source = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW);
188
+ }
189
+ catch {
190
+ continue;
191
+ }
192
+ try {
193
+ const s = await source.stat();
194
+ if (!s.isFile() || s.uid !== uid() || s.size !== artifact.bytes || (s.mode & 0o077) !== 0 || !!artifact.executable !== ((s.mode & 0o100) !== 0))
195
+ continue;
196
+ if ((s.mode & 0o777) === mode) {
197
+ // A link to anything but the opened inode, or bytes failing this manifest, is undone and the candidate skipped.
198
+ let linked = false;
199
+ try {
200
+ await link(candidate, target);
201
+ linked = true;
202
+ const staged = await lstat(target);
203
+ if (staged.isFile() && !staged.isSymbolicLink() && staged.ino === s.ino && staged.dev === s.dev) {
204
+ await verifyArtifact(target, artifact, root);
205
+ return 'linked';
206
+ }
207
+ }
208
+ catch (error) {
209
+ if (linked && !(error instanceof ModelSetupError))
210
+ throw error;
211
+ }
212
+ if (linked) {
213
+ await unlink(target);
214
+ continue;
215
+ }
216
+ }
217
+ const disk = await statfs(root);
218
+ if (disk.bavail * disk.bsize < artifact.bytes + 512 * 1024 ** 2)
219
+ continue;
220
+ try {
221
+ await writeStaged(target, artifact, source.createReadStream({ autoClose: false, start: 0 }), signal);
222
+ await verifyArtifact(target, artifact, root);
223
+ return 'copied';
224
+ }
225
+ catch (error) {
226
+ await rm(target, { force: true });
227
+ if (error instanceof ModelSetupError && error.code === 'cancelled')
228
+ throw error;
229
+ }
230
+ }
231
+ finally {
232
+ await source.close();
233
+ }
234
+ }
235
+ return null;
236
+ }
76
237
  export async function inspectModelPackage(cacheRoot, manifest) {
77
238
  const plan = modelSetupPlan(manifest), root = join(await privateRoot(cacheRoot), plan.fingerprint);
78
239
  await privateRoot(root);
79
240
  const paths = Object.create(null);
80
241
  for (const artifact of manifest.artifacts) {
81
242
  const path = join(root, artifact.name);
82
- await verifyArtifact(path, artifact);
243
+ await verifyArtifact(path, artifact, dirname(root));
83
244
  paths[artifact.name] = path;
84
245
  }
85
246
  return paths;
@@ -203,8 +364,13 @@ export async function provisionModelPackage(cacheRoot, manifest, options = {}) {
203
364
  }
204
365
  let stage;
205
366
  try {
367
+ // Reusable artifacts need no download space; a copy fallback rechecks space for itself.
368
+ let needed = 512 * 1024 ** 2;
369
+ for (const artifact of manifest.artifacts)
370
+ if (!(await reuseCandidates(root, artifact, plan.fingerprint)).length)
371
+ needed += artifact.bytes;
206
372
  const disk = await statfs(root);
207
- if (disk.bavail * disk.bsize < plan.requiredFreeBytes)
373
+ if (disk.bavail * disk.bsize < needed)
208
374
  throw new ModelSetupError('limit-exceeded');
209
375
  stage = join(root, owner.stage);
210
376
  await mkdir(stage, { mode: 0o700 });
@@ -213,6 +379,8 @@ export async function provisionModelPackage(cacheRoot, manifest, options = {}) {
213
379
  await syncDirectory(stage);
214
380
  await syncDirectory(root);
215
381
  for (const artifact of manifest.artifacts) {
382
+ if (await reuseArtifact(root, stage, artifact, plan.fingerprint, options.signal))
383
+ continue;
216
384
  const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(600000)]) : AbortSignal.timeout(600000);
217
385
  const response = await (options.fetch ?? fetch)(artifact.url, { signal });
218
386
  if (!response.ok || !response.body || (response.url && new URL(response.url).protocol !== 'https:'))
@@ -222,33 +390,7 @@ export async function provisionModelPackage(cacheRoot, manifest, options = {}) {
222
390
  await response.body.cancel();
223
391
  throw new ModelSetupError('integrity');
224
392
  }
225
- const file = await open(join(stage, artifact.name), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
226
- const hash = createHash('sha256');
227
- let bytes = 0;
228
- try {
229
- for await (const chunk of response.body) {
230
- if (signal.aborted)
231
- throw new ModelSetupError('cancelled');
232
- bytes += chunk.length;
233
- if (bytes > artifact.bytes)
234
- throw new ModelSetupError('integrity');
235
- hash.update(chunk);
236
- let offset = 0;
237
- while (offset < chunk.length) {
238
- const result = await file.write(chunk, offset, chunk.length - offset);
239
- if (!result.bytesWritten)
240
- throw new ModelSetupError('unavailable');
241
- offset += result.bytesWritten;
242
- }
243
- }
244
- if (bytes !== artifact.bytes || hash.digest('hex') !== artifact.sha256)
245
- throw new ModelSetupError('integrity');
246
- await file.chmod(artifact.executable ? 0o700 : 0o600);
247
- await file.sync();
248
- }
249
- finally {
250
- await file.close();
251
- }
393
+ await writeStaged(join(stage, artifact.name), artifact, response.body, signal);
252
394
  }
253
395
  if (options.signal?.aborted)
254
396
  throw new ModelSetupError('cancelled');
@@ -62,6 +62,9 @@ export function neutralizeRestoredDatabase(path) {
62
62
  db.exec("UPDATE index_jobs SET state='failed',reason='restore-reconciliation-required' WHERE state NOT IN ('succeeded','failed','cancelled')");
63
63
  db.prepare("UPDATE index_attempts SET stage='restored-unexecuted',cleanup='settled',finished_at=COALESCE(finished_at,?) WHERE cleanup='pending' OR finished_at IS NULL").run(new Date().toISOString());
64
64
  }
65
+ // A restored copy is a new device of the same library: a fresh device id keeps its HLCs distinct; clock_ms only moves forward.
66
+ if (inspected.schemaVersion >= 5)
67
+ db.exec('UPDATE device SET device_id=lower(hex(randomblob(16))) WHERE id=1');
65
68
  db.exec('COMMIT; PRAGMA journal_mode=DELETE');
66
69
  return { incarnation, heldJobs: Number(held.changes) };
67
70
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `<sponge-list src="feed.json" limit="20" verify>`: a dependency-free custom element. It builds DOM from a
3
+ * fixed tag set with textContent only and links only http(s) URLs. `verify` checks the Ed25519-signed manifest
4
+ * with WebCrypto first; `verify="KEY"` pins the publisher's key. The source stays ASCII so any charset serves it.
5
+ */
6
+ export declare const componentSource: string;
7
+ /** A complete static page around the component; it carries the feed title only. */
8
+ export declare function indexHtml(title: string): string;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `<sponge-list src="feed.json" limit="20" verify>`: a dependency-free custom element. It builds DOM from a
3
+ * fixed tag set with textContent only and links only http(s) URLs. `verify` checks the Ed25519-signed manifest
4
+ * with WebCrypto first; `verify="KEY"` pins the publisher's key. The source stays ASCII so any charset serves it.
5
+ */
6
+ export const componentSource = String.raw `/* sponge-list: renders a Sponge reading-list feed. */
7
+ (() => {
8
+ 'use strict';
9
+ const web = u => { if (typeof u !== 'string') return null; try { const x = new URL(u); return x.protocol === 'https:' || x.protocol === 'http:' ? x.href : null; } catch (e) { return null; } };
10
+ const str = v => typeof v === 'string' && v.length > 0;
11
+ const obj = v => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
12
+ const list = v => Array.isArray(v) ? v : [];
13
+ /** Feed JSON to a plain node tree: { t: tag, p: part, a: attributes, x: text, c: children }. */
14
+ function view(feed, limit) {
15
+ const items = list(obj(feed).items).filter(i => str(obj(i).title)).slice(0, limit);
16
+ return { t: 'ol', p: 'list', c: items.map(i => {
17
+ const s = obj(i._sponge), d = obj(s.digest), href = web(i.url), day = str(i.date_published) ? i.date_published.slice(0, 10) : null;
18
+ const meta = [str(s.site) ? { t: 'span', p: 'site', x: s.site } : null, day ? { t: 'time', p: 'date', a: { datetime: day }, x: day } : null].filter(Boolean);
19
+ const ideas = list(d.ideas).filter(x => str(obj(x).claim)).map(x => {
20
+ const more = [str(x.detail) ? { t: 'p', p: 'detail', x: x.detail } : null, ...list(x.quotes).filter(str).map(q => ({ t: 'blockquote', p: 'quote', x: q }))].filter(Boolean);
21
+ return { t: 'li', p: 'idea', c: [more.length ? { t: 'details', p: 'idea-body', c: [{ t: 'summary', p: 'claim', x: x.claim }, ...more] } : { t: 'span', p: 'claim', x: x.claim }] };
22
+ });
23
+ return { t: 'li', p: 'item', c: [href ? { t: 'a', p: 'title', a: { href }, x: i.title } : { t: 'span', p: 'title', x: i.title }, ...(meta.length ? [{ t: 'div', p: 'meta', c: meta }] : []), ...(str(d.lead) ? [{ t: 'p', p: 'lead', x: d.lead }] : []), ...(ideas.length ? [{ t: 'ul', p: 'ideas', c: ideas }] : [])] };
24
+ }) };
25
+ }
26
+ const tags = new Set(['ol', 'ul', 'li', 'a', 'span', 'div', 'time', 'p', 'details', 'summary', 'blockquote']);
27
+ /** Only known tags and attributes; text goes through textContent, never markup. */
28
+ function build(doc, n) {
29
+ if (!tags.has(n.t)) throw new Error('tag');
30
+ const e = doc.createElement(n.t), a = obj(n.a);
31
+ e.setAttribute('part', n.p);
32
+ if (n.t === 'a') { const h = web(a.href); if (!h) throw new Error('href'); e.setAttribute('href', h); e.setAttribute('rel', 'noopener noreferrer'); }
33
+ if (n.t === 'time' && str(a.datetime)) e.setAttribute('datetime', a.datetime);
34
+ if (n.x !== undefined) e.textContent = String(n.x); else for (const c of list(n.c)) e.appendChild(build(doc, c));
35
+ return e;
36
+ }
37
+ const bytes64 = s => { const v = String(s).trim().replace(/-/g, '+').replace(/_/g, '/'), b = atob(v + '='.repeat((4 - v.length % 4) % 4)); return Uint8Array.from(b, c => c.charCodeAt(0)); };
38
+ const hex = b => Array.from(new Uint8Array(b), x => x.toString(16).padStart(2, '0')).join('');
39
+ /** True only when the signature over the exact manifest bytes holds and the feed's bytes match its entry. */
40
+ async function verify(subtle, manifestBytes, signature, feedBytes, feedPath, pinned) {
41
+ try { return await check(subtle, manifestBytes, signature, feedBytes, feedPath, pinned); } catch (e) { return false; }
42
+ }
43
+ async function check(subtle, manifestBytes, signature, feedBytes, feedPath, pinned) {
44
+ const m = obj(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(manifestBytes)));
45
+ if (m.algorithm !== 'Ed25519' || !str(m.publicKey) || (pinned && pinned !== m.publicKey)) return false;
46
+ const key = await subtle.importKey('raw', bytes64(m.publicKey), { name: 'Ed25519' }, false, ['verify']);
47
+ if (!await subtle.verify({ name: 'Ed25519' }, key, bytes64(signature), manifestBytes)) return false;
48
+ const entry = list(m.files).find(f => obj(f).path === feedPath);
49
+ return !!entry && entry.bytes === feedBytes.byteLength && entry.sha256 === hex(await subtle.digest('SHA-256', feedBytes));
50
+ }
51
+ const css = ':host{display:block;font:var(--sponge-font,inherit);color:var(--sponge-color,inherit)}' +
52
+ 'ol{list-style:none;margin:0;padding:0;display:grid;gap:var(--sponge-gap,1.25em)}ul{margin:.4em 0 0;padding-left:1.1em}' +
53
+ '[part~=title]{font-weight:600;color:var(--sponge-link,inherit)}[part~=meta]{color:var(--sponge-muted,#666);font-size:.875em}' +
54
+ '[part~=meta]>*+*::before{content:" ' + String.fromCharCode(183) + ' "}[part~=lead]{margin:.4em 0}summary{cursor:pointer}[part~=detail]{margin:.3em 0}' +
55
+ 'blockquote{margin:.3em 0;padding-left:.75em;border-left:2px solid var(--sponge-rule,#ccc);color:var(--sponge-muted,#666)}';
56
+ const Base = typeof HTMLElement === 'undefined' ? class {} : HTMLElement;
57
+ class SpongeList extends Base {
58
+ static get observedAttributes() { return ['src', 'limit', 'verify', 'manifest']; }
59
+ connectedCallback() { this.schedule(); }
60
+ attributeChangedCallback() { this.schedule(); }
61
+ /** Upgrading reports each attribute, then the connection; load once for all of them. */
62
+ schedule() { if (this.pending) return; this.pending = true; queueMicrotask(() => { this.pending = false; if (this.isConnected) this.load(); }); }
63
+ async load() {
64
+ const run = this.run = (this.run || 0) + 1, root = this.shadowRoot || this.attachShadow({ mode: 'open' });
65
+ const fail = reason => { if (run !== this.run) return; root.replaceChildren(); this.dispatchEvent(new CustomEvent('error', { detail: { reason } })); };
66
+ try {
67
+ const get = async u => { const r = await fetch(u, { credentials: 'omit' }); if (!r.ok) throw new Error('fetch'); return new Uint8Array(await r.arrayBuffer()); };
68
+ const src = new URL(this.getAttribute('src') || 'feed.json', document.baseURI), n = Number(this.getAttribute('limit') || 20), limit = Number.isInteger(n) && n > 0 ? Math.min(n, 1000) : 20;
69
+ const body = await get(src), feed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
70
+ if (this.hasAttribute('verify')) {
71
+ const at = this.getAttribute('manifest') || (str(obj(obj(feed)._sponge).manifest) ? feed._sponge.manifest : 'index.json'), manifest = new URL(at, src), dir = new URL('.', manifest).href;
72
+ if (src.search || src.hash || !src.href.startsWith(dir)) return fail('verify');
73
+ const [m, sig] = await Promise.all([get(manifest), get(new URL(manifest.pathname + '.sig', manifest))]);
74
+ if (!await verify(crypto.subtle, m, new TextDecoder().decode(sig), body, src.href.slice(dir.length), this.getAttribute('verify') || null)) return fail('verify');
75
+ }
76
+ if (run !== this.run) return;
77
+ const style = document.createElement('style');
78
+ style.textContent = css;
79
+ root.replaceChildren(style, build(document, view(feed, limit)));
80
+ } catch (e) { fail(e && e.message === 'fetch' ? 'fetch' : 'invalid'); }
81
+ }
82
+ }
83
+ SpongeList.view = view; SpongeList.build = build; SpongeList.verify = verify; SpongeList.safeHref = web;
84
+ if (typeof customElements !== 'undefined' && !customElements.get('sponge-list')) customElements.define('sponge-list', SpongeList);
85
+ })();
86
+ `;
87
+ const html = (s) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
88
+ /** A complete static page around the component; it carries the feed title only. */
89
+ export function indexHtml(title) {
90
+ return `<!doctype html>
91
+ <html lang="en">
92
+ <head>
93
+ <meta charset="utf-8">
94
+ <meta name="viewport" content="width=device-width, initial-scale=1">
95
+ <title>${html(title)}</title>
96
+ <link rel="alternate" type="application/feed+json" href="feed.json">
97
+ <link rel="alternate" type="application/atom+xml" href="feed.xml">
98
+ <style>
99
+ :root{color-scheme:light dark;--sponge-muted:#6b6b6b}
100
+ @media (prefers-color-scheme:dark){:root{--sponge-muted:#a0a0a0;--sponge-rule:#444}}
101
+ body{margin:0 auto;max-width:42rem;padding:2rem 1rem;font:16px/1.5 system-ui,sans-serif}
102
+ h1{font-size:1.5rem}footer{margin-top:3rem;font-size:.875rem;color:var(--sponge-muted)}
103
+ </style>
104
+ </head>
105
+ <body>
106
+ <h1>${html(title)}</h1>
107
+ <sponge-list src="feed.json" limit="100" verify></sponge-list>
108
+ <noscript><p><a href="feed.json">JSON Feed</a> · <a href="feed.xml">Atom</a></p></noscript>
109
+ <footer><a href="feed.xml">Atom</a> · <a href="feed.json">JSON Feed</a> · <a href="identity.json">Signing key</a></footer>
110
+ <script src="sponge-list.js" defer></script>
111
+ </body>
112
+ </html>
113
+ `;
114
+ }
@@ -0,0 +1,24 @@
1
+ import type { PublicEntry } from '../store/foundation.js';
2
+ /** feed.json and feed.xml carry the newest entries; every entry also lives in its month's shard. */
3
+ export declare const feedLimit = 100;
4
+ export declare const manifestPath = "index.json", signaturePath = "index.json.sig";
5
+ export interface PublicIdentity {
6
+ algorithm: 'Ed25519';
7
+ publicKey: string;
8
+ created: string;
9
+ }
10
+ export interface SiteOptions {
11
+ title: string;
12
+ baseUrl?: string;
13
+ }
14
+ /** Sorted keys, no whitespace, undefined fields dropped: equal values always give equal bytes. */
15
+ export declare function canonical(value: unknown): string;
16
+ /** An https origin and path, with no credentials, query or fragment, ending in `/`. */
17
+ export declare function baseUrl(value: string): string;
18
+ /** XML 1.0 text and attribute escaping; characters XML cannot carry become U+FFFD. */
19
+ export declare function xml(value: string): string;
20
+ /**
21
+ * Every file of the static site, keyed by path, from the projection alone. Pure and deterministic:
22
+ * no clock, no randomness, sorted keys. The manifest lists each file's sha256 and is signed once.
23
+ */
24
+ export declare function buildSite(entries: PublicEntry[], identity: PublicIdentity, options: SiteOptions, sign: (bytes: Uint8Array) => Uint8Array): Map<string, Buffer>;
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { StoreError } from '../store/types.js';
3
+ import { componentSource, indexHtml } from './component.js';
4
+ /** feed.json and feed.xml carry the newest entries; every entry also lives in its month's shard. */
5
+ export const feedLimit = 100;
6
+ export const manifestPath = 'index.json', signaturePath = 'index.json.sig';
7
+ const about = 'https://github.com/hraness/sponge/blob/main/docs/decisions/publish-feed.md';
8
+ const fail = (code = 'integrity') => { throw new StoreError(code); };
9
+ /** Sorted keys, no whitespace, undefined fields dropped: equal values always give equal bytes. */
10
+ export function canonical(value) {
11
+ if (Array.isArray(value))
12
+ return '[' + value.map(canonical).join(',') + ']';
13
+ if (value && typeof value === 'object')
14
+ return '{' + Object.keys(value).sort().filter(k => value[k] !== undefined).map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';
15
+ if (typeof value === 'number' && !Number.isSafeInteger(value) || !['string', 'number', 'boolean'].includes(typeof value) && value !== null)
16
+ fail();
17
+ return JSON.stringify(value);
18
+ }
19
+ /** An https origin and path, with no credentials, query or fragment, ending in `/`. */
20
+ export function baseUrl(value) {
21
+ let url;
22
+ try {
23
+ url = new URL(value);
24
+ }
25
+ catch { /* rejected below */ }
26
+ if (!url || url.protocol !== 'https:' || url.username || url.password || /[?#]/.test(value) || value.length > 2048)
27
+ fail('invalid-input');
28
+ return url.href.endsWith('/') ? url.href : url.href + '/';
29
+ }
30
+ const web = (uri) => /^https?:\/\//.test(uri);
31
+ const xmlInvalid = /[^\t\n\r -퟿-�\u{10000}-\u{10ffff}]/gu;
32
+ const xmlEntities = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' };
33
+ /** XML 1.0 text and attribute escaping; characters XML cannot carry become U+FFFD. */
34
+ export function xml(value) { return value.replace(xmlInvalid, '�').replace(/[&<>"']/g, c => xmlEntities[c]); }
35
+ const date = (e) => /^\d{4}-\d{2}-\d{2}$/.test(e.listedOn) ? e.listedOn + 'T00:00:00Z' : fail();
36
+ const summaryText = (e) => [e.digest?.lead, ...(e.digest?.ideas ?? []).map(i => '- ' + i.claim)].filter(Boolean).join('\n') || e.title;
37
+ function item(e) {
38
+ return { id: e.uri, url: web(e.uri) ? e.uri : undefined, title: e.title, content_text: summaryText(e), summary: e.digest?.lead, date_published: date(e), authors: e.authors ? [{ name: e.authors }] : undefined,
39
+ _sponge: { uri: e.uri, aliases: e.aliases, kind: e.kind, site: e.site, published: e.published, digest: e.digest } };
40
+ }
41
+ function atom(entries, identity, options) {
42
+ const base = options.baseUrl, updated = entries.length ? date(entries.reduce((a, b) => b.listedOn > a.listedOn ? b : a)) : identity.created + 'T00:00:00Z';
43
+ const lines = ['<?xml version="1.0" encoding="utf-8"?>', '<feed xmlns="http://www.w3.org/2005/Atom">', `<id>tag:sponge.computer,2026:feed/${xml(identity.publicKey)}</id>`, `<title>${xml(options.title)}</title>`, `<updated>${updated}</updated>`, `<author><name>${xml(options.title)}</name></author>`, '<generator>Sponge</generator>',
44
+ ...(base ? [`<link rel="self" type="application/atom+xml" href="${xml(base + 'feed.xml')}"/>`, `<link rel="alternate" type="text/html" href="${xml(base)}"/>`] : [])];
45
+ for (const e of entries)
46
+ lines.push('<entry>', `<id>${xml(e.uri)}</id>`, `<title>${xml(e.title)}</title>`, ...(web(e.uri) ? [`<link rel="alternate" href="${xml(e.uri)}"/>`] : []), `<updated>${date(e)}</updated>`, ...(e.authors ? [`<author><name>${xml(e.authors)}</name></author>`] : []), ...(e.digest?.lead ? [`<summary>${xml(e.digest.lead)}</summary>`] : []), `<content type="text">${xml(summaryText(e))}</content>`, '</entry>');
47
+ return lines.concat('</feed>', '').join('\n');
48
+ }
49
+ /**
50
+ * Every file of the static site, keyed by path, from the projection alone. Pure and deterministic:
51
+ * no clock, no randomness, sorted keys. The manifest lists each file's sha256 and is signed once.
52
+ */
53
+ export function buildSite(entries, identity, options, sign) {
54
+ if (identity.algorithm !== 'Ed25519' || !/^[A-Za-z0-9_-]{43}$/.test(identity.publicKey) || !/^\d{4}-\d{2}-\d{2}$/.test(identity.created))
55
+ fail();
56
+ const base = options.baseUrl, link = (from, to) => base ? base + to : from.includes('/') && to.startsWith(from.slice(0, from.lastIndexOf('/') + 1)) ? to.slice(from.lastIndexOf('/') + 1) : to;
57
+ const months = new Map();
58
+ for (const e of entries) {
59
+ const month = date(e).slice(0, 7);
60
+ months.set(month, [...months.get(month) ?? [], e]);
61
+ }
62
+ const order = [...months.keys()].sort().reverse(), shard = (month) => `items/${month}.json`;
63
+ const signed = { about, schemaVersion: 1, algorithm: identity.algorithm, publicKey: identity.publicKey };
64
+ const feed = (path, list, next, extra) => Buffer.from(canonical({ version: 'https://jsonfeed.org/version/1.1', title: options.title, home_page_url: base, feed_url: base && base + path, next_url: next && link(path, next), items: list.map(item), _sponge: { ...signed, manifest: link(path, manifestPath), ...extra } }) + '\n');
65
+ const files = new Map(), head = entries.slice(0, feedLimit), omitted = entries[feedLimit];
66
+ files.set('feed.json', feed('feed.json', head, omitted && shard(date(omitted).slice(0, 7)), { total: entries.length, shards: order.map(m => ({ month: m, url: link('feed.json', shard(m)), items: months.get(m).length })) }));
67
+ order.forEach((m, i) => files.set(shard(m), feed(shard(m), months.get(m), order[i + 1] && shard(order[i + 1]), { month: m })));
68
+ files.set('feed.xml', Buffer.from(atom(head, identity, options)));
69
+ files.set('identity.json', Buffer.from(canonical({ schemaVersion: 1, algorithm: identity.algorithm, publicKey: identity.publicKey, created: identity.created }) + '\n'));
70
+ files.set('sponge-list.js', Buffer.from(componentSource));
71
+ files.set('index.html', Buffer.from(indexHtml(options.title)));
72
+ const listed = [...files.keys()].sort().map(path => ({ path, bytes: files.get(path).length, sha256: createHash('sha256').update(files.get(path)).digest('hex') }));
73
+ const manifest = Buffer.from(canonical({ ...signed, files: listed }) + '\n');
74
+ files.set(manifestPath, manifest);
75
+ files.set(signaturePath, Buffer.from(Buffer.from(sign(manifest)).toString('base64url') + '\n'));
76
+ return new Map([...files.entries()].sort(([a], [b]) => a < b ? -1 : 1));
77
+ }
@@ -0,0 +1,6 @@
1
+ import type { PublicIdentity } from './feed.js';
2
+ export interface Identity extends PublicIdentity {
3
+ sign(bytes: Uint8Array): Buffer;
4
+ }
5
+ /** The publishing key never leaves the library: `keys/publish-ed25519.json`, mode 0600 inside a 0700 directory. */
6
+ export declare function publishIdentity(root: string): Identity;
@@ -0,0 +1,68 @@
1
+ import { chmodSync, closeSync, constants, fstatSync, fsyncSync, linkSync, openSync, readFileSync, unlinkSync } from 'node:fs';
2
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { pathExists, regularFile, safeDirectory, syncDirectory } from '../store/files.js';
5
+ import { StoreError } from '../store/types.js';
6
+ import { writeAll } from '../portability/files.js';
7
+ const fail = () => { throw new StoreError('integrity'); };
8
+ const b64 = (v, n) => typeof v === 'string' && new RegExp(`^[A-Za-z0-9_-]{${n}}$`).test(v) ? v : fail();
9
+ /** The publishing key never leaves the library: `keys/publish-ed25519.json`, mode 0600 inside a 0700 directory. */
10
+ export function publishIdentity(root) {
11
+ const dir = safeDirectory(join(root, 'keys'), true);
12
+ chmodSync(dir, 0o700);
13
+ const path = join(dir, 'publish-ed25519.json');
14
+ if (!pathExists(path))
15
+ create(dir, path);
16
+ return load(path);
17
+ }
18
+ /** Write a temporary file, then hard-link it into place, so a concurrent first publish keeps one key. */
19
+ function create(dir, path) {
20
+ const jwk = generateKeyPairSync('ed25519').privateKey.export({ format: 'jwk' });
21
+ const body = JSON.stringify({ schemaVersion: 1, algorithm: 'Ed25519', created: new Date().toISOString().slice(0, 10), privateKey: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, d: jwk.d } }) + '\n';
22
+ const temp = join(dir, randomUUID() + '.tmp'), fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
23
+ try {
24
+ writeAll(fd, Buffer.from(body));
25
+ fsyncSync(fd);
26
+ }
27
+ finally {
28
+ closeSync(fd);
29
+ }
30
+ try {
31
+ linkSync(temp, path);
32
+ }
33
+ catch (error) {
34
+ if (error.code !== 'EEXIST') {
35
+ unlinkSync(temp);
36
+ throw error;
37
+ }
38
+ }
39
+ unlinkSync(temp);
40
+ syncDirectory(dir);
41
+ }
42
+ function load(path) {
43
+ regularFile(path);
44
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
45
+ let raw;
46
+ try {
47
+ const stat = fstatSync(fd);
48
+ if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || stat.size > 4096 || stat.uid !== process.getuid?.())
49
+ fail();
50
+ raw = readFileSync(fd, 'utf8');
51
+ }
52
+ finally {
53
+ closeSync(fd);
54
+ }
55
+ let value;
56
+ try {
57
+ value = JSON.parse(raw);
58
+ }
59
+ catch {
60
+ fail();
61
+ }
62
+ if (!value || value.schemaVersion !== 1 || value.algorithm !== 'Ed25519' || typeof value.created !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.created) || value.privateKey?.kty !== 'OKP' || value.privateKey?.crv !== 'Ed25519')
63
+ fail();
64
+ const x = b64(value.privateKey.x, 43), key = createPrivateKey({ key: { kty: 'OKP', crv: 'Ed25519', x, d: b64(value.privateKey.d, 43) }, format: 'jwk' });
65
+ if (createPublicKey(key).export({ format: 'jwk' }).x !== x)
66
+ fail();
67
+ return { algorithm: 'Ed25519', created: value.created, publicKey: x, sign: bytes => sign(null, bytes, key) };
68
+ }
@@ -0,0 +1,34 @@
1
+ import type { PublicEntry } from '../store/foundation.js';
2
+ export { buildSite, canonical, xml, feedLimit } from './feed.js';
3
+ export declare const publishMarker = ".sponge-publish", markerBody = "sponge-publish-v1\n";
4
+ export interface PublishOptions {
5
+ output: string;
6
+ title?: string;
7
+ baseUrl?: string;
8
+ minAgeDays?: number;
9
+ now?: Date;
10
+ }
11
+ export interface PublishSource {
12
+ root: string;
13
+ publicProjection(): {
14
+ entries: PublicEntry[];
15
+ skipped: {
16
+ itemId: string;
17
+ reason: string;
18
+ }[];
19
+ };
20
+ }
21
+ /** Drop entries listed fewer than `days` days ago, so listing dates do not reveal what is read right now. */
22
+ export declare function minAge(entries: PublicEntry[], days?: number, now?: Date): PublicEntry[];
23
+ /**
24
+ * Write the signed static site for the public projection. The site is built in a temporary sibling and renamed
25
+ * into place; an existing directory is replaced only when it is a previous publish output with no foreign files.
26
+ */
27
+ export declare function publishSite(source: PublishSource, options: PublishOptions): {
28
+ output: string;
29
+ entries: number;
30
+ held: number;
31
+ skipped: number;
32
+ files: number;
33
+ publicKey: string;
34
+ };