@lapxo/bound 0.1.0 → 0.1.1

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.
@@ -1,20 +1,22 @@
1
+ import { selectedWireAt } from "../host/selected-topos.js";
1
2
  import { timing } from "../host/timing.js";
2
3
  import { admittedObjects } from "../host/objects.js";
3
4
  import { existsSync, rmdirSync, rmSync } from 'node:fs';
4
5
  import { dirname, join, basename } from 'node:path';
5
6
  import { closeReceipts, instrumentKeep, instrumentKept } from "../fold/closed.js";
6
7
  import { bytesDigest, fullDigest, instrumentOf, signaturesOf } from "../fold/digests.js";
7
- import { canonical, fromLine, LOCK, parse, wireAt } from '@lapxo/topos/wire';
8
+ import { canonical, fromLine, LOCK, parse } from '@lapxo/topos/wire';
8
9
  import { fieldOf, isConfig, foldClaims, sayingOf, selfName } from "../fold/claims.js";
9
10
  import { isReaderLock } from "../fold/observed.js";
10
11
  import { authorityFor, publicKeyOf, rootSigner, writerFor } from "../fold/signers.js";
11
12
  import { lockLines, lockStanding } from "../fold/keys.js";
12
13
  import { keepVerdict, keptVerdict, land, landBlob, storeOf } from "../land/ledger.js";
13
- import { signConsentLine, verifiesOwnerLine } from "../land/sign.js";
14
- import { appliedIn } from "../fold/signed.js";
14
+ import { signConsentLot, verifiesOwnerLine } from "../land/sign.js";
15
+ import { signerFor } from "../host/signing.js";
16
+ import { appliedIn, ownLockOf } from "../fold/signed.js";
15
17
  import { wordOf } from "../fold/wire.js";
16
18
  import { hasVouchedTree, landCoordinates, locationsOf, vouchedCoordinates } from "../land/vouched.js";
17
- import { entriesIn, observeFile, observeText } from "../observe/files.js";
19
+ import { entriesIn, observeFile } from "../observe/files.js";
18
20
  import { coneOf, standingOf } from "./place.js";
19
21
  import { ownLock, ownRoot, ownStore } from "../observe/runner.js";
20
22
  import { widenedWithoutWitness } from "../judge/widening.js";
@@ -85,40 +87,47 @@ export async function signedBatch(root, args, files) {
85
87
  }
86
88
  const keyFile = valueOf(args, FLAGS.keyFile);
87
89
  const keyId = valueOf(args, FLAGS.key) ?? ownerOf(store);
88
- if (drafted.length && (!keyFile || !existsSync(keyFile))) {
89
- process.stderr.write(`${selfName()}: a drafted batch is signed with --key-file (PKCS8 PEM)\n`);
90
- return { kind: 'exit', code: 2 };
91
- }
92
90
  const publicKey = drafted.length ? publicKeyOf(root, keyId) : undefined;
93
91
  if (drafted.length && !publicKey) {
94
92
  process.stderr.write(`${selfName()}: unknown key ${keyId} — name it in ${selfName()}.keys\n`);
95
93
  return { kind: 'exit', code: 2 };
96
94
  }
