@dcasegr/skein 0.1.1 → 0.2.8

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.
@@ -4,6 +4,7 @@ import { CID } from "multiformats/cid";
4
4
  import type { Stamp } from "../runtime/syscalls.ts";
5
5
  import { Broadcaster, type ArcConfig } from "./arc.ts";
6
6
  import { Feeds, type FeedSpec } from "./feeds.ts";
7
+ import { TeranodeTracker } from "./teranode.ts";
7
8
  import { Cron } from "./cron.ts";
8
9
  import { type DispatchRow } from "../runtime/dispatch.ts";
9
10
  import { type BootSource, type Booted } from "./boot.ts";
@@ -11,7 +12,7 @@ import { ImageChain } from "./image-chain.ts";
11
12
  import { OverlaySync } from "./sync.ts";
12
13
  import { type AddressSeed, type DispatchSpec, type Genesis2Config, type Libp2pSpec } from "./genesis.ts";
13
14
  import { HANDLE, type HostDb, type InstanceRow } from "./instances.ts";
14
- import { type KernelSurface } from "./kernel.ts";
15
+ import { type HostChainTip, type KernelSurface } from "./kernel.ts";
15
16
  import { type Live } from "./liveness.ts";
16
17
  import { EventStreams } from "./events.ts";
17
18
  import { P2PHost, type Beacon, type InboundAnswer, type InboundCall, type P2PHostConfig, type Subscription } from "./p2p.ts";
@@ -53,6 +54,21 @@ export interface RouterOptions {
53
54
  };
54
55
  /** The host's headers feed (#102, SKEIN_HEADERS_URL): an SSE stream of block headers every enabled instance whose dispatch table takes events in box `chain` is subscribed to. */
55
56
  headersFeed?: string;
57
+ /**
58
+ * #156: the host's headers tracker over the Teranode P2P network (teranode.ts): its bootstrap
59
+ * peers (the network's, SKEIN_TERANODE_BOOTSTRAP), its first-sync hub (the network's default,
60
+ * SKEIN_HEADERS_HUB), its key file ($SKEIN_HOME/teranode.key). Beside the headers feed or alone:
61
+ * both feed the same intake. Absent (SKEIN_TERANODE=off, or a network with neither): no tracker.
62
+ */
63
+ teranode?: {
64
+ bootstrap: string[];
65
+ hub?: string;
66
+ peersFile?: string;
67
+ page?: number;
68
+ bulkPage?: number;
69
+ maxCrawl?: number;
70
+ discoveryMs?: number;
71
+ };
56
72
  /** The host's Arcade (#58, #65, arc.ts): where the broadcast events go, the status subscription, the `status` provider. Absent: no broadcaster (a broadcast event is dropped). */
57
73
  arc?: ArcConfig;
58
74
  /** Tests: how the broadcaster reaches Arcade. */
@@ -111,6 +127,11 @@ export interface RouterOptions {
111
127
  libp2p?: P2PHostConfig;
112
128
  /** An instance's libp2p peer key (signer.ts peerKey). Absent: no libp2p host; the kernels' `libp2p` is refused. */
113
129
  peerKeyFor?(handle: string): PrivateKey;
130
+ /**
131
+ * #156: the host node's peer key (P2PHost.hostNode: the Teranode tracker rides it; on a one-key
132
+ * host every instance's peer key, so they share it). Absent: a fresh key per start.
133
+ */
134
+ hostPeerKey?: PrivateKey;
114
135
  /**
115
136
  * A provider's key (#70, signer.ts providerKey: the HTTP proxy `fetch`, the
116
137
  * `waker`, `cron`, `libp2p`, `status`), which each new genesis's address
@@ -150,6 +171,17 @@ export interface RouterOptions {
150
171
  * stock wiring takes, from `handle`, handed over after its step's commit. Absent: logged as ignored.
151
172
  */
152
173
  appEvent?(handle: string, record: Record<string, unknown>): void;
174
+ /**
175
+ * An application host's reads (shruggr/open-exchange 0.11.0, the public read API): a GET for an
176
+ * instance (its hostname, `/@<handle>/`, or the root instance's hostname), asked AFTER the host's own
177
+ * reads there (the instance card, `/<app>/.live/<topic>`) and BEFORE the request is forwarded to the
178
+ * instance's program. `route` is the path at the instance (`/amm/pools`); `undefined`: not the host's,
179
+ * forwarded as before. An answer with `stream` is held open (RouterResponse). What the host needs
180
+ * for its reads is the Router's public surface: `dispatch` (in-process reads of the instances),
181
+ * `p2p.live.live(handle, topic)` (the beats kept), `events.subscribe(handle, query, send, close)` (an
182
+ * instance's emitted events), `o.db.list("enabled")` (the instances). Absent: nothing is asked.
183
+ */
184
+ appRead?(handle: string, route: string, url: URL, req: RouterRequest): Promise<RouterResponse | undefined>;
153
185
  }
