@lapxo/head 0.1.0 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/TARGET.bound +517 -54
- package/dist/src/access/contract.d.ts +2 -0
- package/dist/src/access/obtain.d.ts +7 -1
- package/dist/src/access/obtain.js +6 -6
- package/dist/src/adopt/adopt.js +5 -2
- package/dist/src/compile/pin.js +5 -5
- package/dist/src/core/act.d.ts +1 -0
- package/dist/src/core/act.js +1 -0
- package/dist/src/core/answer.d.ts +18 -4
- package/dist/src/core/answer.js +7 -4
- package/dist/src/core/lock.d.ts +2 -0
- package/dist/src/core/lock.js +2 -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 +3 -0
- package/dist/src/core/read.js +8 -4
- package/dist/src/core/surface.d.ts +1 -40
- package/dist/src/core/surface.js +4 -29
- package/dist/src/core/trust.d.ts +7 -0
- package/dist/src/core/trust.js +19 -0
- package/dist/src/flows/call.d.ts +1 -3
- package/dist/src/flows/call.js +3 -6
- package/dist/src/flows/device.js +1 -6
- package/dist/src/flows/read.js +24 -11
- package/dist/src/flows/world.js +3 -3
- package/dist/src/index.js +45 -21
- package/dist/src/onboard/first.d.ts +9 -0
- package/dist/src/onboard/first.js +38 -0
- package/dist/src/onboard/owed.d.ts +10 -0
- package/dist/src/onboard/owed.js +28 -0
- package/dist/src/onboard/site.d.ts +12 -0
- package/dist/src/onboard/site.js +22 -0
- package/dist/src/places/admit.js +1 -1
- package/dist/src/places/found.d.ts +1 -0
- package/dist/src/places/found.js +8 -7
- 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.js +13 -3
- package/dist/src/ports/access/person.d.ts +3 -2
- package/dist/src/ports/access/person.js +12 -5
- package/dist/src/ports/adapters/request.d.ts +3 -0
- package/dist/src/ports/adapters/request.js +5 -3
- package/dist/src/ports/bound.d.ts +12 -2
- package/dist/src/ports/bound.js +30 -13
- package/dist/src/ports/documents.d.ts +34 -0
- package/dist/src/ports/documents.js +64 -0
- package/dist/src/ports/keychain.d.ts +2 -1
- package/dist/src/ports/keychain.js +3 -2
- package/dist/src/ports/place.d.ts +1 -0
- package/dist/src/ports/place.js +7 -1
- package/dist/src/ports/process.d.ts +9 -0
- package/dist/src/ports/process.js +5 -3
- package/dist/src/ports/spare.d.ts +1 -0
- package/dist/src/ports/spare.js +33 -0
- package/dist/src/ports/surface.d.ts +130 -0
- package/dist/src/ports/surface.js +50 -0
- package/dist/src/ports/warm.d.ts +19 -0
- package/dist/src/ports/warm.js +46 -0
- package/dist/src/signer.js +1 -1
- package/dist/src/sources/access.d.ts +10 -1
- package/dist/src/sources/access.js +15 -10
- package/dist/src/sources/connect.d.ts +13 -2
- package/dist/src/sources/connect.js +69 -38
- package/dist/src/sources/refused.d.ts +7 -0
- package/dist/src/sources/refused.js +16 -0
- package/dist/src/tools/ask.d.ts +1 -0
- package/dist/src/tools/ask.js +1 -0
- package/dist/src/tools/card.d.ts +1 -1
- package/dist/src/tools/card.js +5 -4
- package/dist/src/tools/do.d.ts +1 -0
- package/dist/src/tools/do.js +1 -0
- package/dist/src/tools/look.d.ts +5 -4
- package/dist/src/tools/look.js +15 -13
- package/dist/src/tools/places.d.ts +7 -0
- package/dist/src/tools/places.js +32 -0
- package/dist/src/tools/share.d.ts +1 -0
- package/dist/src/tools/share.js +1 -0
- package/dist/src/verbs/ask.d.ts +11 -0
- package/dist/src/verbs/ask.js +35 -0
- package/dist/src/verbs/card.d.ts +11 -0
- package/dist/src/verbs/card.js +16 -0
- package/dist/src/verbs/do.d.ts +8 -0
- package/dist/src/verbs/do.js +28 -0
- package/package.json +6 -5
- /package/dist/src/{core → ports}/keys.d.ts +0 -0
- /package/dist/src/{core → ports}/keys.js +0 -0
package/dist/src/flows/call.js
CHANGED
|
@@ -27,18 +27,15 @@ export const pickedOrigin = async (context, place, lines, source, plan) => {
|
|
|
27
27
|
const landed = await by.land([canonical(line(chosen))]);
|
|
28
28
|
return 'refused' in landed ? { said: [sayIn(context.lock)('not-landed', { why: landed.refused })] } : { lines: [...lines, line(chosen)] };
|
|
29
29
|
};
|
|
30
|
-
const turns = new Map();
|
|
31
|
-
/** Calls of one place take turns, so one asked while another lands sees its receipt; nothing waits on a timer. */
|
|
32
|
-
export const inTurn = (place, act) => { const next = (turns.get(place) ?? Promise.resolve()).then(act, act); turns.set(place, next.catch(() => undefined)); return next; };
|
|
33
30
|
/**
|
|
34
|
-
* The steps a read and an act share: the place,
|
|
31
|
+
* The steps a read and an act share: the place, the lines it stands on once bound settled them, the offer's
|
|
35
32
|
* plan, the transport its origin names and the request it prepares; then what is particular, given a frame that says
|
|
36
33
|
* by the lock, attaches access when asked, types what came back and lands a line under head's key.
|
|
37
34
|
*/
|
|
38
35
|
export const called = async (asked, context, planOf, then) => {
|
|
39
36
|
const say = (key, fields = {}) => [sayIn(context.lock)(key, fields)];
|
|
40
37
|
const [source, operation] = [asked['source'] ?? '', asked['operation'] ?? ''];
|
|
41
|
-
return inPlace(asked, context,
|
|
38
|
+
return inPlace(asked, context, async (place) => {
|
|
42
39
|
const held = await standingFor(context, place);
|
|
43
40
|
if ('unsettled' in held)
|
|
44
41
|
return say('unsettled', { source, why: held.unsettled });
|
|
@@ -72,5 +69,5 @@ export const called = async (asked, context, planOf, then) => {
|
|
|
72
69
|
const attach = async () => ((got) => ('said' in got ? { said: say(got.said, { source, operation, kinds: plan.access.kind, input: got.input }) } : got))(await attachmentFor(context, place, lines, plan));
|
|
73
70
|
const uncovered = (role, scopes) => uncoveredIn(context.lock, lines, context.device(role), place, [], scopes);
|
|
74
71
|
return then({ widen, word: (scope) => valueOf(context.lock, scope), say, place, lines, source, operation, plan, prepared, most, typed, attach, send: (attachment) => prepared.send(attachment, most('read/response-bytes')), lands, uncovered });
|
|
75
|
-
})
|
|
72
|
+
});
|
|
76
73
|
};
|
package/dist/src/flows/device.js
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import { placeNameOf } from "../core/answer.js";
|
|
2
2
|
import { canonical, parse } from '@lapxo/topos/wire';
|
|
3
3
|
import { deviceOf, idIn, uncoveredIn } from "../core/device.js";
|
|
4
|
-
import { newKey, publicOf } from "../core/keys.js";
|
|
5
4
|
import { members, valueOf } from "../core/lock.js";
|
|
6
5
|
import { fill, sayIn } from "../core/surface.js";
|
|
7
6
|
import { adoptsIn } from "../adopt/adopt.js";
|
|
8
7
|
/** A key of this device by its role: read from the keychain, or made there once; only its public half is ever handed on. */
|
|
9
|
-
export const deviceKey = (context, role) => ((account)
|
|
10
|
-
const held = context.keys.read(account);
|
|
11
|
-
const secret = held ?? ((made) => { context.keys.write(account, made.secret); return context.keys.read(account) === made.secret ? made.secret : undefined; })(newKey());
|
|
12
|
-
return secret === undefined ? undefined : publicOf(secret);
|
|
13
|
-
})(deviceOf(context.lock, role).account);
|
|
8
|
+
export const deviceKey = (context, role) => context.keys.made?.(deviceOf(context.lock, role).account);
|
|
14
9
|
/** The keychain account of a place's root, when this keychain holds it — who configures the place is here; else what head says of that. */
|
|
15
10
|
export const rootIn = (context, place) => ((account) => (context.keys.read(account) === undefined ? { said: [sayIn(context.lock)('no-root', { place })] } : { account }))(fill(valueOf(context.lock, 'founding/root-account'), { place: placeNameOf(place) }));
|
|
16
11
|
export const widening = (context, place, lines, lot) => {
|
package/dist/src/flows/read.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { under } from "../core/lock.js";
|
|
2
|
+
import { observedAt, planRead } from "../core/read.js";
|
|
2
3
|
import { basisOf, draftOf, receiptsOf, scopeOf, spentOf } from "../core/receipt.js";
|
|
3
4
|
import { redIn, sayIn } from "../core/surface.js";
|
|
4
|
-
import { grantedOf, isOpen } from "../core/act.js";
|
|
5
|
+
import { grantedOf, isOpen, untilOf } from "../core/act.js";
|
|
5
6
|
import { canonical } from '@lapxo/topos/wire';
|
|
6
7
|
import { called } from "./call.js";
|
|
7
8
|
import { principalOf } from "../access/obtain.js";
|
|
9
|
+
import { jsonOf } from "../access/contract.js";
|
|
10
|
+
import { refusedAccess } from "../sources/refused.js";
|
|
8
11
|
/** 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
12
|
const closeWith = async (intent, source, digest, { word, lines, lands }, context) => {
|
|
10
13
|
const say = sayIn(context.lock);
|
|
@@ -22,13 +25,18 @@ const closeWith = async (intent, source, digest, { word, lines, lands }, context
|
|
|
22
25
|
* the reuse the place allows, it answers from what was kept: nothing called. Asked for history, it shows what was kept
|
|
23
26
|
* of that request as of when it was read — evidence of that read, never a current answer — where the place allows it.
|
|
24
27
|
*/
|
|
25
|
-
export const readSource = (asked, context) =>
|
|
28
|
+
export const readSource = (asked, context) => called(asked, context, planRead, async ({ word, say, place, lines, source, operation, plan, prepared, most, typed, attach, send, lands, widen, uncovered }) => {
|
|
26
29
|
const [family, at, when] = [word('receipt/family'), word('receipt/at'), new Date(context.now()).toISOString()];
|
|
27
30
|
const closing = (digest) => closeWith(asked['closes'], source, digest, { word, lines, lands }, context);
|
|
28
31
|
const scopes = (principal) => [scopeOf(family, source, operation, prepared.key, principal), `${scopeOf(word('receipt/kept'), source, operation, prepared.key, principal)}/${basisOf(lines, word, source)}`];
|
|
29
32
|
const held = (digest) => ((bytes) => (bytes === undefined || digest === undefined ? undefined : { digest, bytes }))(digest === undefined ? undefined : context.cache.held(digest));
|
|
30
33
|
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;
|
|
34
|
+
const allowed = (key, measure) => (lines.find((l) => l.scope === `${word(key)}/${source}` && l.measure === measure) ?? lines.find((l) => l.scope === word(key) && l.measure === measure))?.value;
|
|
35
|
+
if (asked['why'] === 'true') {
|
|
36
|
+
const asOf = principalOf(context, plan, false), [newest] = asOf === undefined ? [] : receiptsOf(lines, at, scopes(asOf)[0]), pin = lines.find((l) => l.scope === `uses/${source}`);
|
|
37
|
+
const signed = newest && lines.find((l) => l.scope === newest.scope && l.value === newest.digest && l.at === `${at}:${newest.at}`), worlds = lines.filter((l) => l.scope?.startsWith('uses/') && (!l.needs || under(context.lock, 'place/worlds', l.needs))).map((l) => `${l.scope.slice('uses/'.length)} ${l.value}`);
|
|
38
|
+
return newest === undefined ? say('why-none', { source, operation }) : say('why', { source, operation, origin: plan.origin, at: newest.at, by: signed?.by ?? '', epoch: signed?.epoch ?? '', digest: newest.digest, pin: pin?.value ?? '', pinned: pin?.epoch ?? '', worlds: worlds.join(', ') });
|
|
39
|
+
}
|
|
32
40
|
if (asked['history'] === 'true') {
|
|
33
41
|
const asOf = principalOf(context, plan, false);
|
|
34
42
|
if (allowed('history/family', 'status') !== 'present')
|
|
@@ -47,19 +55,19 @@ export const readSource = (asked, context) => ((started) => called(asked, contex
|
|
|
47
55
|
return stopped;
|
|
48
56
|
if (!grantedOf(lines, word('grant/family'), source, context.now())) {
|
|
49
57
|
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 =
|
|
58
|
+
const until = untilOf(word('access/period'), context.now());
|
|
51
59
|
const line = (measure, value) => canonical({ at: 'policy:person/grants', by: 'target', form: 'alphabet', measure, role: 'writes', scope: word('grant/family'), value });
|
|
52
60
|
const granted = await widen(say('grant', { sources: sources.join(', '), place, until })[0], [line('sources', sources.join('|')), line('until', until)]);
|
|
53
61
|
if ('said' in granted)
|
|
54
62
|
return granted.said;
|
|
55
63
|
}
|
|
56
64
|
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) {
|
|
65
|
+
const [newest] = principal === undefined ? [] : receiptsOf(lines, at, scopes(principal)[0]), stood = ((b) => b !== undefined && Number(Buffer.from(b.bytes.subarray(0, b.bytes.indexOf(10))).toString('utf8')) < 300)(held(newest?.digest));
|
|
66
|
+
if (principal !== undefined && newest !== undefined && stood && context.now() - Date.parse(newest.at) <= reuse * 1000) {
|
|
59
67
|
const [scope, kept] = scopes(principal);
|
|
60
68
|
const answered = held(receiptsOf(lines, at, kept).find((k) => k.at >= newest.at)?.digest);
|
|
61
69
|
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}
|
|
70
|
+
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}`, context.begun, context.machine())];
|
|
63
71
|
const body = held(newest.digest);
|
|
64
72
|
if (body !== undefined) {
|
|
65
73
|
const cut = body.bytes.indexOf(10);
|
|
@@ -78,8 +86,13 @@ export const readSource = (asked, context) => ((started) => called(asked, contex
|
|
|
78
86
|
const got = await send(attached.attachment);
|
|
79
87
|
if (got.kind !== 'got')
|
|
80
88
|
return say(got.kind === 'over' ? 'too-large' : 'unreachable-source', { source });
|
|
89
|
+
if (got.status === 401 && attached.principal !== 'anonymous')
|
|
90
|
+
return refusedAccess(context, place, lines, plan, operation);
|
|
81
91
|
const digest = context.cache.keep(Buffer.concat([Buffer.from(`${got.status}\n`), got.bytes]));
|
|
82
92
|
const answer = await typed(String(got.status), got.bytes);
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
}))(
|
|
93
|
+
const observed = plan.access.probe && got.status < 300 ? [canonical({ at: `${at}:${when}`, by: 'target', form: 'alphabet', measure: word('vocab/access'), role: 'writes', scope: observedAt(context.lock, source, operation), value: word('vocab/anonymous') })] : [];
|
|
94
|
+
const found = plan.access.probe && got.status === 401 && got.authenticate ? await (await context.access(word('access/adapter')))?.discover?.(plan.origin, got.authenticate, async (url) => jsonOf((await context.files.read.documentOf(context, place, url))?.bytes ?? new Uint8Array())) : undefined;
|
|
95
|
+
const by = `${word('access/family')}/${source}/${word('access/authorizer')}`, authorizer = Object.entries(found ?? {}).flatMap(([k, f]) => Object.entries(f).map(([measure, all]) => canonical({ at: `${at}:${when}`, by: 'target', form: 'alphabet', measure, role: 'writes', scope: k ? `${by}/${k}` : by, value: all.join('|') })));
|
|
96
|
+
const landed = await lands(draftOf(family, at, scope, digest, when), ...(attached.principal === undefined ? [] : keeping(kept, answer)), ...observed, ...authorizer);
|
|
97
|
+
return [...answer, ...('lines' in landed ? [...landed.lines, ...(await closing(digest))] : say('not-landed', { why: landed.refused })), ...(found?.['']?.['issuer'] && 'lines' in landed ? say('authorizer-observed', { source, issuer: found['']['issuer'][0] }) : [])];
|
|
98
|
+
});
|
package/dist/src/flows/world.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { valueOf } from "../core/lock.js";
|
|
2
|
+
import { under, valueOf } from "../core/lock.js";
|
|
3
3
|
import { sayIn } from "../core/surface.js";
|
|
4
4
|
import { pinnedOf } from "../core/worlds.js";
|
|
5
5
|
import { adoptsIn } from "../adopt/adopt.js";
|
|
@@ -9,14 +9,14 @@ import { adoptsIn } from "../adopt/adopt.js";
|
|
|
9
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
10
|
*/
|
|
11
11
|
export const standingFor = async (context, place) => {
|
|
12
|
-
const [held, view
|
|
12
|
+
const [held, view] = [await context.standing(place), valueOf(context.lock, 'place/view')];
|
|
13
13
|
if ('refused' in held)
|
|
14
14
|
return { unsettled: held.refused };
|
|
15
15
|
const own = held.lines;
|
|
16
16
|
const parent = adoptsIn(context.lock, own);
|
|
17
17
|
if (parent !== undefined)
|
|
18
18
|
return { unsettled: sayIn(context.lock)('parent-lock', { place, parent }) };
|
|
19
|
-
const pins = own.filter((l) => l.scope?.startsWith('uses/') && l.needs
|
|
19
|
+
const pins = own.filter((l) => l.scope?.startsWith('uses/') && under(context.lock, 'place/compiled', l.needs)).map((l) => [l, pinnedOf(context.lock, context.files.take(join(place, l.needs)))]);
|
|
20
20
|
const moved = pins.find(([l, p]) => p?.pin !== l.value);
|
|
21
21
|
if (moved)
|
|
22
22
|
return { unsettled: sayIn(context.lock)('compiled-moved', { file: moved[0].needs }) };
|
package/dist/src/index.js
CHANGED
|
@@ -13,23 +13,30 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
13
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
14
|
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
15
15
|
import { answerOf } from "./core/answer.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
16
|
+
import { parse } from '@lapxo/topos/wire';
|
|
17
|
+
import { aboutOf, valueOf } from "./core/lock.js";
|
|
18
|
+
import { fill, sayIn } from "./core/surface.js";
|
|
19
|
+
import { matchTemplate, refusedOn, shapeOf, shapeSchema, surfaceOf } from "./ports/surface.js";
|
|
20
|
+
import { closing, cooled, founded, land, landAct, limits, pinnedAt, policyAt, run, settledView, sign, standingAt, told, warm, words } from "./ports/bound.js";
|
|
21
|
+
import { warmth } from "./ports/warm.js";
|
|
19
22
|
import { held, keep } from "./ports/cache.js";
|
|
20
23
|
import { registryFor } from "./ports/signers.js";
|
|
21
|
-
import { dropAt, folderOf, hasAt, layAt, lockAt, takeAt } from "./ports/place.js";
|
|
24
|
+
import { dropAt, folderOf, hasAt, layAt, listAt, lockAt, takeAt } from "./ports/place.js";
|
|
25
|
+
import * as documents from "./ports/documents.js";
|
|
22
26
|
import { keychain } from "./ports/keychain.js";
|
|
23
27
|
import { algorithmOf, deviceOf, idIn } from "./core/device.js";
|
|
24
|
-
import { publicOf } from "./
|
|
28
|
+
import { publicOf } from "./ports/keys.js";
|
|
25
29
|
import { personOf } from "./ports/access/person.js";
|
|
26
|
-
import { inTurn } from "./flows/call.js";
|
|
27
30
|
const [ext, root] = ((self) => [self.slice(self.lastIndexOf('.')), fileURLToPath(new URL(self.endsWith('.ts') ? '..' : '../..', self))])(import.meta.url);
|
|
28
31
|
const lock = lockAt(root);
|
|
29
32
|
const surface = surfaceOf(lock);
|
|
30
33
|
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}`)])));
|
|
34
|
+
Object.assign(words, Object.fromEntries(['read', 'view', 'sign', 'land', 'key', 'signer', 'pin', 'topos', 'standing', 'evidence', 'act', 'profile', 'receipts', 'refusal'].map((w) => [w, valueOf(lock, `bound/${w}`)])));
|
|
32
35
|
Object.assign(limits, { wait: Number(valueOf(lock, 'bound/wait').split('..')[1]), most: Number(valueOf(lock, 'bound/printed').split('..')[1]) });
|
|
36
|
+
Object.assign(warmth, { spares: Number(valueOf(lock, 'bound/spares').split('..')[1]), boot: valueOf(lock, 'bound/spare-env').split('|') });
|
|
37
|
+
/** One call at a time, each after the one before it and never on a timer, so one asked while another lands sees its receipt; bound started ahead for it, and nothing of it kept once it is answered. */
|
|
38
|
+
let last = Promise.resolve();
|
|
39
|
+
const turn = (act) => ((next) => { last = next.catch(() => undefined); return next; })(last.then(() => { void warm(runs); return act().finally(cooled); }));
|
|
33
40
|
const offered = (n) => (n ? { listChanged: false } : undefined);
|
|
34
41
|
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
42
|
/** 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. */
|
|
@@ -52,30 +59,48 @@ const device = (role) => devices.get(role) ?? ((secret) => (secret === undefined
|
|
|
52
59
|
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
60
|
/** 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
61
|
const contextOf = (signal) => {
|
|
62
|
+
/** One call's folds of a place, kept until something lands in it or is laid there; the places its root acted in, whose receipts close once, when the call ends; and those where anything but a read's receipts landed, as bound printed it. */
|
|
63
|
+
const [folds, touched, moved] = [new Map(), new Set(), new Set()], forget = (place) => { for (const k of [...folds.keys()])
|
|
64
|
+
if (k.endsWith(`\0${place}`))
|
|
65
|
+
folds.delete(k); };
|
|
66
|
+
const read = (at) => at?.startsWith(`${valueOf(lock, 'receipt/at')}:`) === true;
|
|
67
|
+
const once = (key, place, fold) => (folds.get(`${key}\0${place}`) ?? ((p) => (folds.set(`${key}\0${place}`, p), p))(fold()));
|
|
68
|
+
const acted = (place, root, done) => done.then((got) => { if ('lines' in got && !got.lines.every((l) => ((f) => f.kind === 'fact' && read(f.value.fields['at']))(parse(l))))
|
|
69
|
+
moved.add(place); return got; }).finally(() => { forget(place); if (root)
|
|
70
|
+
touched.add(place); });
|
|
55
71
|
const signRoot = (place, lot, account) => sign(runs, place, lot, ...asRoot(account), signal);
|
|
56
72
|
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
73
|
return {
|
|
58
74
|
lock,
|
|
59
75
|
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
|
|
61
|
-
|
|
76
|
+
standing: (place) => once('standing', place, () => standingAt(runs, place, (lines) => ['head', 'person'].flatMap((role) => ((id) => (id === undefined ? [] : [id]))(idIn(lines, device(role)))), (line) => read(line.at), signal)),
|
|
77
|
+
policy: (place) => once('policy', place, () => policyAt(runs, place, signal)),
|
|
78
|
+
world: (place, view) => once(`world/${view}`, place, () => settledView(runs, place, view, signal)),
|
|
62
79
|
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)) },
|
|
80
|
+
files: { lay: (place, rel, bytes) => { forget(place); layAt(place, rel, bytes); }, drop: (place, rel) => { forget(place); dropAt(place, rel); }, has: hasAt, list: listAt, read: documents, take: takeAt, own: (rel) => takeAt(join(root, rel)) },
|
|
64
81
|
home: () => home,
|
|
65
|
-
|
|
82
|
+
personal: () => process.env[valueOf(lock, `platform/${process.platform}/home`)] ?? '',
|
|
83
|
+
found: (place, account) => acted(place, true, founded(runs, place, ...asRoot(account), signal)),
|
|
66
84
|
signRoot,
|
|
67
|
-
landRoot: (place, lot, account) => landAct(runs, place, lot, ...asRoot(account), signal),
|
|
85
|
+
landRoot: (place, lot, account) => acted(place, true, landAct(runs, place, lot, ...asRoot(account), signal)),
|
|
86
|
+
moved: (place) => moved.has(place),
|
|
87
|
+
settle: async () => { const red = []; for (const place of touched) {
|
|
88
|
+
const why = await closing(runs, place, signal);
|
|
89
|
+
if (why !== undefined)
|
|
90
|
+
red.push(sayIn(lock)('receipts-open', { place, why }));
|
|
91
|
+
} touched.clear(); return red; },
|
|
68
92
|
pinned: (place, file) => pinnedAt(runs, place, file, signal),
|
|
69
93
|
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
94
|
person,
|
|
71
95
|
cache: { keep, held },
|
|
72
96
|
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),
|
|
97
|
+
landAs: (place, lot, role, lines) => acted(place, false, ((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)),
|
|
98
|
+
land: (place, signed) => acted(place, true, land(runs, place, signed, signal)),
|
|
75
99
|
told: (place, signed) => told(runs, place, signed, words.standing, signal),
|
|
76
|
-
elicit: (message) => person.consent(message,
|
|
100
|
+
elicit: (message) => person.consent(message, aboutOf(lock, 'said/elicit/yes')),
|
|
77
101
|
now: () => Date.now(),
|
|
78
102
|
machine: () => Date.now() - waited,
|
|
103
|
+
begun: Date.now() - waited,
|
|
79
104
|
keys,
|
|
80
105
|
device,
|
|
81
106
|
roots: async () => (server.getClientCapabilities()?.roots ? (await server.listRoots()).roots.flatMap((root) => folderOf(root.uri) ?? []) : []),
|
|
@@ -86,23 +111,22 @@ const schemaOf = (fields) => ({ type: 'object',
|
|
|
86
111
|
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
112
|
if (surface.tools.length)
|
|
88
113
|
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])) })) }));
|
|
114
|
+
inputSchema: schemaOf([...tool.fields]), outputSchema: shapeSchema(lock), annotations: Object.fromEntries(tool.hints.map((hint) => [`${hint}Hint`, true])) })) }));
|
|
90
115
|
if (surface.tools.length)
|
|
91
|
-
server.setRequestHandler(CallToolRequestSchema, async (request, extra) =>
|
|
116
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => turn(async () => {
|
|
92
117
|
const tool = surface.tools.find((one) => one.name === request.params.name);
|
|
93
118
|
if (tool === undefined)
|
|
94
119
|
return { isError: true, content: [] };
|
|
95
120
|
const refused = refusedOn(lock, process.platform);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return { content: [{ type: 'text', text: extra.signal.aborted ? '' : (await (await answerOf('tools', tool.name))(askedOf(request.params.arguments), contextOf(extra.signal))).join('\n') }] };
|
|
121
|
+
const context = contextOf(extra.signal), answer = refused !== undefined ? [refused] : extra.signal.aborted ? [] : [...await (await answerOf('tools', tool.name))(askedOf(request.params.arguments), context), ...await context.settle()];
|
|
122
|
+
return { content: [{ type: 'text', text: answer.join('\n') }], structuredContent: shapeOf(answer) };
|
|
99
123
|
}));
|
|
100
124
|
if (surface.resources.length)
|
|
101
125
|
server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
|
|
102
126
|
if (surface.resources.length)
|
|
103
127
|
server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({ resourceTemplates: surface.resources.map((r) => ({ name: r.name, uriTemplate: r.template, description: r.about, mimeType: r.mime })) }));
|
|
104
128
|
if (surface.resources.length)
|
|
105
|
-
server.setRequestHandler(ReadResourceRequestSchema, async (request, extra) =>
|
|
129
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request, extra) => turn(async () => {
|
|
106
130
|
const found = surface.resources.flatMap((r) => ((vars) => (vars === undefined ? [] : [{ r, vars }]))(matchTemplate(r.template, request.params.uri)))[0];
|
|
107
131
|
if (found === undefined)
|
|
108
132
|
return { contents: [] };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Asked, type Context } from '../core/answer.ts';
|
|
2
|
+
/**
|
|
3
|
+
* The first source a person connects with no place named and none to read from, on one yes that says all it grants:
|
|
4
|
+
* their place founded in the folder the lock names under their own home, the source connected to it, and reads of it
|
|
5
|
+
* granted until the access period ends. A document that does not arrive is no refusal: the place is founded owing it
|
|
6
|
+
* by its address. Either way the question asked comes back as what to ask next. A folder that already holds a place is
|
|
7
|
+
* used as it stands, never founded again, and its connect asks its own yes.
|
|
8
|
+
*/
|
|
9
|
+
export declare const firstRun: (asked: Asked, context: Context, then: (place: string, covered: boolean) => Promise<readonly string[]>) => Promise<readonly string[]>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { basename, join } from 'node:path';
|
|
2
|
+
import { canonical, LOCK } from '@lapxo/topos/wire';
|
|
3
|
+
import { unless } from "../core/answer.js";
|
|
4
|
+
import { untilOf } from "../core/act.js";
|
|
5
|
+
import { valueOf } from "../core/lock.js";
|
|
6
|
+
import { sayIn } from "../core/surface.js";
|
|
7
|
+
import { rootIn } from "../flows/device.js";
|
|
8
|
+
import { bornAt } from "../places/found.js";
|
|
9
|
+
import { attachedFor } from "../sources/access.js";
|
|
10
|
+
/**
|
|
11
|
+
* The first source a person connects with no place named and none to read from, on one yes that says all it grants:
|
|
12
|
+
* their place founded in the folder the lock names under their own home, the source connected to it, and reads of it
|
|
13
|
+
* granted until the access period ends. A document that does not arrive is no refusal: the place is founded owing it
|
|
14
|
+
* by its address. Either way the question asked comes back as what to ask next. A folder that already holds a place is
|
|
15
|
+
* used as it stands, never founded again, and its connect asks its own yes.
|
|
16
|
+
*/
|
|
17
|
+
export const firstRun = async (asked, context, then) => {
|
|
18
|
+
const say = sayIn(context.lock), word = (k) => valueOf(context.lock, k), dir = join(context.personal(), word('first-run/folder')), name = basename(dir);
|
|
19
|
+
if (context.files.has(dir, LOCK))
|
|
20
|
+
return then(dir, false);
|
|
21
|
+
const [source, document, until] = [(asked['source'] ?? '').toLowerCase().replace(/[^a-z0-9-]+/g, '-'), asked['document'] ?? '', untilOf(word('access/period'), context.now())];
|
|
22
|
+
const refused = await unless(context, say('elicit-first-run', { dir, source, document, until }));
|
|
23
|
+
if (refused)
|
|
24
|
+
return refused;
|
|
25
|
+
const born = await bornAt(context, dir, name), root = rootIn(context, dir);
|
|
26
|
+
if (born[0] !== say('born', { name, dir }) || 'said' in root)
|
|
27
|
+
return 'said' in root ? [...born, ...root.said] : born;
|
|
28
|
+
const connected = await then(dir, true), unread = connected.some((l) => l.startsWith('said/head/document-unread ')), held = unread ? undefined : await context.policy(dir);
|
|
29
|
+
if (held !== undefined && ('refused' in held || !held.lines.some((l) => l.scope === `uses/${source}`)))
|
|
30
|
+
return [...born, ...connected];
|
|
31
|
+
const line = (scope, measure, value, role = 'writes') => canonical({ at: 'policy:head/first-run', by: 'target', form: 'alphabet', measure, role, scope, value });
|
|
32
|
+
const owed = unread ? [line(`${word('place/needs')}/bytes/${source}`, 'id', document, 'demands')] : [];
|
|
33
|
+
const landed = await context.landRoot(dir, [line(word('grant/family'), 'sources', source), line(word('grant/family'), 'until', until), ...owed], root.account);
|
|
34
|
+
if ('refused' in landed)
|
|
35
|
+
return [...born, ...connected, say('not-landed', { why: landed.refused })];
|
|
36
|
+
const access = unread ? undefined : await attachedFor(context, dir, source);
|
|
37
|
+
return [...born, ...(unread ? [] : connected), ...landed.lines, ...(access !== undefined && 'said' in access ? access.said : []), say(unread ? 'bytes-owed' : 'ready', { source, place: dir, document, operation: asked['operation'], args: asked['args'] })];
|
|
38
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Asked, type Context } from '../core/answer.ts';
|
|
2
|
+
/**
|
|
3
|
+
* What a place founded on a first connect still owes: its source's document, by the address the person gave. Asked again,
|
|
4
|
+
* it is read there; once it arrives, the source is connected from those bytes and pinned by their digest, the demand is
|
|
5
|
+
* withdrawn and the question is answered. Until then it is not: what is owed is said, and nothing else is asked.
|
|
6
|
+
*/
|
|
7
|
+
export declare const paidIn: (asked: Asked, context: Context, place: string) => Promise<{
|
|
8
|
+
readonly said: readonly string[];
|
|
9
|
+
readonly answer: boolean;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { canonical } from '@lapxo/topos/wire';
|
|
2
|
+
import { standingOr } from "../core/answer.js";
|
|
3
|
+
import { valueOf } from "../core/lock.js";
|
|
4
|
+
import { sayIn } from "../core/surface.js";
|
|
5
|
+
import { rootIn } from "../flows/device.js";
|
|
6
|
+
import { laidIn } from "../sources/connect.js";
|
|
7
|
+
/**
|
|
8
|
+
* What a place founded on a first connect still owes: its source's document, by the address the person gave. Asked again,
|
|
9
|
+
* it is read there; once it arrives, the source is connected from those bytes and pinned by their digest, the demand is
|
|
10
|
+
* withdrawn and the question is answered. Until then it is not: what is owed is said, and nothing else is asked.
|
|
11
|
+
*/
|
|
12
|
+
export const paidIn = async (asked, context, place) => {
|
|
13
|
+
const say = sayIn(context.lock), word = (k) => valueOf(context.lock, k), source = asked['source'] ?? '';
|
|
14
|
+
const held = await standingOr(context, place), scope = `${word('place/needs')}/bytes/${source}`;
|
|
15
|
+
const owed = 'said' in held ? undefined : held.lines.find((l) => l.scope === scope && l.role === 'demands' && l.value !== 'withdraw');
|
|
16
|
+
if (owed === undefined)
|
|
17
|
+
return { said: [], answer: true };
|
|
18
|
+
const document = owed.value ?? '', read = await context.files.read.documentOf(context, place, document), root = rootIn(context, place);
|
|
19
|
+
if ('said' in root)
|
|
20
|
+
return { said: root.said, answer: false };
|
|
21
|
+
if (read === undefined)
|
|
22
|
+
return { said: [say('bytes-owed', { source, place, document })], answer: false };
|
|
23
|
+
const laid = await laidIn(context, place, root.account)(source, document, read, undefined, context.machine());
|
|
24
|
+
if (!laid.connected)
|
|
25
|
+
return { said: laid.said, answer: false };
|
|
26
|
+
const landed = await context.landRoot(place, [canonical({ ...Object.fromEntries(Object.entries(owed).filter(([k]) => !['sig', 'epoch'].includes(k))), by: 'target', value: 'withdraw' })], root.account);
|
|
27
|
+
return 'refused' in landed ? { said: [...laid.said, say('not-landed', { why: landed.refused })], answer: false } : { said: [...laid.said, ...landed.lines], answer: true };
|
|
28
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Asked, Context } from '../core/answer.ts';
|
|
2
|
+
/**
|
|
3
|
+
* What a person names as a source's document, made a document head can read, before anything is founded or connected:
|
|
4
|
+
* an address or a file is as given; a site alone is looked for where it says its documents are (its API catalog) and at
|
|
5
|
+
* the paths the lock names, the first that reads taken; a folder, at the same names inside it. A source the person names
|
|
6
|
+
* none for is named after the site or the folder. A site where nothing reads is said, with what was tried.
|
|
7
|
+
*/
|
|
8
|
+
export declare const resolved: (asked: Asked, context: Context) => Promise<{
|
|
9
|
+
readonly asked: Asked;
|
|
10
|
+
} | {
|
|
11
|
+
readonly said: readonly string[];
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { basename, dirname, join } from 'node:path';
|
|
2
|
+
import { EXTENSION } from '@lapxo/topos/wire';
|
|
3
|
+
import { members, valueOf } from "../core/lock.js";
|
|
4
|
+
import { sayIn } from "../core/surface.js";
|
|
5
|
+
/**
|
|
6
|
+
* What a person names as a source's document, made a document head can read, before anything is founded or connected:
|
|
7
|
+
* an address or a file is as given; a site alone is looked for where it says its documents are (its API catalog) and at
|
|
8
|
+
* the paths the lock names, the first that reads taken; a folder, at the same names inside it. A source the person names
|
|
9
|
+
* none for is named after the site or the folder. A site where nothing reads is said, with what was tried.
|
|
10
|
+
*/
|
|
11
|
+
export const resolved = async (asked, context) => {
|
|
12
|
+
const [at, word] = [asked['document'] ?? '', (k) => valueOf(context.lock, k)], named = (name) => asked['source'] || name.toLowerCase().replace(/[^a-z0-9-]+/g, '-');
|
|
13
|
+
if (/^([a-z]+:\/\/)?[^/\s]+\.[^/\s]+\/?$/.test(at) && URL.canParse(/^[a-z]+:\/\//.test(at) ? at : `${word('use/scheme')}//${at}`)) {
|
|
14
|
+
const site = new URL(/^[a-z]+:\/\//.test(at) ? at : `${word('use/scheme')}//${at}`), tried = await context.files.read.describedAt(context, site.origin);
|
|
15
|
+
for (const document of tried)
|
|
16
|
+
if (((read) => read !== undefined && `.${read.kind}` !== EXTENSION)(await context.files.read.documentOf(context, '', document)))
|
|
17
|
+
return { asked: { ...asked, document, source: named(site.hostname) } };
|
|
18
|
+
return { said: [sayIn(context.lock)('use-none', { origin: site.origin, tried: tried.join(', ') || '—' })] };
|
|
19
|
+
}
|
|
20
|
+
const inside = at.startsWith('/') && context.files.list(dirname(at), basename(at)).length ? members(word('use/documents')).map((p) => basename(p)).find((n) => context.files.has(at, n)) : undefined;
|
|
21
|
+
return { asked: inside === undefined ? asked : { ...asked, document: join(at, inside), source: named(basename(at)) } };
|
|
22
|
+
};
|
package/dist/src/places/admit.js
CHANGED
|
@@ -15,7 +15,7 @@ export const admitted = (asked, context) => inPlace(asked, context, async (place
|
|
|
15
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
16
|
if (!name || !key)
|
|
17
17
|
return [say('no-key')];
|
|
18
|
-
const [root, held] = [rootIn(context, place), await standingOr(context, place)];
|
|
18
|
+
const [root, held] = [rootIn(context, place), await standingOr(context, place, context.policy)];
|
|
19
19
|
if ('said' in root)
|
|
20
20
|
return root.said;
|
|
21
21
|
if ('said' in held)
|
|
@@ -4,3 +4,4 @@ import type { Asked, Context } from '../core/answer.ts';
|
|
|
4
4
|
* and named in head's own index of places — itself a place, founded the first time — by a line its root signs.
|
|
5
5
|
*/
|
|
6
6
|
export declare const bornPlace: (asked: Asked, context: Context) => Promise<readonly string[]>;
|
|
7
|
+
export declare const bornAt: (context: Context, dir: string, name: string) => Promise<readonly string[]>;
|
package/dist/src/places/found.js
CHANGED
|
@@ -2,7 +2,6 @@ import { isAbsolute, basename } from 'node:path';
|
|
|
2
2
|
import { canonical, LOCK } from '@lapxo/topos/wire';
|
|
3
3
|
import { unless } from "../core/answer.js";
|
|
4
4
|
import { admissionOf } from "../core/device.js";
|
|
5
|
-
import { newKey } from "../core/keys.js";
|
|
6
5
|
import { valueOf } from "../core/lock.js";
|
|
7
6
|
import { deviceKey } from "../flows/device.js";
|
|
8
7
|
import { walkable } from "../walk/context.js";
|
|
@@ -25,9 +24,10 @@ const founded = async (context, dir, name) => {
|
|
|
25
24
|
return { refused: 'no-keychain' };
|
|
26
25
|
if (context.keys.read(account) !== undefined)
|
|
27
26
|
return { refused: 'place-taken' };
|
|
28
|
-
const root =
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
const root = context.keys.made?.(account);
|
|
28
|
+
if (root === undefined)
|
|
29
|
+
return { refused: 'no-keychain' };
|
|
30
|
+
context.files.lay(dir, LOCK, new TextEncoder().encode(`${birthOf(context.lock, root, head, person, name).join('\n')}\n`));
|
|
31
31
|
const born = await context.found(dir, account);
|
|
32
32
|
return ((got) => ('refused' in got ? { refused: 'not-landed', why: got.refused } : got))('refused' in born ? born : await walkable(context, dir, account));
|
|
33
33
|
};
|
|
@@ -44,9 +44,10 @@ export const bornPlace = async (asked, context) => {
|
|
|
44
44
|
if (context.files.has(dir, LOCK))
|
|
45
45
|
return [say('place-exists', { dir })];
|
|
46
46
|
const refused = await unless(context, say('elicit-place', { name, dir }));
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
return refused ?? bornAt(context, dir, name);
|
|
48
|
+
};
|
|
49
|
+
export const bornAt = async (context, dir, name) => {
|
|
50
|
+
const say = sayIn(context.lock), born = await founded(context, dir, name);
|
|
50
51
|
if ('refused' in born)
|
|
51
52
|
return [say(born.refused, { name, dir, why: born.why ?? '' })];
|
|
52
53
|
const home = context.home();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Fields } from '../../access/contract.ts';
|
|
2
|
+
/**
|
|
3
|
+
* The authorizer a resource names when it refuses a request: its protected resource metadata (RFC 9728) at the address
|
|
4
|
+
* its challenge gives, which must name the very origin that refused; the authorization server's metadata (RFC 8414),
|
|
5
|
+
* which must name the issuer the resource named and take S256; and from them the oauth2 authorization-code flow a
|
|
6
|
+
* document would have declared — the same steps, PKCE through head's page, a client registered (RFC 7591) only where
|
|
7
|
+
* the server offers registration and the person's own where it does not, the token and its refresh, renewed at the
|
|
8
|
+
* token endpoint. Every one of these reads goes with nothing attached; what does not hold is no authorizer.
|
|
9
|
+
*/
|
|
10
|
+
export declare const discovered: (resource: string, challenge: string, get: (url: string) => Promise<unknown>) => Promise<Readonly<Record<string, Fields>> | undefined>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const text = (json, key) => ((v) => (typeof v === 'string' && v ? v : undefined))(json?.[key]);
|
|
2
|
+
const texts = (json, key) => ((v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []))(json?.[key]);
|
|
3
|
+
const fields = (pairs) => Object.fromEntries(Object.entries(pairs).map(([k, v]) => [k, [v].flat()]));
|
|
4
|
+
/**
|
|
5
|
+
* The authorizer a resource names when it refuses a request: its protected resource metadata (RFC 9728) at the address
|
|
6
|
+
* its challenge gives, which must name the very origin that refused; the authorization server's metadata (RFC 8414),
|
|
7
|
+
* which must name the issuer the resource named and take S256; and from them the oauth2 authorization-code flow a
|
|
8
|
+
* document would have declared — the same steps, PKCE through head's page, a client registered (RFC 7591) only where
|
|
9
|
+
* the server offers registration and the person's own where it does not, the token and its refresh, renewed at the
|
|
10
|
+
* token endpoint. Every one of these reads goes with nothing attached; what does not hold is no authorizer.
|
|
11
|
+
*/
|
|
12
|
+
export const discovered = async (resource, challenge, get) => {
|
|
13
|
+
const at = /resource_metadata="([^"]+)"/.exec(challenge)?.[1];
|
|
14
|
+
if (at === undefined || !URL.canParse(at, resource))
|
|
15
|
+
return undefined;
|
|
16
|
+
const named = await get(new URL(at, resource).href), [issuer] = texts(named, 'authorization_servers'), own = text(named, 'resource');
|
|
17
|
+
if (own === undefined || !URL.canParse(own) || new URL(own).origin !== new URL(resource).origin || issuer === undefined || !URL.canParse(issuer))
|
|
18
|
+
return undefined;
|
|
19
|
+
const server = await get(`${new URL(issuer).origin}/.well-known/oauth-authorization-server${new URL(issuer).pathname.replace(/\/$/, '')}`);
|
|
20
|
+
const [authorize, token, register, methods] = [text(server, 'authorization_endpoint'), text(server, 'token_endpoint'), text(server, 'registration_endpoint'), texts(server, 'code_challenge_methods_supported')];
|
|
21
|
+
if (text(server, 'issuer') !== issuer || authorize === undefined || token === undefined || (methods.length > 0 && !methods.includes('S256')))
|
|
22
|
+
return undefined;
|
|
23
|
+
const [client, scopes] = [register ? '$steps.register.outputs.client' : '$client', texts(named, 'scopes_supported').join(' ')];
|
|
24
|
+
const sent = (step, pairs) => Object.fromEntries(Object.entries(pairs).filter(([, v]) => v).map(([k, v]) => [`${step}/payload/${k}`, fields({ value: v })]));
|
|
25
|
+
return {
|
|
26
|
+
'': fields({ type: 'oauth2/authorizationCode', issuer, 'attach-in': 'header', 'attach-name': 'Authorization', 'attach-template': 'Bearer {token}', token: '$steps.exchange.outputs.token', expires: '$steps.exchange.outputs.expires', refresh: '$steps.exchange.outputs.refresh', renew: token }),
|
|
27
|
+
...(register ? { 'steps/0': fields({ id: 'register', url: register, encoding: 'json' }), 'steps/0/payload/redirect_uris': fields({ items: '$callback' }), 'steps/0/payload/grant_types': fields({ items: ['authorization_code', 'refresh_token'] }),
|
|
28
|
+
'steps/0/payload/response_types': fields({ items: 'code' }), ...sent('steps/0', { client_name: 'head', token_endpoint_auth_method: "'none'" }), 'steps/0/outputs/client': fields({ value: '$response.body#/client_id' }) } : {}),
|
|
29
|
+
'steps/1': fields({ id: 'authorize', page: authorize }), 'steps/1/outputs/code': fields({ value: '$response.body#/code' }),
|
|
30
|
+
...sent('steps/1', { client_id: client, redirect_uri: '$callback', response_type: 'code', scope: scopes, state: '$state', code_challenge: 's256($verifier)', code_challenge_method: 'S256', resource: new URL(resource).origin }),
|
|
31
|
+
'steps/2': fields({ id: 'exchange', url: token, encoding: 'form' }), ...sent('steps/2', { grant_type: 'authorization_code', code: '$steps.authorize.outputs.code', redirect_uri: '$callback', client_id: client, code_verifier: '$verifier', resource: new URL(resource).origin }),
|
|
32
|
+
'steps/2/outputs/token': fields({ value: '$response.body#/access_token' }), 'steps/2/outputs/expires': fields({ value: '$response.body#/expires_in' }), 'steps/2/outputs/refresh': fields({ value: '$response.body#/refresh_token' }),
|
|
33
|
+
};
|
|
34
|
+
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { jsonOf } from "../../access/contract.js";
|
|
3
|
+
import { discovered } from "./authorizer.js";
|
|
2
4
|
const one = (f, measure) => f?.[measure]?.[0];
|
|
3
5
|
const pointed = (json, pointer) => pointer.split('/').slice(1).map((s) => s.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
4
6
|
.reduce((at, step) => at.flatMap((v) => (v === null || typeof v !== 'object' ? [] : step === '*' ? Object.values(v) : [v[step]])), [json])
|
|
@@ -18,9 +20,16 @@ const originOf = (url) => (URL.canParse(url) ? new URL(url).origin : '');
|
|
|
18
20
|
* the token is kept, and it rides a request as the flow's template says.
|
|
19
21
|
*/
|
|
20
22
|
export const adapter = {
|
|
23
|
+
discover: discovered,
|
|
21
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)),
|
|
22
25
|
obtain: async (want) => {
|
|
23
|
-
const [d,
|
|
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()];
|
|
24
33
|
let [callback, secrets, refused] = ['', 0, undefined];
|
|
25
34
|
const stop = (why) => { refused ??= why; return undefined; };
|
|
26
35
|
const ask = async (name) => {
|
|
@@ -110,11 +119,12 @@ export const adapter = {
|
|
|
110
119
|
for (const k of page >= 0 ? steps.slice(page + 1) : steps)
|
|
111
120
|
if (refused || !(await run(k)))
|
|
112
121
|
break;
|
|
113
|
-
const [token, expires] =
|
|
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))));
|
|
114
123
|
if (refused)
|
|
115
124
|
return refused;
|
|
116
125
|
if (typeof token !== 'string' || !token)
|
|
117
126
|
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 })
|
|
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') }) } : {}) };
|
|
119
129
|
},
|
|
120
130
|
};
|
|
@@ -14,8 +14,9 @@ export interface Client {
|
|
|
14
14
|
/**
|
|
15
15
|
* The person at this device, asked through the richest channel the client declares, in the order head's lock lines name:
|
|
16
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
|
|
18
|
-
*
|
|
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.
|
|
19
20
|
*/
|
|
20
21
|
export declare const personOf: (lock: readonly Said[], client: Client) => {
|
|
21
22
|
consent: (message: string, word: string) => Promise<"yes" | "no" | "none">;
|