97
- const privateKeyPem = keyFile ? observeText(keyFile) ?? '' : '';
98
- const signed = proposed.filter((line) => fieldOf(line, 'sig'));
99
- for (const line of drafted) {
100
- const got = signConsentLine(line, privateKeyPem, { epoch, by: keyId, algorithm: signaturesOf(store).era });
101
- if (got.kind === 'refuse') {
102
- process.stderr.write(`${selfName()}: ${got.why}\n`);
95
+ let signed = proposed.filter((line) => fieldOf(line, 'sig'));
96
+ if (drafted.length) {
97
+ let invoke;
98
+ try {
99
+ invoke = signerFor([...lockStanding(store), ...destinations.flatMap(place => ownLockOf(root, place))], keyId, valueOf(args, FLAGS.signer), keyFile);
100
+ }
101
+ catch (error) {
102
+ process.stderr.write(`${selfName()}: ${error instanceof Error ? error.message : 'REFUSE·signer unavailable'}\n`);
103
103
  return { kind: 'exit', code: 1 };
104
104
  }
105
- if (!verifiesOwnerLine(got.line, publicKey, signaturesOf(store).admitted)) {
106
- process.stderr.write(`${selfName()}: REFUSE·accept the signature does not hold for ${keyId}\n`);
105
+ const got = await signConsentLot(drafted, { epoch, by: keyId, algorithm: signaturesOf(store).era }, publicKey, signaturesOf(store).admitted, invoke);
106
+ if (got.kind === 'refuse') {
107
+ process.stderr.write(`${selfName()}: ${got.why}\n`);
107
108
  return { kind: 'exit', code: 1 };
108
109
  }
109
- signed.push(got.line);
110
+ let index = 0;
111
+ signed = proposed.map(line => fieldOf(line, 'sig') ? line : got.lines[index++]);
110
112
  }
111
113
  const wireLines = [...signedOwnerLines(store), ...signed].flatMap((line) => {
112
114
  const got = parse(line);
113
115
  return got.kind === 'fact' ? [got.value.fields] : [];
114
116
  });
115
117
  for (const line of signed) {
116
- const got = fromLine(line, wireAt(wireLines, Number(fieldOf(line, 'epoch'))));
118
+ const got = fromLine(line, selectedWireAt(wireLines, Number(fieldOf(line, 'epoch')), foldClaims(signedOwnerLines(store)).standing.filter(one => fieldOf(one, 'scope').startsWith('uses/') && fieldOf(one, 'value') !== 'withdraw')));
117
119
  if (got.kind !== 'fact') {
118
120
  process.stderr.write(`${selfName()}: REFUSE·wire ${fieldOf(line, 'scope')} · ${got.why} · nothing landed\n`);
119
121
  return { kind: 'exit', code: 1 };
120
122
  }
121
123
  }
124
+ const authority = authorityFor([...lockLines(store), ...signed], rootSigner(root), signaturesOf(store).admitted);
125
+ const ownership = ownedOf(root, signed, line => authority.of(line).kind === 'admitted', by => authority.admitted.find(one => one.id === by)?.coverage ?? [], named, line => { const heads = byFile.flatMap(([heads, lines]) => lines.some(draft => sayingOf(draft) === sayingOf(line)) ? heads : []); return heads.length ? heads : undefined; });
126
+ if (ownership.nowhere.length) {
127
+ for (const line of ownership.nowhere)
128
+ process.stderr.write(`${selfName()}: REFUSE·signer ${keyId} uncovered ${fieldOf(line, 'scope')} · nothing signed for delivery\n`);
129
+ return { kind: 'exit', code: 1 };
130
+ }
122
131
  if (signed.some(line => !isConfig(line))) {
123
132
  try {
124
133
  await admittedObjects(root, [...signedOwnerLines(store), ...signed], text => process.stdout.write(text + '\n'));
package/dist/cli/args.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { relative, sep } from 'node:path';
2
2
  import { observeText } from "../observe/files.js";
3
3
  import { sourceOf } from "../observe/runner.js";
4
- import { PROTOCOL } from '@lapxo/topos/wire';
4
+ import { PROTOCOL, parse } from '@lapxo/topos/wire';
5
5
  import { FLAGS, VALUED } from "./names.js";
6
6
  export { VALUED };
7
7
  /** What the loop runs that is not source: its entry, the packages it imports and the readers it loads, read by where they lie. */
@@ -30,5 +30,12 @@ export function positionalsOf(args) {
30
30
  export const placesOfFile = (file) => (observeText(file) ?? '').split('\n').map((line) => line.trim())
31
31
  .filter((line) => line.startsWith('# place ')).map((line) => line.slice('# place '.length).trim().replace(/\/$/, '')).filter(Boolean);
32
32
  export function claimLinesIn(file) {
33
- return (observeText(file) ?? '').split('\n').map((line) => line.trim()).filter((line) => line.startsWith(PROTOCOL));
33
+ const lines = (observeText(file) ?? '').split('\n').map(line => line.trim())
34
+ .filter(line => line.startsWith(`${PROTOCOL.split('/')[0]}/`));
35
+ for (const line of lines) {
36
+ const got = parse(line);
37
+ if (got.kind !== 'fact')
38
+ throw new Error(`REFUSE·wire batch ${got.why} · nothing signed or landed`);
39
+ }
40
+ return lines;
34
41
  }
@@ -7,7 +7,7 @@ export declare const FLAGS: {
7
7
  readonly check: "--check";
8
8
  readonly key: "--key";
9
9
  readonly keyFile: "--key-file";
10
- readonly out: "--out";
10
+ readonly signer: "--signer";
11
11
  readonly place: "--place";
12
12
  };
13
13
  export declare const VALUED: readonly string[];
package/dist/cli/names.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /** The verbs bound answers by name: fold reads, land writes signed lines, sign is a key's act. A directory with no verb is a fold of that place. */
2
2
  export const VERB_NAMES = ['fold', 'land', 'sign'];
3
3
  /** Every flag the CLI reads, spelled once; a valued flag takes the argument after it. */
4
- export const FLAGS = { as: '--as', check: '--check', key: '--key', keyFile: '--key-file', out: '--out', place: '--place' };
5
- export const VALUED = [FLAGS.keyFile, FLAGS.key, FLAGS.out, FLAGS.as, FLAGS.place];
4
+ export const FLAGS = { as: '--as', check: '--check', key: '--key', keyFile: '--key-file', signer: '--signer', place: '--place' };
5
+ export const VALUED = [FLAGS.keyFile, FLAGS.key, FLAGS.signer, FLAGS.as, FLAGS.place];
6
6
  /** Views the CLI renders from these names, never from a handwritten usage string. */
7
7
  export const NAMED_VIEWS = { help: 'help', lines: 'lines', because: 'because' };
8
8
  export const EXIT = { closed: 0, refuse: 1, usage: 2 };
package/dist/cli/verb.js CHANGED
@@ -20,7 +20,8 @@ import { rolesOf } from "../fold/roles.js";
20
20
  import { authorityFor, releaseSigner, rootSigner, writerFor } from "../fold/signers.js";
21
21
  import { land, ledgerLines, storeOf } from "../land/ledger.js";
22
22
  import { lockLines, releaseOf } from "../fold/keys.js";
23
- import { signConsentLine } from "../land/sign.js";
23
+ import { signConsentLot } from "../land/sign.js";
24
+ import { signerFor } from "../host/signing.js";
24
25
  import { admittedIn, ownLockOf, tellOwn } from "../fold/signed.js";
25
26
  import { hasVouchedTree, landCoordinates, coordinatesOf, locationsOf, vouching, sourced } from "../land/vouched.js";
26
27
  import { observeText } from "../observe/files.js";
@@ -39,7 +40,7 @@ import { claimLinesIn, entryOf, namedPlaces, positionalsOf, valueOf } from "./ar
39
40
  import { EXIT, FLAGS, NAMED_VIEWS, helpLines } from "./names.js";
40
41
  import { accept, signedBatch } from "./accept.js";
41
42
  /** The first land of a lock: TARGET.bound into an empty owner ledger, then the sources that lock names. */
42
- function firstLand(root, args) {
43
+ async function firstLand(root, args) {
43
44
  const started = Date.now();
44
45
  const store = storeOf(root);
45
46
  const today = claimLinesIn(targetFile(root));
@@ -63,24 +64,6 @@ function firstLand(root, args) {
63
64
  keyId = valueOf(args, FLAGS.key) ?? owner;
64
65
  if (ledgerLines(store, owner).length)
65
66
  throw new Error('REFUSE·bootstrap the owner ledger already holds claims; the first land happens once');
66
- const keyFile = valueOf(args, FLAGS.keyFile);
67
- if (keyFile !== undefined) {
68
- const pem = observeText(keyFile);
69
- if (pem === undefined)
70
- throw new Error('REFUSE·bootstrap the requested signing key is unreadable');
71
- const signed = [];
72
- for (const line of today) {
73
- if (/\bsig=/.test(line)) {
74
- signed.push(line);
75
- continue;
76
- }
77
- const got = signConsentLine(line, pem, { epoch: 1, by: keyId, algorithm: config.era });
78
- if (got.kind === 'refuse')
79
- throw new Error(got.why);
80
- signed.push(got.line);
81
- }
82
- today.splice(0, today.length, ...signed);
83
- }
84
67
  const said = (measure) => {
85
68
  const values = new Set(today.filter((line) => fieldOf(line, 'scope') === `keys/${owner}` && fieldOf(line, 'measure') === measure && fieldOf(line, 'value') !== 'withdraw').map((line) => fieldOf(line, 'value')));
86
69
  if (values.size !== 1)
@@ -93,6 +76,15 @@ function firstLand(root, args) {
93
76
  const coverage = said('coverage').split('|').filter(Boolean);
94
77
  if (!publicKey || !coverage.length)
95
78
  throw new Error('REFUSE·bootstrap the root key must declare its public key and coverage');
79
+ const drafted = today.filter(line => !fieldOf(line, 'sig'));
80
+ if (drafted.length) {
81
+ const invoke = signerFor(today, keyId, valueOf(args, FLAGS.signer), valueOf(args, FLAGS.keyFile));
82
+ const got = await signConsentLot(drafted, { epoch: 1, by: keyId, algorithm: config.era }, publicKey, config.admitted, invoke);
83
+ if (got.kind === 'refuse')
84
+ throw new Error(got.why);
85
+ let i = 0;
86
+ today.splice(0, today.length, ...today.map(line => fieldOf(line, 'sig') ? line : got.lines[i++]));
87
+ }
96
88
  const authority = authorityFor(today, { ...releaseSigner(today), id: owner, publicKey, coverage }, config.admitted);
97
89
  for (const line of today) {
98
90
  const verdict = authority.of(line);
@@ -446,6 +438,22 @@ const VERBS = {
446
438
  sign: (root, rest) => signVerb(root, rest),
447
439
  };
448
440
  function main(argv) {
441
+ for (let i = 0; i < argv.length; i += 1) {
442
+ const arg = argv[i];
443
+ if (!arg.startsWith('--'))
444
+ continue;
445
+ if (!Object.values(FLAGS).includes(arg)) {
446
+ process.stderr.write(`${selfName()}: REFUSE·flag unknown option ${arg}\n`);
447
+ return EXIT.usage;
448
+ }
449
+ if (arg !== FLAGS.check) {
450
+ if (argv[i + 1] === undefined || argv[i + 1].startsWith('--')) {
451
+ process.stderr.write(`${selfName()}: REFUSE·flag ${arg} needs a value\n`);
452
+ return EXIT.usage;
453
+ }
454
+ i += 1;
455
+ }
456
+ }
449
457
  const verb = argv[0] ?? '';
450
458
  const run = verb in VERBS ? VERBS[verb] : undefined;
451
459
  const places = positionalsOf(run === undefined ? argv : argv.slice(1)).filter((one) => existsSync(resolve(one)) && lstatSync(resolve(one)).isDirectory());
@@ -389,7 +389,11 @@ export function observedClaims(root, store, options = {}) {
389
389
  ? asked.ask(due.map((d) => ({
390
390
  protocol: PROTOCOL, verb: 'read', rootScope: d.place, region: reader.capsule.region, held: heldOf(d.place, d.region),
391
391
  files: (reader.capsule.reads.some((read) => read.includes('@')) ? handedAt(root, reader, roleOf, look, d.place) : [d.place]).map((coordinate) => ({ place: coordinate, text: observeText(resolve(root, coordinate)) ?? '' })),
392
- }))).map((answer) => (answer?.claims ?? []))
392
+ }))).map((answer) => {
393
+ if (answer?.kind !== 'fact')
394
+ throw new Error(`REFUSE·reader ${reader.module} · ${answer?.why ?? 'no capsule answer'}`);
395
+ return (answer.claims ?? []);
396
+ })
393
397
  : runReader(module, root, due.map((d) => ({ place: d.place, ...(reader.kind === 'js' ? { text: new TextDecoder().decode(d.bytes) } : {}), held: heldOf(d.place, d.region) })));
394
398
  }
395
399
  catch (error) {
@@ -8,6 +8,7 @@ import { capsuleAt, placesIn } from "./places.js";
8
8
  import { capsulesFor } from "./reach.js";
9
9
  import { ownLockOf } from "./signed.js";
10
10
  import { ownLock } from "../observe/runner.js";
11
+ import { selectedWireList } from "../host/selected-topos.js";
11
12
  import { entriesIn, coordinatesUnder, observeFile, stampOf } from "../observe/files.js";
12
13
  export function lookOf(root, roleOf) {
13
14
  const walked = new Map();
@@ -42,7 +43,10 @@ const reachOfLine = (module, where) => {
42
43
  */
43
44
  export function capsuleReadersOf(root, store, standing, lines, resolve = capsulesFor) {
44
45
  const reaching = new Set(lines.flatMap((reader) => reachOfLine(reader.module, reader.where)));
45
- const measures = wordsOf(standing, 'region-measures');
46
+ const localMeasures = wordsOf(standing, 'region-measures');
47
+ const pins = standing.filter(line => fieldOf(line, 'scope').startsWith('uses/') && fieldOf(line, 'value') !== 'withdraw');
48
+ const measures = localMeasures.length || !pins.length ? localMeasures
49
+ : selectedWireList(standing, Number.MAX_SAFE_INTEGER, pins, 'region-measures');
46
50
  const world = fields(fieldOf(wireLine(standing, 'offers/world') ?? '', 'value')).map(([region, globs]) => canonical({ scope: `region/${region}`, measure: wordOn(standing, 'region-coordinate', ownLock()), value: globs }));
47
51
  const groups = new Map();
48
52
  for (const place of ['', ...placesIn(root)]) {
@@ -6,7 +6,7 @@ import { signaturesOf } from "./digests.js";
6
6
  import { lockLines, lockStanding } from "./keys.js";
7
7
  import { wordOf } from "./wire.js";
8
8
  import { authorityFor, rootSigner } from "./signers.js";
9
- import { shardLines, storeOf } from "../land/ledger.js";
9
+ import { ledgerLines, shardLines, storeOf } from "../land/ledger.js";
10
10
  import { verifiesFields } from "../land/sign.js";
11
11
  import { observeText } from "../observe/files.js";
12
12
  const held = new Map();
@@ -20,14 +20,16 @@ function verified(root, place) {
20
20
  const hasAuthority = lock.some(line => fieldOf(line, 'sig') !== '');
21
21
  const algorithms = hasAuthority ? signaturesOf(store).admitted : [];
22
22
  const signers = algorithms.length ? authorityFor(lock, rootSigner(root), algorithms).admitted : [];
23
- const history = signers.flatMap(signer => shardLines(store, signer.id, `${place}/${LOCK}`));
23
+ const history = signers.flatMap(signer => place ? shardLines(store, signer.id, `${place}/${LOCK}`) : ledgerLines(store, signer.id)).filter(line => fieldOf(line, 'sig') !== '');
24
24
  const text = [publicText, ...history, ...(told.get(place) ?? [])].join('\n');
25
25
  const key = `${root} ${place}`;
26
26
  const last = held.get(key);
27
27
  if (last !== undefined && last.text === text && last.lock === lock)
28
28
  return last;
29
29
  const lines = [...new Set(text.split('\n').filter(isWire))];
30
- const family = signers.length ? wordOf(lockStanding(store), 'families', 'region') : '';
30
+ // Unrestricted coverage does not construct a region coordinate. Restricted
31
+ // coverage still requires the lock's declared namespace before comparison.
32
+ const family = signers.some(signer => !signer.coverage.includes('*')) ? wordOf(lockStanding(store), 'families', 'region') : '';
31
33
  const stands = (line) => {
32
34
  const got = parse(line);
33
35
  if (got.kind !== 'fact' || got.value.fields['sig'] === undefined)
package/dist/fold/wire.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as topos from '@lapxo/topos/wire';
2
2
  import { fieldOf, selfName } from "./claims.js";
3
+ import { selectedWireList } from "../host/selected-topos.js";
3
4
  const spoken = topos;
4
5
  export const namesAt = (spoken['namesAt'] ?? spoken['namesLeaf']);
5
6
  export const roleAt = (spoken['roleAt'] ?? spoken['leafRoleOf']);
@@ -17,7 +18,11 @@ export function wordsOf(standing, list) {
17
18
  * refusal. The older `audit/wire/` prefix is one era of `wire/`, not a fall-back to another lock.
18
19
  */
19
20
  export function wordOf(standing, list, name) {
20
- const held = wordsOf(standing, list);
21
+ const local = wordsOf(standing, list);
22
+ const pins = standing.filter(line => fieldOf(line, 'scope').startsWith('uses/') && fieldOf(line, 'value') !== 'withdraw');
23
+ // A required word may come from selected standing without copying foreign claims.
24
+ const held = wireLine(standing, list) === undefined && pins.length
25
+ ? selectedWireList(standing, Number.MAX_SAFE_INTEGER, pins, list) : local;
21
26
  const eras = typeof name === 'string' ? [name] : name;
22
27
  const found = eras.find((one) => held.includes(one));
23
28
  if (found !== undefined)
@@ -0,0 +1,31 @@
1
+ /** Contains only host-controlled diagnostics, never child output. */
2
+ export declare class SigningFailure extends Error {
3
+ }
4
+ export interface SigningLot {
5
+ readonly bytes: readonly string[];
6
+ readonly keyId: string;
7
+ readonly algorithm: string;
8
+ }
9
+ export interface SignatureRecord {
10
+ readonly keyId: string;
11
+ readonly signature: string;
12
+ }
13
+ export interface SigningProtocol {
14
+ readonly request: (lot: SigningLot) => string;
15
+ readonly response: (records: readonly SignatureRecord[]) => string;
16
+ readonly reply: (text: string, lot: SigningLot) => readonly SignatureRecord[];
17
+ }
18
+ export type SigningMechanism = {
19
+ readonly kind: 'file';
20
+ readonly path: string;
21
+ } | {
22
+ readonly kind: 'command';
23
+ readonly executable: string;
24
+ readonly args: readonly string[];
25
+ };
26
+ export interface SigningLimits {
27
+ readonly timeoutMs: number;
28
+ readonly responseBytes: number;
29
+ }
30
+ /** Host-approved mechanism only. Repository declarations do not authorize execution. */
31
+ export declare function invokeSigner(lot: SigningLot, mechanism: SigningMechanism, protocol: SigningProtocol, limits: SigningLimits): Promise<readonly SignatureRecord[]>;
@@ -0,0 +1,74 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { signBytes } from "../adapters/keys/ed25519.js";
4
+ /** Contains only host-controlled diagnostics, never child output. */
5
+ export class SigningFailure extends Error {
6
+ }
7
+ /** Host-approved mechanism only. Repository declarations do not authorize execution. */
8
+ export async function invokeSigner(lot, mechanism, protocol, limits) {
9
+ if (!Number.isSafeInteger(limits.timeoutMs) || limits.timeoutMs <= 0
10
+ || !Number.isSafeInteger(limits.responseBytes) || limits.responseBytes <= 0) {
11
+ throw new SigningFailure(`REFUSE·signer ${lot.keyId} has no valid declared limits`);
12
+ }
13
+ const request = protocol.request(lot);
14
+ let output;
15
+ if (mechanism.kind === 'file') {
16
+ try {
17
+ const key = readFileSync(mechanism.path, 'utf8');
18
+ output = protocol.response(lot.bytes.map(bytes => ({ keyId: lot.keyId, signature: signBytes(bytes, key, lot.algorithm) })));
19
+ }
20
+ catch {
21
+ throw new SigningFailure(`REFUSE·signer ${lot.keyId} file mechanism failed`);
22
+ }
23
+ }
24
+ else
25
+ output = await new Promise((resolve, reject) => {
26
+ const child = spawn(mechanism.executable, [...mechanism.args], { stdio: ['pipe', 'pipe', 'pipe'], shell: false });
27
+ let size = 0;
28
+ const chunks = [];
29
+ let failure;
30
+ const refuse = (why) => {
31
+ if (failure)
32
+ return;
33
+ failure = why;
34
+ child.kill('SIGKILL');
35
+ clearTimeout(timer);
36
+ child.stdin.destroy();
37
+ child.stdout.destroy();
38
+ child.stderr.destroy();
39
+ reject(new SigningFailure(`REFUSE·signer ${lot.keyId} ${why} · status=none · signal=SIGKILL`));
40
+ };
41
+ const timer = setTimeout(() => refuse('timeout'), limits.timeoutMs);
42
+ child.stdout.on('data', (chunk) => {
43
+ size += chunk.length;
44
+ if (size > limits.responseBytes)
45
+ refuse('response exceeds declared limit');
46
+ if (!failure)
47
+ chunks.push(chunk);
48
+ });
49
+ // Drain, never forward or retain signer diagnostics.
50
+ child.stderr.on('data', () => { });
51
+ child.stdin.on('error', () => refuse('input delivery failed'));
52
+ child.on('error', () => {
53
+ clearTimeout(timer);
54
+ reject(new SigningFailure(`REFUSE·signer ${lot.keyId} command could not start`));
55
+ });
56
+ child.on('close', (status, signal) => {
57
+ clearTimeout(timer);
58
+ if (failure || status !== 0) {
59
+ reject(new SigningFailure(`REFUSE·signer ${lot.keyId} ${failure ?? 'command failed'} · status=${status ?? 'none'} · signal=${signal ?? 'none'}`));
60
+ }
61
+ else
62
+ resolve(Buffer.concat(chunks).toString('utf8'));
63
+ });
64
+ child.stdin.end(request);
65
+ });
66
+ if (Buffer.byteLength(output) > limits.responseBytes)
67
+ throw new SigningFailure(`REFUSE·signer ${lot.keyId} response exceeds declared limit`);
68
+ try {
69
+ return protocol.reply(output, lot);
70
+ }
71
+ catch {
72
+ throw new SigningFailure(`REFUSE·signer ${lot.keyId} invalid response`);
73
+ }
74
+ }
@@ -6,8 +6,8 @@ interface ResolveResult {
6
6
  }
7
7
  type NextResolve = (specifier: string, context: ResolveContext) => ResolveResult;
8
8
  /**
9
- * Where a named package or world lies: `dep/<name>` for a library the instrument installs, `uses/<name>` for a world
10
- * pinned by its GitHub release. Both are the digest, laid. Nothing here spells a path.
9
+ * Resolve declared library bytes for this host. A library's shape may select an
10
+ * archive coordinate; world selection remains the standing contract.
11
11
  */
12
12
  export declare function pinPlace(name: string): string;
13
13
  export declare function pinExport(name: string, sub: string): string;
@@ -10,8 +10,8 @@ import { theWord } from "../fold/wire.js";
10
10
  const registerHooks = nodeModule.registerHooks;
11
11
  const taken = (exp) => (typeof exp === 'string' ? exp : exp && typeof exp === 'object' && 'default' in exp && typeof exp.default === 'string' ? exp.default : '');
12
12
  /**
13
- * Where a named package or world lies: `dep/<name>` for a library the instrument installs, `uses/<name>` for a world
14
- * pinned by its GitHub release. Both are the digest, laid. Nothing here spells a path.
13
+ * Resolve declared library bytes for this host. A library's shape may select an
14
+ * archive coordinate; world selection remains the standing contract.
15
15
  */
16
16
  export function pinPlace(name) {
17
17
  const lock = ownLock();
@@ -20,7 +20,7 @@ export function pinPlace(name) {
20
20
  if (line === undefined)
21
21
  return '';
22
22
  const digest = fieldOf(line, 'value');
23
- return digest ? runtimeTreeAt(ownStore(), ownRoot(), digest, ownStore()) ?? '' : '';
23
+ return digest ? runtimeTreeAt(ownStore(), ownRoot(), digest, ownStore(), fieldOf(line, 'role') === 'reads' ? fieldOf(line, 'shape') : '') ?? '' : '';
24
24
  }
25
25
  export function pinExport(name, sub) {
26
26
  const at = pinPlace(name);
@@ -9,4 +9,4 @@ export declare const releasesOf: (lock: readonly string[]) => readonly string[];
9
9
  export declare function layReleases(store: string, own: string, lock: readonly string[], contentStore?: string): number;
10
10
  export declare const resolvedOf: (store: string, own: string, lock: readonly string[], contentStore?: string) => readonly string[];
11
11
  /** A host loads verified release bytes from a runtime tree, never interprets a snapshot file as a directory. */
12
- export declare function runtimeTreeAt(store: string, own: string, digest: string, contentStore?: string): string | undefined;
12
+ export declare function runtimeTreeAt(store: string, own: string, digest: string, contentStore?: string, root?: string): string | undefined;
@@ -13,6 +13,15 @@ const digestOf = (bytes) => `sha256:${createHash('sha256').update(bytes).digest(
13
13
  const hashed = (digest, bytes) => createHash(digest.includes(':') ? digest.slice(0, digest.indexOf(':')) : 'sha256').update(bytes).digest('hex') === digest.replace(/^[^:]*:/, '');
14
14
  const snapshotOf = (text) => text.split('\n').filter(isWire).filter((line) => fieldOf(line, 'measure') === 'digest' && fieldOf(line, 'scope').startsWith('file/'))
15
15
  .map((line) => [fieldOf(line, 'scope').slice('file/'.length), fieldOf(line, 'value')]);
16
+ /** Mount an explicitly declared archive coordinate; never infer a package root. */
17
+ function mountedFiles(files, root) {
18
+ if (!root)
19
+ return files;
20
+ const prefix = archiveCoordinate(root) + '/';
21
+ if (!files.length || files.some(([name]) => !name.startsWith(prefix)))
22
+ throw new Error(`REFUSE·release archive root ${root} does not contain every member`);
23
+ return files.map(([name, digest]) => [archiveCoordinate(name.slice(prefix.length)), digest]);
24
+ }
16
25
  function snapshotLines(files) {
17
26
  return files.map(([coord, digest]) => canonical({
18
27
  scope: `file/${coord}`, role: 'writes', form: 'alphabet', measure: 'digest', value: digest, at: `place:${digest}`, by: 'target',
@@ -128,17 +137,18 @@ export function layReleases(store, own, lock, contentStore = own) {
128
137
  const needs = fieldOf(line, 'needs');
129
138
  if (!needs)
130
139
  continue;
140
+ const root = fieldOf(line, 'shape');
131
141
  const place = join(own, archiveCoordinate(needs));
132
142
  const link = lstatSync(place, { throwIfNoEntry: false });
133
143
  if (link?.isSymbolicLink() && !realpathSync(place).includes(`${sep}cas${sep}laid${sep}`))
134
144
  continue;
135
145
  if (dir) {
136
- const files = bytes === undefined ? undefined : members(bytes).map((one) => [one.name, digestOf(one.bytes)]);
146
+ const files = bytes === undefined ? undefined : mountedFiles(members(bytes).map((one) => [one.name, digestOf(one.bytes)]), root);
137
147
  if (!lstatSync(place, { throwIfNoEntry: false })?.isSymbolicLink() && observeText(join(place, laidMark)) === digest && files !== undefined && layoutMatches(place, files))
138
148
  continue;
139
149
  const next = nextPlace(place);
140
150
  try {
141
- layDir(at, next);
151
+ layDir(root ? join(at, archiveCoordinate(root)) : at, next);
142
152
  swapPlace(place, next, digest);
143
153
  }
144
154
  catch (error) {
@@ -151,11 +161,12 @@ export function layReleases(store, own, lock, contentStore = own) {
151
161
  const snap = observeText(at);
152
162
  if (snap === undefined)
153
163
  throw new Error(`REFUSE·release ${fieldOf(line, 'scope')} has no available snapshot`);
154
- if (!lstatSync(place, { throwIfNoEntry: false })?.isSymbolicLink() && observeText(join(place, laidMark)) === digest && layoutMatches(place, snapshotOf(snap)))
164
+ const mounted = mountedFiles(snapshotOf(snap), root);
165
+ if (!lstatSync(place, { throwIfNoEntry: false })?.isSymbolicLink() && observeText(join(place, laidMark)) === digest && layoutMatches(place, mounted))
155
166
  continue;
156
167
  const next = nextPlace(place);
157
168
  try {
158
- layFiles(store, contentStore, snapshotOf(snap), next);
169
+ layFiles(store, contentStore, mounted, next);
159
170
  swapPlace(place, next, digest);
160
171
  }
161
172
  catch (error) {
@@ -180,12 +191,12 @@ export const resolvedOf = (store, own, lock, contentStore = own) => releasesOf(l
180
191
  const historicalDirectory = lstatSync(at, { throwIfNoEntry: false })?.isDirectory() === true;
181
192
  const snapshot = observeText(at);
182
193
  const complete = expected !== undefined && (needs
183
- ? layoutMatches(place, expected)
194
+ ? layoutMatches(place, mountedFiles(expected, fieldOf(line, 'shape')))
184
195
  : historicalDirectory ? layoutMatches(at, expected) : snapshot !== undefined && sameSnapshot(snapshotOf(snapshot), expected));
185
196
  return ok && laid && complete ? [canonical({ scope: `resolved/${fieldOf(line, 'scope').slice('dep/'.length)}`, role: 'writes', form: 'alphabet', measure: 'digest', value: digest, at: `place:${digest}`, by: 'target' })] : [];
186
197
  });
187
198
  /** A host loads verified release bytes from a runtime tree, never interprets a snapshot file as a directory. */
188
- export function runtimeTreeAt(store, own, digest, contentStore = own) {
199
+ export function runtimeTreeAt(store, own, digest, contentStore = own, root = '') {
189
200
  const named = /^([a-zA-Z0-9-]+):([a-fA-F0-9]+)$/.exec(digest);
190
201
  if (named === null)
191
202
  throw new Error(`REFUSE·release invalid digest ${digest}`);
@@ -195,9 +206,11 @@ export function runtimeTreeAt(store, own, digest, contentStore = own) {
195
206
  if (!hashed(digest, bytes))
196
207
  throw new Error(`REFUSE·release ${digest} does not name its available bytes`);
197
208
  const files = members(bytes).map((one) => [one.name, digestOf(one.bytes)]);
209
+ mountedFiles(files, root); // Validate the same declared projection used for installation.
198
210
  const place = storeAt(store, 'cas', 'runtime', named[1], named[2]);
211
+ const selected = root ? join(place, archiveCoordinate(root)) : place;
199
212
  if (observeText(join(place, laidMark)) === digest && layoutMatches(place, files))
200
- return place;
213
+ return selected;
201
214
  for (const member of members(bytes))
202
215
  landBlob(store, digestOf(member.bytes), member.bytes);
203
216
  const next = nextPlace(place);
@@ -209,5 +222,5 @@ export function runtimeTreeAt(store, own, digest, contentStore = own) {
209
222
  rmSync(next, { recursive: true, force: true });
210
223
  throw error;
211
224
  }
212
- return place;
225
+ return selected;
213
226
  }
@@ -1,4 +1,5 @@
1
1
  import type { StandingTopos } from '@lapxo/topos/standing';
2
+ import { wireAt } from '@lapxo/topos/wire';
2
3
  export interface SelectedTopos {
3
4
  readonly digest: string;
4
5
  readonly topos: StandingTopos;
@@ -15,3 +16,6 @@ export declare function selectedTopoi(pins: readonly string[], resolve?: ToposRe
15
16
  * Independent declarations must agree; selection order cannot choose a contract.
16
17
  */
17
18
  export declare function selectedWireList(local: readonly string[], epoch: number, pins: readonly string[], list: string, resolve?: ToposResolver): readonly string[];
19
+ /** Complete an admission snapshot from the selected standing. Local declarations
20
+ * govern their own lists; independent selected contracts cannot choose by order. */
21
+ export declare function selectedWireAt(local: readonly Readonly<Record<string, string>>[], epoch: number, pins: readonly string[], resolve?: ToposResolver): ReturnType<typeof wireAt>;
@@ -59,3 +59,26 @@ export function selectedWireList(local, epoch, pins, list, resolve = toposAt) {
59
59
  throw Error(`REFUSE·wire wire/${list} has conflicting contracts · ${contracts.map(one => one.name).join(' · ')}`);
60
60
  return first.words;
61
61
  }
62
+ /** Complete an admission snapshot from the selected standing. Local declarations
63
+ * govern their own lists; independent selected contracts cannot choose by order. */
64
+ export function selectedWireAt(local, epoch, pins, resolve = toposAt) {
65
+ const own = wireAt(local, epoch);
66
+ const worlds = selectedTopoi(pins, resolve).map(({ digest, topos }) => ({ digest, parts: topos.parts, wire: wireAt(topos.parts, Number.MAX_SAFE_INTEGER) }));
67
+ const key = (scope) => scope.startsWith('wire/') ? scope.slice(5) : scope.startsWith('audit/wire/') ? scope.slice(11) : undefined;
68
+ const declared = new Set(local.filter(f => Number(f.epoch ?? 0) <= epoch).map(f => key(f.scope ?? '')).filter((k) => k !== undefined));
69
+ const selected = new Map();
70
+ for (const world of worlds)
71
+ for (const f of world.parts) {
72
+ const name = key(f.scope ?? '');
73
+ if (name === undefined || declared.has(name))
74
+ continue;
75
+ const list = world.wire?.lists.get(name);
76
+ const value = list ? [...list].sort().join('|') : f.value ?? '';
77
+ const previous = selected.get(name);
78
+ if (previous && previous.value !== value)
79
+ throw Error(`REFUSE·wire wire/${name} has conflicting selected contracts · ${previous.digest} · ${world.digest}`);
80
+ if (!previous)
81
+ selected.set(name, { value, fields: f, digest: world.digest });
82
+ }
83
+ return selected.size ? wireAt([...selected.values()].map(one => one.fields).concat(local), epoch) : own;
84
+ }
@@ -0,0 +1,3 @@
1
+ import type { SigningLot } from './ports/signing.ts';
2
+ /** Resolve data before acquisition; all mechanisms use one wire protocol and verification path. */
3
+ export declare function signerFor(lines: readonly string[], keyId: string, selected?: string, keyFile?: string): (lot: SigningLot) => Promise<readonly import("./ports/signing.ts").SignatureRecord[]>;
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parse, signerSelection, signerRequestBytes, signerResponseBytes, signerResponseOf } from '@lapxo/topos/wire';
3
+ import { invokeSigner, SigningFailure } from "./ports/signing.js";
4
+ /** An explicitly supplied host registry; a repository cannot supply executable bindings. */
5
+ function binding(name, keyFile) {
6
+ if (name === 'file') {
7
+ if (!keyFile)
8
+ throw new SigningFailure('REFUSE·signer file needs --key-file');
9
+ return { kind: 'file', path: keyFile };
10
+ }
11
+ if (keyFile)
12
+ throw new SigningFailure('REFUSE·signer command cannot use --key-file');
13
+ const path = process.env['BOUND_SIGNERS'];
14
+ if (!path)
15
+ throw new SigningFailure('REFUSE·signer no host binding registry');
16
+ try {
17
+ const registry = JSON.parse(readFileSync(path, 'utf8'));
18
+ if (!registry || typeof registry !== 'object' || !Object.prototype.hasOwnProperty.call(registry, name))
19
+ throw Error();
20
+ const record = registry[name];
21
+ if (!record || typeof record !== 'object')
22
+ throw Error();
23
+ const f = record;
24
+ if (f['kind'] !== 'command' || typeof f['executable'] !== 'string' || !f['executable']
25
+ || !Array.isArray(f['args']) || !f['args'].every(a => typeof a === 'string'))
26
+ throw Error();
27
+ return { kind: 'command', executable: f['executable'], args: f['args'] };
28
+ }
29
+ catch {
30
+ throw new SigningFailure('REFUSE·signer host binding unavailable or invalid');
31
+ }
32
+ }
33
+ /** Resolve data before acquisition; all mechanisms use one wire protocol and verification path. */
34
+ export function signerFor(lines, keyId, selected, keyFile) {
35
+ const fields = lines.flatMap(line => { const got = parse(line); return got.kind === 'fact' ? [got.value.fields] : []; });
36
+ const declaration = signerSelection(fields, keyId);
37
+ if (selected !== undefined && selected !== declaration.name)
38
+ throw new SigningFailure(`REFUSE·signer ${keyId} CLI selection conflicts with declaration`);
39
+ const mechanism = binding(declaration.name, keyFile);
40
+ const protocol = { request: signerRequestBytes, response: signerResponseBytes, reply: signerResponseOf };
41
+ return (lot) => invokeSigner(lot, mechanism, protocol, declaration);
42
+ }