154
186
  interface Loaded {
155
187
  row: InstanceRow;
@@ -180,6 +212,13 @@ export interface RouterResponse {
180
212
  status: number;
181
213
  headers: Record<string, string>;
182
214
  body: Uint8Array;
215
+ /**
216
+ * A response held open (an event stream an `appRead` answers): on a socket (Router.handler) the
217
+ * status and headers are written, then `stream` is called with `send` (writes a chunk) and `end`
218
+ * (closes the response); the function it returns is called once when the client goes. `body` is
219
+ * not written. In process (Router.dispatch's caller) it is not called: the caller gets `body`.
220
+ */
221
+ stream?(send: (chunk: string) => void, end: () => void): () => void;
183
222
  }
184
223
  export { HANDLE };
185
224
  /** Handles no instance may take on this host (H3), besides the router origin's own first label (Router.reserved): `id` (the router's own in production) and `host` (the host skein's). */
@@ -262,6 +301,13 @@ export declare class Router {
262
301
  private control?;
263
302
  /** The default image (#89), with its chain part (#132): what createInstance boots a new skein from. */
264
303
  readonly image: ImageChain;
304
+ /** #156: the headers tracker over the Teranode mesh (teranode.ts), when the host runs one. */
305
+ tracker?: TeranodeTracker;
306
+ /** #156: the libp2p the host node runs on: the instances' (`p2p`), else one for the tracker alone. */
307
+ hostP2p?: P2PHost;
308
+ private readonly p2pConfig;
309
+ /** #156: the header intake, one run at a time (the image chain, then the host chains). */
310
+ private intake;
265
311
  /** #130: each billed instance as last read (its host row's terms, the kernel's billing state), and what this host metered since its last tick. */
266
312
  private views;
267
313
  private periods;
@@ -392,6 +438,33 @@ export declare class Router {
392
438
  * libp2p arrivals, which do not queue here, need no queue.
393
439
  */
394
440
  serial<T>(handle: string, f: () => Promise<T>): Promise<T>;
441
+ /** #156: the key the host's headers arrive under (feeds.ts): the feed's URL, else the tracker's own. */
442
+ headersKey(): string | undefined;
443
+ /** #156: the Teranode tracker started: its node on the mesh, its first sync begun (`tracker.ready`). */
444
+ private startTracker;
445
+ /**
446
+ * #156: a run of headers from either source (the feed's, `at` undefined: image-chain.ts places
447
+ * each; the tracker's, at height `at`): into the image's chain part, then every host chain synced
448
+ * from it. One run at a time.
449
+ */
450
+ takeHeaders(raws: Uint8Array[], at?: number): Promise<void>;
451
+ /** #156: the host chains this host keeps once per database (its SQLite one, its RocksDB one, and those its rows name). */
452
+ private hostHolders;
453
+ /** #156: each host chain this host keeps (by database) and its tip (undefined: empty). */
454
+ hostChainTips(): Promise<Array<{
455
+ name: string;
456
+ tip: HostChainTip | undefined;
457
+ }>>;
458
+ /** #156: every host chain brought to the image chain's tip (from `from` when a run replaced from there). */
459
+ private syncHolders;
460
+ /**
461
+ * #156: a host chain brought to the image chain: an EMPTY one imports the image's chain part
462
+ * (`chain-import`, as an instance born with it would bring it: one frame) where its kernel can
463
+ * read it (the wasm kernel's SQLite database), else takes the image's headers from genesis in
464
+ * `headers` runs; one behind takes the headers it lacks; one the image does not hold at its
465
+ * height (a fork) from a little below it, or from `from`.
466
+ */
467
+ private syncHolder;
395
468
  /**
396
469
  * The network the host chain is on: SKEIN_NETWORK (`network`), else the genesis defaults'
397
470
  * `walletNetwork` (a test host's), else the kernel's default (main).
@@ -732,6 +805,15 @@ export declare class Router {
732
805
  * in a wallet tracks them). Unsigned, anyone; mailbox instances are not listed.
733
806
  */
734
807
  private skeinsHeldBy;
808
+ /**
809
+ * GET /.well-known/skein at an instance's origin (its hostname, `/@<handle>/`, or the root instance's
810
+ * hostname): `{handle, identity, origin, claimed}` — `claimed` whether its head `grants` names a root
811
+ * holder (an image instance nobody has claimed: false). Read from its store read-only, no kernel, as
812
+ * /skeins does. Unsigned, anyone; 404 for no instance. "Am I root" is /skeins?key=<identity>.
813
+ */
814
+ private instanceCard;
815
+ /** The keys an instance's head `grants` names root (#143), its store read read-only; none when it has no store yet. */
816
+ private rootsOf;
735
817
  /** A forwarded request, held among the in-flight ones (shutdown answers them 503). */
736
818
  private held;
737
819
  /**
@@ -27,6 +27,8 @@
27
27
  // With no host skein: 404 (tests may answer them with a fixture: `discovery`).
28
28
  // GET /skeins?key=<hex> at the host's own origin: the skeins here whose head `grants` names the key
29
29
  // among root's holders, [{handle, identity, url}] (no wallet token tracks them)
30
+ // GET /.well-known/skein at an instance's origin (its hostname, /@<handle>/, the root instance's): {handle,
31
+ // identity, origin, claimed} — claimed: its head `grants` names a root holder
30
32
  // GET /.well-known/skein-host at every host name, an instance's too (#103): {origin, domain} — this
31
33
  // router's origin and the handle domain (the onboarding app's
32
34
  // config.onboard.domain), for a page an instance serves (the management
@@ -115,7 +117,8 @@ import { CID } from "multiformats/cid";
115
117
  import { rootIdentity } from "../runtime/identity.js";
116
118
  import { DEFAULTS, short, stampMs } from "../runtime/log.js";
117
119
  import { ARC_ROUTE, Broadcaster } from "./arc.js";
118
- import { DEFAULT_BOX, Feeds, feedsOf, txCid } from "./feeds.js";
120
+ import { BACKFILL_RUN, DEFAULT_BOX, Feeds, feedsOf, txCid } from "./feeds.js";
121
+ import { hashOf, TeranodeTracker } from "./teranode.js";
119
122
  import { Cron, dbSchedules } from "./cron.js";
120
123
  import { currentDispatch, takesEvent, takesMail } from "../runtime/dispatch.js";
121
124
  import { isSet, parseRocksSpec, parseSqliteSpec, storeSpec } from "../runtime/index-store.js";
@@ -134,9 +137,9 @@ import { keyBytes, keyHex } from "./genesis.js";
134
137
  import { HANDLE, pathOnly, TOKEN_HANDLE, topicsOf, validHandle } from "./instances.js";
135
138
  import { signClaim } from "../client/raw.js";
136
139
  import { DoorAnswered, Kernel, KernelHost } from "./kernel.js";
137
- import { foldLiveness, livenessEvent, livenessOf } from "./liveness.js";
140
+ import { foldLiveness, LIVENESS_MAX_MS, livenessEvent, livenessOf } from "./liveness.js";
138
141
  import { EventStreams, eventsQuery, sseMessage } from "./events.js";
139
- import { beaconEvent, beaconsOf, DEFAULT_LISTEN, emittedEvents, foldBeacons, foldSubscriptions, libp2pConfig, P2PHost, subscribedTopics, subscriptionEvent, subscriptionsOf } from "./p2p.js";
142
+ import { beaconEvent, beaconsOf, LOOPBACK_LISTEN, emittedEvents, foldBeacons, foldSubscriptions, libp2pConfig, P2PHost, subscribedTopics, subscriptionEvent, subscriptionsOf } from "./p2p.js";
140
143
  import { peerIdFromMultihash, peerIdFromString } from "@libp2p/peer-id";
141
144
  import { MESSAGE_KEY_ID, MESSAGE_PROTOCOL, Providers, tooLarge } from "./providers.js";
142
145
  import { closeControl, listenControl } from "./control.js";
@@ -312,6 +315,13 @@ export class Router {
312
315
  control;
313
316
  /** The default image (#89), with its chain part (#132): what createInstance boots a new skein from. */
314
317
  image;
318
+ /** #156: the headers tracker over the Teranode mesh (teranode.ts), when the host runs one. */
319
+ tracker;
320
+ /** #156: the libp2p the host node runs on: the instances' (`p2p`), else one for the tracker alone. */
321
+ hostP2p;
322
+ p2pConfig;
323
+ /** #156: the header intake, one run at a time (the image chain, then the host chains). */
324
+ intake = Promise.resolve();
315
325
  /** #130: each billed instance as last read (its host row's terms, the kernel's billing state), and what this host metered since its last tick. */
316
326
  views = new Map();
317
327
  periods = new Map();
@@ -385,9 +395,12 @@ export class Router {
385
395
  holds: async (h, txid) => await (await this.hydrate(h)).kernel.hasBlock(txCid(txid)),
386
396
  });
387
397
  }
398
+ // #156: the host's libp2p (one node per key; the host node among them) — for the instances, and for the tracker on its own.
399
+ const hostConfig = { ...(o.libp2p ?? { listen: LOOPBACK_LISTEN, bootstrap: [], dht: "off", relays: [], mdns: false }), network: this.chainNetwork() ?? "main" };
400
+ this.p2pConfig = hostConfig;
388
401
  if (o.peerKeyFor) {
389
402
  this.p2p = new P2PHost({
390
- host: o.libp2p ?? { listen: DEFAULT_LISTEN, bootstrap: [], dht: "off", relays: [], mdns: false },
403
+ host: hostConfig, ...(o.hostPeerKey ? { hostKey: o.hostPeerKey } : {}),
391
404
  keyOf: o.peerKeyFor, discoveryMs: o.libp2pDiscoveryMs,
392
405
  inbound: (h, c) => this.p2pInbound(h, c),
393
406
  log: (s, l) => this.say(s, l),
@@ -395,8 +408,10 @@ export class Router {
395
408
  sign: (h, data) => this.signAs(h, data),
396
409
  identity: async (h) => { const id = this.o.db.get(h)?.identity; return id ? keyBytes(id) : undefined; },
397
410
  beat: (h, topic, b) => { this.sync?.beat(h, topic, b); void this.rememberPeer(h, topic, b.from); },
411
+ beats: o.db,
398
412
  });
399
413
  const p2pHost = this.p2p;
414
+ this.hostP2p = p2pHost;
400
415
  this.sync = new OverlaySync({
401
416
  overlayOf: (h, liveTopic) => this.loaded.get(h)?.beacons.find((b) => b.topic === liveTopic)?.app,
402
417
  local: (h, app, fn, query) => this.overlayRead(h, app, fn, query),
@@ -521,11 +536,28 @@ export class Router {
521
536
  }
522
537
  /** Hydrate every enabled row once (recovery at hydrate time: what a waiting thread awaits is handed to the providers again), then let them idle out. */
523
538
  async start() {
539
+ // #132, #156: the host's headers — the feed's and the Teranode tracker's — into one intake: the image's chain part, then the host chains.
540
+ const key = this.headersKey();
541
+ if (key)
542
+ this.feeds.listen(key, (raws, at) => this.takeHeaders(raws, at));
524
543
  // #132: the image's chain part, filled from the feed's history (from genesis once; then from near its tip), then grown by the feed.
525
544
  if (this.o.headersFeed) {
526
- void this.image.start();
527
- this.feeds.listen(this.o.headersFeed, (raws) => { void this.image.headers(raws); });
528
- }
545
+ const started = this.image.start();
546
+ void started.then(() => this.syncHolders()).catch((e) => this.say("router", `host chain: not filled from the image chain: ${e.message}`));
547
+ // With a feed, its history makes the first sync; the tracker follows the mesh once that is done.
548
+ if (this.o.teranode)
549
+ void started.then(() => this.startTracker()).catch((e) => this.say("router", `headers: the Teranode tracker not started: ${e.message}`));
550
+ }
551
+ else if (this.o.teranode) {
552
+ // #156: the tracker is the source: its first sync (a hub's, when the network has one) before any instance steps.
553
+ await this.startTracker().catch((e) => this.say("router", `headers: the Teranode tracker not started: ${e.message}`));
554
+ if (this.tracker?.o.hub)
555
+ await this.tracker.ready;
556
+ }
557
+ // #156: a host chain that is empty (or behind) filled from the image chain, before any instance is hydrated.
558
+ await this.syncHolders().catch((e) => this.say("router", `host chain: not filled from the image chain: ${e.message}`));
559
+ // #138: the stored liveness beats older than the longest window (a day) go before any instance reloads them.
560
+ this.o.db.pruneBeats(stampMs(this.now()) - LIVENESS_MAX_MS);
529
561
  for (const row of this.o.db.list("enabled")) {
530
562
  try {
531
563
  await this.hydrate(row.handle);
@@ -588,7 +620,8 @@ export class Router {
588
620
  this.control = undefined;
589
621
  this.providers.stop();
590
622
  this.events.stop();
591
- await Promise.all([this.cron.stop(), this.feeds.stop(), this.arc?.stop(), this.p2p?.stop()]);
623
+ await Promise.all([this.cron.stop(), this.feeds.stop(), this.arc?.stop(), this.tracker?.stop()]);
624
+ await Promise.all([this.p2p?.stop(), this.hostP2p !== this.p2p ? this.hostP2p?.stop() : undefined]); // #156: the host node with them
592
625
  // A kernel still hydrating: its load finishes (or fails) first, then it is stopped with the rest.
593
626
  await Promise.all([...this.loading.values()].map((p) => p.catch(() => { }))); // a failed load was reported to whoever asked for it
594
627
  await Promise.all([...this.loaded.values()].map((l) => l.kernel.stop()));
@@ -813,6 +846,128 @@ export class Router {
813
846
  return next;
814
847
  }
815
848
  // ---------------------------------------------------------------- the host chain (#156)
849
+ /** #156: the key the host's headers arrive under (feeds.ts): the feed's URL, else the tracker's own. */
850
+ headersKey() {
851
+ return this.o.headersFeed ?? (this.o.teranode ? `teranode:${this.chainNetwork() ?? "main"}` : undefined);
852
+ }
853
+ /** #156: the Teranode tracker started: its node on the mesh, its first sync begun (`tracker.ready`). */
854
+ async startTracker() {
855
+ const t = this.o.teranode;
856
+ if (!t || this.tracker || this.stopped)
857
+ return;
858
+ const key = this.headersKey();
859
+ // #156: no instance libp2p (no peer keys: a test's host): the host's libp2p for the tracker alone.
860
+ this.hostP2p ??= new P2PHost({
861
+ host: this.p2pConfig, ...(this.o.hostPeerKey ? { hostKey: this.o.hostPeerKey } : {}), discoveryMs: this.o.libp2pDiscoveryMs,
862
+ keyOf: () => { throw new Error("no instance libp2p on this host"); },
863
+ inbound: async () => ({ verdict: "ignore" }),
864
+ log: (s, l) => this.say(s, l),
865
+ });
866
+ this.tracker = new TeranodeTracker({
867
+ network: this.chainNetwork() ?? "main", ...t, p2p: this.hostP2p,
868
+ chain: {
869
+ tip: () => this.image.tip(),
870
+ find: (hash) => this.image.find(hash),
871
+ // A long run (a first sync, a deep catch-up): into the image and the host chains, then each running instance its chain event; a short one as the feed's headers go.
872
+ take: async (at, raws) => {
873
+ if (raws.length <= 4 * BACKFILL_RUN)
874
+ return await this.feeds.inject(key, raws, at);
875
+ await this.takeHeaders(raws, at);
876
+ for (const h of [...this.loaded.keys()])
877
+ void this.chainBirth(h).catch((e) => this.say(h, `host chain: the chain event not sent: ${e.message}`));
878
+ },
879
+ },
880
+ log: (l) => this.say("router", l),
881
+ });
882
+ this.image.ready = this.tracker.ready;
883
+ await this.tracker.start();
884
+ this.image.ready = this.tracker.ready;
885
+ }
886
+ /**
887
+ * #156: a run of headers from either source (the feed's, `at` undefined: image-chain.ts places
888
+ * each; the tracker's, at height `at`): into the image's chain part, then every host chain synced
889
+ * from it. One run at a time.
890
+ */
891
+ takeHeaders(raws, at) {
892
+ const p = this.intake.then(async () => {
893
+ if (at === undefined)
894
+ await this.image.headers(raws);
895
+ else
896
+ await this.image.put(at, raws);
897
+ await this.syncHolders(at);
898
+ });
899
+ this.intake = p.catch(() => { });
900
+ return p;
901
+ }
902
+ /** #156: the host chains this host keeps once per database (its SQLite one, its RocksDB one, and those its rows name). */
903
+ hostHolders() {
904
+ const sq = new Set(), rocks = new Set();
905
+ if (this.o.sqliteStore)
906
+ sq.add(this.o.sqliteStore);
907
+ if (this.o.rocksStore)
908
+ rocks.add(this.o.rocksStore);
909
+ for (const row of this.o.db.list("enabled")) {
910
+ const spec = storeSpec(row);
911
+ const r = parseRocksSpec(spec), q = parseSqliteSpec(spec);
912
+ if (r)
913
+ rocks.add(r.dir);
914
+ if (q)
915
+ sq.add(q.db);
916
+ }
917
+ return [
918
+ ...[...sq].map((db) => { const w = this.wasmHost(db); return { name: db, tip: () => w.chainTip(), headers: (raws) => w.chainHeaders(raws), importChain: w.importChain.bind(w) }; }),
919
+ ...[...rocks].map((dir) => { const k = this.kernelHost(dir); return { name: dir, tip: () => k.chainTip(), headers: (raws) => k.chainHeaders(raws) }; }),
920
+ ];
921
+ }
922
+ /** #156: each host chain this host keeps (by database) and its tip (undefined: empty). */
923
+ async hostChainTips() {
924
+ return await Promise.all(this.hostHolders().map(async (h) => ({ name: h.name, tip: await h.tip() })));
925
+ }
926
+ /** #156: every host chain brought to the image chain's tip (from `from` when a run replaced from there). */
927
+ async syncHolders(from) {
928
+ for (const h of this.hostHolders())
929
+ await this.syncHolder(h.name, h, from).catch((e) => this.say("router", `host chain ${h.name}: not synced from the image chain: ${e.message}`));
930
+ }
931
+ /**
932
+ * #156: a host chain brought to the image chain: an EMPTY one imports the image's chain part
933
+ * (`chain-import`, as an instance born with it would bring it: one frame) where its kernel can
934
+ * read it (the wasm kernel's SQLite database), else takes the image's headers from genesis in
935
+ * `headers` runs; one behind takes the headers it lacks; one the image does not hold at its
936
+ * height (a fork) from a little below it, or from `from`.
937
+ */
938
+ async syncHolder(name, h, from) {
939
+ const top = this.image.height();
940
+ if (top < 0)
941
+ return;
942
+ const tip = await h.tip();
943
+ const t0 = Date.now();
944
+ if (!tip && h.importChain) {
945
+ const part = this.image.chainPart();
946
+ if (part) {
947
+ const got = await h.importChain(part.chain, part.objects);
948
+ this.say("router", `host chain: ${got.added ?? 0} headers imported from the image chain (tip ${got.height}, ${Date.now() - t0} ms)`);
949
+ return;
950
+ }
951
+ }
952
+ let start = 0;
953
+ if (tip) {
954
+ const held = this.image.headerAt(tip.height);
955
+ start = held && hashOf(held) === tip.hash ? tip.height + 1 : Math.max(0, tip.height - 6);
956
+ if (from !== undefined)
957
+ start = Math.min(start, from);
958
+ }
959
+ if (start > top)
960
+ return;
961
+ let n = 0;
962
+ for (const run of this.image.runs(start, BACKFILL_RUN)) {
963
+ await h.headers(run);
964
+ n += run.length;
965
+ if (top - start > 10 * BACKFILL_RUN && n % (50 * BACKFILL_RUN) < BACKFILL_RUN)
966
+ this.say("router", `host chain: ${start + n}/${top + 1} from the image chain`);
967
+ }
968
+ if (n > BACKFILL_RUN)
969
+ this.say("router", `host chain: ${n} headers from the image chain (to ${top}, ${Date.now() - t0} ms)`);
970
+ }
816
971
  /**
817
972
  * The network the host chain is on: SKEIN_NETWORK (`network`), else the genesis defaults'
818
973
  * `walletNetwork` (a test host's), else the kernel's default (main).
@@ -953,6 +1108,9 @@ export class Router {
953
1108
  await kernel.start();
954
1109
  // #156: after the start (the genesis processed: its tree is the head `main`), before any event is admitted.
955
1110
  await this.chainBootstrap(handle, kernel);
1111
+ // #156: a serve kernel's own host chain still empty (born before the image held a chain): from the image chain now.
1112
+ if (kernel instanceof Kernel)
1113
+ await this.syncHolder(handle, { tip: () => kernel.chainTip(), headers: (raws) => kernel.chainHeaders(raws) }).catch((e) => this.say(handle, `host chain: not synced from the image chain: ${e.message}`));
956
1114
  await kernel.running(identity);
957
1115
  void kernel.idle().catch(() => { }); // busy until what start resumed is done; a kernel that exits meanwhile is reported by its exit
958
1116
  if (!row.identity)
@@ -1186,7 +1344,14 @@ export class Router {
1186
1344
  // operator's key, `appConfig` merged.
1187
1345
  // #162: any instance's app config at birth (a create's `config`), the host skein's owner at birth.
1188
1346
  // #164: an owned instance's genesis names `owner` root (boot.ts then seeds no claim route), as the host skein's does.
1189
- await this.bootRow(handle, await this.namedImage(o.image, o.host), { image: true, manager: o.host, ...(owned ? { owner } : {}), ...(o.appConfig ? { appConfig: o.appConfig } : {}) });
1347
+ // The image read from its root to the last object walked: a header taken meanwhile keeps the objects it replaces.
1348
+ const release = this.image.hold();
1349
+ try {
1350
+ await this.bootRow(handle, await this.namedImage(o.image, o.host), { image: true, manager: o.host, ...(owned ? { owner } : {}), ...(o.appConfig ? { appConfig: o.appConfig } : {}) });
1351
+ }
1352
+ finally {
1353
+ release();
1354
+ }
1190
1355
  const l = await this.hydrate(handle);
1191
1356
  if (claim) {
1192
1357
  // #127: the owner's own message, forwarded as signed; the host signs nothing for the owner. #143: one the door
@@ -1397,7 +1562,7 @@ export class Router {
1397
1562
  * handled and unhandled) or stopped. A failure is logged, not fatal.
1398
1563
  */
1399
1564
  async syncDispatch(l, first = false) {
1400
- if ((!this.p2p && !this.o.headersFeed && !this.events.open(l.row.handle)) || l.kernel.gone)
1565
+ if ((!this.p2p && !this.headersKey() && !this.events.open(l.row.handle)) || l.kernel.gone)
1401
1566
  return;
1402
1567
  const d = await l.kernel.dispatch().catch((e) => { this.say(l.row.handle, `the dispatch table not read: ${e.message}`); return undefined; });
1403
1568
  if (d === undefined)
@@ -1718,7 +1883,7 @@ export class Router {
1718
1883
  * else not. One line when that changes.
1719
1884
  */
1720
1885
  followHeaders(handle, rows) {
1721
- const url = this.o.headersFeed;
1886
+ const url = this.headersKey();
1722
1887
  if (!url)
1723
1888
  return;
1724
1889
  const want = this.o.db.get(handle)?.status === "enabled" && rows.some((r) => r.address === DEFAULT_BOX && takesEvent(r, DEFAULT_BOX));
@@ -1747,7 +1912,7 @@ export class Router {
1747
1912
  }
1748
1913
  /** Follow the host's headers feed for an instance whose status changed (#102): its table read again. */
1749
1914
  async refollowHeaders(handle) {
1750
- if (!this.o.headersFeed)
1915
+ if (!this.headersKey())
1751
1916
  return;
1752
1917
  const l = this.loaded.get(handle);
1753
1918
  const d = l && !l.kernel.gone ? await l.kernel.dispatch().catch((e) => { this.say(handle, `the dispatch table not read: ${e.message}`); return undefined; }) : undefined;
@@ -2060,6 +2225,9 @@ export class Router {
2060
2225
  return await this.held(this.forward(o.handle, url.pathname, url, req));
2061
2226
  }
2062
2227
  const serve = async (x) => {
2228
+ // The instance's own card, the host's read (no program): whether anyone holds root yet.
2229
+ if (req.method === "GET" && x.route === "/.well-known/skein")
2230
+ return await this.instanceCard(x.handle);
2063
2231
  // #138: the liveness tool's read, the host's own (no program).
2064
2232
  const live = req.method === "GET" ? /^\/([^/]+)\/\.live\/([^/]+)$/.exec(x.route) : null;
2065
2233
  if (live)
@@ -2067,6 +2235,12 @@ export class Router {
2067
2235
  // #148: a stream, served on a socket only (handler).
2068
2236
  if (req.method === "GET" && EVENTS_ROUTE.test(x.route))
2069
2237
  return json(400, { status: "error", code: "ERR_BAD_REQUEST", description: "an event stream (text/event-stream) is served on a connection of its own, not in process" });
2238
+ // An application host's own reads (appRead): before the instance's program; undefined falls through.
2239
+ if (req.method === "GET" && this.o.appRead) {
2240
+ const a = await this.o.appRead(x.handle, x.route, url, req);
2241
+ if (a)
2242
+ return a;
2243
+ }
2070
2244
  return await this.held(this.forward(x.handle, x.route, url, req));
2071
2245
  };
2072
2246
  if (t)
@@ -2074,6 +2248,16 @@ export class Router {
2074
2248
  const path = url.pathname;
2075
2249
  if (req.method === "GET" && path === "/skeins")
2076
2250
  return await this.skeinsHeldBy(url.searchParams.get("key") ?? "");
2251
+ if (path === `${ARC_ROUTE}/callback`)
2252
+ return await this.arcRequest(req, path);
2253
+ // SKEIN_ROOT_INSTANCE: a hostname that is no instance's serves the root instance (the standalone's one app at
2254
+ // localhost:<port>/) — every path but the router's own three above (.well-known/skein-host, /skeins, the ARC
2255
+ // callback). The BRC-169 set below is the host skein's only when no root instance takes the hostname: with one,
2256
+ // POST /.well-known/auth must shake hands with the root instance (its sessions are its own, #68) and
2257
+ // GET /manifest.json is the app's (its grouped permissions), as at an instance's own hostname.
2258
+ const root = this.o.rootInstance;
2259
+ if (root && this.o.db.get(root)?.status === "enabled")
2260
+ return await serve({ handle: root, route: path });
2077
2261
  // #113: the host's own origin's BRC-169 requests are the host skein's (its onboarding app's routes).
2078
2262
  const route = DISCOVERY[`${req.method} ${path}`] ?? (req.method === "GET" && path.startsWith(PAYMAIL_PREFIX) ? `/${ONBOARD_APP}${path}` : undefined);
2079
2263
  if (route) {
@@ -2085,13 +2269,6 @@ export class Router {
2085
2269
  return f;
2086
2270
  return json(404, { status: "error", code: "ERR_NOT_FOUND", description: "this host has no host skein (skein-host init): no BRC-169 here" });
2087
2271
  }
2088
- if (path === `${ARC_ROUTE}/callback`)
2089
- return await this.arcRequest(req, path);
2090
- // SKEIN_ROOT_INSTANCE: a hostname that is no instance's serves the root instance (the standalone's one app at localhost:<port>/),
2091
- // after the host's own paths above.
2092
- const root = this.o.rootInstance;
2093
- if (root && this.o.db.get(root)?.status === "enabled")
2094
- return await serve({ handle: root, route: path });
2095
2272
  return json(404, { status: "error", code: "ERR_NOT_FOUND", description: "no instance here: an instance is at http://<handle>.localhost:<port>/ or /@<handle>/" });
2096
2273
  }
2097
2274
  /**
@@ -2183,24 +2360,41 @@ export class Router {
2183
2360
  return json(400, { status: "error", code: "ERR_BAD_REQUEST", description: "key: an identity key (33 bytes, hex)" });
2184
2361
  const out = [];
2185
2362
  for (const row of this.o.db.list("enabled")) {
2186
- if (row.kind === "mailbox" || !row.identity || !storeExists(storeSpec(row)))
2363
+ if (row.kind === "mailbox" || !row.identity)
2187
2364
  continue;
2188
- let roots;
2189
- const s = openStoreFile(storeSpec(row), { readOnly: true });
2190
- try {
2191
- roots = await rootOf(s);
2192
- }
2193
- catch {
2194
- roots = [];
2195
- }
2196
- finally {
2197
- s.close();
2198
- }
2199
- if (roots.includes(key))
2365
+ if ((await this.rootsOf(row)).includes(key))
2200
2366
  out.push({ handle: row.handle, identity: row.identity, url: this.originOf(row.handle) });
2201
2367
  }
2202
2368
  return json(200, out);
2203
2369
  }
2370
+ /**
2371
+ * GET /.well-known/skein at an instance's origin (its hostname, `/@<handle>/`, or the root instance's
2372
+ * hostname): `{handle, identity, origin, claimed}` — `claimed` whether its head `grants` names a root
2373
+ * holder (an image instance nobody has claimed: false). Read from its store read-only, no kernel, as
2374
+ * /skeins does. Unsigned, anyone; 404 for no instance. "Am I root" is /skeins?key=<identity>.
2375
+ */
2376
+ async instanceCard(handle) {
2377
+ const row = this.o.db.get(handle);
2378
+ if (!row?.identity)
2379
+ return json(404, { status: "error", code: "ERR_NOT_FOUND", description: `no instance ${handle} here` });
2380
+ return json(200, { handle, identity: row.identity, origin: this.originOf(handle), claimed: (await this.rootsOf(row)).length > 0 });
2381
+ }
2382
+ /** The keys an instance's head `grants` names root (#143), its store read read-only; none when it has no store yet. */
2383
+ async rootsOf(row) {
2384
+ const spec = storeSpec(row);
2385
+ if (!storeExists(spec))
2386
+ return [];
2387
+ const s = openStoreFile(spec, { readOnly: true });
2388
+ try {
2389
+ return await rootOf(s);
2390
+ }
2391
+ catch {
2392
+ return [];
2393
+ }
2394
+ finally {
2395
+ s.close();
2396
+ }
2397
+ }
2204
2398
  /** A forwarded request, held among the in-flight ones (shutdown answers them 503). */
2205
2399
  async held(p) {
2206
2400
  this.inflight.add(p);
@@ -2300,7 +2494,28 @@ export class Router {
2300
2494
  req.on("end", () => {
2301
2495
  const url = `http://${req.headers.host ?? `127.0.0.1:${this.port}`}${req.url ?? "/"}`;
2302
2496
  this.dispatch({ method: req.method ?? "GET", url, headers: headerMap(req.headers), body: new Uint8Array(Buffer.concat(chunks)) })
2303
- .then((r) => { res.writeHead(r.status, { ...cors, ...r.headers, "content-length": String(r.body.length) }); res.end(r.body); })
2497
+ .then((r) => {
2498
+ if (!r.stream) {
2499
+ res.writeHead(r.status, { ...cors, ...r.headers, "content-length": String(r.body.length) });
2500
+ res.end(r.body);
2501
+ return;
2502
+ }
2503
+ // A response held open (an appRead's event stream): written as it comes, until either side ends it.
2504
+ if (res.destroyed)
2505
+ return;
2506
+ res.writeHead(r.status, { ...cors, ...r.headers });
2507
+ res.flushHeaders();
2508
+ let forget;
2509
+ let gone = false;
2510
+ const leave = () => { if (gone)
2511
+ return; gone = true; forget?.(); };
2512
+ res.on("close", leave);
2513
+ forget = r.stream((chunk) => { if (!gone)
2514
+ res.write(chunk); }, () => { if (!gone)
2515
+ res.end(); });
2516
+ if (gone)
2517
+ forget();
2518
+ })
2304
2519
  .catch((e) => { if (!res.headersSent) {
2305
2520
  res.writeHead(500, { ...cors, "content-type": "application/json" });
2306
2521
  res.end(JSON.stringify({ status: "error", code: "ERR_INTERNAL", description: e.message }));
@@ -32,6 +32,12 @@ export declare class Signer {
32
32
  * (p2p.ts peerIdOf).
33
33
  */
34
34
  peerKey(id: string): PrivateKey;
35
+ /**
36
+ * The host node's peer key (#156): the key's child [2, "skein instance"], key ID `libp2p`, self —
37
+ * in one-key mode every instance's peer key (peerKey), so they share the host node; in master-key
38
+ * mode the same rule over the master (its own node beside the instances').
39
+ */
40
+ hostPeerKey(): PrivateKey;
35
41
  /** The instance's identity key (hex): what `skein-host add` records. */
36
42
  identity(id: string): string;
37
43
  /** The instance's signer: a ProtoWallet over its root key (a WalletInterface whose actions reject). */
@@ -137,6 +137,14 @@ export class Signer {
137
137
  return this.deriver.derivePrivateKey(INSTANCE_PROTOCOL, "libp2p", "self");
138
138
  return new KeyDeriver(this.instanceKey(id)).derivePrivateKey(INSTANCE_PROTOCOL, `libp2p:${id}`, "self");
139
139
  }
140
+ /**
141
+ * The host node's peer key (#156): the key's child [2, "skein instance"], key ID `libp2p`, self —
142
+ * in one-key mode every instance's peer key (peerKey), so they share the host node; in master-key
143
+ * mode the same rule over the master (its own node beside the instances').
144
+ */
145
+ hostPeerKey() {
146
+ return this.deriver.derivePrivateKey(INSTANCE_PROTOCOL, "libp2p", "self");
147
+ }
140
148
  /** The instance's identity key (hex): what `skein-host add` records. */
141
149
  identity(id) {
142
150
  return this.instanceKey(id).toPublicKey().toString();