@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.
Files changed (87) hide show
  1. package/README.md +24 -0
  2. package/TARGET.bound +517 -54
  3. package/dist/src/access/contract.d.ts +2 -0
  4. package/dist/src/access/obtain.d.ts +7 -1
  5. package/dist/src/access/obtain.js +6 -6
  6. package/dist/src/adopt/adopt.js +5 -2
  7. package/dist/src/compile/pin.js +5 -5
  8. package/dist/src/core/act.d.ts +1 -0
  9. package/dist/src/core/act.js +1 -0
  10. package/dist/src/core/answer.d.ts +18 -4
  11. package/dist/src/core/answer.js +7 -4
  12. package/dist/src/core/lock.d.ts +2 -0
  13. package/dist/src/core/lock.js +2 -0
  14. package/dist/src/core/package.d.ts +54 -0
  15. package/dist/src/core/package.js +65 -0
  16. package/dist/src/core/read.d.ts +3 -0
  17. package/dist/src/core/read.js +8 -4
  18. package/dist/src/core/surface.d.ts +1 -40
  19. package/dist/src/core/surface.js +4 -29
  20. package/dist/src/core/trust.d.ts +7 -0
  21. package/dist/src/core/trust.js +19 -0
  22. package/dist/src/flows/call.d.ts +1 -3
  23. package/dist/src/flows/call.js +3 -6
  24. package/dist/src/flows/device.js +1 -6
  25. package/dist/src/flows/read.js +24 -11
  26. package/dist/src/flows/world.js +3 -3
  27. package/dist/src/index.js +45 -21
  28. package/dist/src/onboard/first.d.ts +9 -0
  29. package/dist/src/onboard/first.js +38 -0
  30. package/dist/src/onboard/owed.d.ts +10 -0
  31. package/dist/src/onboard/owed.js +28 -0
  32. package/dist/src/onboard/site.d.ts +12 -0
  33. package/dist/src/onboard/site.js +22 -0
  34. package/dist/src/places/admit.js +1 -1
  35. package/dist/src/places/found.d.ts +1 -0
  36. package/dist/src/places/found.js +8 -7
  37. package/dist/src/ports/access/authorizer.d.ts +10 -0
  38. package/dist/src/ports/access/authorizer.js +34 -0
  39. package/dist/src/ports/access/flow.js +13 -3
  40. package/dist/src/ports/access/person.d.ts +3 -2
  41. package/dist/src/ports/access/person.js +12 -5
  42. package/dist/src/ports/adapters/request.d.ts +3 -0
  43. package/dist/src/ports/adapters/request.js +5 -3
  44. package/dist/src/ports/bound.d.ts +12 -2
  45. package/dist/src/ports/bound.js +30 -13
  46. package/dist/src/ports/documents.d.ts +34 -0
  47. package/dist/src/ports/documents.js +64 -0
  48. package/dist/src/ports/keychain.d.ts +2 -1
  49. package/dist/src/ports/keychain.js +3 -2
  50. package/dist/src/ports/place.d.ts +1 -0
  51. package/dist/src/ports/place.js +7 -1
  52. package/dist/src/ports/process.d.ts +9 -0
  53. package/dist/src/ports/process.js +5 -3
  54. package/dist/src/ports/spare.d.ts +1 -0
  55. package/dist/src/ports/spare.js +33 -0
  56. package/dist/src/ports/surface.d.ts +130 -0
  57. package/dist/src/ports/surface.js +50 -0
  58. package/dist/src/ports/warm.d.ts +19 -0
  59. package/dist/src/ports/warm.js +46 -0
  60. package/dist/src/signer.js +1 -1
  61. package/dist/src/sources/access.d.ts +10 -1
  62. package/dist/src/sources/access.js +15 -10
  63. package/dist/src/sources/connect.d.ts +13 -2
  64. package/dist/src/sources/connect.js +69 -38
  65. package/dist/src/sources/refused.d.ts +7 -0
  66. package/dist/src/sources/refused.js +16 -0
  67. package/dist/src/tools/ask.d.ts +1 -0
  68. package/dist/src/tools/ask.js +1 -0
  69. package/dist/src/tools/card.d.ts +1 -1
  70. package/dist/src/tools/card.js +5 -4
  71. package/dist/src/tools/do.d.ts +1 -0
  72. package/dist/src/tools/do.js +1 -0
  73. package/dist/src/tools/look.d.ts +5 -4
  74. package/dist/src/tools/look.js +15 -13
  75. package/dist/src/tools/places.d.ts +7 -0
  76. package/dist/src/tools/places.js +32 -0
  77. package/dist/src/tools/share.d.ts +1 -0
  78. package/dist/src/tools/share.js +1 -0
  79. package/dist/src/verbs/ask.d.ts +11 -0
  80. package/dist/src/verbs/ask.js +35 -0
  81. package/dist/src/verbs/card.d.ts +11 -0
  82. package/dist/src/verbs/card.js +16 -0
  83. package/dist/src/verbs/do.d.ts +8 -0
  84. package/dist/src/verbs/do.js +28 -0
  85. package/package.json +6 -5
  86. /package/dist/src/{core → ports}/keys.d.ts +0 -0
  87. /package/dist/src/{core → ports}/keys.js +0 -0
@@ -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, its turn, the lines it stands on once bound settled them, the offer's
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, (place) => inTurn(place, async () => {
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
  };
@@ -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) => {
@@ -1,10 +1,13 @@
1
- import { planRead } from "../core/read.js";
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) => ((started) => called(asked, context, planRead, async ({ word, say, place, lines, source, operation, plan, prepared, most, typed, attach, send, lands, widen, uncovered }) => {
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 = new Date(context.now() + Number(word('access/period')) * 86_400_000).toISOString().slice(0, 10);
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}@${word('look/call')}`, started, context.machine())];
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 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());
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
+ });
@@ -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, compiled] = [await context.standing(place), valueOf(context.lock, 'place/view'), valueOf(context.lock, 'place/compiled')];
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?.startsWith(`${compiled}/`)).map((l) => [l, pinnedOf(context.lock, context.files.take(join(place, 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 { 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";
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 "./core/keys.js";
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?.startsWith(`${valueOf(lock, 'receipt/at')}:`) === true, signal),
61
- world: (place, view) => settledView(runs, place, view, signal),
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
- found: (place, account) => founded(runs, place, ...asRoot(account), signal),
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, valueOf(lock, 'said/elicit/yes')),
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) => inTurn('', async () => {
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
- 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') }] };
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) => inTurn('', async () => {
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
+ };
@@ -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[]>;
@@ -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 = 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`));
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
- if (refused)
48
- return refused;
49
- const born = await founded(context, dir, name);
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, gen, inputs, outputs] = [want.declared, { state: randomBytes(24).toString('base64url'), verifier: randomBytes(32).toString('base64url') }, new Map(), new Map()];
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] = [(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))];
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 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.
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">;