@lapxo/head 0.0.0-stage → 0.1.0
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 +443 -0
- package/dist/src/access/contract.d.ts +69 -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 +37 -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 +17 -0
- package/dist/src/core/act.js +17 -0
- package/dist/src/core/answer.d.ts +90 -0
- package/dist/src/core/answer.js +23 -0
- package/dist/src/core/device.d.ts +26 -0
- package/dist/src/core/device.js +27 -0
- package/dist/src/core/keys.d.ts +14 -0
- package/dist/src/core/keys.js +20 -0
- package/dist/src/core/lock.d.ts +9 -0
- package/dist/src/core/lock.js +11 -0
- package/dist/src/core/read.d.ts +51 -0
- package/dist/src/core/read.js +60 -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 +45 -0
- package/dist/src/core/surface.js +32 -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 +37 -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 +85 -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 +119 -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 +6 -0
- package/dist/src/places/found.js +59 -0
- package/dist/src/ports/access/flow.d.ts +13 -0
- package/dist/src/ports/access/flow.js +120 -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 +25 -0
- package/dist/src/ports/access/person.js +34 -0
- package/dist/src/ports/adapters/request.d.ts +38 -0
- package/dist/src/ports/adapters/request.js +67 -0
- package/dist/src/ports/bound.d.ts +51 -0
- package/dist/src/ports/bound.js +68 -0
- package/dist/src/ports/cache.d.ts +4 -0
- package/dist/src/ports/cache.js +14 -0
- package/dist/src/ports/keychain.d.ts +7 -0
- package/dist/src/ports/keychain.js +11 -0
- package/dist/src/ports/place.d.ts +10 -0
- package/dist/src/ports/place.js +26 -0
- package/dist/src/ports/process.d.ts +20 -0
- package/dist/src/ports/process.js +49 -0
- package/dist/src/ports/signers.d.ts +6 -0
- package/dist/src/ports/signers.js +14 -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/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 +8 -0
- package/dist/src/sources/access.js +34 -0
- package/dist/src/sources/connect.d.ts +10 -0
- package/dist/src/sources/connect.js +68 -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 +40 -0
- package/dist/src/tools/place.d.ts +1 -0
- package/dist/src/tools/place.js +1 -0
- package/dist/src/tools/plan.d.ts +1 -0
- package/dist/src/tools/plan.js +1 -0
- package/dist/src/walk/context.d.ts +7 -0
- package/dist/src/walk/context.js +23 -0
- package/package.json +35 -4
- package/README.md +0 -3
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { planRead } from "../core/read.js";
|
|
2
|
+
import { basisOf, draftOf, receiptsOf, scopeOf, spentOf } from "../core/receipt.js";
|
|
3
|
+
import { redIn, sayIn } from "../core/surface.js";
|
|
4
|
+
import { grantedOf, isOpen } from "../core/act.js";
|
|
5
|
+
import { canonical } from '@lapxo/topos/wire';
|
|
6
|
+
import { called } from "./call.js";
|
|
7
|
+
import { principalOf } from "../access/obtain.js";
|
|
8
|
+
/** An intent an earlier act left open, closed by the read that looked at what it would have changed: a line naming that read-s digest, signed by head. */
|
|
9
|
+
const closeWith = async (intent, source, digest, { word, lines, lands }, context) => {
|
|
10
|
+
const say = sayIn(context.lock);
|
|
11
|
+
if (!intent)
|
|
12
|
+
return [];
|
|
13
|
+
if (!intent.startsWith(`${word('act/family')}/${source}/`) || !isOpen(lines, intent, [word('act/done'), word('act/closed')]))
|
|
14
|
+
return [say('nothing-open', { intent })];
|
|
15
|
+
const landed = await lands(draftOf('', word('receipt/at'), `${intent}/${word('act/closed')}`, digest, new Date(context.now()).toISOString()));
|
|
16
|
+
return 'refused' in landed ? [say('not-landed', { why: landed.refused })] : [...landed.lines, say('closed', { intent, digest })];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A read of one offer the place declares of the class head reads: the response comes back once, typed by the world,
|
|
20
|
+
* and both are kept — the body's digest and the typed answer's, receipts the device key signs. Asked again as the same
|
|
21
|
+
* principal, while the place stands on what typed it, the person's grant holds now and the newest read is younger than
|
|
22
|
+
* the reuse the place allows, it answers from what was kept: nothing called. Asked for history, it shows what was kept
|
|
23
|
+
* of that request as of when it was read — evidence of that read, never a current answer — where the place allows it.
|
|
24
|
+
*/
|
|
25
|
+
export const readSource = (asked, context) => ((started) => called(asked, context, planRead, async ({ word, say, place, lines, source, operation, plan, prepared, most, typed, attach, send, lands, widen, uncovered }) => {
|
|
26
|
+
const [family, at, when] = [word('receipt/family'), word('receipt/at'), new Date(context.now()).toISOString()];
|
|
27
|
+
const closing = (digest) => closeWith(asked['closes'], source, digest, { word, lines, lands }, context);
|
|
28
|
+
const scopes = (principal) => [scopeOf(family, source, operation, prepared.key, principal), `${scopeOf(word('receipt/kept'), source, operation, prepared.key, principal)}/${basisOf(lines, word, source)}`];
|
|
29
|
+
const held = (digest) => ((bytes) => (bytes === undefined || digest === undefined ? undefined : { digest, bytes }))(digest === undefined ? undefined : context.cache.held(digest));
|
|
30
|
+
const keeping = (kept, answer) => (answer.length > 1 ? [draftOf(word('receipt/kept'), at, kept, context.cache.keep(Buffer.from(answer.join('\n'))), when)] : []);
|
|
31
|
+
const allowed = (key, measure) => lines.find((l) => l.scope === `${word(key)}/${source}` && l.measure === measure)?.value;
|
|
32
|
+
if (asked['history'] === 'true') {
|
|
33
|
+
const asOf = principalOf(context, plan, false);
|
|
34
|
+
if (allowed('history/family', 'status') !== 'present')
|
|
35
|
+
return [...say('history-undeclared', { place, source }), canonical({ at: 'policy:person/history', by: 'target', form: 'alphabet', measure: 'status', role: 'writes', scope: `${word('history/family')}/${source}`, value: 'present' })];
|
|
36
|
+
const [newest, ...earlier] = asOf === undefined ? [] : receiptsOf(lines, at, scopes(asOf)[0]);
|
|
37
|
+
if (newest === undefined)
|
|
38
|
+
return say('history-none', { source, operation });
|
|
39
|
+
const keptThen = receiptsOf(lines, at, scopeOf(word('receipt/kept'), source, operation, prepared.key, asOf), true).find((k) => k.at === newest.at);
|
|
40
|
+
const typedThen = held(keptThen?.digest);
|
|
41
|
+
return [...say('history', { source, operation, at: newest.at, digest: newest.digest }), ...(typedThen ? new TextDecoder().decode(typedThen.bytes).split('\n') : say(keptThen ? 'history-gone' : 'history-untyped')),
|
|
42
|
+
...earlier.flatMap((r) => say('history-earlier', { source, at: r.at, digest: r.digest }))];
|
|
43
|
+
}
|
|
44
|
+
const principal = principalOf(context, plan);
|
|
45
|
+
const stopped = uncovered('head', scopes(principal ?? ''));
|
|
46
|
+
if (stopped)
|
|
47
|
+
return stopped;
|
|
48
|
+
if (!grantedOf(lines, word('grant/family'), source, context.now())) {
|
|
49
|
+
const sources = [...new Set(lines.filter((l) => l.scope?.startsWith(`${word('vocab/region')}/`) && l.measure === word('vocab/origins')).map((l) => l.scope.split('/')[1]))].sort();
|
|
50
|
+
const until = new Date(context.now() + Number(word('access/period')) * 86_400_000).toISOString().slice(0, 10);
|
|
51
|
+
const line = (measure, value) => canonical({ at: 'policy:person/grants', by: 'target', form: 'alphabet', measure, role: 'writes', scope: word('grant/family'), value });
|
|
52
|
+
const granted = await widen(say('grant', { sources: sources.join(', '), place, until })[0], [line('sources', sources.join('|')), line('until', until)]);
|
|
53
|
+
if ('said' in granted)
|
|
54
|
+
return granted.said;
|
|
55
|
+
}
|
|
56
|
+
const reuse = Number(/^\d+\.\.(\d+)$/.exec(allowed('reuse/family', 'seconds') ?? '')?.[1] ?? Number.NaN);
|
|
57
|
+
const [newest] = principal === undefined ? [] : receiptsOf(lines, at, scopes(principal)[0]);
|
|
58
|
+
if (principal !== undefined && newest !== undefined && context.now() - Date.parse(newest.at) <= reuse * 1000) {
|
|
59
|
+
const [scope, kept] = scopes(principal);
|
|
60
|
+
const answered = held(receiptsOf(lines, at, kept).find((k) => k.at >= newest.at)?.digest);
|
|
61
|
+
if (answered !== undefined)
|
|
62
|
+
return [...new TextDecoder().decode(answered.bytes).split('\n'), ...say('reused', { scope: kept, digest: answered.digest, at: newest.at }), ...(await closing(newest.digest)), ...redIn(context.lock)('answer/kept', `answer/${source}/${operation}@${word('look/call')}`, started, context.machine())];
|
|
63
|
+
const body = held(newest.digest);
|
|
64
|
+
if (body !== undefined) {
|
|
65
|
+
const cut = body.bytes.indexOf(10);
|
|
66
|
+
const answer = await typed(Buffer.from(body.bytes.subarray(0, cut)).toString('utf8'), body.bytes.subarray(cut + 1));
|
|
67
|
+
const keptLines = keeping(kept, answer);
|
|
68
|
+
const landed = keptLines.length ? await lands(...keptLines) : { lines: [] };
|
|
69
|
+
return [...answer, ...say('reread', { scope, digest: body.digest, at: newest.at }), ...('lines' in landed ? landed.lines : []), ...(await closing(body.digest))];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (spentOf(lines, family, at, source, context.now()) >= most('read/rate'))
|
|
73
|
+
return say('rate', { source });
|
|
74
|
+
const attached = await attach();
|
|
75
|
+
if ('said' in attached)
|
|
76
|
+
return attached.said;
|
|
77
|
+
const [scope, kept] = scopes(attached.principal ?? '');
|
|
78
|
+
const got = await send(attached.attachment);
|
|
79
|
+
if (got.kind !== 'got')
|
|
80
|
+
return say(got.kind === 'over' ? 'too-large' : 'unreachable-source', { source });
|
|
81
|
+
const digest = context.cache.keep(Buffer.concat([Buffer.from(`${got.status}\n`), got.bytes]));
|
|
82
|
+
const answer = await typed(String(got.status), got.bytes);
|
|
83
|
+
const landed = await lands(draftOf(family, at, scope, digest, when), ...(attached.principal === undefined ? [] : keeping(kept, answer)));
|
|
84
|
+
return [...answer, ...('lines' in landed ? [...landed.lines, ...(await closing(digest))] : say('not-landed', { why: landed.refused }))];
|
|
85
|
+
}))(context.machine());
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Context } from '../core/answer.ts';
|
|
2
|
+
import type { Said } from '../core/lock.ts';
|
|
3
|
+
/**
|
|
4
|
+
* The lines a place stands on with what its sources compile to. A source the place pins is read from the standing it
|
|
5
|
+
* pins, checked by hashing the bytes it holds — compiled once, never folded again; a file that no longer hashes to its pin
|
|
6
|
+
* is no standing. A place that pins a lock as its parent stands on nothing head may read: composing the two is bound's, never head's. A place that pins none and names a view has bound compile it, asked once; a fold bound refused is no standing either.
|
|
7
|
+
*/
|
|
8
|
+
export declare const standingFor: (context: Context, place: string) => Promise<{
|
|
9
|
+
readonly lines: readonly Said[];
|
|
10
|
+
} | {
|
|
11
|
+
readonly unsettled: string;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { valueOf } from "../core/lock.js";
|
|
3
|
+
import { sayIn } from "../core/surface.js";
|
|
4
|
+
import { pinnedOf } from "../core/worlds.js";
|
|
5
|
+
import { adoptsIn } from "../adopt/adopt.js";
|
|
6
|
+
/**
|
|
7
|
+
* The lines a place stands on with what its sources compile to. A source the place pins is read from the standing it
|
|
8
|
+
* pins, checked by hashing the bytes it holds — compiled once, never folded again; a file that no longer hashes to its pin
|
|
9
|
+
* is no standing. A place that pins a lock as its parent stands on nothing head may read: composing the two is bound's, never head's. A place that pins none and names a view has bound compile it, asked once; a fold bound refused is no standing either.
|
|
10
|
+
*/
|
|
11
|
+
export const standingFor = async (context, place) => {
|
|
12
|
+
const [held, view, compiled] = [await context.standing(place), valueOf(context.lock, 'place/view'), valueOf(context.lock, 'place/compiled')];
|
|
13
|
+
if ('refused' in held)
|
|
14
|
+
return { unsettled: held.refused };
|
|
15
|
+
const own = held.lines;
|
|
16
|
+
const parent = adoptsIn(context.lock, own);
|
|
17
|
+
if (parent !== undefined)
|
|
18
|
+
return { unsettled: sayIn(context.lock)('parent-lock', { place, parent }) };
|
|
19
|
+
const pins = own.filter((l) => l.scope?.startsWith('uses/') && l.needs?.startsWith(`${compiled}/`)).map((l) => [l, pinnedOf(context.lock, context.files.take(join(place, l.needs)))]);
|
|
20
|
+
const moved = pins.find(([l, p]) => p?.pin !== l.value);
|
|
21
|
+
if (moved)
|
|
22
|
+
return { unsettled: sayIn(context.lock)('compiled-moved', { file: moved[0].needs }) };
|
|
23
|
+
if (pins.length)
|
|
24
|
+
return { lines: [...own, ...pins.flatMap(([, p]) => p.lines)] };
|
|
25
|
+
if (!own.some((line) => line.scope === `view/${view}`))
|
|
26
|
+
return { lines: own };
|
|
27
|
+
const world = await context.world(place, view);
|
|
28
|
+
return 'refused' in world ? { unsettled: world.refused } : { lines: [...own, ...world.lines] };
|
|
29
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
3
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
4
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
5
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
return path;
|
|
9
|
+
};
|
|
10
|
+
import { basename, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
15
|
+
import { answerOf } from "./core/answer.js";
|
|
16
|
+
import { valueOf } from "./core/lock.js";
|
|
17
|
+
import { fill, matchTemplate, refusedOn, surfaceOf } from "./core/surface.js";
|
|
18
|
+
import { founded, land, landAct, limits, pinnedAt, run, settledView, sign, standingAt, told, words } from "./ports/bound.js";
|
|
19
|
+
import { held, keep } from "./ports/cache.js";
|
|
20
|
+
import { registryFor } from "./ports/signers.js";
|
|
21
|
+
import { dropAt, folderOf, hasAt, layAt, lockAt, takeAt } from "./ports/place.js";
|
|
22
|
+
import { keychain } from "./ports/keychain.js";
|
|
23
|
+
import { algorithmOf, deviceOf, idIn } from "./core/device.js";
|
|
24
|
+
import { publicOf } from "./core/keys.js";
|
|
25
|
+
import { personOf } from "./ports/access/person.js";
|
|
26
|
+
import { inTurn } from "./flows/call.js";
|
|
27
|
+
const [ext, root] = ((self) => [self.slice(self.lastIndexOf('.')), fileURLToPath(new URL(self.endsWith('.ts') ? '..' : '../..', self))])(import.meta.url);
|
|
28
|
+
const lock = lockAt(root);
|
|
29
|
+
const surface = surfaceOf(lock);
|
|
30
|
+
const runs = valueOf(lock, 'runs');
|
|
31
|
+
Object.assign(words, Object.fromEntries(['read', 'view', 'sign', 'land', 'key', 'signer', 'pin', 'topos', 'standing', 'evidence', 'act', 'profile'].map((w) => [w, valueOf(lock, `bound/${w}`)])));
|
|
32
|
+
Object.assign(limits, { wait: Number(valueOf(lock, 'bound/wait').split('..')[1]), most: Number(valueOf(lock, 'bound/printed').split('..')[1]) });
|
|
33
|
+
const offered = (n) => (n ? { listChanged: false } : undefined);
|
|
34
|
+
const server = new Server({ name: surface.name, version: surface.version }, { capabilities: { tools: offered(surface.tools.length), resources: offered(surface.resources.length), prompts: offered(surface.prompts.length) } });
|
|
35
|
+
/** The person, asked as the client lets head ask; every wait on them is counted, so a time ceiling measures head's work and never the person's. */
|
|
36
|
+
let waited = 0;
|
|
37
|
+
const timed = (ask) => async (...args) => { const t = Date.now(); try {
|
|
38
|
+
return await ask(...args);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
waited += Date.now() - t;
|
|
42
|
+
} };
|
|
43
|
+
const asked = personOf(lock, { can: () => ((e) => ({ url: Boolean(e?.url), form: e !== undefined && (e.form !== undefined || Object.keys(e).length === 0) }))(server.getClientCapabilities()?.elicitation), elicit: (params) => server.elicitInput(params, { timeout: Number(valueOf(lock, 'access/wait').split('..')[1]) }) });
|
|
44
|
+
const person = { consent: timed(asked.consent), enter: timed(asked.enter), choose: timed(asked.choose), visit: timed(asked.visit) };
|
|
45
|
+
/** head is its home: the folder of its index of places, and the keychain service its keys live under, one per home, so two heads on one keychain never share a key. A place's root is told to bound by the key id its lock names, its secret kept there under the place's account. */
|
|
46
|
+
const home = process.env[valueOf(lock, 'home/env')] || join(process.env[valueOf(lock, `platform/${process.platform}/home`)] ?? '', valueOf(lock, 'home/folder'));
|
|
47
|
+
const service = `${deviceOf(lock).service}/${basename(home).replace(/[^\w.-]+/g, '-')}`;
|
|
48
|
+
const store = valueOf(lock, `platform/${process.platform}/store`);
|
|
49
|
+
const keys = keychain(service, store);
|
|
50
|
+
const devices = new Map();
|
|
51
|
+
const device = (role) => devices.get(role) ?? ((secret) => (secret === undefined ? undefined : ((key) => { devices.set(role, key); return key; })(publicOf(secret))))(keys.read(deviceOf(lock, role).account));
|
|
52
|
+
const asRoot = (account) => ((device, key) => [key, device.signer, { BOUND_SIGNERS: registryFor(device.signer, service, account, key, valueOf(lock, 'founding/algorithm'), store) }])(deviceOf(lock), valueOf(lock, 'founding/root').split('/')[1]);
|
|
53
|
+
/** What a call is answered with, one call at a time as when bound blocked head: every bound process it starts is stopped when the client cancels it, and a call cancelled while it waits its turn is never begun. */
|
|
54
|
+
const contextOf = (signal) => {
|
|
55
|
+
const signRoot = (place, lot, account) => sign(runs, place, lot, ...asRoot(account), signal);
|
|
56
|
+
const signAs = (place, lot, role, lines) => ((one, id) => sign(runs, place, lot, id, one.signer, { BOUND_SIGNERS: registryFor(one.signer, service, one.account, id, algorithmOf(lines), store) }, signal))(deviceOf(lock, role), idIn(lines, device(role)) ?? deviceOf(lock, role).account);
|
|
57
|
+
return {
|
|
58
|
+
lock,
|
|
59
|
+
bound: (cwd, args) => run(runs, cwd, args, {}, signal),
|
|
60
|
+
standing: (place) => standingAt(runs, place, (lines) => ['head', 'person'].flatMap((role) => ((id) => (id === undefined ? [] : [id]))(idIn(lines, device(role)))), (line) => line.at?.startsWith(`${valueOf(lock, 'receipt/at')}:`) === true, signal),
|
|
61
|
+
world: (place, view) => settledView(runs, place, view, signal),
|
|
62
|
+
adapter: async (origin) => ((name) => (!/^[a-z-]+$/.test(name) ? undefined : import(__rewriteRelativeImportExtension(new URL(`./ports/adapters/${name}${ext}`, import.meta.url).href))))(URL.canParse(origin) ? valueOf(lock, `adapter/${new URL(origin).protocol.slice(0, -1)}`) : ''),
|
|
63
|
+
files: { lay: layAt, drop: dropAt, has: hasAt, take: takeAt, own: (rel) => takeAt(join(root, rel)) },
|
|
64
|
+
home: () => home,
|
|
65
|
+
found: (place, account) => founded(runs, place, ...asRoot(account), signal),
|
|
66
|
+
signRoot,
|
|
67
|
+
landRoot: (place, lot, account) => landAct(runs, place, lot, ...asRoot(account), signal),
|
|
68
|
+
pinned: (place, file) => pinnedAt(runs, place, file, signal),
|
|
69
|
+
access: async (kind) => (kind !== valueOf(lock, 'access/adapter') || !/^[a-z0-9-]+$/.test(kind) ? undefined : import(__rewriteRelativeImportExtension(new URL(`./ports/access/${kind}${ext}`, import.meta.url).href)).then((m) => m.adapter)),
|
|
70
|
+
person,
|
|
71
|
+
cache: { keep, held },
|
|
72
|
+
signAs,
|
|
73
|
+
landAs: (place, lot, role, lines) => ((one, id) => landAct(runs, place, lot, id, one.signer, { BOUND_SIGNERS: registryFor(one.signer, service, one.account, id, algorithmOf(lines), store) }, signal))(deviceOf(lock, role), idIn(lines, device(role)) ?? deviceOf(lock, role).account),
|
|
74
|
+
land: (place, signed) => land(runs, place, signed, signal),
|
|
75
|
+
told: (place, signed) => told(runs, place, signed, words.standing, signal),
|
|
76
|
+
elicit: (message) => person.consent(message, valueOf(lock, 'said/elicit/yes')),
|
|
77
|
+
now: () => Date.now(),
|
|
78
|
+
machine: () => Date.now() - waited,
|
|
79
|
+
keys,
|
|
80
|
+
device,
|
|
81
|
+
roots: async () => (server.getClientCapabilities()?.roots ? (await server.listRoots()).roots.flatMap((root) => folderOf(root.uri) ?? []) : []),
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
const askedOf = (raw) => Object.fromEntries(Object.entries(raw ?? {}).map(([k, v]) => [k, v === undefined ? undefined : typeof v === 'object' ? JSON.stringify(v) : String(v)]));
|
|
85
|
+
const schemaOf = (fields) => ({ type: 'object',
|
|
86
|
+
properties: Object.fromEntries(fields.map((f) => [f.name, { type: f.type, description: f.about }])), required: fields.filter((f) => f.required).map((f) => f.name) });
|
|
87
|
+
if (surface.tools.length)
|
|
88
|
+
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: surface.tools.map((tool) => ({ name: tool.name, description: tool.about,
|
|
89
|
+
inputSchema: schemaOf([...tool.fields]), annotations: Object.fromEntries(tool.hints.map((hint) => [`${hint}Hint`, true])) })) }));
|
|
90
|
+
if (surface.tools.length)
|
|
91
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => inTurn('', async () => {
|
|
92
|
+
const tool = surface.tools.find((one) => one.name === request.params.name);
|
|
93
|
+
if (tool === undefined)
|
|
94
|
+
return { isError: true, content: [] };
|
|
95
|
+
const refused = refusedOn(lock, process.platform);
|
|
96
|
+
if (refused !== undefined)
|
|
97
|
+
return { content: [{ type: 'text', text: refused }] };
|
|
98
|
+
return { content: [{ type: 'text', text: extra.signal.aborted ? '' : (await (await answerOf('tools', tool.name))(askedOf(request.params.arguments), contextOf(extra.signal))).join('\n') }] };
|
|
99
|
+
}));
|
|
100
|
+
if (surface.resources.length)
|
|
101
|
+
server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
|
|
102
|
+
if (surface.resources.length)
|
|
103
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({ resourceTemplates: surface.resources.map((r) => ({ name: r.name, uriTemplate: r.template, description: r.about, mimeType: r.mime })) }));
|
|
104
|
+
if (surface.resources.length)
|
|
105
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request, extra) => inTurn('', async () => {
|
|
106
|
+
const found = surface.resources.flatMap((r) => ((vars) => (vars === undefined ? [] : [{ r, vars }]))(matchTemplate(r.template, request.params.uri)))[0];
|
|
107
|
+
if (found === undefined)
|
|
108
|
+
return { contents: [] };
|
|
109
|
+
const refused = refusedOn(lock, process.platform);
|
|
110
|
+
if (refused !== undefined)
|
|
111
|
+
return { contents: [{ uri: request.params.uri, mimeType: found.r.mime, text: refused }] };
|
|
112
|
+
const lines = await (await answerOf('resources', found.r.name))(askedOf(found.vars), contextOf(extra.signal));
|
|
113
|
+
return { contents: [{ uri: request.params.uri, mimeType: found.r.mime, text: lines.join('\n') }] };
|
|
114
|
+
}));
|
|
115
|
+
if (surface.prompts.length)
|
|
116
|
+
server.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: surface.prompts.map((p) => ({ name: p.name, description: p.about, arguments: p.fields.map((f) => ({ name: f.name, description: f.about, required: f.required })) })) }));
|
|
117
|
+
if (surface.prompts.length)
|
|
118
|
+
server.setRequestHandler(GetPromptRequestSchema, (request) => ((p) => ({ messages: p === undefined ? [] : [{ role: 'user', content: { type: 'text', text: [p.text, p.line].filter(Boolean).map((t) => fill(t, askedOf(request.params.arguments))).join('\n') } }] }))(surface.prompts.find((one) => one.name === request.params.name)));
|
|
119
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Asked, Context } from '../core/answer.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Another device admitted into a place by whoever holds its root, once they said yes: the public key its own lines hand
|
|
4
|
+
* over — what those lines claim it may sign is not read — admitted under the name given here, covering what is given here
|
|
5
|
+
* or, without it, what a member is covered for: it asks, keeps what it read and proves access, and widens nothing.
|
|
6
|
+
*/
|
|
7
|
+
export declare const admitted: (asked: Asked, context: Context) => Promise<readonly string[]>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { parse } from '@lapxo/topos/wire';
|
|
2
|
+
import { inPlace, standingOr, unless } from "../core/answer.js";
|
|
3
|
+
import { admissionOf, idIn } from "../core/device.js";
|
|
4
|
+
import { valueOf } from "../core/lock.js";
|
|
5
|
+
import { sayIn } from "../core/surface.js";
|
|
6
|
+
import { rootIn } from "../flows/device.js";
|
|
7
|
+
/**
|
|
8
|
+
* Another device admitted into a place by whoever holds its root, once they said yes: the public key its own lines hand
|
|
9
|
+
* over — what those lines claim it may sign is not read — admitted under the name given here, covering what is given here
|
|
10
|
+
* or, without it, what a member is covered for: it asks, keeps what it read and proves access, and widens nothing.
|
|
11
|
+
*/
|
|
12
|
+
export const admitted = (asked, context) => inPlace(asked, context, async (place) => {
|
|
13
|
+
const say = sayIn(context.lock), word = (k) => valueOf(context.lock, k);
|
|
14
|
+
const name = (asked['name'] ?? '').toLowerCase().replace(/[^a-z0-9-]+/g, '-');
|
|
15
|
+
const key = (asked['key'] ?? '').split('\n').map((line) => parse(line.trim())).flatMap((got) => (got.kind === 'fact' && got.value.fields['measure'] === 'public-key' ? [got.value.fields['value'] ?? ''] : []))[0];
|
|
16
|
+
if (!name || !key)
|
|
17
|
+
return [say('no-key')];
|
|
18
|
+
const [root, held] = [rootIn(context, place), await standingOr(context, place)];
|
|
19
|
+
if ('said' in root)
|
|
20
|
+
return root.said;
|
|
21
|
+
if ('said' in held)
|
|
22
|
+
return held.said;
|
|
23
|
+
if (held.lines.some((l) => l.scope === `keys/${name}`) || idIn(held.lines, key) !== undefined)
|
|
24
|
+
return [say('key-taken', { name, place })];
|
|
25
|
+
const coverage = asked['coverage'] || word('member/coverage');
|
|
26
|
+
const refused = await unless(context, say('elicit-admit', { name, place, coverage }));
|
|
27
|
+
if (refused)
|
|
28
|
+
return refused;
|
|
29
|
+
const lot = admissionOf(context.lock, key, 'head', { id: name, coverage, at: `policy:head/admit/${new Date(context.now()).toISOString()}` });
|
|
30
|
+
const landed = await context.landRoot(place, lot, root.account);
|
|
31
|
+
return 'refused' in landed ? [say('not-landed', { why: landed.refused })] : [say('admitted', { name, place, coverage }), ...landed.lines];
|
|
32
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Asked, Context } from '../core/answer.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A place born where the person names, once they said yes: founded with its own root and the prelude head declares,
|
|
4
|
+
* and named in head's own index of places — itself a place, founded the first time — by a line its root signs.
|
|
5
|
+
*/
|
|
6
|
+
export declare const bornPlace: (asked: Asked, context: Context) => Promise<readonly string[]>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { isAbsolute, basename } from 'node:path';
|
|
2
|
+
import { canonical, LOCK } from '@lapxo/topos/wire';
|
|
3
|
+
import { unless } from "../core/answer.js";
|
|
4
|
+
import { admissionOf } from "../core/device.js";
|
|
5
|
+
import { newKey } from "../core/keys.js";
|
|
6
|
+
import { valueOf } from "../core/lock.js";
|
|
7
|
+
import { deviceKey } from "../flows/device.js";
|
|
8
|
+
import { walkable } from "../walk/context.js";
|
|
9
|
+
import { fill, sayIn } from "../core/surface.js";
|
|
10
|
+
/**
|
|
11
|
+
* The lines a place is born with, every one drafted by target: its root, by the public half of a key kept only in the
|
|
12
|
+
* keychain and the signer that reaches it; this device's keys admitted; and the prelude head's lock declares under
|
|
13
|
+
* birth/ — the wire, the regions and views a world is read through, and the lifetime every reader is bounded by.
|
|
14
|
+
*/
|
|
15
|
+
const birthOf = (lock, root, head, person, place) => [
|
|
16
|
+
...[['class', 'authorize'], ['coverage', '*'], ['signer', valueOf(lock, 'device/head/signer')], ['public-key', root]].map(([measure, value]) => canonical({ at: 'policy:head/birth', by: 'target', form: 'alphabet', measure: measure, role: 'writes', scope: valueOf(lock, 'founding/root'), value: value })),
|
|
17
|
+
...admissionOf(lock, head, 'head'), ...admissionOf(lock, person, 'person'),
|
|
18
|
+
...lock.filter((l) => l.scope?.startsWith('birth/')).map(({ about: _about, scope, ...line }) => canonical({ ...line, value: fill(line.value ?? '', { place }), scope: scope.slice('birth/'.length), at: 'policy:head/birth', by: 'target' })),
|
|
19
|
+
];
|
|
20
|
+
/** A place founded in a folder: a root key made in the keychain under the place's name — never a file — the birth lines written, and the first land signed by that root. */
|
|
21
|
+
const founded = async (context, dir, name) => {
|
|
22
|
+
const account = fill(valueOf(context.lock, 'founding/root-account'), { place: name });
|
|
23
|
+
const [head, person] = [deviceKey(context, 'head'), deviceKey(context, 'person')];
|
|
24
|
+
if (head === undefined || person === undefined)
|
|
25
|
+
return { refused: 'no-keychain' };
|
|
26
|
+
if (context.keys.read(account) !== undefined)
|
|
27
|
+
return { refused: 'place-taken' };
|
|
28
|
+
const root = newKey();
|
|
29
|
+
context.keys.write(account, root.secret);
|
|
30
|
+
context.files.lay(dir, LOCK, new TextEncoder().encode(`${birthOf(context.lock, root.publicKey, head, person, name).join('\n')}\n`));
|
|
31
|
+
const born = await context.found(dir, account);
|
|
32
|
+
return ((got) => ('refused' in got ? { refused: 'not-landed', why: got.refused } : got))('refused' in born ? born : await walkable(context, dir, account));
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* A place born where the person names, once they said yes: founded with its own root and the prelude head declares,
|
|
36
|
+
* and named in head's own index of places — itself a place, founded the first time — by a line its root signs.
|
|
37
|
+
*/
|
|
38
|
+
export const bornPlace = async (asked, context) => {
|
|
39
|
+
const say = sayIn(context.lock);
|
|
40
|
+
const dir = asked['dir'] ?? '';
|
|
41
|
+
if (!isAbsolute(dir))
|
|
42
|
+
return [say('place-absolute')];
|
|
43
|
+
const name = (asked['name'] || basename(dir)).toLowerCase().replace(/[^a-z0-9-]+/g, '-');
|
|
44
|
+
if (context.files.has(dir, LOCK))
|
|
45
|
+
return [say('place-exists', { dir })];
|
|
46
|
+
const refused = await unless(context, say('elicit-place', { name, dir }));
|
|
47
|
+
if (refused)
|
|
48
|
+
return refused;
|
|
49
|
+
const born = await founded(context, dir, name);
|
|
50
|
+
if ('refused' in born)
|
|
51
|
+
return [say(born.refused, { name, dir, why: born.why ?? '' })];
|
|
52
|
+
const home = context.home();
|
|
53
|
+
const homed = context.files.has(home, LOCK) ? { lines: [] } : await founded(context, home, valueOf(context.lock, 'home/name'));
|
|
54
|
+
if ('refused' in homed)
|
|
55
|
+
return [say(homed.refused, { name: valueOf(context.lock, 'home/name'), dir: home, why: homed.why ?? '' })];
|
|
56
|
+
const line = canonical({ at: 'policy:head/places', by: 'target', form: 'alphabet', measure: 'id', role: 'writes', scope: `${valueOf(context.lock, 'home/family')}/${name}`, value: dir });
|
|
57
|
+
const indexed = await context.landRoot(home, [line], fill(valueOf(context.lock, 'founding/root-account'), { place: valueOf(context.lock, 'home/name') }));
|
|
58
|
+
return 'refused' in indexed ? [say('not-landed', { why: indexed.refused })] : [say('born', { name, dir }), ...indexed.lines];
|
|
59
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AccessAdapter } from '../../access/contract.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Access as its document declares it, one way for every kind: the steps its flow names, in order — an operation of a
|
|
4
|
+
* source, an address its authorizer names, or a page the person is sent to and comes back from, opened first so every
|
|
5
|
+
* step before it knows where the person comes back — each sent what its payload says, with the token so far when it
|
|
6
|
+
* says so, keeping what its outputs point at — whatever its status, since an answer that calls for a next step may come
|
|
7
|
+
* as an error; a step whose when holds nothing is passed over, one whose answer holds nothing its outputs name stops the
|
|
8
|
+
* 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
|
|
9
|
+
* 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
|
|
10
|
+
* place's root allows. What the person gives travels only to the source's origin or one its own document names. Only
|
|
11
|
+
* the token is kept, and it rides a request as the flow's template says.
|
|
12
|
+
*/
|
|
13
|
+
export declare const adapter: AccessAdapter;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
const one = (f, measure) => f?.[measure]?.[0];
|
|
3
|
+
const pointed = (json, pointer) => pointer.split('/').slice(1).map((s) => s.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
4
|
+
.reduce((at, step) => at.flatMap((v) => (v === null || typeof v !== 'object' ? [] : step === '*' ? Object.values(v) : [v[step]])), [json])
|
|
5
|
+
.flatMap((v) => (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? [String(v)] : []));
|
|
6
|
+
const transforms = { s256: (s) => createHash('sha256').update(s).digest('base64url'), base64url: (s) => Buffer.from(s).toString('base64url'), base64: (s) => Buffer.from(s).toString('base64') };
|
|
7
|
+
const empty = (v) => v === undefined || v === '' || (Array.isArray(v) && v.length === 0);
|
|
8
|
+
const originOf = (url) => (URL.canParse(url) ? new URL(url).origin : '');
|
|
9
|
+
/**
|
|
10
|
+
* Access as its document declares it, one way for every kind: the steps its flow names, in order — an operation of a
|
|
11
|
+
* source, an address its authorizer names, or a page the person is sent to and comes back from, opened first so every
|
|
12
|
+
* step before it knows where the person comes back — each sent what its payload says, with the token so far when it
|
|
13
|
+
* says so, keeping what its outputs point at — whatever its status, since an answer that calls for a next step may come
|
|
14
|
+
* as an error; a step whose when holds nothing is passed over, one whose answer holds nothing its outputs name stops the
|
|
15
|
+
* 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
|
|
16
|
+
* 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
|
|
17
|
+
* place's root allows. What the person gives travels only to the source's origin or one its own document names. Only
|
|
18
|
+
* the token is kept, and it rides a request as the flow's template says.
|
|
19
|
+
*/
|
|
20
|
+
export const adapter = {
|
|
21
|
+
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)),
|
|
22
|
+
obtain: async (want) => {
|
|
23
|
+
const [d, gen, inputs, outputs] = [want.declared, { state: randomBytes(24).toString('base64url'), verifier: randomBytes(32).toString('base64url') }, new Map(), new Map()];
|
|
24
|
+
let [callback, secrets, refused] = ['', 0, undefined];
|
|
25
|
+
const stop = (why) => { refused ??= why; return undefined; };
|
|
26
|
+
const ask = async (name) => {
|
|
27
|
+
const [f, label] = [d[`inputs/${name}`], one(d[`inputs/${name}`], 'label') ?? name];
|
|
28
|
+
const how = one(f, 'ask');
|
|
29
|
+
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])
|
|
30
|
+
: how === 'choice' ? await (async () => {
|
|
31
|
+
const [values, labels] = [[(await resolve(one(f, 'from') ?? '')) ?? []].flat(), [(await resolve(one(f, 'label') ?? '')) ?? []].flat()];
|
|
32
|
+
return (await want.choose(`Choose ${name} for ${want.source} in ${want.place}`, [{ name, options: values.map((value, i) => ({ value, label: labels[i] ?? value })) }]))?.[name];
|
|
33
|
+
})()
|
|
34
|
+
: 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]
|
|
35
|
+
: stop({ refused: 'carry-unsettled', input: name });
|
|
36
|
+
if (got !== undefined)
|
|
37
|
+
inputs.set(name, got);
|
|
38
|
+
return got ?? stop({ refused: 'entry-unfinished', input: name });
|
|
39
|
+
};
|
|
40
|
+
const resolve = async (expr, response) => {
|
|
41
|
+
const [fn, head] = [/^(\w+)\((.*)\)$/.exec(expr), expr.split(/[.#(]/)[0]];
|
|
42
|
+
if (fn !== null)
|
|
43
|
+
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] });
|
|
44
|
+
if (/\{[^}]+\}/.test(expr)) {
|
|
45
|
+
let out = expr;
|
|
46
|
+
for (const [m, ref] of expr.matchAll(/\{([^}]+)\}/g))
|
|
47
|
+
out = out.replace(m, String((await resolve(ref, response)) ?? ''));
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
if (/^'.*'$/.test(expr))
|
|
51
|
+
return expr.slice(1, -1);
|
|
52
|
+
if (!expr.startsWith('$'))
|
|
53
|
+
return expr;
|
|
54
|
+
if (!want.values.includes(head))
|
|
55
|
+
return stop({ refused: 'carry-unsettled', input: head });
|
|
56
|
+
const [, a = '', , b = ''] = expr.split('.');
|
|
57
|
+
return head === '$callback' ? callback : head === '$state' ? gen.state : head === '$verifier' ? gen.verifier : head === '$scopes' ? want.scopes.join(' ')
|
|
58
|
+
: 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)
|
|
59
|
+
: ((all) => (expr.includes('*') ? all : all[0]))(pointed(response, expr.slice(expr.indexOf('#') + 1)));
|
|
60
|
+
};
|
|
61
|
+
const gather = async (k, kind, response) => {
|
|
62
|
+
const held = {};
|
|
63
|
+
for (const key of Object.keys(d).filter((one_) => one_.startsWith(`${k}/${kind}/`)))
|
|
64
|
+
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);
|
|
65
|
+
return held;
|
|
66
|
+
};
|
|
67
|
+
const kept = async (k, response, status = 200) => {
|
|
68
|
+
const held = await gather(k, 'outputs', response);
|
|
69
|
+
if (Object.values(held).every(empty) && (status >= 400 || Object.keys(held).length))
|
|
70
|
+
return Boolean(stop({ refused: 'step-unanswered', input: one(d[k], 'id') ?? k }));
|
|
71
|
+
outputs.set(one(d[k], 'id') ?? k, new Map(Object.entries(held)));
|
|
72
|
+
return true;
|
|
73
|
+
};
|
|
74
|
+
const guarded = (k) => ((at) => !(at !== undefined && d[k]['overlay'] && originOf(at) !== originOf(want.origin)
|
|
75
|
+
&& 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'));
|
|
76
|
+
const run = async (k) => {
|
|
77
|
+
const f = d[k];
|
|
78
|
+
if (one(f, 'when') !== undefined && empty(await resolve(one(f, 'when'))))
|
|
79
|
+
return true;
|
|
80
|
+
if (!guarded(k))
|
|
81
|
+
return false;
|
|
82
|
+
const [sent, token] = [await gather(k, 'payload'), one(f, 'token') === undefined ? undefined : await resolve(one(f, 'token'))];
|
|
83
|
+
if (refused)
|
|
84
|
+
return false;
|
|
85
|
+
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)
|
|
86
|
+
: await want.post(one(f, 'url'), sent, one(f, 'encoding') === 'json' ? 'json' : 'form');
|
|
87
|
+
return refused ? false : answered === undefined ? Boolean(stop({ refused: 'login-refused' })) : kept(k, answered.json, answered.status);
|
|
88
|
+
};
|
|
89
|
+
const steps = Object.keys(d).filter((key) => /^steps\/\d+$/.test(key)).sort((a, b) => Number(a.slice(6)) - Number(b.slice(6)));
|
|
90
|
+
const page = steps.findIndex((k) => one(d[k], 'page') !== undefined);
|
|
91
|
+
if (page >= 0) {
|
|
92
|
+
const back = await want.visit(`Sign in to ${want.source} at its provider`, async (address) => {
|
|
93
|
+
callback = address;
|
|
94
|
+
for (const k of steps.slice(0, page))
|
|
95
|
+
if (!(await run(k)))
|
|
96
|
+
return undefined;
|
|
97
|
+
if (!guarded(steps[page]))
|
|
98
|
+
return undefined;
|
|
99
|
+
const to = new URL(one(d[steps[page]], 'page'));
|
|
100
|
+
for (const [name, v] of Object.entries(await gather(steps[page], 'payload')))
|
|
101
|
+
if (!empty(v))
|
|
102
|
+
to.searchParams.set(name, String(v));
|
|
103
|
+
return refused ? undefined : to.href;
|
|
104
|
+
});
|
|
105
|
+
if (!refused && (back === undefined || (back['state'] !== undefined && back['state'] !== gen.state)))
|
|
106
|
+
stop({ refused: 'login-refused' });
|
|
107
|
+
if (!refused)
|
|
108
|
+
await kept(steps[page], back);
|
|
109
|
+
}
|
|
110
|
+
for (const k of page >= 0 ? steps.slice(page + 1) : steps)
|
|
111
|
+
if (refused || !(await run(k)))
|
|
112
|
+
break;
|
|
113
|
+
const [token, expires] = [(await Promise.all((d['']?.['token'] ?? []).map((e) => resolve(e)))).find((v) => !empty(v)), (await Promise.all((d['']?.['expires'] ?? []).map((e) => resolve(e)))).find((v) => !empty(v))];
|
|
114
|
+
if (refused)
|
|
115
|
+
return refused;
|
|
116
|
+
if (typeof token !== 'string' || !token)
|
|
117
|
+
return { refused: 'login-refused' };
|
|
118
|
+
return { secret: token, ...(typeof expires === 'string' && Number.isFinite(Number(expires)) ? { expires: want.now + Number(expires) * 1000 } : {}), ...(steps.length ? {} : { manual: true }) };
|
|
119
|
+
},
|
|
120
|
+
};
|
|
@@ -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,25 @@
|
|
|
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 only through what the
|
|
18
|
+
* client can ask, else it is the person's to sign. A visit to a provider comes back to such a page.
|
|
19
|
+
*/
|
|
20
|
+
export declare const personOf: (lock: readonly Said[], client: Client) => {
|
|
21
|
+
consent: (message: string, word: string) => Promise<"yes" | "no" | "none">;
|
|
22
|
+
enter: (title: string, fields: readonly Field[]) => Promise<Held | undefined>;
|
|
23
|
+
choose: (message: string, choices: readonly Choice[]) => Promise<Held | undefined>;
|
|
24
|
+
visit: (message: string, to: (back: string) => string | undefined | Promise<string | undefined>) => Promise<Held | undefined>;
|
|
25
|
+
};
|