@lapxo/bound 0.1.3 → 0.1.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/LICENSE +0 -1
- package/README.md +26 -5
- package/TARGET.bound +101 -22
- package/dist/cli/accept.js +21 -9
- package/dist/cli/owner.d.ts +2 -0
- package/dist/cli/owner.js +17 -2
- package/dist/cli/pass.js +15 -10
- package/dist/cli/place.js +8 -1
- package/dist/cli/verb.js +43 -3
- package/dist/fold/claims.js +5 -1
- package/dist/fold/closed.js +44 -13
- package/dist/fold/digests.js +5 -2
- package/dist/fold/observed.js +197 -174
- package/dist/fold/readers.d.ts +2 -0
- package/dist/fold/readers.js +3 -3
- package/dist/fold/resolved.js +6 -2
- package/dist/host/act-cost.d.ts +13 -0
- package/dist/host/act-cost.js +25 -0
- package/dist/host/adapters/modules/javascript.d.ts +1 -0
- package/dist/host/adapters/modules/javascript.js +48 -0
- package/dist/host/adapters/transport/http.d.ts +5 -0
- package/dist/host/adapters/transport/http.js +28 -0
- package/dist/host/capsule-process.d.ts +2 -1
- package/dist/host/capsule-process.js +24 -20
- package/dist/host/capsule.d.ts +1 -0
- package/dist/host/capsule.js +13 -16
- package/dist/host/cell-receipts.js +1 -1
- package/dist/host/content-store.d.ts +8 -0
- package/dist/host/content-store.js +31 -0
- package/dist/host/lifetime.d.ts +1 -0
- package/dist/host/lifetime.js +40 -0
- package/dist/host/objects.js +1 -1
- package/dist/host/ports/ingress.d.ts +24 -0
- package/dist/host/ports/ingress.js +81 -0
- package/dist/host/ports/transport.d.ts +1 -0
- package/dist/host/ports/transport.js +48 -0
- package/dist/host/process-lifetime.d.ts +11 -0
- package/dist/host/process-lifetime.js +12 -0
- package/dist/host/reading-closures.d.ts +28 -0
- package/dist/host/reading-closures.js +146 -0
- package/dist/host/release.js +24 -2
- package/dist/host/selected-capsules.js +38 -12
- package/dist/host/selected-topos.d.ts +4 -4
- package/dist/host/selected-topos.js +8 -30
- package/dist/host/source-content.d.ts +1 -1
- package/dist/host/source-content.js +15 -15
- package/dist/host/walk.d.ts +6 -0
- package/dist/host/walk.js +193 -0
- package/dist/observe/identity.d.ts +2 -0
- package/dist/observe/identity.js +3 -3
- package/dist/observe/regions.js +3 -5
- package/dist/observe/run.d.ts +2 -1
- package/dist/observe/run.js +5 -3
- package/dist/render/field.d.ts +1 -7
- package/dist/render/field.js +6 -12
- package/dist/render/names.d.ts +1 -1
- package/dist/render/names.js +1 -1
- package/dist/render/receipts.js +2 -1
- package/dist/render/view.js +16 -11
- package/docs/contracts/archive.md +11 -0
- package/docs/contracts/cli.md +11 -0
- package/docs/contracts/documents.md +35 -0
- package/docs/contracts/evidence-view.md +6 -0
- package/docs/contracts/publication.md +17 -0
- package/docs/contracts/typed-object.md +27 -0
- package/docs/contracts/view-walk.md +7 -0
- package/docs/contracts/views.md +29 -0
- package/docs/usage.md +6 -0
- package/package.json +6 -4
- package/receipts.bound +314 -94
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReaderLifetime } from '@lapxo/topos/wire';
|
|
1
2
|
import type { SpawnSyncOptionsWithStringEncoding } from 'node:child_process';
|
|
2
3
|
import type { Request, Response } from '@lapxo/topos/contract';
|
|
3
4
|
/** Operational process failure, distinct from a valid contract refusal or abstention. */
|
|
@@ -17,4 +18,4 @@ export declare class CapsuleProcessError extends Error {
|
|
|
17
18
|
}
|
|
18
19
|
export declare function validResponse(value: unknown, request: Request): value is Response;
|
|
19
20
|
/** Last-line JSON is the wire framing. No partial batch, failed exit or synthetic missing response is accepted. */
|
|
20
|
-
export declare function executeCapsule(command: string, args: readonly string[], requests: readonly Request[], limits?: Pick<SpawnSyncOptionsWithStringEncoding, 'timeout' | 'maxBuffer'
|
|
21
|
+
export declare function executeCapsule(command: string, args: readonly string[], requests: readonly Request[], limits?: Pick<SpawnSyncOptionsWithStringEncoding, 'timeout' | 'maxBuffer'>, lifetime?: ReaderLifetime): readonly Response[];
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { actCosts } from "./act-cost.js";
|
|
2
|
+
import { boundedProcess } from "./process-lifetime.js";
|
|
1
3
|
import { spawnSync } from 'node:child_process';
|
|
2
4
|
import { PROTOCOL } from '@lapxo/topos/wire';
|
|
3
5
|
/** Operational process failure, distinct from a valid contract refusal or abstention. */
|
|
@@ -31,24 +33,26 @@ export function validResponse(value, request) {
|
|
|
31
33
|
return true;
|
|
32
34
|
}
|
|
33
35
|
/** Last-line JSON is the wire framing. No partial batch, failed exit or synthetic missing response is accepted. */
|
|
34
|
-
export function executeCapsule(command, args, requests, limits = {}) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
36
|
+
export function executeCapsule(command, args, requests, limits = {}, lifetime) {
|
|
37
|
+
return actCosts.measure(() => {
|
|
38
|
+
const run = lifetime === undefined ? spawnSync(command, args, { input: JSON.stringify(requests), encoding: 'utf8', maxBuffer: 1 << 26, ...limits }) : boundedProcess(command, args, JSON.stringify(requests), lifetime);
|
|
39
|
+
if ('error' in run && run.error)
|
|
40
|
+
throw new CapsuleProcessError(`failed: ${run.error.message}`, run);
|
|
41
|
+
if (run.signal !== null || run.status !== 0)
|
|
42
|
+
throw new CapsuleProcessError(`exited ${run.status ?? `with signal ${run.signal}`}`, run);
|
|
43
|
+
const last = /(?:^|\n)([^\n]*)$/.exec(run.stdout.trimEnd())?.[1] ?? '';
|
|
44
|
+
let values;
|
|
45
|
+
try {
|
|
46
|
+
values = JSON.parse(last);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new CapsuleProcessError('returned malformed or absent JSON', { ...run, error });
|
|
50
|
+
}
|
|
51
|
+
if (!Array.isArray(values) || values.length !== requests.length || !values.every((value, i) => validResponse(value, requests[i]))) {
|
|
52
|
+
throw new CapsuleProcessError(`returned an invalid batch; expected ${requests.length} responses`, run);
|
|
53
|
+
}
|
|
54
|
+
if (run.stderr)
|
|
55
|
+
process.stderr.write(run.stderr);
|
|
56
|
+
return values;
|
|
57
|
+
});
|
|
54
58
|
}
|
package/dist/host/capsule.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Declaration } from '@lapxo/topos/capsule';
|
|
2
2
|
import type { Request, Response } from '@lapxo/topos/contract';
|
|
3
3
|
export interface Capsule {
|
|
4
|
+
readonly selection?: string;
|
|
4
5
|
readonly digest: string;
|
|
5
6
|
readonly declaration: Declaration;
|
|
6
7
|
readonly lines: readonly string[];
|
package/dist/host/capsule.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readerLifetime, parse } from '@lapxo/topos/wire';
|
|
2
|
+
import { contentStore, verifiedContent, contentPolicy } from "./content-store.js";
|
|
2
3
|
import { readOnly } from "./read-only.js";
|
|
3
4
|
import { sha } from "./hash.js";
|
|
4
5
|
import { wireLinesOf as linesOf } from "../fold/claims.js";
|
|
@@ -10,7 +11,7 @@ import { CAPSULE } from '@lapxo/topos/wire';
|
|
|
10
11
|
import { declarationOf } from '@lapxo/topos/capsule';
|
|
11
12
|
import { unpack } from "./archive.js";
|
|
12
13
|
import { executeCapsule, validResponse } from "./capsule-process.js";
|
|
13
|
-
import {
|
|
14
|
+
import { holdStore, replaceWhole, storeAt } from "../land/ledger.js";
|
|
14
15
|
import { coordinatesUnder, observeFile, observeText } from "../observe/files.js";
|
|
15
16
|
import { hostOf } from "../observe/run.js";
|
|
16
17
|
import { ownLock, ownRoot, ownStore, processOf } from "../observe/runner.js";
|
|
@@ -37,22 +38,19 @@ function unpacked(digest, bytes) {
|
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
39
40
|
* A capsule is the bytes a digest names in the instrument's own store, and only when they hash to it: its own lock is
|
|
40
|
-
* read from them, and the coordinate a lock names runs as a process of its own, like a reader. Each answer is kept
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* read from them, and the coordinate a lock names runs as a process of its own, like a reader. Each answer is kept by the
|
|
42
|
+
* digest of its complete declared request and implementation; distinct views retain distinct answers, and the same
|
|
43
|
+
* request reads its verified answer without running again. Operational failures never enter the cache; refusals and abstentions are asked again.
|
|
43
44
|
*/
|
|
44
45
|
const capsules = new Map();
|
|
45
46
|
/** A blob is loaded once in an act. A later ask for the same digest reads that capsule, and does not hash or unpack it again. */
|
|
46
47
|
export function capsuleAt(digest, entry, store) {
|
|
47
|
-
const
|
|
48
|
+
const cacheStore = store ?? contentStore();
|
|
49
|
+
const { bytes } = verifiedContent(digest, cacheStore);
|
|
50
|
+
const identity = `${cacheStore}\0${digest}\0${entry}`;
|
|
48
51
|
const held = capsules.get(identity);
|
|
49
52
|
if (held !== undefined)
|
|
50
53
|
return held;
|
|
51
|
-
const bytes = observeFile(blobAt(store ?? ownStore(), digest));
|
|
52
|
-
if (bytes === undefined)
|
|
53
|
-
throw new ContentNeeded(digest, `REFUSE·pin ${digest} offered capsule unavailable · not laid`);
|
|
54
|
-
if (`sha256:${createHash('sha256').update(bytes).digest('hex')}` !== digest)
|
|
55
|
-
throw Error(`REFUSE·pin ${digest} content hash mismatch · not laid`);
|
|
56
54
|
const known = declared.get(digest) ?? ((lines) => ({ lines, declaration: declarationOf(lines) }))(linesOf(observeText(join(unpacked(digest, bytes), CAPSULE))));
|
|
57
55
|
declared.set(digest, known);
|
|
58
56
|
const names = Object.keys(known.declaration.regions);
|
|
@@ -65,7 +63,7 @@ export function capsuleAt(digest, entry, store) {
|
|
|
65
63
|
const capsule = { digest, ...known, ask: (requests) => {
|
|
66
64
|
if (!statSync(module, { throwIfNoEntry: false })?.isFile())
|
|
67
65
|
throw new Error(`REFUSE·capsule ${digest} declared entry ${entry} unavailable`);
|
|
68
|
-
return answered(digest, bytes, requests, names, root,
|
|
66
|
+
return answered(digest, bytes, requests, names, root, cacheStore, module);
|
|
69
67
|
} };
|
|
70
68
|
capsules.set(identity, capsule);
|
|
71
69
|
return capsule;
|
|
@@ -95,10 +93,9 @@ function answered(digest, bytes, requests, names, tree, cacheStore = ownStore(),
|
|
|
95
93
|
return requests.map(() => undefined);
|
|
96
94
|
}
|
|
97
95
|
const loader = entry !== undefined ? `${createHash('sha256').update(`${relative(tree, entry)}\n`).digest('hex')}\n` : child.length > 1 ? `${createHash('sha256').update(observeFile(child[0]) ?? new Uint8Array()).digest('hex')}\n` : '';
|
|
98
|
-
const slotOf = (request) => sha(`${digest}\n${loader}${request.verb}\n${request.rootScope}\n${request.region ?? ''}`);
|
|
99
96
|
const hitOf = (request) => sha(`${digest}\n${loader}${JSON.stringify(request)}`);
|
|
100
|
-
const kept = requests.map((request) => storeAt(cacheStore, 'cas', 'answers', slotOf(request)));
|
|
101
97
|
const hits = requests.map(hitOf);
|
|
98
|
+
const kept = hits.map(hit => storeAt(cacheStore, 'cas', 'answers', hit));
|
|
102
99
|
const held = kept.map((at, i) => {
|
|
103
100
|
const text = observeText(at);
|
|
104
101
|
if (text === undefined)
|
|
@@ -117,7 +114,7 @@ function answered(digest, bytes, requests, names, tree, cacheStore = ownStore(),
|
|
|
117
114
|
if (missing.length)
|
|
118
115
|
process.stderr.write(`CAPSULE ${digest.slice(7, 19)} · ${requests.length} asked · ${missing.length} run · ${missing.map((one) => `${one.rootScope}${one.region}`).join(' ')}\n`);
|
|
119
116
|
// Execute and validate the entire batch before committing any answer to the cache.
|
|
120
|
-
const ran = !missing.length ? [] : executeCapsule(process.execPath, [...childFlags(), hostOf(), ...child], missing);
|
|
117
|
+
const ran = !missing.length ? [] : executeCapsule(process.execPath, [...childFlags(), hostOf(), ...child], missing, {}, readerLifetime(contentPolicy().flatMap(line => { const p = parse(line); return p.kind === 'fact' ? [p.value.fields] : []; }), digest));
|
|
121
118
|
return requests.map((request, i) => {
|
|
122
119
|
if (held[i] !== undefined)
|
|
123
120
|
return held[i];
|
|
@@ -125,7 +122,7 @@ function answered(digest, bytes, requests, names, tree, cacheStore = ownStore(),
|
|
|
125
122
|
if (said?.kind === 'fact') {
|
|
126
123
|
const text = JSON.stringify({ format: 'capsule-answer/2', hit: hits[i], said });
|
|
127
124
|
replaceWhole(kept[i], text);
|
|
128
|
-
holdStore(cacheStore, 'answers',
|
|
125
|
+
holdStore(cacheStore, 'answers', hits[i], Buffer.byteLength(text));
|
|
129
126
|
}
|
|
130
127
|
return said;
|
|
131
128
|
});
|
|
@@ -43,7 +43,7 @@ export async function cellsWithReceipts(root, history, emit) {
|
|
|
43
43
|
for (const record of parsed.value.objects.filter(r => r.record === 'cell')) {
|
|
44
44
|
const pin = record.fields.topos;
|
|
45
45
|
if (!pins.has(pin)) {
|
|
46
|
-
const top = toposAt(pin);
|
|
46
|
+
const top = toposAt(pin, store);
|
|
47
47
|
pins.set(pin, [pin, ...top.dependencies]);
|
|
48
48
|
}
|
|
49
49
|
const scope = `receipts/cells/${record.fields.scope}`;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const withContentStore: <T>(store: string, run: () => T, policy?: () => readonly string[]) => T;
|
|
2
|
+
export declare const contentPolicy: () => readonly string[];
|
|
3
|
+
export declare const contentStore: () => string;
|
|
4
|
+
/** Only immutable verified bytes may be shared with the instrument. Local corruption is never hidden by fallback. */
|
|
5
|
+
export declare function verifiedContent(digest: string, store?: string, instrument?: string): {
|
|
6
|
+
readonly bytes: Uint8Array;
|
|
7
|
+
readonly path: string;
|
|
8
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { blobAt } from "../land/ledger.js";
|
|
4
|
+
import { observeFile } from "../observe/files.js";
|
|
5
|
+
import { ownLock, ownStore } from "../observe/runner.js";
|
|
6
|
+
import { parse, wireAt } from '@lapxo/topos/wire';
|
|
7
|
+
import { ContentNeeded } from "./content-needed.js";
|
|
8
|
+
// Each invocation carries its place explicitly; asynchronous requests cannot share a mutable current root.
|
|
9
|
+
const requested = new AsyncLocalStorage();
|
|
10
|
+
export const withContentStore = (store, run, policy = () => []) => requested.run({ store, policy }, run);
|
|
11
|
+
export const contentPolicy = () => requested.getStore()?.policy() ?? [];
|
|
12
|
+
export const contentStore = () => requested.getStore()?.store ?? ownStore();
|
|
13
|
+
/** Only immutable verified bytes may be shared with the instrument. Local corruption is never hidden by fallback. */
|
|
14
|
+
export function verifiedContent(digest, store = contentStore(), instrument = ownStore()) {
|
|
15
|
+
const fields = ownLock().flatMap(line => { const p = parse(line); return p.kind === 'fact' ? [p.value.fields] : []; });
|
|
16
|
+
const wire = wireAt(fields, Number.MAX_SAFE_INTEGER);
|
|
17
|
+
if (!wire)
|
|
18
|
+
throw Error('REFUSE·pin instrument has no admitted wire');
|
|
19
|
+
const split = digest.indexOf(':'), algorithm = digest.slice(0, split), hex = digest.slice(split + 1);
|
|
20
|
+
if (split < 1 || !wire.digests.has(algorithm) || !/^[a-f0-9]+$/.test(hex))
|
|
21
|
+
throw Error(`REFUSE·pin ${digest} digest is not admitted`);
|
|
22
|
+
for (const at of new Set([store, instrument])) {
|
|
23
|
+
const path = blobAt(at, digest), bytes = observeFile(path);
|
|
24
|
+
if (bytes === undefined)
|
|
25
|
+
continue;
|
|
26
|
+
if (createHash(algorithm).update(bytes).digest('hex') !== hex)
|
|
27
|
+
throw Error(`REFUSE·pin ${digest} content hash mismatch · store ${at} · not laid`);
|
|
28
|
+
return { bytes, path };
|
|
29
|
+
}
|
|
30
|
+
throw new ContentNeeded(digest);
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
// This is a host mechanism, not a wire reader or a provider. The parent supplies its already selected policy.
|
|
4
|
+
const request = JSON.parse(readFileSync(0, 'utf8'));
|
|
5
|
+
if (!Number.isSafeInteger(request.limits.timeoutMs) || request.limits.timeoutMs <= 0 || !Number.isSafeInteger(request.limits.responseBytes) || request.limits.responseBytes <= 0)
|
|
6
|
+
throw Error('REFUSE·reader invalid process limits');
|
|
7
|
+
const grouped = process.platform !== 'win32';
|
|
8
|
+
const child = spawn(request.command, [...request.args], { cwd: request.cwd, env: request.env, detached: grouped, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
9
|
+
const stdout = [], stderr = [];
|
|
10
|
+
let size = 0, failure, finished = false;
|
|
11
|
+
const cancel = () => { if (child.pid === undefined)
|
|
12
|
+
return; try {
|
|
13
|
+
if (grouped)
|
|
14
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
15
|
+
else
|
|
16
|
+
child.kill('SIGKILL');
|
|
17
|
+
}
|
|
18
|
+
catch { } };
|
|
19
|
+
const refuse = (why) => { failure ?? (failure = why); cancel(); };
|
|
20
|
+
const receive = (chunks, bytes) => { size += bytes.length; if (size > request.limits.responseBytes) {
|
|
21
|
+
refuse('response exceeds declared byte bound');
|
|
22
|
+
return;
|
|
23
|
+
} chunks.push(bytes); };
|
|
24
|
+
child.stdout.on('data', (bytes) => receive(stdout, bytes));
|
|
25
|
+
child.stderr.on('data', (bytes) => receive(stderr, bytes));
|
|
26
|
+
child.stdin.on('error', error => { if (!finished)
|
|
27
|
+
refuse(`input failed: ${error.message}`); });
|
|
28
|
+
const timer = setTimeout(() => refuse('declared timeout exhausted'), request.limits.timeoutMs);
|
|
29
|
+
const finish = (status, signal) => {
|
|
30
|
+
if (finished)
|
|
31
|
+
return;
|
|
32
|
+
finished = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
cancel();
|
|
35
|
+
const result = { stdout: failure ? '' : Buffer.concat(stdout).toString('utf8'), stdoutBytes: failure ? '' : Buffer.concat(stdout).toString('base64'), stderr: failure ? '' : Buffer.concat(stderr).toString('utf8'), status, signal, ...(failure ? { failure } : {}) };
|
|
36
|
+
process.stdout.write(JSON.stringify(result));
|
|
37
|
+
};
|
|
38
|
+
child.on('error', error => { failure = error.message; finish(null, null); });
|
|
39
|
+
child.on('close', finish);
|
|
40
|
+
child.stdin.end(request.input);
|
package/dist/host/objects.js
CHANGED
|
@@ -10,7 +10,7 @@ export async function admittedObjects(root, lines, emit = () => { }) {
|
|
|
10
10
|
if (history.kind !== 'fact')
|
|
11
11
|
throw Error(`REFUSE·wire ${history.why}`);
|
|
12
12
|
const authority = authorityFor(lines, rootSigner(root), signaturesOf(storeOf(root)).admitted);
|
|
13
|
-
const context = await objectProvider(history.value.objects, history.value.configuration, { admit: fields => authority.of(canonical(fields)).kind === 'admitted', emit, topos: toposAt, module: contextModuleAt });
|
|
13
|
+
const context = await objectProvider(history.value.objects, history.value.configuration, { admit: fields => authority.of(canonical(fields)).kind === 'admitted', emit, topos: digest => toposAt(digest, storeOf(root)), module: digest => contextModuleAt(digest, storeOf(root)) });
|
|
14
14
|
const checked = validateObjectContext(history.value.objects, context);
|
|
15
15
|
if (checked.kind !== 'fact')
|
|
16
16
|
throw Error(`REFUSE·context ${checked.why}`);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Prepared by the admitted Topos contract and receiver signer. The host does not infer origins, coverage or epochs. */
|
|
2
|
+
export interface IngressBundle {
|
|
3
|
+
readonly evidence: string;
|
|
4
|
+
readonly receipt: string;
|
|
5
|
+
}
|
|
6
|
+
export interface IngressResult extends IngressBundle {
|
|
7
|
+
readonly kind: 'landed' | 'once';
|
|
8
|
+
readonly evidenceBytes: number;
|
|
9
|
+
readonly receiptBytes: number;
|
|
10
|
+
}
|
|
11
|
+
export interface IngressStorage {
|
|
12
|
+
publish(staged: string, committed: string): void;
|
|
13
|
+
}
|
|
14
|
+
/** Directory publication makes the pair visible together; there is no later ledger append that can expose half. */
|
|
15
|
+
export declare const nativeIngressStorage: IngressStorage;
|
|
16
|
+
/** Native signed receipt lines only. Foreign bytes are never added to the local ledger or epoch allocator. */
|
|
17
|
+
export declare function ingressReceipts(store: string): readonly string[];
|
|
18
|
+
/** Private staging directories are invisible to readers and may be discarded after host recovery. */
|
|
19
|
+
export declare function ingressEvidence(store: string, identity: string): IngressBundle | undefined;
|
|
20
|
+
export declare function ingressBundles(store: string): readonly IngressBundle[];
|
|
21
|
+
/** Verification and signing complete before staging. Replay performs no signer invocation or persistent write. */
|
|
22
|
+
export declare function commitIngress(store: string, identity: string, prepare: (localImportReceipts: readonly string[]) => Promise<IngressBundle>, verify: (bundle: IngressBundle) => boolean, storage?: IngressStorage): Promise<IngressResult>;
|
|
23
|
+
/** Costs distinguish delivered bytes from newly committed bytes; no count is called information or freedom. */
|
|
24
|
+
export declare function ingressCost(result: IngressResult, deliveredBytes: number, protocolBytes: number): string;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { requireEffects } from "../read-only.js";
|
|
5
|
+
import { underTheLock } from "../../land/act.js";
|
|
6
|
+
const syncDirectory = (path) => { const fd = openSync(path, 'r'); try {
|
|
7
|
+
fsyncSync(fd);
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
closeSync(fd);
|
|
11
|
+
} };
|
|
12
|
+
const durableFile = (path, text) => { const fd = openSync(path, 'wx', 0o600); try {
|
|
13
|
+
writeFileSync(fd, text);
|
|
14
|
+
fsyncSync(fd);
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
closeSync(fd);
|
|
18
|
+
} };
|
|
19
|
+
/** Directory publication makes the pair visible together; there is no later ledger append that can expose half. */
|
|
20
|
+
export const nativeIngressStorage = { publish(staged, committed) { renameSync(staged, committed); syncDirectory(join(committed, '..')); } };
|
|
21
|
+
const directory = (store) => join(store, 'ingress');
|
|
22
|
+
const nameOf = (identity) => {
|
|
23
|
+
if (!identity || identity.includes('\0'))
|
|
24
|
+
throw Error('REFUSE·ingress missing import identity');
|
|
25
|
+
return 'committed-' + encodeURIComponent(identity);
|
|
26
|
+
};
|
|
27
|
+
const read = (at) => ({ evidence: readFileSync(join(at, 'evidence.bound'), 'utf8'), receipt: readFileSync(join(at, 'receipt.bound'), 'utf8') });
|
|
28
|
+
const measured = (kind, bundle) => ({ kind, ...bundle, evidenceBytes: Buffer.byteLength(bundle.evidence), receiptBytes: Buffer.byteLength(bundle.receipt) });
|
|
29
|
+
/** Native signed receipt lines only. Foreign bytes are never added to the local ledger or epoch allocator. */
|
|
30
|
+
export function ingressReceipts(store) {
|
|
31
|
+
const root = directory(store);
|
|
32
|
+
if (!existsSync(root))
|
|
33
|
+
return [];
|
|
34
|
+
return readdirSync(root).filter(name => name.startsWith('committed-')).sort().flatMap(name => read(join(root, name)).receipt.split('\n').filter(Boolean));
|
|
35
|
+
}
|
|
36
|
+
/** Private staging directories are invisible to readers and may be discarded after host recovery. */
|
|
37
|
+
export function ingressEvidence(store, identity) {
|
|
38
|
+
const at = join(directory(store), nameOf(identity));
|
|
39
|
+
return existsSync(at) ? read(at) : undefined;
|
|
40
|
+
}
|
|
41
|
+
export function ingressBundles(store) {
|
|
42
|
+
const root = directory(store);
|
|
43
|
+
if (!existsSync(root))
|
|
44
|
+
return [];
|
|
45
|
+
return readdirSync(root).filter(name => name.startsWith('committed-')).sort().map(name => read(join(root, name)));
|
|
46
|
+
}
|
|
47
|
+
/** Verification and signing complete before staging. Replay performs no signer invocation or persistent write. */
|
|
48
|
+
export async function commitIngress(store, identity, prepare, verify, storage = nativeIngressStorage) {
|
|
49
|
+
requireEffects('foreign evidence ingress');
|
|
50
|
+
return underTheLock(store, 'ingress', async () => {
|
|
51
|
+
const root = directory(store), at = join(root, nameOf(identity));
|
|
52
|
+
if (existsSync(at)) {
|
|
53
|
+
const prior = read(at);
|
|
54
|
+
if (verify(prior) !== true)
|
|
55
|
+
throw Error('REFUSE·ingress committed bytes do not verify');
|
|
56
|
+
return measured('once', prior);
|
|
57
|
+
}
|
|
58
|
+
const bundle = await prepare(ingressReceipts(store));
|
|
59
|
+
if (!bundle.evidence || !bundle.receipt || verify(bundle) !== true)
|
|
60
|
+
throw Error('REFUSE·ingress evidence and receipt do not verify');
|
|
61
|
+
mkdirSync(root, { recursive: true });
|
|
62
|
+
const staged = join(root, '.stage-' + randomUUID());
|
|
63
|
+
mkdirSync(staged, { mode: 0o700 });
|
|
64
|
+
try {
|
|
65
|
+
durableFile(join(staged, 'evidence.bound'), bundle.evidence);
|
|
66
|
+
durableFile(join(staged, 'receipt.bound'), bundle.receipt);
|
|
67
|
+
syncDirectory(staged);
|
|
68
|
+
storage.publish(staged, at);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
rmSync(staged, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
return measured('landed', bundle);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Costs distinguish delivered bytes from newly committed bytes; no count is called information or freedom. */
|
|
77
|
+
export function ingressCost(result, deliveredBytes, protocolBytes) {
|
|
78
|
+
if (!Number.isSafeInteger(deliveredBytes) || deliveredBytes < 0 || !Number.isSafeInteger(protocolBytes) || protocolBytes < 0)
|
|
79
|
+
throw Error('REFUSE·ingress invalid exchange byte measurement');
|
|
80
|
+
return `EXCHANGE ${deliveredBytes} payload bytes · ${protocolBytes} protocol bytes · ${result.kind === 'once' ? 0 : result.evidenceBytes} new evidence bytes · ${result.kind === 'once' ? 0 : result.receiptBytes} new receipt bytes`;
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function transportBytes(location: string, digest: string, lines: readonly string[]): Promise<Uint8Array>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { contentRequest, transportSelection, parse } from '@lapxo/topos/wire';
|
|
3
|
+
import { boundedProcess } from "../process-lifetime.js";
|
|
4
|
+
import { fetchBytes } from "../adapters/transport/http.js";
|
|
5
|
+
export async function transportBytes(location, digest, lines) {
|
|
6
|
+
const scheme = new URL(location).protocol.slice(0, -1);
|
|
7
|
+
const fields = lines.flatMap(line => { const p = parse(line); return p.kind === 'fact' ? [p.value.fields] : []; });
|
|
8
|
+
const selected = transportSelection(fields, scheme);
|
|
9
|
+
if (selected === undefined)
|
|
10
|
+
throw Error(`REFUSE·transport ${scheme} no admitted content-request/1 policy`);
|
|
11
|
+
if (selected.name === 'fetch') {
|
|
12
|
+
if (!['http', 'https'].includes(scheme))
|
|
13
|
+
throw Error(`REFUSE·transport ${scheme} is not supported by this host adapter`);
|
|
14
|
+
return fetchBytes(location, selected);
|
|
15
|
+
}
|
|
16
|
+
const registryPath = process.env['BOUND_TRANSPORTS'];
|
|
17
|
+
if (!registryPath)
|
|
18
|
+
throw Error(`REFUSE·transport ${scheme} no host binding for ${selected.name}`);
|
|
19
|
+
let record;
|
|
20
|
+
try {
|
|
21
|
+
const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
22
|
+
if (!registry || typeof registry !== 'object' || !Object.prototype.hasOwnProperty.call(registry, selected.name))
|
|
23
|
+
throw Error();
|
|
24
|
+
const value = registry[selected.name];
|
|
25
|
+
if (!value || typeof value !== 'object')
|
|
26
|
+
throw Error();
|
|
27
|
+
const f = value;
|
|
28
|
+
if (f.kind !== 'command' || typeof f.executable !== 'string' || !f.executable || !Array.isArray(f.args) || !f.args.every(x => typeof x === 'string'))
|
|
29
|
+
throw Error();
|
|
30
|
+
if (f.environment !== undefined && (!Array.isArray(f.environment) || !f.environment.every(x => typeof x === 'string')))
|
|
31
|
+
throw Error();
|
|
32
|
+
const names = (f.environment ?? []);
|
|
33
|
+
const env = {};
|
|
34
|
+
if (process.env.PATH !== undefined)
|
|
35
|
+
env.PATH = process.env.PATH;
|
|
36
|
+
for (const name of names)
|
|
37
|
+
if (process.env[name] !== undefined)
|
|
38
|
+
env[name] = process.env[name];
|
|
39
|
+
record = { executable: f.executable, args: f.args, env };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw Error(`REFUSE·transport ${scheme} host binding unavailable or invalid`);
|
|
43
|
+
}
|
|
44
|
+
const result = boundedProcess(record.executable, record.args, contentRequest(location, digest, selected), selected, undefined, record.env);
|
|
45
|
+
if (result.status !== 0 || result.signal !== null)
|
|
46
|
+
throw Error(`REFUSE·transport ${scheme} failed · status ${result.status ?? result.signal}`);
|
|
47
|
+
return Buffer.from(result.stdoutBytes ?? '', 'base64');
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReaderLifetime } from '@lapxo/topos/wire';
|
|
2
|
+
export interface ProcessResult {
|
|
3
|
+
readonly stdout: string;
|
|
4
|
+
readonly stdoutBytes?: string;
|
|
5
|
+
readonly stderr: string;
|
|
6
|
+
readonly status: number | null;
|
|
7
|
+
readonly signal: NodeJS.Signals | null;
|
|
8
|
+
readonly failure?: string;
|
|
9
|
+
}
|
|
10
|
+
/** A synchronous fold waits on a supervising host process; the supervisor owns cancellation of its child group. */
|
|
11
|
+
export declare function boundedProcess(command: string, args: readonly string[], input: string, limits: ReaderLifetime, cwd?: string, env?: Readonly<Record<string, string>>): ProcessResult;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { processOf } from "../observe/runner.js";
|
|
3
|
+
/** A synchronous fold waits on a supervising host process; the supervisor owns cancellation of its child group. */
|
|
4
|
+
export function boundedProcess(command, args, input, limits, cwd, env) {
|
|
5
|
+
const run = spawnSync(process.execPath, [processOf('lifetime', 'host')], { input: JSON.stringify({ command, args, input, limits, cwd, env }), encoding: 'utf8', maxBuffer: limits.responseBytes * 12 + 4096 });
|
|
6
|
+
if (run.error || run.status !== 0)
|
|
7
|
+
throw Error(`REFUSE·reader lifetime supervisor ${run.error?.message ?? run.status}`);
|
|
8
|
+
const result = JSON.parse(run.stdout);
|
|
9
|
+
if (result.failure)
|
|
10
|
+
throw Error(`REFUSE·reader ${result.failure} · status ${result.status ?? result.signal ?? 'cancelled'}`);
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Capsule } from './capsule.ts';
|
|
2
|
+
type File = {
|
|
3
|
+
readonly place: string;
|
|
4
|
+
readonly text: string;
|
|
5
|
+
};
|
|
6
|
+
export declare const inputLines: (files: readonly File[]) => readonly string[];
|
|
7
|
+
/** The selected world owns input projection and reading. The host only verifies closures and keeps native count/bytes receipts. */
|
|
8
|
+
export declare function readingClosures(options: {
|
|
9
|
+
root: string;
|
|
10
|
+
store: string;
|
|
11
|
+
reader: string;
|
|
12
|
+
speaker: string;
|
|
13
|
+
digest: (text: string) => string;
|
|
14
|
+
capsule: Capsule;
|
|
15
|
+
region: string;
|
|
16
|
+
projection: string;
|
|
17
|
+
allowsEmpty?: boolean;
|
|
18
|
+
files: readonly File[];
|
|
19
|
+
emit?: (line: string) => void;
|
|
20
|
+
}): {
|
|
21
|
+
lines: readonly string[];
|
|
22
|
+
read: number;
|
|
23
|
+
opened: number;
|
|
24
|
+
executed: number;
|
|
25
|
+
};
|
|
26
|
+
/** Verify the native reading carriers before a file seal may reuse them. These are semantic receipts, not file-region seals. */
|
|
27
|
+
export declare function verifiedReadingReceipts(store: string, lines: readonly string[], digest: (text: string) => string): number | undefined;
|
|
28
|
+
export {};
|