@lapxo/head 0.0.0-stage → 0.1.3
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 +21 -0
- package/TARGET.bound +786 -0
- package/dist/src/access/contract.d.ts +71 -0
- package/dist/src/access/contract.js +6 -0
- package/dist/src/access/obtain.d.ts +19 -0
- package/dist/src/access/obtain.js +70 -0
- package/dist/src/adopt/adopt.d.ts +12 -0
- package/dist/src/adopt/adopt.js +40 -0
- package/dist/src/compile/pin.d.ts +26 -0
- package/dist/src/compile/pin.js +74 -0
- package/dist/src/core/act.d.ts +18 -0
- package/dist/src/core/act.js +18 -0
- package/dist/src/core/answer.d.ts +102 -0
- package/dist/src/core/answer.js +26 -0
- package/dist/src/core/device.d.ts +26 -0
- package/dist/src/core/device.js +27 -0
- package/dist/src/core/lock.d.ts +11 -0
- package/dist/src/core/lock.js +13 -0
- package/dist/src/core/package.d.ts +54 -0
- package/dist/src/core/package.js +65 -0
- package/dist/src/core/read.d.ts +54 -0
- package/dist/src/core/read.js +64 -0
- package/dist/src/core/receipt.d.ts +13 -0
- package/dist/src/core/receipt.js +12 -0
- package/dist/src/core/surface.d.ts +6 -0
- package/dist/src/core/surface.js +7 -0
- package/dist/src/core/trust.d.ts +7 -0
- package/dist/src/core/trust.js +19 -0
- package/dist/src/core/worlds.d.ts +10 -0
- package/dist/src/core/worlds.js +9 -0
- package/dist/src/flows/act.d.ts +7 -0
- package/dist/src/flows/act.js +43 -0
- package/dist/src/flows/answer.d.ts +12 -0
- package/dist/src/flows/answer.js +25 -0
- package/dist/src/flows/call.d.ts +50 -0
- package/dist/src/flows/call.js +76 -0
- package/dist/src/flows/device.d.ts +26 -0
- package/dist/src/flows/device.js +32 -0
- package/dist/src/flows/plan.d.ts +8 -0
- package/dist/src/flows/plan.js +51 -0
- package/dist/src/flows/read.d.ts +9 -0
- package/dist/src/flows/read.js +95 -0
- package/dist/src/flows/world.d.ts +12 -0
- package/dist/src/flows/world.js +29 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +138 -0
- package/dist/src/onboard/first.d.ts +7 -0
- package/dist/src/onboard/first.js +21 -0
- package/dist/src/places/admit.d.ts +7 -0
- package/dist/src/places/admit.js +32 -0
- package/dist/src/places/found.d.ts +7 -0
- package/dist/src/places/found.js +60 -0
- package/dist/src/ports/access/authorizer.d.ts +10 -0
- package/dist/src/ports/access/authorizer.js +34 -0
- package/dist/src/ports/access/flow.d.ts +13 -0
- package/dist/src/ports/access/flow.js +130 -0
- package/dist/src/ports/access/page.d.ts +12 -0
- package/dist/src/ports/access/page.js +50 -0
- package/dist/src/ports/access/person.d.ts +26 -0
- package/dist/src/ports/access/person.js +41 -0
- package/dist/src/ports/adapters/request.d.ts +41 -0
- package/dist/src/ports/adapters/request.js +69 -0
- package/dist/src/ports/bound.d.ts +61 -0
- package/dist/src/ports/bound.js +85 -0
- package/dist/src/ports/cache.d.ts +4 -0
- package/dist/src/ports/cache.js +14 -0
- package/dist/src/ports/documents.d.ts +34 -0
- package/dist/src/ports/documents.js +64 -0
- package/dist/src/ports/keychain.d.ts +8 -0
- package/dist/src/ports/keychain.js +12 -0
- package/dist/src/ports/keys.d.ts +14 -0
- package/dist/src/ports/keys.js +20 -0
- package/dist/src/ports/place.d.ts +11 -0
- package/dist/src/ports/place.js +32 -0
- package/dist/src/ports/process.d.ts +29 -0
- package/dist/src/ports/process.js +51 -0
- package/dist/src/ports/signers.d.ts +6 -0
- package/dist/src/ports/signers.js +14 -0
- package/dist/src/ports/spare.d.ts +1 -0
- package/dist/src/ports/spare.js +33 -0
- package/dist/src/ports/stores/credential-manager.d.ts +3 -0
- package/dist/src/ports/stores/credential-manager.js +22 -0
- package/dist/src/ports/stores/keychain.d.ts +3 -0
- package/dist/src/ports/stores/keychain.js +16 -0
- package/dist/src/ports/stores/secret-tool.d.ts +3 -0
- package/dist/src/ports/stores/secret-tool.js +16 -0
- package/dist/src/ports/surface.d.ts +126 -0
- package/dist/src/ports/surface.js +48 -0
- package/dist/src/ports/warm.d.ts +19 -0
- package/dist/src/ports/warm.js +46 -0
- package/dist/src/resources/view.d.ts +1 -0
- package/dist/src/resources/view.js +1 -0
- package/dist/src/signer.d.ts +2 -0
- package/dist/src/signer.js +14 -0
- package/dist/src/sources/access.d.ts +17 -0
- package/dist/src/sources/access.js +39 -0
- package/dist/src/sources/connect.d.ts +13 -0
- package/dist/src/sources/connect.js +130 -0
- package/dist/src/tools/access.d.ts +1 -0
- package/dist/src/tools/access.js +1 -0
- package/dist/src/tools/act.d.ts +1 -0
- package/dist/src/tools/act.js +1 -0
- package/dist/src/tools/admit.d.ts +1 -0
- package/dist/src/tools/admit.js +1 -0
- package/dist/src/tools/card.d.ts +3 -0
- package/dist/src/tools/card.js +13 -0
- package/dist/src/tools/connect.d.ts +1 -0
- package/dist/src/tools/connect.js +1 -0
- package/dist/src/tools/key.d.ts +3 -0
- package/dist/src/tools/key.js +11 -0
- package/dist/src/tools/land.d.ts +1 -0
- package/dist/src/tools/land.js +1 -0
- package/dist/src/tools/look.d.ts +8 -0
- package/dist/src/tools/look.js +41 -0
- package/dist/src/tools/place.d.ts +1 -0
- package/dist/src/tools/place.js +1 -0
- package/dist/src/tools/places.d.ts +7 -0
- package/dist/src/tools/places.js +32 -0
- package/dist/src/tools/plan.d.ts +1 -0
- package/dist/src/tools/plan.js +1 -0
- package/dist/src/tools/share.d.ts +1 -0
- package/dist/src/tools/share.js +1 -0
- package/dist/src/walk/context.d.ts +7 -0
- package/dist/src/walk/context.js +23 -0
- package/package.json +36 -4
- package/README.md +0 -3
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { jsonOf } from "../../access/contract.js";
|
|
3
|
+
import { discovered } from "./authorizer.js";
|
|
4
|
+
const one = (f, measure) => f?.[measure]?.[0];
|
|
5
|
+
const pointed = (json, pointer) => pointer.split('/').slice(1).map((s) => s.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
6
|
+
.reduce((at, step) => at.flatMap((v) => (v === null || typeof v !== 'object' ? [] : step === '*' ? Object.values(v) : [v[step]])), [json])
|
|
7
|
+
.flatMap((v) => (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? [String(v)] : []));
|
|
8
|
+
const transforms = { s256: (s) => createHash('sha256').update(s).digest('base64url'), base64url: (s) => Buffer.from(s).toString('base64url'), base64: (s) => Buffer.from(s).toString('base64') };
|
|
9
|
+
const empty = (v) => v === undefined || v === '' || (Array.isArray(v) && v.length === 0);
|
|
10
|
+
const originOf = (url) => (URL.canParse(url) ? new URL(url).origin : '');
|
|
11
|
+
/**
|
|
12
|
+
* Access as its document declares it, one way for every kind: the steps its flow names, in order — an operation of a
|
|
13
|
+
* source, an address its authorizer names, or a page the person is sent to and comes back from, opened first so every
|
|
14
|
+
* step before it knows where the person comes back — each sent what its payload says, with the token so far when it
|
|
15
|
+
* says so, keeping what its outputs point at — whatever its status, since an answer that calls for a next step may come
|
|
16
|
+
* as an error; a step whose when holds nothing is passed over, one whose answer holds nothing its outputs name stops the
|
|
17
|
+
* flow by its name, and so does one that answers an error and names nothing to keep. What the person is asked is asked the first time a step needs it, as the
|
|
18
|
+
* flow says: a form, a one-time code, a choice among what an answer listed, or a secret on head's own page as far as the
|
|
19
|
+
* place's root allows. What the person gives travels only to the source's origin or one its own document names. Only
|
|
20
|
+
* the token is kept, and it rides a request as the flow's template says.
|
|
21
|
+
*/
|
|
22
|
+
export const adapter = {
|
|
23
|
+
discover: discovered,
|
|
24
|
+
attach: (secret, params) => ((into, name, value) => (into === 'header' ? { headers: { [name]: value }, query: {} } : into === 'query' ? { headers: {}, query: { [name]: value } } : { headers: {}, query: {} }))(one(params, 'attach-in'), one(params, 'attach-name') ?? '', (one(params, 'attach-template') ?? '{token}').split('{token}').join(secret)),
|
|
25
|
+
obtain: async (want) => {
|
|
26
|
+
const [d, renewed] = [want.declared, ((r) => (r && typeof r === 'object' ? r : undefined))(jsonOf(want.renew ?? ''))];
|
|
27
|
+
if (renewed?.refresh && one(d[''], 'renew')) {
|
|
28
|
+
const got = await want.post(one(d[''], 'renew'), { grant_type: 'refresh_token', refresh_token: renewed.refresh, ...(renewed.client ? { client_id: renewed.client } : {}) }, 'form'), [secret, lasts, refresh] = ['/access_token', '/expires_in', '/refresh_token'].map((at) => pointed(got?.json, at)[0]);
|
|
29
|
+
if (got !== undefined && got.status < 300 && secret)
|
|
30
|
+
return { secret, ...(Number.isFinite(Number(lasts)) && lasts ? { expires: want.now + Number(lasts) * 1000 } : {}), renew: JSON.stringify({ ...renewed, refresh: refresh ?? renewed.refresh }) };
|
|
31
|
+
}
|
|
32
|
+
const [gen, inputs, outputs] = [{ state: randomBytes(24).toString('base64url'), verifier: randomBytes(32).toString('base64url') }, new Map(), new Map()];
|
|
33
|
+
let [callback, secrets, refused] = ['', 0, undefined];
|
|
34
|
+
const stop = (why) => { refused ??= why; return undefined; };
|
|
35
|
+
const ask = async (name) => {
|
|
36
|
+
const [f, label] = [d[`inputs/${name}`], one(d[`inputs/${name}`], 'label') ?? name];
|
|
37
|
+
const how = one(f, 'ask');
|
|
38
|
+
const got = how === 'secret' ? (++secrets > want.secrets ? stop({ refused: 'secret-unallowed', input: name }) : (await want.enter(`${want.source} in ${want.place} · ${label}`, [{ name, label, secret: true }]))?.[name])
|
|
39
|
+
: how === 'choice' ? await (async () => {
|
|
40
|
+
const [values, labels] = [[(await resolve(one(f, 'from') ?? '')) ?? []].flat(), [(await resolve(one(f, 'label') ?? '')) ?? []].flat()];
|
|
41
|
+
return (await want.choose(`Choose ${name} for ${want.source} in ${want.place}`, [{ name, options: values.map((value, i) => ({ value, label: labels[i] ?? value })) }]))?.[name];
|
|
42
|
+
})()
|
|
43
|
+
: how === 'form' || how === 'one-time' ? (await want.choose(how === 'form' ? `${want.source} in ${want.place} asks for ${label}` : `${want.source} in ${want.place} sent you a one-time code: enter it within ${one(f, 'ttl') ?? '?'} seconds`, [{ name, label }]))?.[name]
|
|
44
|
+
: stop({ refused: 'carry-unsettled', input: name });
|
|
45
|
+
if (got !== undefined)
|
|
46
|
+
inputs.set(name, got);
|
|
47
|
+
return got ?? stop({ refused: 'entry-unfinished', input: name });
|
|
48
|
+
};
|
|
49
|
+
const resolve = async (expr, response) => {
|
|
50
|
+
const [fn, head] = [/^(\w+)\((.*)\)$/.exec(expr), expr.split(/[.#(]/)[0]];
|
|
51
|
+
if (fn !== null)
|
|
52
|
+
return want.values.includes(fn[1]) && transforms[fn[1]] ? transforms[fn[1]](String(await resolve(fn[2], response) ?? '')) : stop({ refused: 'carry-unsettled', input: fn[1] });
|
|
53
|
+
if (/\{[^}]+\}/.test(expr)) {
|
|
54
|
+
let out = expr;
|
|
55
|
+
for (const [m, ref] of expr.matchAll(/\{([^}]+)\}/g))
|
|
56
|
+
out = out.replace(m, String((await resolve(ref, response)) ?? ''));
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
if (/^'.*'$/.test(expr))
|
|
60
|
+
return expr.slice(1, -1);
|
|
61
|
+
if (!expr.startsWith('$'))
|
|
62
|
+
return expr;
|
|
63
|
+
if (!want.values.includes(head))
|
|
64
|
+
return stop({ refused: 'carry-unsettled', input: head });
|
|
65
|
+
const [, a = '', , b = ''] = expr.split('.');
|
|
66
|
+
return head === '$callback' ? callback : head === '$state' ? gen.state : head === '$verifier' ? gen.verifier : head === '$scopes' ? want.scopes.join(' ')
|
|
67
|
+
: head === '$client' ? one(d[''], 'client') ?? stop({ refused: 'no-client' }) : head === '$host' ? want.host[a] ?? stop({ refused: 'host-input', input: a }) : head === '$inputs' ? inputs.get(a) ?? (await ask(a)) : head === '$steps' ? outputs.get(a)?.get(b)
|
|
68
|
+
: ((all) => (expr.includes('*') ? all : all[0]))(pointed(response, expr.slice(expr.indexOf('#') + 1)));
|
|
69
|
+
};
|
|
70
|
+
const gather = async (k, kind, response) => {
|
|
71
|
+
const held = {};
|
|
72
|
+
for (const key of Object.keys(d).filter((one_) => one_.startsWith(`${k}/${kind}/`)))
|
|
73
|
+
held[key.slice(k.length + kind.length + 2)] = d[key]['items'] ? (await Promise.all(d[key]['items'].map((e) => resolve(e, response)))).flatMap((v) => (v === undefined ? [] : [v].flat())) : await resolve(one(d[key], 'value') ?? '', response);
|
|
74
|
+
return held;
|
|
75
|
+
};
|
|
76
|
+
const kept = async (k, response, status = 200) => {
|
|
77
|
+
const held = await gather(k, 'outputs', response);
|
|
78
|
+
if (Object.values(held).every(empty) && (status >= 400 || Object.keys(held).length))
|
|
79
|
+
return Boolean(stop({ refused: 'step-unanswered', input: one(d[k], 'id') ?? k }));
|
|
80
|
+
outputs.set(one(d[k], 'id') ?? k, new Map(Object.entries(held)));
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
const guarded = (k) => ((at) => !(at !== undefined && d[k]['overlay'] && originOf(at) !== originOf(want.origin)
|
|
84
|
+
&& Object.keys(d).some((key) => key.startsWith(`${k}/payload/`) && (d[key]['value'] ?? d[key]['items'] ?? []).some((e) => e.includes('$inputs.')))) || Boolean(stop({ refused: 'cross-origin' })))(one(d[k], 'url') ?? one(d[k], 'page'));
|
|
85
|
+
const run = async (k) => {
|
|
86
|
+
const f = d[k];
|
|
87
|
+
if (one(f, 'when') !== undefined && empty(await resolve(one(f, 'when'))))
|
|
88
|
+
return true;
|
|
89
|
+
if (!guarded(k))
|
|
90
|
+
return false;
|
|
91
|
+
const [sent, token] = [await gather(k, 'payload'), one(f, 'token') === undefined ? undefined : await resolve(one(f, 'token'))];
|
|
92
|
+
if (refused)
|
|
93
|
+
return false;
|
|
94
|
+
const answered = one(f, 'operation') !== undefined ? await want.call(one(f, 'source') ?? want.source, one(f, 'operation'), sent, typeof token === 'string' ? adapter.attach(token, d[''] ?? {}) : undefined)
|
|
95
|
+
: await want.post(one(f, 'url'), sent, one(f, 'encoding') === 'json' ? 'json' : 'form');
|
|
96
|
+
return refused ? false : answered === undefined ? Boolean(stop({ refused: 'login-refused' })) : kept(k, answered.json, answered.status);
|
|
97
|
+
};
|
|
98
|
+
const steps = Object.keys(d).filter((key) => /^steps\/\d+$/.test(key)).sort((a, b) => Number(a.slice(6)) - Number(b.slice(6)));
|
|
99
|
+
const page = steps.findIndex((k) => one(d[k], 'page') !== undefined);
|
|
100
|
+
if (page >= 0) {
|
|
101
|
+
const back = await want.visit(`Sign in to ${want.source} at its provider`, async (address) => {
|
|
102
|
+
callback = address;
|
|
103
|
+
for (const k of steps.slice(0, page))
|
|
104
|
+
if (!(await run(k)))
|
|
105
|
+
return undefined;
|
|
106
|
+
if (!guarded(steps[page]))
|
|
107
|
+
return undefined;
|
|
108
|
+
const to = new URL(one(d[steps[page]], 'page'));
|
|
109
|
+
for (const [name, v] of Object.entries(await gather(steps[page], 'payload')))
|
|
110
|
+
if (!empty(v))
|
|
111
|
+
to.searchParams.set(name, String(v));
|
|
112
|
+
return refused ? undefined : to.href;
|
|
113
|
+
});
|
|
114
|
+
if (!refused && (back === undefined || (back['state'] !== undefined && back['state'] !== gen.state)))
|
|
115
|
+
stop({ refused: 'login-refused' });
|
|
116
|
+
if (!refused)
|
|
117
|
+
await kept(steps[page], back);
|
|
118
|
+
}
|
|
119
|
+
for (const k of page >= 0 ? steps.slice(page + 1) : steps)
|
|
120
|
+
if (refused || !(await run(k)))
|
|
121
|
+
break;
|
|
122
|
+
const [token, expires, refresh] = await Promise.all(['token', 'expires', 'refresh'].map(async (k) => (await Promise.all((d['']?.[k] ?? []).map((e) => resolve(e)))).find((v) => !empty(v))));
|
|
123
|
+
if (refused)
|
|
124
|
+
return refused;
|
|
125
|
+
if (typeof token !== 'string' || !token)
|
|
126
|
+
return { refused: 'login-refused' };
|
|
127
|
+
return { secret: token, ...(typeof expires === 'string' && Number.isFinite(Number(expires)) ? { expires: want.now + Number(expires) * 1000 } : {}), ...(steps.length ? {} : { manual: true }),
|
|
128
|
+
...(typeof refresh === 'string' && refresh ? { renew: JSON.stringify({ refresh, client: outputs.get('register')?.get('client') ?? one(d[''], 'client') }) } : {}) };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Field, Held } from '../../access/contract.ts';
|
|
2
|
+
/**
|
|
3
|
+
* One page on this device, for one person, once: served on a loopback port under a path no one can guess, it takes the
|
|
4
|
+
* fields it was opened with — a secret one as a password, never echoed, a choice among its options — or, as a return
|
|
5
|
+
* address, what a visit sends back.
|
|
6
|
+
* It answers the first submission and closes; a wait that runs out closes it with nothing.
|
|
7
|
+
*/
|
|
8
|
+
export declare const open: (title: string, fields: readonly Field[] | "return", wait: number) => Promise<{
|
|
9
|
+
readonly url: string;
|
|
10
|
+
readonly held: Promise<Held | undefined>;
|
|
11
|
+
readonly close: () => void;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
const escape = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
4
|
+
const box = 'style="width:100%;font:inherit;padding:.4rem"', press = 'style="font:inherit;padding:.4rem 1rem;margin-right:.5rem"';
|
|
5
|
+
/** One field as the page asks it: a secret as a password, a choice as a list of its options — or, when it is the page's only question, as one button an option — anything else as text. */
|
|
6
|
+
const asked = (f, alone) => (f.options && alone ? `<p>${escape(f.label)}</p>${f.options.map((o) => `<button name="${escape(f.name)}" value="${escape(o.value)}" ${press}>${escape(o.label)}</button>`).join('')}`
|
|
7
|
+
: `<label style="display:block;margin:1rem 0">${escape(f.label)}<br>${f.options ? `<select name="${escape(f.name)}" required ${box}>${f.options.map((o) => `<option value="${escape(o.value)}">${escape(o.label)}</option>`).join('')}</select>`
|
|
8
|
+
: `<input name="${escape(f.name)}" type="${f.secret ? 'password' : 'text'}" autocomplete="off" required ${box}>`}</label>`);
|
|
9
|
+
const form = (title, fields) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(title)}</title>
|
|
10
|
+
<body style="font:16px system-ui;max-width:28rem;margin:3rem auto;padding:0 1rem"><h1 style="font-size:1.2rem">${escape(title)}</h1><form method="post">${fields.map((f) => asked(f, fields.length === 1)).join('')}
|
|
11
|
+
${fields.length === 1 && fields[0].options ? '' : `<button ${press}>Send</button>`}</form></body>`;
|
|
12
|
+
const done = `<!doctype html><meta charset="utf-8"><title>done</title><body style="font:16px system-ui;margin:3rem">Received. You can close this page.</body>`;
|
|
13
|
+
/**
|
|
14
|
+
* One page on this device, for one person, once: served on a loopback port under a path no one can guess, it takes the
|
|
15
|
+
* fields it was opened with — a secret one as a password, never echoed, a choice among its options — or, as a return
|
|
16
|
+
* address, what a visit sends back.
|
|
17
|
+
* It answers the first submission and closes; a wait that runs out closes it with nothing.
|
|
18
|
+
*/
|
|
19
|
+
export const open = async (title, fields, wait) => {
|
|
20
|
+
const nonce = randomBytes(24).toString('base64url');
|
|
21
|
+
let settle = () => undefined;
|
|
22
|
+
const held = new Promise((resolve) => { settle = resolve; });
|
|
23
|
+
const server = createServer((request, response) => {
|
|
24
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
25
|
+
if (url.pathname !== `/${nonce}`) {
|
|
26
|
+
response.writeHead(404).end();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (fields === 'return' && request.method === 'GET') {
|
|
30
|
+
response.writeHead(200, { 'content-type': 'text/html' }).end(done);
|
|
31
|
+
finish(Object.fromEntries(url.searchParams));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (fields !== 'return' && request.method === 'GET') {
|
|
35
|
+
response.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' }).end(form(title, fields));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let body = '';
|
|
39
|
+
request.on('data', (chunk) => { body += chunk.toString('utf8'); if (body.length > 65536)
|
|
40
|
+
request.destroy(); });
|
|
41
|
+
request.on('end', () => { response.writeHead(200, { 'content-type': 'text/html' }).end(done); finish(Object.fromEntries(new URLSearchParams(body))); });
|
|
42
|
+
});
|
|
43
|
+
let closed = false;
|
|
44
|
+
const finish = (got) => { if (closed)
|
|
45
|
+
return; closed = true; server.close(); settle(got); };
|
|
46
|
+
const signal = AbortSignal.timeout(wait);
|
|
47
|
+
signal.addEventListener('abort', () => finish(undefined), { once: true });
|
|
48
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
49
|
+
return { url: `http://127.0.0.1:${server.address().port}/${nonce}`, held, close: () => finish(undefined) };
|
|
50
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Choice, Field, Held } from '../../access/contract.ts';
|
|
2
|
+
import type { Said } from '../../core/lock.ts';
|
|
3
|
+
/** What the client lets head ask of the person: whether it sends addresses and asks forms, and the asking itself. */
|
|
4
|
+
export interface Client {
|
|
5
|
+
readonly can: () => {
|
|
6
|
+
readonly url: boolean;
|
|
7
|
+
readonly form: boolean;
|
|
8
|
+
};
|
|
9
|
+
readonly elicit: (params: Readonly<Record<string, unknown>>) => Promise<{
|
|
10
|
+
readonly action: string;
|
|
11
|
+
readonly content?: Readonly<Record<string, unknown>>;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The person at this device, asked through the richest channel the client declares, in the order head's lock lines name:
|
|
16
|
+
* the client's own form; head's one-time page, sent by the client as an address; the same page opened by this device's
|
|
17
|
+
* browser. A secret goes only through the page — never a form the model could see — and a yes is the person's answer on
|
|
18
|
+
* any of them, never the model's: where none reaches the person, nothing was asked and the lines are theirs to sign.
|
|
19
|
+
* A visit to a provider comes back to such a page.
|
|
20
|
+
*/
|
|
21
|
+
export declare const personOf: (lock: readonly Said[], client: Client) => {
|
|
22
|
+
consent: (message: string, word: string) => Promise<"yes" | "no" | "none">;
|
|
23
|
+
enter: (title: string, fields: readonly Field[]) => Promise<Held | undefined>;
|
|
24
|
+
choose: (message: string, choices: readonly Choice[]) => Promise<Held | undefined>;
|
|
25
|
+
visit: (message: string, to: (back: string) => string | undefined | Promise<string | undefined>) => Promise<Held | undefined>;
|
|
26
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { aboutOf, members, valueOf } from "../../core/lock.js";
|
|
4
|
+
import { fill } from "../../core/surface.js";
|
|
5
|
+
import { open } from "./page.js";
|
|
6
|
+
/**
|
|
7
|
+
* The person at this device, asked through the richest channel the client declares, in the order head's lock lines name:
|
|
8
|
+
* the client's own form; head's one-time page, sent by the client as an address; the same page opened by this device's
|
|
9
|
+
* browser. A secret goes only through the page — never a form the model could see — and a yes is the person's answer on
|
|
10
|
+
* any of them, never the model's: where none reaches the person, nothing was asked and the lines are theirs to sign.
|
|
11
|
+
* A visit to a provider comes back to such a page.
|
|
12
|
+
*/
|
|
13
|
+
export const personOf = (lock, client) => {
|
|
14
|
+
const wait = Number(valueOf(lock, 'access/wait').split('..')[1]);
|
|
15
|
+
const channel = (rule) => ((can) => members(valueOf(lock, rule)).find((c) => (c === 'device' ? true : can[c])))(client.can());
|
|
16
|
+
const offer = async (message, url, how) => (how === 'url' ? (await client.elicit({ mode: 'url', message, url, elicitationId: randomUUID() })).action === 'accept'
|
|
17
|
+
: how === 'device' ? new Promise((resolve) => ((opener) => execFile(opener[0] ?? '', [...opener.slice(1), url], (error) => resolve(error === null)))(members(valueOf(lock, `platform/${process.platform}/browser`)))) : false);
|
|
18
|
+
const offered = async (message, page, how, url = page.url) => ((await offer(message, url, how)) ? page.held : (page.close(), undefined));
|
|
19
|
+
const paged = async (title, fields, how) => offered(fill(aboutOf(lock, fields.some((f) => f.secret) ? 'said/enter' : 'said/answer'), { title }), await open(title, fields, wait), how);
|
|
20
|
+
const form = async (message, properties) => ((got) => (got.action === 'accept' ? Object.fromEntries(Object.entries(got.content ?? {}).map(([k, v]) => [k, String(v)])) : undefined))(await client.elicit({ message, requestedSchema: { type: 'object', properties, required: Object.keys(properties) } }));
|
|
21
|
+
return {
|
|
22
|
+
consent: async (message, word) => {
|
|
23
|
+
const how = channel('ask/consent-channels'), said = (got) => (got?.['yes'] === 'true' ? 'yes' : 'no');
|
|
24
|
+
if (how === undefined)
|
|
25
|
+
return 'none';
|
|
26
|
+
if (how === 'form')
|
|
27
|
+
return said(await form(message, { yes: { type: 'boolean', title: word } }));
|
|
28
|
+
const page = await open(message, [{ name: 'yes', label: word, secret: false, options: [{ value: 'true', label: word }, { value: 'false', label: 'No' }] }], wait);
|
|
29
|
+
return (await offer(fill(aboutOf(lock, 'said/answer'), { title: message }), page.url, how)) ? said(await page.held) : (page.close(), 'none');
|
|
30
|
+
},
|
|
31
|
+
enter: async (title, fields) => paged(title, fields, channel('ask/secret-channels')),
|
|
32
|
+
choose: async (message, choices) => ((how) => (how === 'form'
|
|
33
|
+
? form(message, Object.fromEntries(choices.map((c) => [c.name, { type: 'string', title: c.label ?? c.name, ...(c.options ? { oneOf: c.options.map((o) => ({ const: o.value, title: o.label })) } : {}) }])))
|
|
34
|
+
: how === undefined ? Promise.resolve(undefined) : paged(message, choices.map((c) => ({ name: c.name, label: c.label ?? c.name, secret: false, ...(c.options ? { options: c.options } : {}) })), how)))(channel('ask/channels')),
|
|
35
|
+
visit: async (message, to) => {
|
|
36
|
+
const page = await open(message, 'return', wait);
|
|
37
|
+
const url = await to(page.url);
|
|
38
|
+
return url === undefined ? (page.close(), undefined) : offered(fill(aboutOf(lock, 'said/visit'), { message }), page, channel('ask/secret-channels'), url);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Said } from '../../core/lock.ts';
|
|
2
|
+
import type { Call } from '../../core/read.ts';
|
|
3
|
+
import type { Attachment } from '../../access/contract.ts';
|
|
4
|
+
export type Fetched = {
|
|
5
|
+
readonly kind: 'got';
|
|
6
|
+
readonly status: number;
|
|
7
|
+
readonly bytes: Uint8Array;
|
|
8
|
+
readonly type: string;
|
|
9
|
+
readonly location?: string;
|
|
10
|
+
readonly authenticate?: string;
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: 'over';
|
|
13
|
+
} | {
|
|
14
|
+
readonly kind: 'failed';
|
|
15
|
+
};
|
|
16
|
+
export type Prepared = {
|
|
17
|
+
readonly key: string;
|
|
18
|
+
readonly describe: string;
|
|
19
|
+
readonly send: (attachment: Attachment | undefined, most: number) => Promise<Fetched>;
|
|
20
|
+
} | {
|
|
21
|
+
readonly missing: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly refused: string;
|
|
24
|
+
};
|
|
25
|
+
export declare const published: (url: string, digest: string, most: number, wait: number) => Promise<Uint8Array | undefined>;
|
|
26
|
+
export declare const fetched: (url: string, attachment: Attachment | undefined, accept: string | undefined, most: number, wait: number) => Promise<Fetched>;
|
|
27
|
+
export declare const get: (url: string, wait: number) => Promise<{
|
|
28
|
+
readonly bytes: Uint8Array;
|
|
29
|
+
readonly type: string;
|
|
30
|
+
} | undefined>;
|
|
31
|
+
/** A body posted to an address a declaration names — as a form, or as JSON where the declaration's protocol asks it — as one request under the same rules: encrypted unless it is this device, capped, never redirected. */
|
|
32
|
+
export declare const post: (url: string, body: Readonly<Record<string, unknown>>, most: number, wait: number, as?: "form" | "json") => Promise<{
|
|
33
|
+
readonly status: number;
|
|
34
|
+
readonly json: unknown;
|
|
35
|
+
} | undefined>;
|
|
36
|
+
/**
|
|
37
|
+
* The request an offer's carry describes: its method and path, the path and query parameters its form names filled from
|
|
38
|
+
* what was asked, its body, and the headers and query members an access adapter attaches — only when it is sent, never
|
|
39
|
+
* in the request's key or its description. Plain transport reaches loopback only; anything else must be encrypted.
|
|
40
|
+
*/
|
|
41
|
+
export declare const prepare: (call: Call, args: Readonly<Record<string, unknown>>, lock: readonly Said[]) => Prepared;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { valueOf } from "../../core/lock.js";
|
|
3
|
+
import { jsonOf } from "../../access/contract.js";
|
|
4
|
+
/**
|
|
5
|
+
* One request, stopped as soon as the body runs past the bytes it may hold or the wait runs out, its stream released
|
|
6
|
+
* either way; nothing is retried. No redirect is followed, but to bytes a digest pins: asked with no header and no body,
|
|
7
|
+
* so a secret stays with its origin, and answered only when they hash to the pin, their integrity and never the address.
|
|
8
|
+
*/
|
|
9
|
+
const requestCapped = async (method, url, headers, body, most, wait, redirect = 'manual') => {
|
|
10
|
+
try {
|
|
11
|
+
const response = await fetch(url, { method, headers, body, redirect: redirect === 'follow' && !Object.keys(headers).length && body === undefined ? 'follow' : 'manual', signal: AbortSignal.timeout(wait) });
|
|
12
|
+
const chunks = [];
|
|
13
|
+
let size = 0;
|
|
14
|
+
for await (const chunk of response.body ?? []) {
|
|
15
|
+
size += chunk.byteLength;
|
|
16
|
+
if (size > most)
|
|
17
|
+
break;
|
|
18
|
+
chunks.push(chunk);
|
|
19
|
+
}
|
|
20
|
+
return size > most ? { kind: 'over' } : { kind: 'got', status: response.status, bytes: Buffer.concat(chunks), type: response.headers.get('content-type') ?? '', ...((at) => (at ? { location: at } : {}))(response.headers.get('location')), ...((asks) => (asks ? { authenticate: asks } : {}))(response.headers.get('www-authenticate')) };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return { kind: 'failed' };
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const loopback = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
27
|
+
export const published = async (url, digest, most, wait) => (URL.canParse(url) && ((at) => at.protocol === 'https:' || loopback.has(at.hostname))(new URL(url))
|
|
28
|
+
? ((got) => (got.kind === 'got' && got.status < 300 && `sha256:${createHash('sha256').update(got.bytes).digest('hex')}` === digest ? got.bytes : undefined))(await requestCapped('GET', url, {}, undefined, most, wait, 'follow')) : undefined);
|
|
29
|
+
export const fetched = async (url, attachment, accept, most, wait) => (!URL.canParse(url) || !((at) => at.protocol === 'https:' || loopback.has(at.hostname))(new URL(url)) ? { kind: 'failed' }
|
|
30
|
+
: requestCapped('GET', ((extra) => (extra.size ? `${url}${url.includes('?') ? '&' : '?'}${extra}` : url))(new URLSearchParams(Object.entries(attachment?.query ?? {}))), { ...(attachment?.headers ?? {}), ...(accept ? { accept } : {}) }, undefined, most, wait));
|
|
31
|
+
export const get = async (url, wait) => (URL.canParse(url) && ((at) => at.protocol === 'https:' || loopback.has(at.hostname))(new URL(url)) ? ((got) => (got.kind === 'got' && got.status < 300 ? got : undefined))(await requestCapped('GET', url, {}, undefined, 16_777_216, wait)) : undefined);
|
|
32
|
+
/** A body posted to an address a declaration names — as a form, or as JSON where the declaration's protocol asks it — as one request under the same rules: encrypted unless it is this device, capped, never redirected. */
|
|
33
|
+
export const post = async (url, body, most, wait, as = 'form') => {
|
|
34
|
+
if (!URL.canParse(url) || !((at) => at.protocol === 'https:' || loopback.has(at.hostname))(new URL(url)))
|
|
35
|
+
return undefined;
|
|
36
|
+
const got = await requestCapped('POST', url, { 'content-type': as === 'json' ? 'application/json' : 'application/x-www-form-urlencoded', accept: 'application/json' }, as === 'json' ? JSON.stringify(body) : new URLSearchParams(Object.entries(body).map(([k, v]) => [k, String(v)])).toString(), most, wait);
|
|
37
|
+
return got.kind === 'got' ? { status: got.status, json: jsonOf(got.bytes) } : undefined;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The request an offer's carry describes: its method and path, the path and query parameters its form names filled from
|
|
41
|
+
* what was asked, its body, and the headers and query members an access adapter attaches — only when it is sent, never
|
|
42
|
+
* in the request's key or its description. Plain transport reaches loopback only; anything else must be encrypted.
|
|
43
|
+
*/
|
|
44
|
+
export const prepare = (call, args, lock) => {
|
|
45
|
+
const word = (key) => valueOf(lock, `adapter/request/${key}`);
|
|
46
|
+
const [method, ...more] = call.carry['method'] ?? [];
|
|
47
|
+
const [template, ...paths] = call.carry['path'] ?? [];
|
|
48
|
+
if (method === undefined || template === undefined || more.length || paths.length)
|
|
49
|
+
return { refused: 'carry-unsettled' };
|
|
50
|
+
const origin = ((at) => (at !== undefined && (at.protocol === 'https:' || loopback.has(at.hostname)) ? at : undefined))(URL.canParse(call.origin) ? new URL(call.origin) : undefined);
|
|
51
|
+
if (origin === undefined)
|
|
52
|
+
return { refused: 'insecure-origin' };
|
|
53
|
+
const named = (where) => (call.form['params'] ?? []).filter((p) => p.startsWith(`${where}:`)).map((p) => p.slice(where.length + 1));
|
|
54
|
+
const missing = named('path').find((p) => args[p] === undefined);
|
|
55
|
+
if (missing !== undefined)
|
|
56
|
+
return { missing };
|
|
57
|
+
const path = named('path').reduce((at, p) => at.split(`{${p}}`).join(encodeURIComponent(String(args[p]))), template);
|
|
58
|
+
const query = new URLSearchParams(named('query').flatMap((p) => (args[p] === undefined ? [] : [[p, String(args[p])]])));
|
|
59
|
+
const url = `${origin.href.replace(/\/$/, '')}${path}${query.size ? `?${query}` : ''}`;
|
|
60
|
+
const body = args['body'] === undefined ? undefined : JSON.stringify(args['body']);
|
|
61
|
+
const key = createHash('sha256').update(`${method.toUpperCase()} ${url} ${body ?? 'null'}`).digest('hex');
|
|
62
|
+
const idempotency = call.carry['idempotency']?.[0];
|
|
63
|
+
const send = (attachment, most) => {
|
|
64
|
+
const extra = new URLSearchParams(Object.entries(attachment?.query ?? {}));
|
|
65
|
+
const sent = extra.size ? `${url}${query.size ? '&' : '?'}${extra}` : url;
|
|
66
|
+
return requestCapped(method.toUpperCase(), sent, { ...(attachment?.headers ?? {}), ...(idempotency ? { [idempotency]: key } : {}), ...(body === undefined ? {} : { [word('content-type')]: word('json') }) }, body, most, Number(word('wait').split('..')[1]));
|
|
67
|
+
};
|
|
68
|
+
return { key, describe: `${method.toUpperCase()} ${url}`, send };
|
|
69
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Said } from '../core/lock.ts';
|
|
2
|
+
import type { Outcome } from '../core/answer.ts';
|
|
3
|
+
import { cooled } from './warm.ts';
|
|
4
|
+
import type { Printed } from './process.ts';
|
|
5
|
+
export type { Printed };
|
|
6
|
+
export declare const limits: {
|
|
7
|
+
wait: number;
|
|
8
|
+
most: number;
|
|
9
|
+
};
|
|
10
|
+
/** bound run as a process in a folder, beside head and never blocking it, within the wait and the bytes head's lock gives it and stopped when the asker cancels: one started ahead when head holds one, else one started now. */
|
|
11
|
+
export declare const run: (name: string, cwd: string, args: readonly string[], env?: Readonly<Record<string, string>>, signal?: AbortSignal) => Promise<Printed | undefined>;
|
|
12
|
+
/** bound started ahead of the first ask of a call, as many times as head's lock keeps it started; resolved once each is ready. */
|
|
13
|
+
export declare const warm: (name: string) => Promise<unknown>;
|
|
14
|
+
export { cooled };
|
|
15
|
+
export type Folded = {
|
|
16
|
+
readonly lines: readonly Said[];
|
|
17
|
+
} | {
|
|
18
|
+
readonly refused: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The lines a place stands on, from bound's public views alone: its folded normative standing (the lines view) — its
|
|
22
|
+
* policy, all that a reader of its pins, keys and views needs, read alone where nothing else is — and from
|
|
23
|
+
* its authenticated evidence what this device's own keys signed. Receipts are history, each kept as it was made; any other
|
|
24
|
+
* line of a device key, the person's own attestation, stands only while it is the one line of its scope and measure,
|
|
25
|
+
* unwithdrawn and beside no line of the place's own: anything else needs a fold bound does not publish, and is refused by
|
|
26
|
+
* name. A place whose land prints no act result is refused before anything lands in it. Any refusal of bound is no standing.
|
|
27
|
+
*/
|
|
28
|
+
export declare const policyAt: (name: string, place: string, signal?: AbortSignal) => Promise<Folded>;
|
|
29
|
+
export declare const standingAt: (name: string, place: string, devicesOf: (lines: readonly Said[]) => readonly string[], isHistory: (line: Said) => boolean, signal?: AbortSignal) => Promise<Folded>;
|
|
30
|
+
export declare const sign: (name: string, place: string, lot: readonly string[], key: string, signer: string, env: Readonly<Record<string, string>>, signal?: AbortSignal) => Promise<Outcome>;
|
|
31
|
+
/** A lot signed and landed in one process, its result read from what bound printed of the committed act: the records it admitted, never the receipt beside them; a place that declares no act result is refused by name. */
|
|
32
|
+
/** A place's receipts rendered beside its lock, so bound alone checks the place: nothing when they closed, else why they did not. */
|
|
33
|
+
export declare const closing: (name: string, place: string, signal?: AbortSignal) => Promise<string | undefined>;
|
|
34
|
+
export declare const landAct: (name: string, place: string, lot: readonly string[], key: string, signer: string, env: Readonly<Record<string, string>>, signal?: AbortSignal) => Promise<Outcome>;
|
|
35
|
+
export declare const land: (name: string, place: string, signed: readonly string[], signal?: AbortSignal) => Promise<Outcome>;
|
|
36
|
+
export declare const told: (name: string, place: string, signed: readonly string[], view: string, signal?: AbortSignal) => Promise<readonly string[]>;
|
|
37
|
+
export declare const pinnedAt: (name: string, place: string, file: string, signal?: AbortSignal) => Promise<{
|
|
38
|
+
readonly pin: string;
|
|
39
|
+
readonly bytes: string;
|
|
40
|
+
readonly lines: readonly Said[];
|
|
41
|
+
} | {
|
|
42
|
+
readonly refused: string;
|
|
43
|
+
} | undefined>;
|
|
44
|
+
export declare const founded: (name: string, place: string, key: string, signer: string, env: Readonly<Record<string, string>>, signal?: AbortSignal) => Promise<Outcome>;
|
|
45
|
+
export declare const words: {
|
|
46
|
+
read: string;
|
|
47
|
+
view: string;
|
|
48
|
+
sign: string;
|
|
49
|
+
land: string;
|
|
50
|
+
key: string;
|
|
51
|
+
signer: string;
|
|
52
|
+
pin: string;
|
|
53
|
+
topos: string;
|
|
54
|
+
standing: string;
|
|
55
|
+
evidence: string;
|
|
56
|
+
act: string;
|
|
57
|
+
profile: string;
|
|
58
|
+
receipts: string;
|
|
59
|
+
refusal: string;
|
|
60
|
+
};
|
|
61
|
+
export declare const settledView: (name: string, place: string, view: string, signal?: AbortSignal) => Promise<Folded>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { parse } from '@lapxo/topos/wire';
|
|
6
|
+
import { spawned } from "./process.js";
|
|
7
|
+
import { cooled, kept, warmed } from "./warm.js";
|
|
8
|
+
const binOf = (name) => {
|
|
9
|
+
try {
|
|
10
|
+
const manifest = createRequire(import.meta.url).resolve(join(name, 'package.json'));
|
|
11
|
+
const entry = Object.values(JSON.parse(readFileSync(manifest, 'utf8')).bin ?? {})[0];
|
|
12
|
+
return entry === undefined ? undefined : join(dirname(manifest), entry);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
export const limits = { wait: 0, most: 0 };
|
|
19
|
+
/** bound run as a process in a folder, beside head and never blocking it, within the wait and the bytes head's lock gives it and stopped when the asker cancels: one started ahead when head holds one, else one started now. */
|
|
20
|
+
export const run = async (name, cwd, args, env = {}, signal) => ((bin, at, bounds) => (bin === undefined ? undefined
|
|
21
|
+
: (warmed(bin, args, at, bounds) ?? spawned(process.execPath, [bin, ...args], at, bounds)).then(apart)))(binOf(name), { cwd, env: { ...process.env, ...env } }, { wait: limits.wait, most: limits.most || Number.MAX_SAFE_INTEGER, ...(signal ? { signal } : {}) });
|
|
22
|
+
/** The record bound prints after a refusal's diagnostic, read apart from it: what bound printed is kept whole, the record beside it, and why bound refused is the diagnostic's last line, never the record. */
|
|
23
|
+
const recordOf = (line) => { try {
|
|
24
|
+
return ((got) => (got?.profile === words.refusal ? got : undefined))(JSON.parse(line));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
} };
|
|
29
|
+
const isRecord = (line) => line.startsWith('{') && recordOf(line) !== undefined;
|
|
30
|
+
const apart = (printed) => ((record) => (record === undefined ? printed : { ...printed, refusal: recordOf(record) }))([...printed.err].reverse().find(isRecord));
|
|
31
|
+
const why = (printed) => printed?.err.filter((line) => !isRecord(line)).at(-1);
|
|
32
|
+
/** bound started ahead of the first ask of a call, as many times as head's lock keeps it started; resolved once each is ready. */
|
|
33
|
+
export const warm = (name) => ((bin) => (bin === undefined ? Promise.resolve() : kept(bin)))(binOf(name));
|
|
34
|
+
export { cooled };
|
|
35
|
+
const facts = (out) => out.flatMap((line) => ((got) => (got.kind === 'fact' ? [got.value.fields] : []))(parse(line)));
|
|
36
|
+
const readOf = (printed) => (printed === undefined || printed.code !== 0 ? { refused: why(printed) ?? '' } : { lines: facts(printed.out) });
|
|
37
|
+
/**
|
|
38
|
+
* The lines a place stands on, from bound's public views alone: its folded normative standing (the lines view) — its
|
|
39
|
+
* policy, all that a reader of its pins, keys and views needs, read alone where nothing else is — and from
|
|
40
|
+
* its authenticated evidence what this device's own keys signed. Receipts are history, each kept as it was made; any other
|
|
41
|
+
* line of a device key, the person's own attestation, stands only while it is the one line of its scope and measure,
|
|
42
|
+
* unwithdrawn and beside no line of the place's own: anything else needs a fold bound does not publish, and is refused by
|
|
43
|
+
* name. A place whose land prints no act result is refused before anything lands in it. Any refusal of bound is no standing.
|
|
44
|
+
*/
|
|
45
|
+
export const policyAt = async (name, place, signal) => ((folded) => ('refused' in folded || folded.lines.some((line) => line.scope === words.profile) ? folded
|
|
46
|
+
: { refused: `REFUSE·act the place declares no ${words.profile}: land would print nothing of what it admits, so head lands nothing there; its root can land the local-act@1 profile and view/act` }))(readOf(await run(name, place, [words.read, words.view, words.standing], {}, signal)));
|
|
47
|
+
export const standingAt = async (name, place, devicesOf, isHistory, signal) => {
|
|
48
|
+
const folded = await policyAt(name, place, signal);
|
|
49
|
+
if ('refused' in folded)
|
|
50
|
+
return folded;
|
|
51
|
+
const evidence = readOf(await run(name, place, [words.read, words.view, words.evidence], {}, signal));
|
|
52
|
+
if ('refused' in evidence)
|
|
53
|
+
return evidence;
|
|
54
|
+
const ids = new Set(devicesOf(folded.lines)), own = evidence.lines.filter((line) => ids.has(line.by ?? ''));
|
|
55
|
+
const key = (line) => `${line.scope} ${line.measure}`, attested = own.filter((line) => !isHistory(line));
|
|
56
|
+
const unfolded = attested.find((line) => line.value === 'withdraw' || attested.filter((other) => key(other) === key(line)).length > 1 || folded.lines.some((other) => key(other) === key(line)));
|
|
57
|
+
return unfolded === undefined ? { lines: [...folded.lines, ...own] }
|
|
58
|
+
: { refused: `REFUSE·standing ${unfolded.by} attests ${key(unfolded)} more than once, withdrawn, or beside the place's own line: folding a device key's lines is bound's, and bound publishes no view of it` };
|
|
59
|
+
};
|
|
60
|
+
const lotFile = (lines) => ((at) => { writeFileSync(at, `${lines.join('\n')}\n`); return at; })(join(mkdtempSync(join(tmpdir(), 'head-lot-')), 'lot.bound'));
|
|
61
|
+
export const sign = async (name, place, lot, key, signer, env, signal) => ((got) => (got !== undefined && got.code === 0 ? { lines: got.out.filter((line) => parse(line).kind === 'fact') } : { refused: why(got) ?? '' }))(await run(name, place, [words.sign, lotFile(lot), words.key, key, words.signer, signer], env, signal));
|
|
62
|
+
/** A lot signed and landed in one process, its result read from what bound printed of the committed act: the records it admitted, never the receipt beside them; a place that declares no act result is refused by name. */
|
|
63
|
+
/** A place's receipts rendered beside its lock, so bound alone checks the place: nothing when they closed, else why they did not. */
|
|
64
|
+
export const closing = async (name, place, signal) => ((got) => (got !== undefined && got.code === 0 ? undefined : why(got) ?? 'nothing printed'))(await run(name, place, [words.read, words.view, words.receipts], {}, signal));
|
|
65
|
+
export const landAct = async (name, place, lot, key, signer, env, signal) => ((got) => {
|
|
66
|
+
if (got === undefined || got.code !== 0)
|
|
67
|
+
return { refused: why(got) ?? '' };
|
|
68
|
+
const at = got.out.findIndex((line) => line.startsWith(words.act));
|
|
69
|
+
return at < 0 ? { refused: `REFUSE·act bound printed no act result, and the place declares ${words.profile}: what was admitted is bound's to say` }
|
|
70
|
+
: { lines: got.out.slice(at + 1).filter((line) => parse(line).kind === 'fact').slice(0, -1) };
|
|
71
|
+
})(await run(name, place, [words.land, lotFile(lot), words.key, key, words.signer, signer], env, signal));
|
|
72
|
+
export const land = async (name, place, signed, signal) => ((got) => (got !== undefined && got.code === 0 ? { lines: signed } : { refused: why(got) ?? '' }))(await run(name, place, [words.land, lotFile(signed)], {}, signal));
|
|
73
|
+
export const told = async (name, place, signed, view, signal) => ((got) => [...(got?.out ?? []), ...(got?.err ?? [])])(await run(name, place, [words.read, words.view, view, lotFile(signed)], {}, signal));
|
|
74
|
+
export const pinnedAt = async (name, place, file, signal) => {
|
|
75
|
+
const printed = await run(name, place, [words.read, file], {}, signal);
|
|
76
|
+
const read = readOf(printed);
|
|
77
|
+
if ('refused' in read)
|
|
78
|
+
return read;
|
|
79
|
+
const pin = printed.out.find((line) => line.startsWith(words.pin))?.slice(words.pin.length);
|
|
80
|
+
const bytes = printed.out.filter((line) => line.startsWith(words.topos)).map((line) => `${line.slice(words.topos.length)}\n`).join('');
|
|
81
|
+
return pin === undefined || !bytes ? undefined : { pin, bytes, lines: read.lines };
|
|
82
|
+
};
|
|
83
|
+
export const founded = async (name, place, key, signer, env, signal) => ((got) => (got !== undefined && got.code === 0 ? { lines: got.out } : { refused: why(got) ?? '' }))(await run(name, place, [words.land, words.key, key, words.signer, signer], env, signal));
|
|
84
|
+
export const words = { read: '', view: '', sign: '', land: '', key: '', signer: '', pin: '', topos: '', standing: '', evidence: '', act: '', profile: '', receipts: '', refusal: '' };
|
|
85
|
+
export const settledView = async (name, place, view, signal) => readOf(await run(name, place, [words.read, words.view, view], {}, signal));
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const digestOf: (bytes: Uint8Array) => string;
|
|
2
|
+
/** A read-s body kept by its digest, readable only by its owner; what is read back must hash to the name it is kept under. */
|
|
3
|
+
export declare const keep: (bytes: Uint8Array) => string;
|
|
4
|
+
export declare const held: (digest: string) => Uint8Array | undefined;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const folder = () => { const at = join(tmpdir(), 'head-reads'); mkdirSync(at, { recursive: true, mode: 0o700 }); return at; };
|
|
6
|
+
export const digestOf = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
7
|
+
/** A read-s body kept by its digest, readable only by its owner; what is read back must hash to the name it is kept under. */
|
|
8
|
+
export const keep = (bytes) => ((digest) => { writeFileSync(join(folder(), digest.slice(7)), bytes, { mode: 0o600 }); return digest; })(digestOf(bytes));
|
|
9
|
+
export const held = (digest) => { try {
|
|
10
|
+
return ((bytes) => (digestOf(bytes) === digest ? bytes : undefined))(readFileSync(join(folder(), digest.slice(7))));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
} };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Attachment } from '../access/contract.ts';
|
|
2
|
+
import type { Context } from '../core/answer.ts';
|
|
3
|
+
import type { Said } from '../core/lock.ts';
|
|
4
|
+
import type { Call } from '../core/read.ts';
|
|
5
|
+
export type Read = {
|
|
6
|
+
readonly bytes: Uint8Array;
|
|
7
|
+
readonly kind: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const kindOf: (at: string, type?: string) => string;
|
|
10
|
+
/**
|
|
11
|
+
* A document the person names: a file inside the place or a folder the client shares, or an encrypted address read with
|
|
12
|
+
* nothing attached — signed or expiring as it may be — and, when its digest is named, followed wherever it moves to the
|
|
13
|
+
* bytes that digest names, and only to them. Anything else is not read. Its kind is named, never read for: its file's
|
|
14
|
+
* extension, its address's, or the media type it was served as; bytes that say none are octet-stream.
|
|
15
|
+
*/
|
|
16
|
+
export declare const documentOf: (context: Context, place: string, at: string, named?: string) => Promise<Read | undefined>;
|
|
17
|
+
/** A document read with the access a source the place connected holds: sent to that source's own origin alone, never redirected with it; a move is followed with nothing attached, only to the bytes its named digest names. */
|
|
18
|
+
export declare const fetchedAs: (context: Context, at: string, via: string, got: {
|
|
19
|
+
readonly plan: Call;
|
|
20
|
+
readonly attachment: Attachment | undefined;
|
|
21
|
+
}, named: string, accept: string | undefined) => Promise<Read | {
|
|
22
|
+
readonly said: readonly string[];
|
|
23
|
+
}>;
|
|
24
|
+
/**
|
|
25
|
+
* Every byte a package's lines name, by the path each names: read where a line of the same scope says — an encrypted
|
|
26
|
+
* address, signed or expiring, read with nothing attached and only to the bytes its digest names — or else beside the
|
|
27
|
+
* package's lock, in its folder or at its address. A path outside the package is never read. And what a place laid
|
|
28
|
+
* for a source it connected — its document and the overlay beside it — by the path the place keeps them at. And where
|
|
29
|
+
* an origin says its documents are: the catalog it publishes at its well-known address (RFC 9727), then the paths head's
|
|
30
|
+
* lock names, each once.
|
|
31
|
+
*/
|
|
32
|
+
export declare const besideOf: (context: Context, document: string, lines: readonly Said[]) => Promise<ReadonlyMap<string, Uint8Array | undefined>>;
|
|
33
|
+
export declare const describedAt: (context: Context, origin: string) => Promise<readonly string[]>;
|
|
34
|
+
export declare const laidOf: (context: Context, place: string, source: string) => readonly (readonly [string, Uint8Array])[];
|