@lapxo/head 0.2.0 → 0.2.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.
- package/README.md +1 -1
- package/TARGET.bound +150 -34
- package/dist/src/adopt/adopt.d.ts +2 -2
- package/dist/src/adopt/adopt.js +20 -6
- package/dist/src/answers/answered.js +1 -1
- package/dist/src/compile/pin.d.ts +1 -1
- package/dist/src/compile/pin.js +5 -3
- package/dist/src/core/answer.d.ts +10 -0
- package/dist/src/core/answer.js +1 -1
- package/dist/src/core/association.d.ts +16 -0
- package/dist/src/core/association.js +34 -0
- package/dist/src/core/composition.d.ts +32 -0
- package/dist/src/core/composition.js +28 -0
- package/dist/src/core/package.d.ts +1 -0
- package/dist/src/core/package.js +14 -3
- package/dist/src/core/read.d.ts +2 -1
- package/dist/src/core/read.js +2 -1
- package/dist/src/core/receiver.d.ts +11 -0
- package/dist/src/core/receiver.js +122 -0
- package/dist/src/core/share-bundle.d.ts +9 -0
- package/dist/src/core/share-bundle.js +56 -0
- package/dist/src/doors/head.d.ts +1 -0
- package/dist/src/doors/head.js +3 -0
- package/dist/src/flows/answer.js +7 -3
- package/dist/src/flows/call.d.ts +1 -1
- package/dist/src/flows/call.js +28 -4
- package/dist/src/flows/device.d.ts +11 -5
- package/dist/src/flows/device.js +13 -5
- package/dist/src/flows/plan.js +1 -1
- package/dist/src/index.js +3 -31
- package/dist/src/links/inspect.js +1 -1
- package/dist/src/onboard/first.js +1 -1
- package/dist/src/onboard/owed.js +1 -1
- package/dist/src/places/admit.js +1 -1
- package/dist/src/places/found.js +3 -1
- package/dist/src/ports/adapters/bundle.d.ts +44 -0
- package/dist/src/ports/adapters/bundle.js +173 -0
- package/dist/src/ports/adapters/configured.d.ts +35 -0
- package/dist/src/ports/adapters/configured.js +67 -0
- package/dist/src/ports/adapters/mcp.d.ts +59 -0
- package/dist/src/ports/adapters/mcp.js +122 -0
- package/dist/src/ports/adapters/network.d.ts +12 -0
- package/dist/src/ports/adapters/network.js +67 -0
- package/dist/src/ports/adapters/protocol.d.ts +6 -0
- package/dist/src/ports/adapters/protocol.js +51 -0
- package/dist/src/ports/adapters/remote.d.ts +13 -0
- package/dist/src/ports/adapters/remote.js +33 -0
- package/dist/src/sources/connect.d.ts +1 -0
- package/dist/src/sources/connect.js +13 -10
- package/dist/src/verbs/ask.js +6 -0
- package/dist/src/verbs/do.js +2 -1
- package/package.json +5 -4
- package/runtime.bundle.json +24026 -0
|
@@ -2,11 +2,11 @@ import type { Context, Outcome } from '../core/answer.ts';
|
|
|
2
2
|
import type { Said } from '../core/lock.ts';
|
|
3
3
|
import type { Line } from '../compile/pin.ts';
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Only the requested worlds, or the default for an unspecified protocol, adopted once per place: its standing and blob pinned where they are published, for bound to
|
|
6
6
|
* fetch and verify, and each of its readers told where head lays what it reads — read off the standing head reads from
|
|
7
7
|
* the same address once per device, kept by its digest and answered only when it hashes to the pin. A world whose
|
|
8
8
|
* standing cannot be read so is no adoption.
|
|
9
9
|
*/
|
|
10
|
-
export declare const adopted: (context: Context, place: string, lands: (lot: readonly string[]) => Promise<Outcome>, line: Line) => Promise<Outcome>;
|
|
10
|
+
export declare const adopted: (context: Context, place: string, lands: (lot: readonly string[]) => Promise<Outcome>, line: Line, additional?: readonly string[]) => Promise<Outcome>;
|
|
11
11
|
/** The name of a lock the place pins as its parent, when it pins one: composing a parent's policy with a child's is bound's, so head reads nothing of such a place and signs nothing for it. */
|
|
12
12
|
export declare const adoptsIn: (lock: readonly Said[], lines: readonly Said[]) => string | undefined;
|
package/dist/src/adopt/adopt.js
CHANGED
|
@@ -9,27 +9,41 @@ import { linesOf } from "../core/worlds.js";
|
|
|
9
9
|
const readersOf = (lock, standing) => {
|
|
10
10
|
const region = valueOf(lock, 'vocab/region');
|
|
11
11
|
const reads = (view) => members(standing.find((l) => l.scope === `${region}/${valueOf(lock, view)}` && l.measure === 'reads')?.value ?? '');
|
|
12
|
-
const
|
|
12
|
+
const answer = valueOf(standing, 'capsule/answer-region');
|
|
13
|
+
const [connected, answered] = [reads('place/view'), [...reads('place/answer'), ...members(standing.find(l => l.scope === `${region}/${answer}` && l.measure === 'reads')?.value ?? '')]];
|
|
13
14
|
return standing.filter((l) => l.scope?.startsWith(`${region}/`) && l.measure === 'writes' && l.role === 'writes').flatMap((l) => ((coordinates) => (coordinates
|
|
14
|
-
? [{ scope: l.scope, measure: 'coordinates', value: coordinates }, { scope: l.scope, measure: 'receipts', value: l.value }] : []))(connected.includes(l.scope) ? valueOf(lock, 'place/connected/coordinates') : answered.includes(l.scope) ? valueOf(lock, 'place/answers/coordinates') : ''));
|
|
15
|
+
? [{ scope: l.scope, measure: 'coordinates', value: coordinates }, { scope: l.scope, measure: 'receipts', value: l.value }] : []))(connected.includes(l.scope) || standing.some(input => input.scope === l.scope && input.measure === 'reads' && input.role === 'receipt') && members(l.value ?? '').some(output => connected.includes(output)) ? valueOf(lock, 'place/connected/coordinates') : answered.includes(l.scope) || standing.some(input => input.scope === l.scope && input.measure === 'reads' && input.role === 'receipt') && members(l.value ?? '').some(output => answered.includes(output)) ? valueOf(lock, 'place/answers/coordinates') : ''));
|
|
15
16
|
};
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
+
* Only the requested worlds, or the default for an unspecified protocol, adopted once per place: its standing and blob pinned where they are published, for bound to
|
|
18
19
|
* fetch and verify, and each of its readers told where head lays what it reads — read off the standing head reads from
|
|
19
20
|
* the same address once per device, kept by its digest and answered only when it hashes to the pin. A world whose
|
|
20
21
|
* standing cannot be read so is no adoption.
|
|
21
22
|
*/
|
|
22
|
-
export const adopted = async (context, place, lands, line) => {
|
|
23
|
+
export const adopted = async (context, place, lands, line, additional = []) => {
|
|
23
24
|
const [word, held] = [(k) => valueOf(context.lock, k), await context.policy(place)];
|
|
24
25
|
if ('refused' in held)
|
|
25
26
|
return held;
|
|
26
27
|
const own = held.lines, [wait, most] = ['adapter/request/wait', 'world/standing-bytes'].map((k) => Number(word(k).split('..')[1]));
|
|
27
|
-
const lot = [];
|
|
28
|
-
for (const w of
|
|
28
|
+
const lot = [], worlds = [...new Set(additional.length ? additional : members(word('world/adopts')))];
|
|
29
|
+
for (const w of worlds) {
|
|
30
|
+
const existing = own.filter(l => l.scope === `uses/${w}`);
|
|
31
|
+
if (existing.length > 1 || existing.some(l => l.value !== word(`world/${w}/standing`)))
|
|
32
|
+
return { refused: `REFUSE·world/pin-mismatch ${w}` };
|
|
33
|
+
}
|
|
34
|
+
for (const w of worlds.filter((w) => !own.some((l) => l.scope === `uses/${w}`))) {
|
|
29
35
|
const [standing, blob, at, blobAt] = [word(`world/${w}/standing`), word(`world/${w}/blob`), word(`sources/${w}`), word(`sources/${w}-blob`)];
|
|
30
36
|
const bytes = context.cache.held(standing) ?? ((got) => (got === undefined ? undefined : (context.cache.keep(got), got)))(await (await context.adapter(at))?.published(at, standing, most, wait));
|
|
31
37
|
if (bytes === undefined)
|
|
32
38
|
return { refused: `REFUSE·world ${w}: ${at} answered no bytes that hash to its pin ${standing}` };
|
|
39
|
+
const provider = linesOf(bytes), answers = provider.filter(l => l.scope === 'capsule/answer-region').map(l => l.value);
|
|
40
|
+
if (answers.length > 1 || answers.some(name => !/^[a-z][a-z0-9-]*$/.test(name)))
|
|
41
|
+
return { refused: 'REFUSE·world/answer-view' };
|
|
42
|
+
const answer = answers[0], selected = answer && word('birth/view/answer').replace(/^[^@]+/, answer);
|
|
43
|
+
if (answer && own.some(l => l.scope === `view/${answer}` && (l.value !== selected || l.role !== 'demands')))
|
|
44
|
+
return { refused: 'REFUSE·world/answer-view' };
|
|
45
|
+
if (answer && !own.some(l => l.scope === `view/${answer}`))
|
|
46
|
+
lot.push(line(`view/${answer}`, 'id', selected, { role: 'demands' }));
|
|
33
47
|
const laid = `${word('place/worlds')}/${w}${EXTENSION}`;
|
|
34
48
|
context.files.lay(place, laid, bytes);
|
|
35
49
|
lot.push(line(`uses/${w}`, 'digest', standing, { needs: laid }), line(`sources/${w}`, 'id', at), line(`dep/${w}-blob`, 'digest', blob, { shape: word('world/shape') }), line(`sources/${w}-blob`, 'id', blobAt), ...readersOf(context.lock, linesOf(bytes)).map((r) => line(r.scope, r.measure, r.value, { role: 'reads' })));
|
|
@@ -6,7 +6,7 @@ const nothing = (bytes) => ((text) => text === '' || /^\[\s*\]$/.test(text))(new
|
|
|
6
6
|
/** Distinguish an empty response from missing transport bytes; only the latter leaves a named need. */
|
|
7
7
|
export const answering = (context, place, asked, frame) => {
|
|
8
8
|
const need = `${valueOf(context.lock, 'place/needs')}/bytes/${frame.source}/${frame.operation}`, held = frame.lines.find((l) => l.scope === need && l.role === 'demands' && l.value !== 'withdraw');
|
|
9
|
-
const owe = (line) => ((root) => ('said' in root ? undefined : context.landRoot(place, [line], root.account)))(rootIn(context, place));
|
|
9
|
+
const owe = async (line) => ((root) => ('said' in root ? undefined : context.landRoot(place, [line], root.account)))(await rootIn(context, place));
|
|
10
10
|
const withdrawn = ({ about: _about, sig: _sig, epoch: _epoch, ...l }) => canonical({ ...l, by: 'target', value: 'withdraw' });
|
|
11
11
|
return { ...frame,
|
|
12
12
|
typed: (status, bytes) => (nothing(bytes) ? Promise.resolve(frame.say('answered-nothing', { source: frame.source, operation: frame.operation, status, at: new Date(context.now()).toISOString() })) : frame.typed(status, bytes)),
|
|
@@ -21,7 +21,7 @@ export declare const pinnedAs: (context: Context, place: string, file: string, l
|
|
|
21
21
|
* — so the same input finds its compiled standing by name, and pinned from the place's store: a new pin lands with the old
|
|
22
22
|
* one's withdraw, said at its own time, since a withdrawn saying stays withdrawn.
|
|
23
23
|
*/
|
|
24
|
-
export declare const pinIn: (context: Context, place: string, lands: (lot: readonly string[]) => Promise<Outcome>, line: Line) => (source: string, read: readonly string[]) => Promise<Pinned>;
|
|
24
|
+
export declare const pinIn: (context: Context, place: string, lands: (lot: readonly string[]) => Promise<Outcome>, line: Line, validate?: (lines: readonly Said[]) => void) => (source: string, read: readonly string[]) => Promise<Pinned>;
|
|
25
25
|
/**
|
|
26
26
|
* The demands a document laid in a place pays: every other pin whose compile named a reference to it unresolved, as it
|
|
27
27
|
* was not there yet, recompiled now that it is — the input its old pin read and the place's pins as they now stand — and
|
package/dist/src/compile/pin.js
CHANGED
|
@@ -4,14 +4,14 @@ import { members, under, valueOf } from "../core/lock.js";
|
|
|
4
4
|
import { fieldsOf, vocabOf } from "../core/read.js";
|
|
5
5
|
import { sayIn } from "../core/surface.js";
|
|
6
6
|
import { digestIn, linesOf, pinnedOf } from "../core/worlds.js";
|
|
7
|
-
/** Pin
|
|
7
|
+
/** Pin source declarations and read/write bounds; host output-presence facts are not source policy. */
|
|
8
8
|
const compiledOf = async (context, place, source, file) => {
|
|
9
9
|
const say = sayIn(context.lock), v = vocabOf(context.lock);
|
|
10
10
|
const compiled = await context.world(place, valueOf(context.lock, 'place/view'));
|
|
11
11
|
if ('refused' in compiled)
|
|
12
12
|
return { said: [say('unsettled', { source, why: compiled.refused })] };
|
|
13
13
|
const issued = (fieldsOf(compiled.lines, `${v.region}/${source}`)[v.issuers] ?? []).map((issuer) => `${v.keys}/${issuer}`);
|
|
14
|
-
const mine = compiled.lines.filter((l) => l.scope === `${v.region}/${source}` || l.scope?.startsWith(`${v.keys}/${source}/`) || l.scope?.startsWith(`${v.offers}/${source}/`) || /^(read|write)\//.test(l.scope ?? '')
|
|
14
|
+
const mine = compiled.lines.filter((l) => l.scope === `${v.region}/${source}` || l.scope?.startsWith(`${v.keys}/${source}/`) || l.scope?.startsWith(`${v.offers}/${source}/`) || (l.form === 'interval' && /^(read|write)\//.test(l.scope ?? ''))
|
|
15
15
|
|| issued.some((at) => l.scope === at || l.scope?.startsWith(`${at}/`)));
|
|
16
16
|
if (!mine.some((l) => !/^(read|write)\//.test(l.scope ?? '')))
|
|
17
17
|
return { said: [], open: true };
|
|
@@ -36,7 +36,7 @@ export const pinnedAs = async (context, place, file, lines) => {
|
|
|
36
36
|
* — so the same input finds its compiled standing by name, and pinned from the place's store: a new pin lands with the old
|
|
37
37
|
* one's withdraw, said at its own time, since a withdrawn saying stays withdrawn.
|
|
38
38
|
*/
|
|
39
|
-
export const pinIn = (context, place, lands, line) => async (source, read) => {
|
|
39
|
+
export const pinIn = (context, place, lands, line, validate) => async (source, read) => {
|
|
40
40
|
const [say, digest, held] = [sayIn(context.lock), digestIn(context.lock), await context.policy(place)];
|
|
41
41
|
if ('refused' in held)
|
|
42
42
|
return { said: [say('unsettled', { source, why: held.refused })] };
|
|
@@ -45,6 +45,8 @@ export const pinIn = (context, place, lands, line) => async (source, read) => {
|
|
|
45
45
|
const file = `${members(valueOf(context.lock, 'place/compiled'))[0]}/${source}/${input.split(':')[1].slice(0, 16)}${EXTENSION}`;
|
|
46
46
|
const old = own.find((l) => l.scope === `uses/${source}`);
|
|
47
47
|
const pinned = ((kept) => (kept !== undefined && kept.pin === old?.value ? kept : undefined))(pinnedOf(context.lock, old?.needs === file ? context.files.take(join(place, file)) : undefined)) ?? await compiledOf(context, place, source, file);
|
|
48
|
+
if (!('said' in pinned))
|
|
49
|
+
validate?.(pinned.lines);
|
|
48
50
|
if ('said' in pinned || (old?.needs === file && old.value === pinned.pin))
|
|
49
51
|
return pinned;
|
|
50
52
|
const withdraw = old === undefined ? [] : [canonical({ ...Object.fromEntries(Object.entries(old).filter(([k]) => !['sig', 'epoch'].includes(k))), by: 'target', value: 'withdraw' })];
|
|
@@ -13,6 +13,16 @@ import type { Call } from './read.ts';
|
|
|
13
13
|
import type { AccessAdapter, Answered, Attachment, Choice, Field, Held } from '../access/contract.ts';
|
|
14
14
|
export type Asked = Readonly<Record<string, string | undefined>>;
|
|
15
15
|
export interface Context {
|
|
16
|
+
readonly remote?: () => Promise<import('../ports/adapters/remote.ts').RemotePort>;
|
|
17
|
+
readonly packaged?: () => Promise<{
|
|
18
|
+
readonly root: string;
|
|
19
|
+
readonly read: (path: string) => Uint8Array | undefined;
|
|
20
|
+
}>;
|
|
21
|
+
readonly bundle?: (payload: import('../ports/adapters/bundle.ts').Payload | undefined) => Promise<{
|
|
22
|
+
readonly bytes: Uint8Array;
|
|
23
|
+
readonly digest: string;
|
|
24
|
+
readonly sources: readonly string[];
|
|
25
|
+
}>;
|
|
16
26
|
readonly lock: readonly Said[];
|
|
17
27
|
readonly bound: (cwd: string, args: readonly string[]) => Promise<Printed | undefined>;
|
|
18
28
|
readonly roots: () => Promise<readonly string[]>;
|
package/dist/src/core/answer.js
CHANGED
|
@@ -13,7 +13,7 @@ import { basename, isAbsolute, resolve, sep } from 'node:path';
|
|
|
13
13
|
import { sayIn } from "./surface.js";
|
|
14
14
|
/** What answers a line of the surface: the module named as the line, in the folder of its family. */
|
|
15
15
|
export const answerOf = async (family, name) => (await import(__rewriteRelativeImportExtension(new URL(`../${family}/${name}${import.meta.url.slice(import.meta.url.lastIndexOf('.'))}`, import.meta.url).href))).answer;
|
|
16
|
-
export const placeOf = async (asked, context) => asked['place'] || (await context.roots())
|
|
16
|
+
export const placeOf = async (asked, context) => asked['place'] || (await context.roots()).find((root) => context.files.has(root, LOCK) || context.files.has(root, '.bound')) || ((held) => ('refused' in held ? undefined
|
|
17
17
|
: ((named) => (named.length === 1 ? named[0] : undefined))(held.lines.filter((l) => l.scope?.startsWith(`${valueOf(context.lock, 'home/family')}/`) && l.value !== 'withdraw').map((l) => l.value))))(context.files.has(context.home(), LOCK) ? await context.policy(context.home()) : { refused: '' });
|
|
18
18
|
export const inPlace = async (asked, context, then) => ((place) => (place === undefined ? [sayIn(context.lock)('no-place')] : then(place)))(await placeOf(asked, context));
|
|
19
19
|
/** The person's yes to exactly what a message says; anything else is what head says of it, and nothing is done. */
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Said } from './lock.ts';
|
|
2
|
+
export interface PackageIdentity {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly revision: string;
|
|
5
|
+
readonly digest: string;
|
|
6
|
+
}
|
|
7
|
+
/** Receiver-local records only: no grants, source pins or sender keys. Use the existing wire fields and forms. */
|
|
8
|
+
export declare function associationFor(prefix: string, incoming: PackageIdentity, authenticated: readonly Said[]): {
|
|
9
|
+
readonly state: "collision" | "new" | "same" | "revision";
|
|
10
|
+
readonly previous: Readonly<Record<string, string>>[];
|
|
11
|
+
readonly incoming: PackageIdentity;
|
|
12
|
+
readonly scope: string;
|
|
13
|
+
readonly place: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
/** Ordinary recipient import must succeed before approval. Bound admits all exact withdrawals and records in one lot. */
|
|
16
|
+
export declare function associationLines(plan: ReturnType<typeof associationFor>, place: string, approved: boolean, act: string): readonly string[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { canonical } from '@lapxo/topos/wire';
|
|
2
|
+
/** Receiver-local records only: no grants, source pins or sender keys. Use the existing wire fields and forms. */
|
|
3
|
+
export function associationFor(prefix, incoming, authenticated) {
|
|
4
|
+
if (!/^[a-z][a-z0-9/-]*$/.test(prefix) || !/^[a-z][a-z0-9-]*$/.test(incoming.id) || !/^\d+\.\d+\.\d+$/.test(incoming.revision) || !/^sha256:[a-f0-9]{64}$/.test(incoming.digest))
|
|
5
|
+
throw Error('package/identity');
|
|
6
|
+
const scope = `${prefix}/${incoming.id}`, names = [scope, `${scope}/revision`, `${scope}/place`];
|
|
7
|
+
const previous = authenticated.filter(line => names.includes(line.scope ?? '') && line.value !== 'withdraw');
|
|
8
|
+
if (names.some(name => previous.filter(line => line.scope === name).length > 1))
|
|
9
|
+
throw Error('package/association-ambiguous');
|
|
10
|
+
if (previous.length && previous.length !== 3)
|
|
11
|
+
throw Error('package/association-incomplete');
|
|
12
|
+
const held = (at) => previous.find(line => line.scope === at)?.value;
|
|
13
|
+
const state = !previous.length ? 'new' : held(scope) === incoming.digest && held(`${scope}/revision`) === incoming.revision ? 'same'
|
|
14
|
+
: held(`${scope}/revision`) === incoming.revision ? 'collision' : 'revision';
|
|
15
|
+
return { state, previous, incoming, scope, place: held(`${scope}/place`) };
|
|
16
|
+
}
|
|
17
|
+
/** Ordinary recipient import must succeed before approval. Bound admits all exact withdrawals and records in one lot. */
|
|
18
|
+
export function associationLines(plan, place, approved, act) {
|
|
19
|
+
if (!approved)
|
|
20
|
+
return [];
|
|
21
|
+
if (plan.state === 'collision' || !place || !/^policy:[A-Za-z0-9/._:-]+$/.test(act))
|
|
22
|
+
throw Error('package/association-collision');
|
|
23
|
+
if (plan.state === 'same' && plan.place === place)
|
|
24
|
+
return [];
|
|
25
|
+
const base = { at: act, by: 'target', role: 'writes', form: 'alphabet' };
|
|
26
|
+
return [[plan.scope, 'digest', plan.incoming.digest], [`${plan.scope}/revision`, 'id', plan.incoming.revision], [`${plan.scope}/place`, 'id', place]].flatMap(([scope, measure, value]) => {
|
|
27
|
+
const old = plan.previous.find(line => line.scope === scope);
|
|
28
|
+
if (old?.measure === measure && old.value === value)
|
|
29
|
+
return [];
|
|
30
|
+
if (old?.at === act)
|
|
31
|
+
throw Error('package/association-stale-act');
|
|
32
|
+
return [...(old ? [canonical({ ...Object.fromEntries(Object.entries(old).filter(([key]) => !['sig', 'epoch'].includes(key))), by: 'target', value: 'withdraw' })] : []), canonical({ ...base, scope, measure, value })];
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type Source = {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly kind: string;
|
|
4
|
+
readonly document: string;
|
|
5
|
+
readonly pin: string;
|
|
6
|
+
readonly offers: readonly string[];
|
|
7
|
+
};
|
|
8
|
+
export type Selection = {
|
|
9
|
+
readonly source: string;
|
|
10
|
+
readonly namespace: string;
|
|
11
|
+
readonly offers: readonly string[];
|
|
12
|
+
};
|
|
13
|
+
export type Recipe = {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly revision: string;
|
|
16
|
+
readonly title: string;
|
|
17
|
+
readonly description: string;
|
|
18
|
+
readonly sources: readonly Selection[];
|
|
19
|
+
};
|
|
20
|
+
/** A selection of already interpreted, authenticated sources. It neither interprets a document nor grants an action. */
|
|
21
|
+
export declare function compose(recipe: Recipe, sources: readonly Source[], most: number): {
|
|
22
|
+
bytes: Buffer<ArrayBuffer>;
|
|
23
|
+
digest: string;
|
|
24
|
+
entries: {
|
|
25
|
+
source: string;
|
|
26
|
+
namespace: string;
|
|
27
|
+
kind: string;
|
|
28
|
+
document: string;
|
|
29
|
+
pin: string;
|
|
30
|
+
offers: string[];
|
|
31
|
+
}[];
|
|
32
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const name = /^[a-z][a-z0-9-]*$/;
|
|
3
|
+
const digest = /^sha256:[a-f0-9]{64}$/;
|
|
4
|
+
/** A selection of already interpreted, authenticated sources. It neither interprets a document nor grants an action. */
|
|
5
|
+
export function compose(recipe, sources, most) {
|
|
6
|
+
if (!Number.isSafeInteger(most) || most < 1 || !name.test(recipe.id) || !/^\d+\.\d+\.\d+$/.test(recipe.revision) || !recipe.title.trim() || !recipe.description.trim()
|
|
7
|
+
|| recipe.sources.length < 1 || recipe.sources.length > most)
|
|
8
|
+
throw Error('composition/recipe');
|
|
9
|
+
const namespaces = new Set(), selected = new Set();
|
|
10
|
+
const entries = recipe.sources.map(choice => {
|
|
11
|
+
if (!name.test(choice.namespace) || namespaces.has(choice.namespace) || selected.has(choice.source))
|
|
12
|
+
throw Error('composition/collision');
|
|
13
|
+
namespaces.add(choice.namespace);
|
|
14
|
+
selected.add(choice.source);
|
|
15
|
+
const found = sources.filter(source => source.name === choice.source);
|
|
16
|
+
if (found.length !== 1)
|
|
17
|
+
throw Error('composition/source');
|
|
18
|
+
const source = found[0];
|
|
19
|
+
if (!digest.test(source.pin) || !digest.test(source.document) || !source.kind.trim())
|
|
20
|
+
throw Error('composition/pin');
|
|
21
|
+
if (!choice.offers.length || choice.offers.length > most || new Set(choice.offers).size !== choice.offers.length
|
|
22
|
+
|| choice.offers.some(offer => !source.offers.includes(offer)))
|
|
23
|
+
throw Error('composition/offer');
|
|
24
|
+
return { source: source.name, namespace: choice.namespace, kind: source.kind, document: source.document, pin: source.pin, offers: [...choice.offers].sort() };
|
|
25
|
+
}).sort((a, b) => a.namespace < b.namespace ? -1 : a.namespace > b.namespace ? 1 : 0);
|
|
26
|
+
const bytes = Buffer.from(JSON.stringify({ protocol: 'head-composition@1', id: recipe.id, revision: recipe.revision, title: recipe.title, description: recipe.description, sources: entries }) + '\n');
|
|
27
|
+
return { bytes, digest: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, entries };
|
|
28
|
+
}
|
|
@@ -2,6 +2,7 @@ import { type Asked, type Context } from './answer.ts';
|
|
|
2
2
|
import { type Said } from './lock.ts';
|
|
3
3
|
/** A line no package carries: one that is not a world, or a source's document, overlay or pin, said once by digest — with, beside a digest that names bytes, the encrypted address they are read at. */
|
|
4
4
|
export declare const foreignIn: (lines: readonly Said[]) => Said | undefined;
|
|
5
|
+
export declare const sharePath: (context: Context, place: string, to: string) => Promise<boolean>;
|
|
5
6
|
/** A place packed as its published lock would name it, into a folder inside it or one the client shares, apart from what head and bound keep: each world its sources were compiled under, and each source — document, overlay, and compiled pin resting on the sources whose issuers it names — by digest, beside the bytes those digests name, and where the person says they are also published, at that encrypted address. No line of access, key or grant: a package grants nothing, and nothing is asked, signed or landed. */
|
|
6
7
|
export declare const shared: (asked: Asked, context: Context) => Promise<readonly string[]>;
|
|
7
8
|
/** What a package names, checked whole before anything is asked: every line a world or a source's document, overlay or pin, by digest; each world the one this head pins; an order its pins rest in. A source whose bytes did not arrive, or did not hash to their line, is open, and so is every source resting on it: absence is not a refusal, and the rest arrives. */
|
package/dist/src/core/package.js
CHANGED
|
@@ -6,10 +6,12 @@ import { fieldsOf, vocabOf } from "./read.js";
|
|
|
6
6
|
import { sayIn } from "./surface.js";
|
|
7
7
|
import { digestIn, linesOf } from "./worlds.js";
|
|
8
8
|
import { sharedBirth } from "../links/birth.js";
|
|
9
|
+
import { shareBundle } from "./share-bundle.js";
|
|
9
10
|
import { linkOf } from "../links/link.js";
|
|
10
11
|
/** A line no package carries: one that is not a world, or a source's document, overlay or pin, said once by digest — with, beside a digest that names bytes, the encrypted address they are read at. */
|
|
11
12
|
export const foreignIn = (lines) => lines.find((l) => !/^(sources\/[a-z0-9-]+(\/overlay)?|uses\/[a-z0-9-]+)$/.test(l.scope ?? '') || lines.some((o) => o !== l && o.scope === l.scope && o.measure === l.measure)
|
|
12
13
|
|| (l.measure !== 'digest' && !(l.measure === 'id' && URL.canParse(l.value ?? '') && lines.some((o) => o.scope === l.scope && o.measure === 'digest' && o.needs))));
|
|
14
|
+
export const sharePath = async (context, place, to) => await within(context, place, to) && ((rel) => rel.startsWith('..') || (rel !== '' && !rel.startsWith('.') && ![valueOf(context.lock, 'place/connected'), ...members(valueOf(context.lock, 'place/compiled')), valueOf(context.lock, 'walk/context/file'), valueOf(context.lock, 'place/worlds')].some(at => rel.split(sep)[0] === at.split('/')[0])))(relative(place, to));
|
|
13
15
|
const orderOf = (all, out = []) => ((next) => (next === undefined ? (out.length === all.length ? out : undefined) : orderOf(all, [...out, next])))(all.find((s) => !out.includes(s) && s.rests.every((r) => out.some((o) => o.source === r))));
|
|
14
16
|
const packed = (context, place, own, urls) => {
|
|
15
17
|
const [word, v, digest, held] = [(k) => valueOf(context.lock, k), vocabOf(context.lock), digestIn(context.lock), (rel) => context.files.take(join(place, rel)) ?? new Uint8Array()];
|
|
@@ -18,14 +20,23 @@ const packed = (context, place, own, urls) => {
|
|
|
18
20
|
const pins = own.filter((l) => l.scope?.startsWith('uses/') && under(context.lock, 'place/compiled', l.needs)).map((l) => ((source) => ({ source, pin: l.value, needs: l.needs, lines: linesOf(held(l.needs)), laid: context.files.read.laidOf(context, place, source) }))(l.scope.slice('uses/'.length)));
|
|
19
21
|
const declares = new Map(pins.flatMap((p) => (fieldsOf(p.lines, `${v.region}/${p.source}`)[v.issuers] ?? []).map((issuer) => [issuer, p.source])));
|
|
20
22
|
const rested = pins.map((p) => ({ ...p, rests: [...new Set(p.lines.filter((l) => l.measure === 'issuer').flatMap((l) => declares.get(l.value ?? '') ?? []))].filter((s) => s !== p.source).sort() })), sources = orderOf(rested) ?? rested;
|
|
21
|
-
return { names: sources.map((s) => s.source), files: sources.flatMap((s) => [...s.laid, [s.needs, held(s.needs)]]), lines: [...own.filter((l) =>
|
|
23
|
+
return { names: sources.map((s) => s.source), files: sources.flatMap((s) => [...s.laid, [s.needs, held(s.needs)]]), lines: [...own.filter((l) => l.scope?.startsWith('uses/') && l.value === word(`world/${l.scope.slice(5)}/standing`)).flatMap((l) => row(l.scope, l.value)),
|
|
22
24
|
...sources.flatMap((s) => [...s.laid.flatMap(([rel, bytes]) => row(/\.overlay\.[^.]+$/.test(rel) ? `sources/${s.source}/overlay` : `sources/${s.source}`, digest(bytes), { needs: rel })),
|
|
23
25
|
...row(`uses/${s.source}`, s.pin, { needs: s.needs, ...(s.rests.length ? { restsOn: s.rests.map((r) => `uses/${r}`).join('|') } : {}) })])] };
|
|
24
26
|
};
|
|
25
27
|
/** A place packed as its published lock would name it, into a folder inside it or one the client shares, apart from what head and bound keep: each world its sources were compiled under, and each source — document, overlay, and compiled pin resting on the sources whose issuers it names — by digest, beside the bytes those digests name, and where the person says they are also published, at that encrypted address. No line of access, key or grant: a package grants nothing, and nothing is asked, signed or landed. */
|
|
26
28
|
export const shared = (asked, context) => inPlace(asked, context, async (place) => {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
+
const bundled = await shareBundle({ ...asked, place }, context, async () => {
|
|
30
|
+
const own = await standingOr(context, place, context.policy);
|
|
31
|
+
if ('said' in own)
|
|
32
|
+
throw Error('bundle/standing');
|
|
33
|
+
const pack = packed(context, place, own.lines, {}), v = vocabOf(context.lock);
|
|
34
|
+
return { files: new Map([...pack.files, [LOCK, Buffer.from(pack.lines.join('\n') + '\n')]]), sources: pack.names.map(name => { const pin = own.lines.find(line => line.scope === `uses/${name}`); const doc = linesOf(Buffer.from(pack.lines.join('\n'))).find(line => line.scope === `sources/${name}` && line.measure === 'digest'); return { name, kind: 'compiled', document: doc.value, pin: pin.value, offers: [...new Set(linesOf(context.files.take(join(place, pin.needs))).filter(line => line.scope?.startsWith(`${v.offers}/${name}/`)).map(line => line.scope.split('/')[2]))] }; }) };
|
|
35
|
+
});
|
|
36
|
+
if (bundled !== undefined)
|
|
37
|
+
return bundled;
|
|
38
|
+
const [say, to, held] = [sayIn(context.lock), asked['to'] ?? '', await standingOr(context, place, context.policy)], there = linesOf(context.files.take(join(to, LOCK)) ?? new Uint8Array());
|
|
39
|
+
if (!await sharePath(context, place, to) || foreignIn(there))
|
|
29
40
|
return [say('share-to', { place, to })];
|
|
30
41
|
if ('said' in held)
|
|
31
42
|
return held.said;
|
package/dist/src/core/read.d.ts
CHANGED
|
@@ -51,4 +51,5 @@ export type Vocab = ReturnType<typeof vocabOf>;
|
|
|
51
51
|
export declare const observedAt: (lock: readonly Said[], source: string, operation: string) => string;
|
|
52
52
|
export declare const stepOf: (lines: readonly Said[], lock: readonly Said[], source: string, operation: string) => Call | Plan;
|
|
53
53
|
export declare const planRead: (lines: readonly Said[], lock: readonly Said[], source: string, operation: string, place: string) => Call | Plan;
|
|
54
|
-
|
|
54
|
+
/** An explicit configured invocation may attempt anonymous access, still under the action flow and separate per-call consent. This is not an authentication claim. */
|
|
55
|
+
export declare const planAct: (lines: readonly Said[], lock: readonly Said[], source: string, operation: string, place: string, explicitInvocation?: boolean) => Call | Plan;
|
package/dist/src/core/read.js
CHANGED
|
@@ -60,4 +60,5 @@ const callOf = (lines, lock, source, operation, place) => {
|
|
|
60
60
|
};
|
|
61
61
|
const isCallable = (lock, classes) => classes.length === 1 && classes[0] === vocabOf(lock).callable;
|
|
62
62
|
export const planRead = (lines, lock, source, operation, place) => ((call) => (call.kind === 'call' && !isCallable(lock, call.classes) ? said('not-callable', { source, operation, class: call.classes.join(', ') }) : call))(callOf(lines, lock, source, operation, place));
|
|
63
|
-
|
|
63
|
+
/** An explicit configured invocation may attempt anonymous access, still under the action flow and separate per-call consent. This is not an authentication claim. */
|
|
64
|
+
export const planAct = (lines, lock, source, operation, place, explicitInvocation = false) => ((call) => (call.kind === 'call' && isCallable(lock, call.classes) ? said('is-read', { source, operation }) : call.kind === 'call' && call.access.probe && !explicitInvocation ? said('access-unstated', { source, operation, method: call.carry['method']?.[0], path: call.carry['path']?.[0] }) : call))(callOf(lines, lock, source, operation, place));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Asked, Context } from './answer.ts';
|
|
2
|
+
/** Questions only describe the required connect, or route an already associated package; they never import or grant. */
|
|
3
|
+
export declare function receiverQuestion(asked: Asked, context: Context): Promise<{
|
|
4
|
+
place: string;
|
|
5
|
+
} | {
|
|
6
|
+
said: readonly string[];
|
|
7
|
+
} | undefined>;
|
|
8
|
+
/** Existing connect does the import; only its successful exact pins allow the receiver-owned association. */
|
|
9
|
+
export declare function receiverConnect(asked: Asked, context: Context): Promise<readonly string[] | undefined>;
|
|
10
|
+
/** Selection narrows this packaged surface; it never grants a call or replaces Bound's authority checks. */
|
|
11
|
+
export declare function receiverAllows(context: Context, place: string, source: string, operation: string): Promise<boolean>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { configuredPackage } from "../ports/adapters/configured.js";
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { LOCK } from '@lapxo/topos/wire';
|
|
4
|
+
import { baseOf, unless } from "./answer.js";
|
|
5
|
+
import { valueOf } from "./lock.js";
|
|
6
|
+
import { shapeOf } from "../ports/surface.js";
|
|
7
|
+
import { sayIn } from "./surface.js";
|
|
8
|
+
import { compose } from "./composition.js";
|
|
9
|
+
import { digestIn, linesOf } from "./worlds.js";
|
|
10
|
+
import { unpacked } from "./package.js";
|
|
11
|
+
import { associationFor, associationLines } from "./association.js";
|
|
12
|
+
import { received, laidIn } from "../sources/connect.js";
|
|
13
|
+
import { rootIn, homeIndex } from "../flows/device.js";
|
|
14
|
+
import { bundleRefusal } from "./share-bundle.js";
|
|
15
|
+
import { bornAt } from "../places/found.js";
|
|
16
|
+
/** Inspect only public installed data. Identity is integrity, never sender authentication or permission. */
|
|
17
|
+
async function material(context) {
|
|
18
|
+
const port = await context.packaged?.(), selection = port?.read('composition/selection.json');
|
|
19
|
+
if (!port || !selection)
|
|
20
|
+
return undefined;
|
|
21
|
+
const selected = JSON.parse(Buffer.from(selection).toString()), manifest = JSON.parse(Buffer.from(port.read('manifest.json') ?? []).toString());
|
|
22
|
+
if (selected.protocol !== 'head-composition@1' || !Array.isArray(selected.sources) || manifest.name !== selected.id || manifest.version !== selected.revision)
|
|
23
|
+
throw Error('bundle/identity');
|
|
24
|
+
const recipe = { id: selected.id, revision: selected.revision, title: selected.title, description: selected.description, sources: selected.sources.map((s) => ({ source: s.source, namespace: s.namespace, offers: s.offers })) };
|
|
25
|
+
const sources = selected.sources.map((s) => ({ name: s.source, kind: s.kind, document: s.document, pin: s.pin, offers: s.offers }));
|
|
26
|
+
const made = compose(recipe, sources, Number(valueOf(context.lock, 'share/bundle-selections').split('..')[1]));
|
|
27
|
+
if (!Buffer.from(made.bytes).equals(Buffer.from(selection)))
|
|
28
|
+
throw Error('bundle/selection');
|
|
29
|
+
const bytes = port.read(`composition/${LOCK}`);
|
|
30
|
+
if (!bytes)
|
|
31
|
+
throw Error('bundle/public-lock');
|
|
32
|
+
const files = new Map();
|
|
33
|
+
for (const line of linesOf(bytes))
|
|
34
|
+
if (line.needs)
|
|
35
|
+
files.set(line.needs, port.read(`composition/${line.needs}`));
|
|
36
|
+
const documents = configuredPackage(context, files);
|
|
37
|
+
const document = join(port.root, 'composition', LOCK), got = unpacked(context, document, bytes, files), digest = digestIn(context.lock);
|
|
38
|
+
if (!got.sources || got.open.length || got.sources.length !== sources.length || sources.some(s => s.kind !== 'compiled' || !got.sources.some(g => g.source === s.name && g.pin === s.pin && digest(g.document.bytes) === s.document)))
|
|
39
|
+
throw Error('bundle/public-package');
|
|
40
|
+
const identity = { id: recipe.id, revision: recipe.revision, digest: digest(Buffer.from(JSON.stringify([digest(selection), digest(bytes)]))) };
|
|
41
|
+
return { identity, document, lockDigest: digest(bytes), bytes, files, documents, sources, selection: made.entries };
|
|
42
|
+
}
|
|
43
|
+
async function associated(context, payload) {
|
|
44
|
+
const index = await homeIndex(context);
|
|
45
|
+
if ('refused' in index)
|
|
46
|
+
throw Error('bundle/index-unread');
|
|
47
|
+
return { index: index.lines, plan: associationFor(valueOf(context.lock, 'bundle/association-family'), payload.identity, index.lines) };
|
|
48
|
+
}
|
|
49
|
+
const validPins = async (context, place, sources) => {
|
|
50
|
+
if (!context.files.has(place, LOCK))
|
|
51
|
+
return false;
|
|
52
|
+
const held = await context.policy(place);
|
|
53
|
+
return !('refused' in held) && sources.every(s => held.lines.some(l => l.scope === `uses/${s.name}` && l.value === s.pin));
|
|
54
|
+
};
|
|
55
|
+
/** Questions only describe the required connect, or route an already associated package; they never import or grant. */
|
|
56
|
+
export async function receiverQuestion(asked, context) {
|
|
57
|
+
try {
|
|
58
|
+
const payload = await material(context);
|
|
59
|
+
if (!payload || asked['place'])
|
|
60
|
+
return undefined;
|
|
61
|
+
const { plan } = await associated(context, payload);
|
|
62
|
+
if (plan.state === 'collision')
|
|
63
|
+
throw Error('bundle/revision-collision');
|
|
64
|
+
if (plan.state === 'same' && plan.place && await validPins(context, plan.place, payload.sources))
|
|
65
|
+
return { place: plan.place };
|
|
66
|
+
return { said: [sayIn(context.lock)('bundle-receiver-ready', { document: payload.document, digest: payload.lockDigest, name: payload.identity.id, revision: payload.identity.revision, place: plan.place })] };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return { said: bundleRefusal(context, error) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Existing connect does the import; only its successful exact pins allow the receiver-owned association. */
|
|
73
|
+
export async function receiverConnect(asked, context) {
|
|
74
|
+
const say = sayIn(context.lock);
|
|
75
|
+
try {
|
|
76
|
+
const payload = await material(context);
|
|
77
|
+
if (!payload || asked['document'] !== payload.document)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (asked['digest'] !== payload.lockDigest)
|
|
80
|
+
throw Error('bundle/document-digest');
|
|
81
|
+
const { plan } = await associated(context, payload);
|
|
82
|
+
if (plan.state === 'collision')
|
|
83
|
+
throw Error('bundle/revision-collision');
|
|
84
|
+
const place = asked['place'] || plan.place || join(context.personal(), valueOf(context.lock, 'bundle/receiver-folder'), payload.identity.id);
|
|
85
|
+
if (plan.state === 'same' && plan.place === place && await validPins(context, place, payload.sources))
|
|
86
|
+
return [say('bundle-associated', { place, name: payload.identity.id, revision: payload.identity.revision })];
|
|
87
|
+
const refused = await unless(context, say('elicit-bundle-receive', { name: payload.identity.id, revision: payload.identity.revision, place, sources: payload.sources.map(s => { const doc = payload.documents.find(doc => doc.configuration.name === s.name); return doc ? `${s.name} (${doc.configuration.endpoint})` : s.name; }).join(', ') }));
|
|
88
|
+
if (refused)
|
|
89
|
+
return refused;
|
|
90
|
+
if (!context.files.has(place, LOCK)) {
|
|
91
|
+
const born = await bornAt(context, place, `bundle-${payload.identity.id}`);
|
|
92
|
+
if (!context.files.has(place, LOCK))
|
|
93
|
+
return born;
|
|
94
|
+
}
|
|
95
|
+
const root = await rootIn(context, place);
|
|
96
|
+
if ('said' in root)
|
|
97
|
+
return root.said;
|
|
98
|
+
const imported = await received(context, place, payload.document, payload.bytes, payload.lockDigest, laidIn(context, place, root.account), root.account, payload.files);
|
|
99
|
+
if (shapeOf(imported).cells.some(cell => ['refused', 'open'].includes(cell.state)) || !await validPins(context, place, payload.sources))
|
|
100
|
+
return imported;
|
|
101
|
+
const home = await rootIn(context, context.home());
|
|
102
|
+
if ('said' in home)
|
|
103
|
+
return [...imported, ...home.said];
|
|
104
|
+
const current = await context.policy(context.home());
|
|
105
|
+
if ('refused' in current)
|
|
106
|
+
throw Error('bundle/index-unread');
|
|
107
|
+
const fresh = associationFor(valueOf(context.lock, 'bundle/association-family'), payload.identity, current.lines);
|
|
108
|
+
const lot = associationLines(fresh, place, true, `policy:head/bundle/${payload.identity.id}/${baseOf(current.lines)}`), landed = lot.length ? await context.landRoot(context.home(), lot, home.account) : undefined;
|
|
109
|
+
return landed && 'refused' in landed ? [...imported, say('not-landed', { why: landed.refused })] : [...imported, say('bundle-associated', { place, name: payload.identity.id, revision: payload.identity.revision })];
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return bundleRefusal(context, error);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Selection narrows this packaged surface; it never grants a call or replaces Bound's authority checks. */
|
|
116
|
+
export async function receiverAllows(context, place, source, operation) {
|
|
117
|
+
const payload = await material(context);
|
|
118
|
+
if (!payload)
|
|
119
|
+
return true;
|
|
120
|
+
const { plan } = await associated(context, payload);
|
|
121
|
+
return plan.state === 'same' && plan.place === place && await validPins(context, place, payload.sources) && payload.selection.some(s => s.source === source && s.offers.includes(operation));
|
|
122
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Asked, Context } from './answer.ts';
|
|
2
|
+
import { type Source } from './composition.ts';
|
|
3
|
+
/** Refusals expose only declared public bundle codes, never host paths or parsed payloads. */
|
|
4
|
+
export declare const bundleRefusal: (context: Context, error: unknown) => string[];
|
|
5
|
+
/** Export only the existing public package. Connecting a document remains the consented connect action; it is never hidden in export. */
|
|
6
|
+
export declare function shareBundle(asked: Asked, context: Context, read: () => Promise<{
|
|
7
|
+
readonly files: ReadonlyMap<string, Uint8Array>;
|
|
8
|
+
readonly sources: readonly Source[];
|
|
9
|
+
}>): Promise<string[] | undefined>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { configuredProtocol } from "../ports/adapters/configured.js";
|
|
2
|
+
import { dirname, basename } from 'node:path';
|
|
3
|
+
import { canonical, parse } from '@lapxo/topos/wire';
|
|
4
|
+
import { compose } from "./composition.js";
|
|
5
|
+
import { sayIn } from "./surface.js";
|
|
6
|
+
import { digestIn, linesOf } from "./worlds.js";
|
|
7
|
+
import { foreignIn, sharePath } from "./package.js";
|
|
8
|
+
import { valueOf } from "./lock.js";
|
|
9
|
+
/** Refusals expose only declared public bundle codes, never host paths or parsed payloads. */
|
|
10
|
+
export const bundleRefusal = (context, error) => [sayIn(context.lock)('bundle-refused', { why: error instanceof Error && /^bundle\/[a-z-]+$/.test(error.message) ? error.message : 'bundle/invalid' })];
|
|
11
|
+
/** Export only the existing public package. Connecting a document remains the consented connect action; it is never hidden in export. */
|
|
12
|
+
export async function shareBundle(asked, context, read) {
|
|
13
|
+
const to = asked['to'] ?? '', say = sayIn(context.lock), suffix = valueOf(context.lock, 'share/bundle-suffix');
|
|
14
|
+
if (!suffix || !to.endsWith(suffix))
|
|
15
|
+
return undefined;
|
|
16
|
+
if (!context.bundle || !await sharePath(context, asked['place'] ?? '', to) || context.files.has(dirname(to), basename(to)))
|
|
17
|
+
return [say('bundle-refused', { why: 'bundle/unavailable-or-destination' })];
|
|
18
|
+
try {
|
|
19
|
+
const payload = asked['args'] ? JSON.parse(asked['args']) : undefined;
|
|
20
|
+
let material = payload ? await read() : undefined;
|
|
21
|
+
if (material) {
|
|
22
|
+
for (const choice of payload.sources)
|
|
23
|
+
if (!material.sources.some(source => source.name === choice.source)) {
|
|
24
|
+
const url = choice.document && URL.canParse(choice.document) ? new URL(choice.document) : undefined;
|
|
25
|
+
if (!url || url.protocol !== valueOf(context.lock, 'use/scheme') || url.href !== url.origin + url.pathname)
|
|
26
|
+
throw Error('bundle/source-document');
|
|
27
|
+
return [say(choice.protocol === configuredProtocol.id ? configuredProtocol.sourceNeeded : 'bundle-source-needed', { source: choice.source, document: url.href })];
|
|
28
|
+
}
|
|
29
|
+
const lock = material.files.get('TARGET.bound');
|
|
30
|
+
if (!lock || new TextDecoder().decode(lock).split('\n').filter(Boolean).some(line => parse(line).kind !== 'fact') || foreignIn(linesOf(lock)))
|
|
31
|
+
throw Error('bundle/public-lock');
|
|
32
|
+
const declared = linesOf(lock);
|
|
33
|
+
for (const source of material.sources)
|
|
34
|
+
if (!declared.some(line => line.scope === `uses/${source.name}` && line.value === source.pin) || !declared.some(line => line.scope === `sources/${source.name}` && line.value === source.document))
|
|
35
|
+
throw Error('bundle/source-pin');
|
|
36
|
+
const made = compose(payload, material.sources, Number(valueOf(context.lock, 'share/bundle-selections').split('..')[1])), selected = new Set(made.entries.map(entry => entry.source));
|
|
37
|
+
const lines = linesOf(lock).filter(line => !line.needs || selected.has(line.scope.split('/')[1]));
|
|
38
|
+
if (lines.some(line => line.restsOn?.split('|').some(parent => !selected.has(parent.split('/')[1]))))
|
|
39
|
+
throw Error('bundle/unselected-parent');
|
|
40
|
+
const retained = new Map(lines.filter(line => line.needs).map(line => [line.needs, material.files.get(line.needs)]));
|
|
41
|
+
retained.set('TARGET.bound', Buffer.from(lines.map(line => canonical(line)).join('\n') + '\n'));
|
|
42
|
+
material = { sources: material.sources.filter(source => selected.has(source.name)), files: retained };
|
|
43
|
+
for (const line of lines.filter(line => line.needs && line.measure === 'digest')) {
|
|
44
|
+
const bytes = material.files.get(line.needs);
|
|
45
|
+
if (!bytes || digestIn(context.lock)(bytes) !== line.value)
|
|
46
|
+
throw Error('bundle/public-digest');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const got = await context.bundle(material && payload ? { recipe: payload, ...material } : undefined);
|
|
50
|
+
context.files.lay(dirname(to), basename(to), got.bytes);
|
|
51
|
+
return [say('bundle-shared', { document: to, digest: got.digest, sources: got.sources.join(', ') })];
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return bundleRefusal(context, error);
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/src/doors/head.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface Door extends Client {
|
|
|
5
5
|
readonly roots: () => Promise<readonly string[]>;
|
|
6
6
|
readonly client: () => string | undefined;
|
|
7
7
|
readonly name: string;
|
|
8
|
+
readonly network?: Parameters<typeof import('../ports/adapters/remote.ts').remoteAt>[2];
|
|
8
9
|
}
|
|
9
10
|
export declare const askedOf: (raw: Readonly<Record<string, unknown>> | undefined) => Asked;
|
|
10
11
|
/** head behind a door: its lock read, every call answered one at a time and each answer's next read ahead, the same for every door. */
|
package/dist/src/doors/head.js
CHANGED
|
@@ -74,6 +74,9 @@ export const headOf = (lock, root, ext, door) => {
|
|
|
74
74
|
};
|
|
75
75
|
return {
|
|
76
76
|
lock,
|
|
77
|
+
remote: async () => (await import(__rewriteRelativeImportExtension(new URL(`../ports/adapters/remote${ext}`, import.meta.url).href))).remoteAt({ pages: Number(valueOf(lock, 'share/bundle-selections').split('..')[1]), tools: Number(valueOf(lock, 'share/bundle-selections').split('..')[1]), bytes: Number(valueOf(lock, 'share/bundle-bytes').split('..')[1]), wireBytes: Number(valueOf(lock, 'share/bundle-bytes').split('..')[1]), milliseconds: limits.wait }, signal, door.network),
|
|
78
|
+
packaged: async () => (await import(__rewriteRelativeImportExtension(new URL(`../ports/adapters/bundle${ext}`, import.meta.url).href))).publicAt(root, Number(valueOf(lock, 'share/bundle-bytes').split('..')[1])),
|
|
79
|
+
bundle: async (payload) => (await import(__rewriteRelativeImportExtension(new URL(`../ports/adapters/bundle${ext}`, import.meta.url).href))).bundleAt(root, lock, payload, signal),
|
|
77
80
|
bound: (cwd, args) => run(runs, cwd, args, {}, signal),
|
|
78
81
|
standing: (place) => once('standing', place, () => standingAt(runs, place, (lines) => ['head', 'person'].flatMap((role) => ((id) => (id === undefined ? [] : [id]))(idIn(lines, device(role)))), (line) => read(line.at), signal)),
|
|
79
82
|
authority: (place, key, kind, records, presented) => authorityAt(place, key, kind, records, Date.now(), signal, presented),
|
package/dist/src/flows/answer.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { canonical } from '@lapxo/topos/wire';
|
|
2
2
|
import { valueOf } from "../core/lock.js";
|
|
3
|
+
import { fieldsOf, vocabOf } from "../core/read.js";
|
|
3
4
|
/**
|
|
4
5
|
* What a source answered, as the world types it: the body is laid in the place's answers for as long as bound reads it,
|
|
5
6
|
* then taken away, and only the lines the world answers for this request come back — a value of each declared field, the
|
|
6
7
|
* counts, what it held back. A place that names no view to type it gets no answer at all, never the body.
|
|
7
8
|
*/
|
|
8
9
|
export const typedAnswer = async (context, place, lines, source, operation, key, body) => {
|
|
9
|
-
const
|
|
10
|
-
if (
|
|
11
|
-
return { untyped: '' };
|
|
10
|
+
const v = vocabOf(context.lock), models = fieldsOf(lines, `${v.offers}/${source}/${operation}/${v.form}`)['answer-view'] ?? [];
|
|
11
|
+
if (models.length > 1 || models.some(model => !/^[a-z][a-z0-9-]*$/.test(model)))
|
|
12
|
+
return { untyped: 'answer/model-ambiguous' };
|
|
13
|
+
const view = models[0] ?? valueOf(context.lock, 'place/answer');
|
|
14
|
+
if (!lines.some((line) => line.scope === `view/${view}` && line.role === 'demands'))
|
|
15
|
+
return { untyped: 'answer/model-not-admitted' };
|
|
12
16
|
const [request, family] = [key.slice(0, 16), valueOf(context.lock, 'vocab/answer')];
|
|
13
17
|
const at = `${valueOf(context.lock, 'place/answers')}/${source}/${encodeURIComponent(operation).replaceAll('.', '%2E')}/${request}.json`;
|
|
14
18
|
context.files.lay(place, at, body);
|
package/dist/src/flows/call.d.ts
CHANGED
|
@@ -38,4 +38,4 @@ export interface Frame {
|
|
|
38
38
|
readonly uncovered: (role: string, scopes: readonly string[]) => readonly string[] | undefined;
|
|
39
39
|
}
|
|
40
40
|
/** Prepare one call, authorize its access and pass its frame to the read or action flow. */
|
|
41
|
-
export declare const called: (asked: Asked, context: Context, planOf: (lines: readonly Said[], lock: readonly Said[], source: string, operation: string, place: string) => Call | Plan, then: (frame: Frame) => Promise<readonly string[]>) => Promise<readonly string[]>;
|
|
41
|
+
export declare const called: (asked: Asked, context: Context, planOf: (lines: readonly Said[], lock: readonly Said[], source: string, operation: string, place: string, explicitInvocation?: boolean) => Call | Plan, then: (frame: Frame) => Promise<readonly string[]>) => Promise<readonly string[]>;
|