@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.
- package/README.md +53 -0
- package/dist/migrations/005.d.ts +3 -0
- package/dist/migrations/005.js +19 -0
- package/dist/migrations/006.d.ts +3 -0
- package/dist/migrations/006.js +9 -0
- package/dist/migrations/007.d.ts +3 -0
- package/dist/migrations/007.js +10 -0
- package/dist/src/annotations/quote.d.ts +32 -0
- package/dist/src/annotations/quote.js +135 -0
- package/dist/src/annotations/store.d.ts +50 -0
- package/dist/src/annotations/store.js +89 -0
- package/dist/src/app/source.d.ts +2 -0
- package/dist/src/app/source.js +13 -17
- package/dist/src/cli/arguments.js +24 -7
- package/dist/src/cli/digest.d.ts +10 -0
- package/dist/src/cli/digest.js +63 -0
- package/dist/src/cli/import.d.ts +11 -0
- package/dist/src/cli/import.js +10 -0
- package/dist/src/cli/indexing.d.ts +3 -0
- package/dist/src/cli/indexing.js +5 -2
- package/dist/src/cli/main.js +94 -8
- package/dist/src/cli/runtimes.d.ts +10 -0
- package/dist/src/cli/runtimes.js +32 -0
- package/dist/src/digest/editorial.d.ts +53 -0
- package/dist/src/digest/editorial.js +81 -0
- package/dist/src/digest/endpoint.d.ts +20 -0
- package/dist/src/digest/endpoint.js +103 -0
- package/dist/src/digest/pipeline.d.ts +148 -0
- package/dist/src/digest/pipeline.js +410 -0
- package/dist/src/digest/schema.d.ts +85 -0
- package/dist/src/digest/schema.js +118 -0
- package/dist/src/digest/store.d.ts +57 -0
- package/dist/src/digest/store.js +77 -0
- package/dist/src/identity/query-policy.d.ts +21 -0
- package/dist/src/identity/query-policy.js +55 -0
- package/dist/src/identity/uri.d.ts +30 -0
- package/dist/src/identity/uri.js +94 -0
- package/dist/src/identity/visibility.d.ts +24 -0
- package/dist/src/identity/visibility.js +75 -0
- package/dist/src/importers/apply.d.ts +10 -0
- package/dist/src/importers/apply.js +25 -22
- package/dist/src/importers/jungle-digest.d.ts +97 -0
- package/dist/src/importers/jungle-digest.js +221 -0
- package/dist/src/importers/jungle-store.d.ts +45 -0
- package/dist/src/importers/jungle-store.js +95 -0
- package/dist/src/importers/jungle.d.ts +54 -0
- package/dist/src/importers/jungle.js +134 -0
- package/dist/src/indexing/index.d.ts +7 -0
- package/dist/src/indexing/index.js +31 -8
- package/dist/src/indexing/prune-run.d.ts +22 -0
- package/dist/src/indexing/prune-run.js +67 -0
- package/dist/src/indexing/prune.d.ts +30 -0
- package/dist/src/indexing/prune.js +95 -0
- package/dist/src/indexing/runner.js +5 -5
- package/dist/src/inference/embedding/index.d.ts +25 -0
- package/dist/src/inference/embedding/index.js +11 -0
- package/dist/src/models/artifacts.js +175 -33
- package/dist/src/portability/database.js +3 -0
- package/dist/src/publish/component.d.ts +8 -0
- package/dist/src/publish/component.js +114 -0
- package/dist/src/publish/feed.d.ts +24 -0
- package/dist/src/publish/feed.js +77 -0
- package/dist/src/publish/identity.d.ts +6 -0
- package/dist/src/publish/identity.js +68 -0
- package/dist/src/publish/index.d.ts +34 -0
- package/dist/src/publish/index.js +137 -0
- package/dist/src/publish/projection.d.ts +29 -0
- package/dist/src/publish/projection.js +43 -0
- package/dist/src/search/hybrid.js +2 -2
- package/dist/src/search/lexical.d.ts +11 -0
- package/dist/src/search/lexical.js +6 -0
- package/dist/src/search/query.d.ts +7 -3
- package/dist/src/search/query.js +52 -33
- package/dist/src/search/vector.d.ts +1 -1
- package/dist/src/search/vector.js +6 -5
- package/dist/src/store/changes.d.ts +42 -0
- package/dist/src/store/changes.js +178 -0
- package/dist/src/store/foundation.d.ts +7 -0
- package/dist/src/store/foundation.js +7 -0
- package/dist/src/store/index.d.ts +69 -0
- package/dist/src/store/index.js +134 -51
- package/dist/src/worker/runtime-gc.d.ts +30 -0
- package/dist/src/worker/runtime-gc.js +296 -0
- package/native/runtime-manifest.json +4 -4
- 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
|
-
|
|
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 <
|
|
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
|
-
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
+
};
|