@dcasegr/skein 0.0.0-stage → 0.1.1
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 +522 -2
- package/bin/skein-host.js +22 -0
- package/bin/skein.js +8 -0
- package/dist/client/admin-cli.d.ts +10 -0
- package/dist/client/admin-cli.js +370 -0
- package/dist/client/admin.d.ts +139 -0
- package/dist/client/admin.js +256 -0
- package/dist/client/bundle.d.ts +9 -0
- package/dist/client/bundle.js +34 -0
- package/dist/client/cli.d.ts +44 -0
- package/dist/client/cli.js +306 -0
- package/dist/client/client.d.ts +161 -0
- package/dist/client/client.js +237 -0
- package/dist/client/config.d.ts +18 -0
- package/dist/client/config.js +31 -0
- package/dist/client/conversation.d.ts +62 -0
- package/dist/client/conversation.js +113 -0
- package/dist/client/raw.d.ts +94 -0
- package/dist/client/raw.js +154 -0
- package/dist/client/target.d.ts +34 -0
- package/dist/client/target.js +158 -0
- package/dist/dev/cli.d.ts +8 -0
- package/dist/dev/cli.js +248 -0
- package/dist/dev/scan.d.ts +15 -0
- package/dist/dev/scan.js +173 -0
- package/dist/envelope-cbor.d.ts +110 -0
- package/dist/envelope-cbor.js +160 -0
- package/dist/envelope.d.ts +38 -0
- package/dist/envelope.js +101 -0
- package/dist/host/arc.d.ts +131 -0
- package/dist/host/arc.js +425 -0
- package/dist/host/billing.d.ts +102 -0
- package/dist/host/billing.js +167 -0
- package/dist/host/boot.d.ts +246 -0
- package/dist/host/boot.js +643 -0
- package/dist/host/cli.d.ts +57 -0
- package/dist/host/cli.js +1253 -0
- package/dist/host/clock.d.ts +3 -0
- package/dist/host/clock.js +8 -0
- package/dist/host/control.d.ts +45 -0
- package/dist/host/control.js +170 -0
- package/dist/host/cron.d.ts +99 -0
- package/dist/host/cron.js +196 -0
- package/dist/host/deliver.d.ts +53 -0
- package/dist/host/deliver.js +251 -0
- package/dist/host/deploy.d.ts +29 -0
- package/dist/host/deploy.js +55 -0
- package/dist/host/door-answer.d.ts +24 -0
- package/dist/host/door-answer.js +21 -0
- package/dist/host/events.d.ts +89 -0
- package/dist/host/events.js +222 -0
- package/dist/host/fake-arcade.d.ts +40 -0
- package/dist/host/fake-arcade.js +149 -0
- package/dist/host/fake-discovery.d.ts +4 -0
- package/dist/host/fake-discovery.js +58 -0
- package/dist/host/feeds.d.ts +127 -0
- package/dist/host/feeds.js +422 -0
- package/dist/host/frontdoor.d.ts +92 -0
- package/dist/host/frontdoor.js +172 -0
- package/dist/host/genesis.d.ts +259 -0
- package/dist/host/genesis.js +353 -0
- package/dist/host/handles.d.ts +40 -0
- package/dist/host/handles.js +88 -0
- package/dist/host/hostenv.d.ts +30 -0
- package/dist/host/hostenv.js +83 -0
- package/dist/host/image-chain.d.ts +127 -0
- package/dist/host/image-chain.js +440 -0
- package/dist/host/install.d.ts +22 -0
- package/dist/host/install.js +139 -0
- package/dist/host/instances.d.ts +227 -0
- package/dist/host/instances.js +430 -0
- package/dist/host/kernel.d.ts +246 -0
- package/dist/host/kernel.js +603 -0
- package/dist/host/liveness.d.ts +65 -0
- package/dist/host/liveness.js +155 -0
- package/dist/host/manifest.d.ts +160 -0
- package/dist/host/manifest.js +597 -0
- package/dist/host/p2p.d.ts +337 -0
- package/dist/host/p2p.js +1002 -0
- package/dist/host/packet.d.ts +121 -0
- package/dist/host/packet.js +562 -0
- package/dist/host/plan.d.ts +154 -0
- package/dist/host/plan.js +418 -0
- package/dist/host/providers.d.ts +238 -0
- package/dist/host/providers.js +565 -0
- package/dist/host/roster.d.ts +72 -0
- package/dist/host/roster.js +172 -0
- package/dist/host/router.d.ts +773 -0
- package/dist/host/router.js +2389 -0
- package/dist/host/signer.d.ts +49 -0
- package/dist/host/signer.js +167 -0
- package/dist/host/store-sqlite.d.ts +39 -0
- package/dist/host/store-sqlite.js +170 -0
- package/dist/host/stores.d.ts +43 -0
- package/dist/host/stores.js +342 -0
- package/dist/host/sync.d.ts +121 -0
- package/dist/host/sync.js +467 -0
- package/dist/host/testbeef.d.ts +21 -0
- package/dist/host/testbeef.js +37 -0
- package/dist/host/testhost.d.ts +149 -0
- package/dist/host/testhost.js +211 -0
- package/dist/host/vcdiff.d.ts +9 -0
- package/dist/host/vcdiff.js +210 -0
- package/dist/host/wasm-kernel.d.ts +87 -0
- package/dist/host/wasm-kernel.js +296 -0
- package/dist/host/wasm-worker.d.ts +1 -0
- package/dist/host/wasm-worker.js +186 -0
- package/dist/peers/cron.d.ts +33 -0
- package/dist/peers/cron.js +75 -0
- package/dist/peers/infer-main.d.ts +2 -0
- package/dist/peers/infer-main.js +74 -0
- package/dist/peers/infer.d.ts +163 -0
- package/dist/peers/infer.js +367 -0
- package/dist/runtime/beef.d.ts +28 -0
- package/dist/runtime/beef.js +107 -0
- package/dist/runtime/bitcoin.d.ts +36 -0
- package/dist/runtime/bitcoin.js +141 -0
- package/dist/runtime/cid.d.ts +14 -0
- package/dist/runtime/cid.js +51 -0
- package/dist/runtime/dispatch.d.ts +92 -0
- package/dist/runtime/dispatch.js +142 -0
- package/dist/runtime/envelope.d.ts +60 -0
- package/dist/runtime/envelope.js +101 -0
- package/dist/runtime/heads.d.ts +25 -0
- package/dist/runtime/heads.js +37 -0
- package/dist/runtime/identity.d.ts +28 -0
- package/dist/runtime/identity.js +67 -0
- package/dist/runtime/index-store.d.ts +152 -0
- package/dist/runtime/index-store.js +715 -0
- package/dist/runtime/log.d.ts +62 -0
- package/dist/runtime/log.js +104 -0
- package/dist/runtime/programs.d.ts +30 -0
- package/dist/runtime/programs.js +61 -0
- package/dist/runtime/records.d.ts +135 -0
- package/dist/runtime/records.js +149 -0
- package/dist/runtime/sqlite.d.ts +33 -0
- package/dist/runtime/sqlite.js +604 -0
- package/dist/runtime/store.d.ts +141 -0
- package/dist/runtime/store.js +24 -0
- package/dist/runtime/syscalls.d.ts +9 -0
- package/dist/runtime/syscalls.js +41 -0
- package/dist/runtime/tree.d.ts +60 -0
- package/dist/runtime/tree.js +196 -0
- package/dist/runtime/types.d.ts +100 -0
- package/dist/runtime/types.js +7 -0
- package/dist/testapps.d.ts +101 -0
- package/dist/testapps.js +275 -0
- package/dist/testkit.d.ts +25 -0
- package/dist/testkit.js +48 -0
- package/dist/wallet.d.ts +35 -0
- package/dist/wallet.js +87 -0
- package/images/README.md +68 -0
- package/images/default/README.md +38 -0
- package/images/default/bin/frontdoor.cid +1 -0
- package/images/default/bin/frontdoor.json +5 -0
- package/images/default/bin/messagebox.cid +1 -0
- package/images/default/bin/messagebox.json +8 -0
- package/images/default/bin/wallet.cid +1 -0
- package/images/default/bin/wallet.json +14 -0
- package/images/default/etc/config.json +1 -0
- package/images/default/etc/dispatch.json +82 -0
- package/images/host/etc/apps.json +8 -0
- package/kernel-zig/README.md +1247 -0
- package/kernel-zig/web/client.js +65 -0
- package/kernel-zig/web/kernel.d.ts +59 -0
- package/kernel-zig/web/kernel.js +329 -0
- package/kernel-zig/web/store.js +67 -0
- package/kernel-zig/web/worker.js +79 -0
- package/kernel-zig/zig-out/web/skein-kernel.wasm +0 -0
- package/package.json +77 -4
- package/wasm/README.md +35 -0
- package/wasm/frontdoor.wasm +0 -0
- package/wasm/messagebox.wasm +0 -0
- package/wasm/resolve.wasm +0 -0
- package/wasm/wallet.wasm +0 -0
- package/wasm/wire-probe.wasm +0 -0
|
@@ -0,0 +1,2389 @@
|
|
|
1
|
+
// The host as a reverse proxy (#40): each instance is an HTTP server — its
|
|
2
|
+
// front door (programs/frontdoor) — at an origin of its own, and the router
|
|
3
|
+
// only picks the instance a request is for and forwards it. Routing comes
|
|
4
|
+
// before authentication, because a BRC-104 handshake does not name its
|
|
5
|
+
// recipient: the URL is the recipient. The router holds no sessions and no
|
|
6
|
+
// mailbox: it keeps the hostname → instance map (host.db), the kernels it has
|
|
7
|
+
// hydrated, the providers (the waker and the cron provider among them), the
|
|
8
|
+
// feeds, the broadcaster, the fuel ledger.
|
|
9
|
+
//
|
|
10
|
+
// http://<handle>.localhost:<port>/… an instance's origin (the Host header). The stock AuthFetch keeps one
|
|
11
|
+
// session per origin, and shakes hands at <origin>/.well-known/auth: this
|
|
12
|
+
// is the form it is served by. (SKEIN_INSTANCE_ORIGIN: another template.)
|
|
13
|
+
// http://<host>:<port>/@<handle>/… the same instance for our own clients (a dev form): the router strips
|
|
14
|
+
// the prefix for the routes; the client signs the path it sent, and its
|
|
15
|
+
// handshake goes under the prefix (src/client/raw.ts).
|
|
16
|
+
// The host's own origin (a host name whose first label is no instance) is the host skein's BRC-169 server
|
|
17
|
+
// (#113): these requests go to the host skein, as any request goes to its instance, under the onboarding
|
|
18
|
+
// app's routes (shruggr/skein-onboard: it registers, certifies through the certifier provider, records and
|
|
19
|
+
// answers; DISCOVERY below):
|
|
20
|
+
// GET /manifest.json → /onboard/manifest.json BRC-169 §5.1
|
|
21
|
+
// GET /.well-known/metanet-handles/resolve → /onboard/resolve BRC-169 §5.2
|
|
22
|
+
// GET /.well-known/metanet-handles/search → /onboard/search BRC-169 §5.6
|
|
23
|
+
// POST /.well-known/auth → /.well-known/auth the BRC-103 handshake (#135: a registration is signed)
|
|
24
|
+
// POST /account/register → /onboard/register a mailbox instance and its certificate (signed)
|
|
25
|
+
// POST /account/profile → /onboard/profile a handle holder's signed profile
|
|
26
|
+
// GET /bsvalias/id/<handle>[@<domain>] → /onboard/bsvalias/id/… the paymail PKI, from the same records
|
|
27
|
+
// With no host skein: 404 (tests may answer them with a fixture: `discovery`).
|
|
28
|
+
// GET /skeins?key=<hex> at the host's own origin: the skeins here whose head `grants` names the key
|
|
29
|
+
// among root's holders, [{handle, identity, url}] (no wallet token tracks them)
|
|
30
|
+
// GET /.well-known/skein-host at every host name, an instance's too (#103): {origin, domain} — this
|
|
31
|
+
// router's origin and the handle domain (the onboarding app's
|
|
32
|
+
// config.onboard.domain), for a page an instance serves (the management
|
|
33
|
+
// site) to find the manifest and register
|
|
34
|
+
// GET /<app>/.live/<topic> at an instance's origin (#138): the liveness tool's beats for that app's
|
|
35
|
+
// liveness on the topic (liveness.ts), served by the host, no program:
|
|
36
|
+
// JSON [{sender: <identity key, hex>, at: <ms>, body: <base64>, from: <peer
|
|
37
|
+
// ID>}], newest first; 404 when the app keeps no liveness for the topic.
|
|
38
|
+
// Unsigned, nothing logged; metered as a read (billing.ts)
|
|
39
|
+
// GET /<app>/.events?event=<name>&topic=<t>[&topic=<t2>…]
|
|
40
|
+
// at an instance's origin (#148): a Server-Sent Events stream of the app's
|
|
41
|
+
// emitted events of that name whose `topic` is one of those (events.ts),
|
|
42
|
+
// served by the host, no program: `id` the event's place in the log
|
|
43
|
+
// (<n>.<seq>.<i>.<thread>), `event` its name, `data` the record as DAG-JSON.
|
|
44
|
+
// First the last one per topic (or, with Last-Event-ID, every one after
|
|
45
|
+
// it, folded from the log), then each as handed over (its place in the
|
|
46
|
+
// kernel's notice: no log read). 400 without
|
|
47
|
+
// `event` or a `topic`; 404 when the app is not installed. Unsigned,
|
|
48
|
+
// nothing logged; each message metered as a read. Closed at the app's
|
|
49
|
+
// uninstall and when the instance stops
|
|
50
|
+
// POST /arc/callback Arcade's webhook (Bearer: the host token)
|
|
51
|
+
//
|
|
52
|
+
// The broadcaster (#58, #65, arc.ts) takes the instances' broadcast events
|
|
53
|
+
// (a durable queue in host.db, retries, one Arcade session) and routes what
|
|
54
|
+
// Arcade says of a transaction to every instance whose state holds it (a
|
|
55
|
+
// `has` of its CID, cached): a proof as an unsigned event in box `chain`,
|
|
56
|
+
// any other status as a signed message from the status provider.
|
|
57
|
+
//
|
|
58
|
+
// A request for an instance goes to one of its two doors (#135, frontdoor.ts
|
|
59
|
+
// serveHttp): a read — a path the instance's reads head names — is served by
|
|
60
|
+
// a `call` (no entry; its fuel metered); a signed request for a message route
|
|
61
|
+
// (#68, #66) is appended as received — one `request` entry, verified by
|
|
62
|
+
// nothing here — and the client's connection held until the thread the kernel
|
|
63
|
+
// steps the front door on comes to rest; its answer, signed on the session
|
|
64
|
+
// inside, is the response. Past `answerWaitMs`, or at shutdown, 503 +
|
|
65
|
+
// Retry-After. An unsigned request goes through the door only at an open row
|
|
66
|
+
// whose filter validates the payload (#135: signed or validated; answered
|
|
67
|
+
// plain); otherwise for a message route it is 401, for nothing 404, answered
|
|
68
|
+
// without an entry.
|
|
69
|
+
//
|
|
70
|
+
// What the instances send out goes through the host's providers (#70,
|
|
71
|
+
// providers.ts): each kernel hands over the messages its steps emitted
|
|
72
|
+
// (unsigned, #126 step 4) to a `local` provider or a libp2p recipient, and the provider's
|
|
73
|
+
// answer comes back as a signed message, a `local` request entry. The HTTP
|
|
74
|
+
// proxy (`fetch`) answers a URL of this host's own in process (the same
|
|
75
|
+
// path, no socket) and sends any other out; the waker keeps the instances'
|
|
76
|
+
// deadlines and their shells' sleeps (#69); the cron provider their
|
|
77
|
+
// schedules, each tick a signed message (#69, cron.ts; host.db
|
|
78
|
+
// `cron_schedule`); the status provider tells the instances that subscribe
|
|
79
|
+
// to it how their transactions stand (#65); the libp2p node is the
|
|
80
|
+
// instance's own (p2p.ts); the instance manager (#90) creates, starts and
|
|
81
|
+
// stops instances for the host skein alone (host.db `host_settings`:
|
|
82
|
+
// `skein-host init` made it; createInstance claims a new instance before
|
|
83
|
+
// its row is enabled, which publishes its hostname; image `mailbox`, #113, a
|
|
84
|
+
// mailbox instance for a registration); the certifier (#113) signs handle
|
|
85
|
+
// certificates for the host skein alone. Each genesis it writes names the providers' keys
|
|
86
|
+
// in its address book (`addressBook`). A kernel's broadcast events go to the
|
|
87
|
+
// broadcaster (#65).
|
|
88
|
+
//
|
|
89
|
+
// The libp2p host (#51, p2p.ts) is the router's too: one node per instance
|
|
90
|
+
// whose genesis declares `libp2p` or whose dispatch table has a libp2p row
|
|
91
|
+
// (#72, #77: a topic or a protocol), each topic message and stream
|
|
92
|
+
// frame one request entry (`p2pInbound`: the verdict or the frame's answer
|
|
93
|
+
// read off its thread and handed back to GossipSub or the stream). The node
|
|
94
|
+
// follows the dispatch table: once the kernel has processed what the host
|
|
95
|
+
// handed it (`settle`), the router reads the head (a kernel read), and when
|
|
96
|
+
// it moved, declares the instance's libp2p config again — an install's
|
|
97
|
+
// topics subscribed, an uninstall's unsubscribed, live (`syncDispatch`).
|
|
98
|
+
// The host's headers feed (#102, `headersFeed`, SKEIN_HEADERS_URL; feeds.ts)
|
|
99
|
+
// follows the table the same way: an enabled instance is subscribed while
|
|
100
|
+
// a row of it takes events in box `chain` (the chain app's), else not. The
|
|
101
|
+
// router itself listens to it too (#132, image-chain.ts): the default image
|
|
102
|
+
// carries the whole header chain and grows with every header the feed
|
|
103
|
+
// brings (filled from genesis at start from the feed's chaintracks history),
|
|
104
|
+
// and createInstance boots each new skein from the image as it stands.
|
|
105
|
+
//
|
|
106
|
+
// Nothing here is an instance's clock but its providers (#69): a schedule
|
|
107
|
+
// originates in a program's step, as a message to the cron provider; an
|
|
108
|
+
// idle-stopped instance is hydrated for a tick only if something in it
|
|
109
|
+
// subscribes the tick's box (from the cron provider, or from anyone).
|
|
110
|
+
import { createServer } from "node:http";
|
|
111
|
+
import { join } from "node:path";
|
|
112
|
+
import { KeyDeriver, P2PKH, PrivateKey, ProtoWallet, Transaction } from "@bsv/sdk";
|
|
113
|
+
import * as dagCbor from "@ipld/dag-cbor";
|
|
114
|
+
import { CID } from "multiformats/cid";
|
|
115
|
+
import { rootIdentity } from "../runtime/identity.js";
|
|
116
|
+
import { DEFAULTS, short, stampMs } from "../runtime/log.js";
|
|
117
|
+
import { ARC_ROUTE, Broadcaster } from "./arc.js";
|
|
118
|
+
import { DEFAULT_BOX, Feeds, feedsOf, txCid } from "./feeds.js";
|
|
119
|
+
import { Cron, dbSchedules } from "./cron.js";
|
|
120
|
+
import { currentDispatch, takesEvent, takesMail } from "../runtime/dispatch.js";
|
|
121
|
+
import { isSet, parseRocksSpec, parseSqliteSpec, storeSpec } from "../runtime/index-store.js";
|
|
122
|
+
import { WasmHost } from "./wasm-kernel.js";
|
|
123
|
+
import { openStoreFile, storeExists } from "./stores.js";
|
|
124
|
+
import { rootOf } from "./install.js";
|
|
125
|
+
import { OVERLAY_ROLE } from "./manifest.js";
|
|
126
|
+
import { appHead } from "./plan.js";
|
|
127
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
128
|
+
import { now as clockNow } from "./clock.js";
|
|
129
|
+
import { boot, bootStore, imageSource, isOverlayImage } from "./boot.js";
|
|
130
|
+
import { chainHeader, historyOf, ImageChain, skeinTip } from "./image-chain.js";
|
|
131
|
+
import { admitOf, BEEF_PROTOCOL, OverlaySync, SYNC_PROTOCOL } from "./sync.js";
|
|
132
|
+
import { ANSWER_WAIT_MS, appendRequest, cidOf, headerMap, serveHttp, unavailable } from "./frontdoor.js";
|
|
133
|
+
import { keyBytes, keyHex } from "./genesis.js";
|
|
134
|
+
import { HANDLE, pathOnly, TOKEN_HANDLE, topicsOf, validHandle } from "./instances.js";
|
|
135
|
+
import { signClaim } from "../client/raw.js";
|
|
136
|
+
import { DoorAnswered, Kernel, KernelHost } from "./kernel.js";
|
|
137
|
+
import { foldLiveness, livenessEvent, livenessOf } from "./liveness.js";
|
|
138
|
+
import { EventStreams, eventsQuery, sseMessage } from "./events.js";
|
|
139
|
+
import { beaconEvent, beaconsOf, DEFAULT_LISTEN, emittedEvents, foldBeacons, foldSubscriptions, libp2pConfig, P2PHost, subscribedTopics, subscriptionEvent, subscriptionsOf } from "./p2p.js";
|
|
140
|
+
import { peerIdFromMultihash, peerIdFromString } from "@libp2p/peer-id";
|
|
141
|
+
import { MESSAGE_KEY_ID, MESSAGE_PROTOCOL, Providers, tooLarge } from "./providers.js";
|
|
142
|
+
import { closeControl, listenControl } from "./control.js";
|
|
143
|
+
import { Deliverer } from "./deliver.js";
|
|
144
|
+
import { certify, RESOLVE_PATH, SEARCH_PATH } from "./handles.js";
|
|
145
|
+
import { allocation, BILLING_HEAD, closedBy, mismatch, Period, periodRecord, priceHost, stateOf, termsOf, tickBody } from "./billing.js";
|
|
146
|
+
import * as Digest from "multiformats/hashes/digest";
|
|
147
|
+
const json = (status, v) => ({ status, headers: { "content-type": "application/json" }, body: new TextEncoder().encode(JSON.stringify(v)) });
|
|
148
|
+
const KEY = /^0[23][0-9a-f]{64}$/;
|
|
149
|
+
/** #148: an app's event stream, at an instance's origin. */
|
|
150
|
+
const EVENTS_ROUTE = /^\/([^/]+)\/\.events$/;
|
|
151
|
+
/** The apps a dispatch table names (an app with no row left is uninstalled). */
|
|
152
|
+
const appsOf = (rows) => new Set(rows.map((r) => r.app).filter((x) => typeof x === "string"));
|
|
153
|
+
export { HANDLE };
|
|
154
|
+
/** 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). */
|
|
155
|
+
export const RESERVED_HANDLES = new Set(["id", "host"]);
|
|
156
|
+
/** The registration signature (#113: the onboarding app checks it): [2, "skein register"], key ID the username, counterparty anyone, over `register <username>@<domain>`. */
|
|
157
|
+
export const REGISTER_PROTOCOL = [2, "skein register"];
|
|
158
|
+
/** A handle's domain as a manager's `create` takes it: a host name, lower case. */
|
|
159
|
+
const DOMAIN = /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/;
|
|
160
|
+
/** The onboarding app (shruggr/skein-onboard, #90, #113): the host skein's app that serves BRC-169 on the host's own origin. */
|
|
161
|
+
export const ONBOARD_APP = "onboard";
|
|
162
|
+
/**
|
|
163
|
+
* The host's own origin's BRC-169 requests (#113) → the host skein's routes
|
|
164
|
+
* (the onboarding app's, under /onboard/): method + path → route.
|
|
165
|
+
*/
|
|
166
|
+
export const DISCOVERY = {
|
|
167
|
+
"GET /manifest.json": `/${ONBOARD_APP}/manifest.json`,
|
|
168
|
+
[`GET ${RESOLVE_PATH}`]: `/${ONBOARD_APP}/resolve`,
|
|
169
|
+
[`GET ${SEARCH_PATH}`]: `/${ONBOARD_APP}/search`,
|
|
170
|
+
"POST /account/register": `/${ONBOARD_APP}/register`,
|
|
171
|
+
// #135: a registration is a signed request: the stock client's handshake at the host's own origin is the host skein's.
|
|
172
|
+
"POST /.well-known/auth": "/.well-known/auth",
|
|
173
|
+
"POST /account/profile": `/${ONBOARD_APP}/profile`,
|
|
174
|
+
};
|
|
175
|
+
/** The paymail PKI (`GET /bsvalias/id/<handle>[@<domain>]`) → the app's prefix row `/onboard/bsvalias/id/…`. */
|
|
176
|
+
export const PAYMAIL_PREFIX = "/bsvalias/id/";
|
|
177
|
+
/** The BRC-169 domain a host name stands for: a dev router at a loopback address answers for `localhost`. */
|
|
178
|
+
export const domainOf = (hostname) => {
|
|
179
|
+
const h = hostname.toLowerCase();
|
|
180
|
+
return h === "127.0.0.1" || h === "::1" || h === "[::1]" || h === "" ? "localhost" : h;
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* The BRC-169 domain of this host (H4): its public origin's host name
|
|
184
|
+
* (SKEIN_ROUTER_ORIGIN, default the loopback: `localhost`). What a created
|
|
185
|
+
* instance, a claim and a mailbox instance record when nothing names one,
|
|
186
|
+
* and what /.well-known/skein-host answers.
|
|
187
|
+
*/
|
|
188
|
+
export const hostDomain = (origin) => {
|
|
189
|
+
try {
|
|
190
|
+
return domainOf(new URL(origin.replace("{port}", "0")).hostname);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return "localhost";
|
|
194
|
+
} // an origin that is no URL: the loopback's
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* SKEIN_HTTP=fetch: the HTTP proxy's requests (#70, the `fetch` provider)
|
|
198
|
+
* performed for real; `timeoutMs` (default 30 000) bounds the whole exchange,
|
|
199
|
+
* `maxBytes` (#91) the response body: reading stops past it, and the request
|
|
200
|
+
* fails.
|
|
201
|
+
*/
|
|
202
|
+
export async function fetchHttp(req) {
|
|
203
|
+
const ac = new AbortController();
|
|
204
|
+
const timer = setTimeout(() => ac.abort(new Error(`timed out after ${req.timeoutMs ?? 30_000} ms`)), req.timeoutMs ?? 30_000);
|
|
205
|
+
try {
|
|
206
|
+
const r = await fetch(req.url, { method: req.method, headers: req.headers, body: req.body, signal: ac.signal });
|
|
207
|
+
const headers = Object.fromEntries(r.headers.entries());
|
|
208
|
+
if (req.maxBytes === undefined || !r.body)
|
|
209
|
+
return { status: r.status, headers, body: new Uint8Array(await r.arrayBuffer()) };
|
|
210
|
+
const chunks = [];
|
|
211
|
+
let n = 0;
|
|
212
|
+
for await (const c of r.body) {
|
|
213
|
+
n += c.length;
|
|
214
|
+
if (n > req.maxBytes) {
|
|
215
|
+
ac.abort();
|
|
216
|
+
throw new Error(tooLarge(req.maxBytes));
|
|
217
|
+
}
|
|
218
|
+
chunks.push(c);
|
|
219
|
+
}
|
|
220
|
+
return { status: r.status, headers, body: new Uint8Array(Buffer.concat(chunks)) };
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The warning for an agent whose genesis names no owner messagebox
|
|
228
|
+
* (`defaults.ownerMessagebox`): its sends to the owner — every answer to him —
|
|
229
|
+
* fail, since nothing else tells it where his mailbox is. Undefined if it names one.
|
|
230
|
+
*/
|
|
231
|
+
export function noOwnerMessagebox(genesis) {
|
|
232
|
+
// An image (#89, #143) names no root: the claim brings the claimant's messagebox (into the address book).
|
|
233
|
+
if (genesis && genesis.root === undefined)
|
|
234
|
+
return undefined;
|
|
235
|
+
const d = genesis?.defaults;
|
|
236
|
+
if (typeof d?.ownerMessagebox === "string" && d.ownerMessagebox)
|
|
237
|
+
return undefined;
|
|
238
|
+
return "WARNING: its genesis names no owner messagebox (defaults.ownerMessagebox): nothing it sends the owner (its answers) can be delivered. " +
|
|
239
|
+
"Create the owner's mailbox instance (skein-host add <name> --mailbox --owner <key>) or set SKEIN_OWNER_MESSAGEBOX, then re-genesis it (a new store)";
|
|
240
|
+
}
|
|
241
|
+
/** A sendMessage request's box and recipient, for the delivery log line ("" if the body is neither CBOR nor JSON). */
|
|
242
|
+
function describeDelivery(req) {
|
|
243
|
+
const b = req.body ?? new Uint8Array();
|
|
244
|
+
let m;
|
|
245
|
+
try {
|
|
246
|
+
m = dagCbor.decode(b).message;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
try {
|
|
250
|
+
m = JSON.parse(new TextDecoder().decode(b)).message;
|
|
251
|
+
}
|
|
252
|
+
catch { /* neither: the line names no box; the delivery's result is logged beside it */ }
|
|
253
|
+
}
|
|
254
|
+
if (!m)
|
|
255
|
+
return "";
|
|
256
|
+
const to = m.recipient instanceof Uint8Array ? Buffer.from(m.recipient).toString("hex") : typeof m.recipient === "string" ? m.recipient : "";
|
|
257
|
+
return `${String(m.messageBox ?? "?")}${to ? ` for ${short(to)}` : ""}`;
|
|
258
|
+
}
|
|
259
|
+
/** An error answer's description (JSON {description | error}), else its first 200 bytes. */
|
|
260
|
+
function errorOf(body) {
|
|
261
|
+
const t = new TextDecoder().decode(body.subarray(0, 2000));
|
|
262
|
+
try {
|
|
263
|
+
const j = JSON.parse(t);
|
|
264
|
+
return String(j.description ?? j.error ?? t.slice(0, 200));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return t.slice(0, 200);
|
|
268
|
+
} // not JSON: the text itself is the description
|
|
269
|
+
}
|
|
270
|
+
export class Router {
|
|
271
|
+
o;
|
|
272
|
+
feeds;
|
|
273
|
+
/** The cron provider's schedules (#69). */
|
|
274
|
+
cron;
|
|
275
|
+
/** The host's broadcaster (#58, #65): present when the host has an Arcade. */
|
|
276
|
+
arc;
|
|
277
|
+
/** The libp2p host (#51): one node per instance whose genesis declares libp2p. */
|
|
278
|
+
p2p;
|
|
279
|
+
/** shruggr/skein#150: overlay sync over the libp2p node (sync.ts). */
|
|
280
|
+
sync;
|
|
281
|
+
/** The host's providers (#70): what carries the instances' messages out. */
|
|
282
|
+
providers;
|
|
283
|
+
/** #148: the pages' subscriptions to their apps' emitted events (GET /<app>/.events), per instance. */
|
|
284
|
+
events;
|
|
285
|
+
/** The certifier (#100, #113): signs the BRC-169 handle certificates, for the host skein's onboarding app (the `certifier` provider). */
|
|
286
|
+
certifier;
|
|
287
|
+
/** #157: the `deliver` intention's carrier (BRC-103/104, the sessions the host keeps). */
|
|
288
|
+
deliverer;
|
|
289
|
+
loaded = new Map();
|
|
290
|
+
loading = new Map();
|
|
291
|
+
/** #156: the host chain record each running instance was last sent (a `chain` event), by handle; cleared at each start. */
|
|
292
|
+
sentChain = new Map();
|
|
293
|
+
/** Rows being created (#90): hydrated before their hostname is published (disabled until claimed). */
|
|
294
|
+
unpublished = new Set();
|
|
295
|
+
queues = new Map();
|
|
296
|
+
/** The libp2p nodes following their dispatch tables (#72: syncDispatch after a settle). */
|
|
297
|
+
syncing = new Set();
|
|
298
|
+
idleTimer;
|
|
299
|
+
ledgerTimer;
|
|
300
|
+
/** The fuel of calls not yet written to the ledger: instance\0caller\0op → {calls, fuel}. */
|
|
301
|
+
owed = new Map();
|
|
302
|
+
stopped = false;
|
|
303
|
+
closing;
|
|
304
|
+
/** Settles when the router starts shutting down: every client still waiting on a thread is answered 503 (#66). */
|
|
305
|
+
stopping;
|
|
306
|
+
stopNow;
|
|
307
|
+
/** Requests being forwarded (their clients held on a thread). */
|
|
308
|
+
inflight = new Set();
|
|
309
|
+
servers = [];
|
|
310
|
+
port = 0;
|
|
311
|
+
/** The control socket (control.ts), while this router listens on one. */
|
|
312
|
+
control;
|
|
313
|
+
/** The default image (#89), with its chain part (#132): what createInstance boots a new skein from. */
|
|
314
|
+
image;
|
|
315
|
+
/** #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
|
+
views = new Map();
|
|
317
|
+
periods = new Map();
|
|
318
|
+
/** Instances a tick is on its way to; and those sent their first (billing starts with it) since this router started: the timer sends it again. */
|
|
319
|
+
ticking = new Set();
|
|
320
|
+
firstTicks = new Set();
|
|
321
|
+
tickTimer;
|
|
322
|
+
/** The host's provider keys (its billing key among them, #130). */
|
|
323
|
+
providerKey;
|
|
324
|
+
/** The one-process kernels this router holds (#158), by database directory. */
|
|
325
|
+
kernelHosts = new Map();
|
|
326
|
+
wasmHosts = new Map();
|
|
327
|
+
/** The wasm kernel's host over the SQLite database at `db` (the settled split): its machines, its host chain. */
|
|
328
|
+
wasmHost(db) {
|
|
329
|
+
let h = this.wasmHosts.get(db);
|
|
330
|
+
if (!h) {
|
|
331
|
+
h = WasmHost.acquire(db, { network: this.chainNetwork() ?? "main", log: (l) => this.say("kernel", l) });
|
|
332
|
+
this.wasmHosts.set(db, h);
|
|
333
|
+
}
|
|
334
|
+
return h;
|
|
335
|
+
}
|
|
336
|
+
/** The `skein-kernel host` process for the RocksDB database at `dir` (#158), started on first use. */
|
|
337
|
+
kernelHost(dir) {
|
|
338
|
+
let h = this.kernelHosts.get(dir);
|
|
339
|
+
if (!h) {
|
|
340
|
+
h = KernelHost.acquire(dir, { command: this.o.kernel?.command, env: this.kernelEnv(), log: (l) => this.say("kernel", l) });
|
|
341
|
+
this.kernelHosts.set(dir, h);
|
|
342
|
+
}
|
|
343
|
+
return h;
|
|
344
|
+
}
|
|
345
|
+
constructor(o) {
|
|
346
|
+
this.o = o;
|
|
347
|
+
if (o.rocksStore)
|
|
348
|
+
this.kernelHost(o.rocksStore); // started with the router: the host's reads go through it (stores.ts)
|
|
349
|
+
this.image = new ImageChain({ db: o.db, history: historyOf(o.headersFeed), log: (l) => this.say("router", l) });
|
|
350
|
+
this.stopping = new Promise((r) => { this.stopNow = r; });
|
|
351
|
+
this.feeds = new Feeds({
|
|
352
|
+
headers: (h, box, raws) => this.chainHeaders(h, box, raws), log: (s, l) => this.say(s, l),
|
|
353
|
+
maxQueue: o.feeds?.maxQueue, backoff: o.feeds?.backoff,
|
|
354
|
+
});
|
|
355
|
+
this.cron = new Cron({
|
|
356
|
+
now: () => stampMs(this.now()), store: dbSchedules(o.db), log: (s, l) => this.say(s, l),
|
|
357
|
+
tick: async (s, body) => {
|
|
358
|
+
const row = this.o.db.get(s.instance);
|
|
359
|
+
if (!row || row.status !== "enabled") {
|
|
360
|
+
this.cron.forget(s.instance);
|
|
361
|
+
throw new Error("the instance is gone");
|
|
362
|
+
}
|
|
363
|
+
await this.providers.send(s.instance, "cron", keyBytes(s.recipient), s.box, body);
|
|
364
|
+
},
|
|
365
|
+
// An idle-stopped instance is hydrated for a tick only if something in it takes the box (from the cron provider, or anyone).
|
|
366
|
+
wake: async (s) => {
|
|
367
|
+
const l = this.loaded.get(s.instance);
|
|
368
|
+
if ((l && !l.kernel.gone) || this.loading.has(s.instance))
|
|
369
|
+
return true;
|
|
370
|
+
return await this.subscribes(s.instance, s.box, this.providers.key("cron"));
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
if (o.arc) {
|
|
374
|
+
this.arc = new Broadcaster({
|
|
375
|
+
arc: o.arc, db: o.db, fetch: o.arcFetch, backoff: o.feeds?.backoff, ...(o.arcRetry ? { retry: o.arcRetry } : {}), log: (s, l) => this.say(s, l),
|
|
376
|
+
admit: (h, box, ev) => this.admitEvent(h, box, ev),
|
|
377
|
+
status: async (h, body, subject) => {
|
|
378
|
+
const id = this.o.db.get(h)?.identity;
|
|
379
|
+
if (!id)
|
|
380
|
+
throw new Error("its identity is not known yet");
|
|
381
|
+
await this.providers.send(h, "status", keyBytes(id), "chain/status", body, subject);
|
|
382
|
+
},
|
|
383
|
+
now: () => stampMs(this.now()),
|
|
384
|
+
instances: () => ({ all: this.o.db.list("enabled").map((r) => r.handle), running: [...this.loaded].filter(([, l]) => !l.kernel.gone).map(([h]) => h) }),
|
|
385
|
+
holds: async (h, txid) => await (await this.hydrate(h)).kernel.hasBlock(txCid(txid)),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
if (o.peerKeyFor) {
|
|
389
|
+
this.p2p = new P2PHost({
|
|
390
|
+
host: o.libp2p ?? { listen: DEFAULT_LISTEN, bootstrap: [], dht: "off", relays: [], mdns: false },
|
|
391
|
+
keyOf: o.peerKeyFor, discoveryMs: o.libp2pDiscoveryMs,
|
|
392
|
+
inbound: (h, c) => this.p2pInbound(h, c),
|
|
393
|
+
log: (s, l) => this.say(s, l),
|
|
394
|
+
// #126: a beacon's beat signed by the instance's signer, as an intention request is.
|
|
395
|
+
sign: (h, data) => this.signAs(h, data),
|
|
396
|
+
identity: async (h) => { const id = this.o.db.get(h)?.identity; return id ? keyBytes(id) : undefined; },
|
|
397
|
+
beat: (h, topic, b) => { this.sync?.beat(h, topic, b); void this.rememberPeer(h, topic, b.from); },
|
|
398
|
+
});
|
|
399
|
+
const p2pHost = this.p2p;
|
|
400
|
+
this.sync = new OverlaySync({
|
|
401
|
+
overlayOf: (h, liveTopic) => this.loaded.get(h)?.beacons.find((b) => b.topic === liveTopic)?.app,
|
|
402
|
+
local: (h, app, fn, query) => this.overlayRead(h, app, fn, query),
|
|
403
|
+
ownBeat: (h, liveTopic) => this.loaded.get(h)?.beacons.find((b) => b.topic === liveTopic)?.body,
|
|
404
|
+
deliver: (h, peer, beef) => this.p2pInbound(h, { transport: "libp2p", protocol: BEEF_PROTOCOL, from: peerIdFromString(peer).toMultihash().bytes, body: beef }),
|
|
405
|
+
header: async (h, height) => {
|
|
406
|
+
const row = this.o.db.get(h);
|
|
407
|
+
if (!row || !storeExists(storeSpec(row)))
|
|
408
|
+
return undefined;
|
|
409
|
+
const st = openStoreFile(storeSpec(row), { readOnly: true });
|
|
410
|
+
try {
|
|
411
|
+
return await chainHeader(st, height);
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
st.close();
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
request: (h, peer, body) => p2pHost.request(h, peer, SYNC_PROTOCOL, body),
|
|
418
|
+
beats: (h, liveTopic) => p2pHost.live.live(h, liveTopic),
|
|
419
|
+
self: (h) => p2pHost.peerId(h),
|
|
420
|
+
log: (s, l) => this.say(s, l),
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
// Without a host's own provider keys: a router's own, fresh (a test's).
|
|
424
|
+
const own = o.providerKeyFor ? undefined : new KeyDeriver(PrivateKey.fromRandom());
|
|
425
|
+
const keyOf = o.providerKeyFor ?? ((n) => own.derivePrivateKey([2, "skein provider"], n, "self"));
|
|
426
|
+
this.providerKey = keyOf;
|
|
427
|
+
// The certifier key is the certifier provider's (signer.ts certifierKey: providerKey("certifier")).
|
|
428
|
+
this.certifier = new ProtoWallet(keyOf("certifier"));
|
|
429
|
+
const p2p = this.p2p;
|
|
430
|
+
this.deliverer = new Deliverer({ http: (req, from) => this.http(req, from), log: (s, l) => this.say(s, l) });
|
|
431
|
+
this.events = new EventStreams({
|
|
432
|
+
open: (h) => { const row = this.o.db.get(h); return row && storeExists(storeSpec(row)) ? openStoreFile(storeSpec(row), { readOnly: true }) : undefined; },
|
|
433
|
+
});
|
|
434
|
+
this.providers = new Providers({
|
|
435
|
+
keyOf,
|
|
436
|
+
append: (h, pkg) => this.appendLocal(h, pkg),
|
|
437
|
+
identity: async (h) => { const id = this.o.db.get(h)?.identity; return id ? keyBytes(id) : undefined; },
|
|
438
|
+
sign: (h, data) => this.signAs(h, data),
|
|
439
|
+
fetch: (req, from) => this.http(req, from),
|
|
440
|
+
...(this.arc ? { broadcast: (h, tx, beef) => { this.arc.enqueue(h, tx, beef); } } : {}),
|
|
441
|
+
...(p2p ? { topicEvent: (h, rec) => this.topicEvent(h, rec) } : {}),
|
|
442
|
+
...(o.appEvent ? { appEvent: o.appEvent } : {}),
|
|
443
|
+
emitted: (h, rec, place) => this.events.emitted(h, rec, place),
|
|
444
|
+
deliver: (h, id, rec) => this.deliver(h, id, rec),
|
|
445
|
+
cron: (h, sender, id, body) => this.cron.request(h, sender, id, body),
|
|
446
|
+
// #90: the instance manager acts for the host skein alone.
|
|
447
|
+
manager: {
|
|
448
|
+
from: () => { const r = this.o.db.hostSkein(); return r?.identity ? { handle: r.handle, identity: r.identity } : undefined; },
|
|
449
|
+
// #162: SKEIN_MANAGER_FOR — these instances too (each by its own identity).
|
|
450
|
+
alsoFor: () => (this.o.managerFor ?? []).flatMap((h) => { const r = this.o.db.get(h); return r?.identity && r.status === "enabled" ? [{ handle: r.handle, identity: r.identity }] : []; }),
|
|
451
|
+
request: (box, body) => this.manage(box, body),
|
|
452
|
+
},
|
|
453
|
+
// #113: the certifier signs for the host skein alone (the onboarding app records each issue).
|
|
454
|
+
certifier: (box, body) => certify(this.certifier, box, body),
|
|
455
|
+
// #130: a payment is this host's: kept and broadcast.
|
|
456
|
+
...(o.billing ? { payment: (h, rec) => this.payment(h, rec) } : {}),
|
|
457
|
+
...(p2p ? {
|
|
458
|
+
p2p: {
|
|
459
|
+
publish: (h, topic, body) => p2p.publish(h, topic, body),
|
|
460
|
+
dial: (h, peer, protocol, frames) => p2p.dial(h, peer, protocol, frames),
|
|
461
|
+
send: (h, s, body) => p2p.send(h, s, body),
|
|
462
|
+
close: (h, s) => p2p.close(h, s),
|
|
463
|
+
},
|
|
464
|
+
} : {}),
|
|
465
|
+
now: () => stampMs(this.now()),
|
|
466
|
+
log: (s, l) => this.say(s, l),
|
|
467
|
+
});
|
|
468
|
+
if (o.cron !== false)
|
|
469
|
+
this.cron.start();
|
|
470
|
+
const idle = o.idleMs ?? 0;
|
|
471
|
+
if (idle > 0)
|
|
472
|
+
this.idleTimer = setInterval(() => void this.reap(idle), Math.max(50, Math.min(idle / 4, 10_000)));
|
|
473
|
+
this.ledgerTimer = setInterval(() => this.flushLedger(), o.ledgerMs ?? 5000);
|
|
474
|
+
if (o.billing)
|
|
475
|
+
this.tickTimer = setInterval(() => void this.tickAll(), o.billing.tickMs);
|
|
476
|
+
}
|
|
477
|
+
say(source, line) { this.o.log?.(source, line); }
|
|
478
|
+
now() { return (this.o.now ?? clockNow)(); }
|
|
479
|
+
/** The router's clock, as entries are stamped. */
|
|
480
|
+
nowStamp() { return this.now(); }
|
|
481
|
+
/** This host's own origin. */
|
|
482
|
+
origin() { return (this.o.origin ?? "http://127.0.0.1:{port}").replace("{port}", String(this.port || this.o.port || 0)); }
|
|
483
|
+
/**
|
|
484
|
+
* The first label of the router's own origin, when its host name is a
|
|
485
|
+
* name (`id` for id.skein.nexus), else undefined: no instance may take it,
|
|
486
|
+
* so a request to the router's origin never reaches an instance (H3).
|
|
487
|
+
*/
|
|
488
|
+
ownLabel() {
|
|
489
|
+
let h;
|
|
490
|
+
try {
|
|
491
|
+
h = new URL(this.origin()).hostname.toLowerCase();
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
return undefined;
|
|
495
|
+
} // an origin that is no URL names no label
|
|
496
|
+
if (!h.includes(".") || /^[0-9.]+$/.test(h) || h.startsWith("["))
|
|
497
|
+
return undefined;
|
|
498
|
+
return h.split(".")[0];
|
|
499
|
+
}
|
|
500
|
+
/** Whether `handle` is reserved on this host: `id`, `host` (RESERVED_HANDLES), or the router origin's own first label. */
|
|
501
|
+
reserved(handle) {
|
|
502
|
+
return RESERVED_HANDLES.has(handle) || (handle === this.ownLabel() && !this.ownLabelInstance(handle));
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The router's own label as an instance (2026-10-28, one public name): allowed only when the operator names
|
|
506
|
+
* that handle in SKEIN_MANAGER_FOR (managerFor) — the Open Exchange's config instance at the host's own
|
|
507
|
+
* origin. Its hostname then serves the instance; the router keeps /.well-known/skein-host, /skeins and
|
|
508
|
+
* /arc/callback there. Any other row named like the router's origin still never takes its requests.
|
|
509
|
+
*/
|
|
510
|
+
ownLabelInstance(handle) {
|
|
511
|
+
return handle === this.ownLabel() && this.managerFor(handle);
|
|
512
|
+
}
|
|
513
|
+
/** This host's BRC-169 domain: its origin's host name (hostDomain). */
|
|
514
|
+
domain() { return hostDomain(this.origin()); }
|
|
515
|
+
/** An instance's origin: where its front door is, what BRC-169 publishes as its messagebox. */
|
|
516
|
+
originOf(handle) {
|
|
517
|
+
// #161: a path-only handle (a token id) has no hostname: its origin is /@<handle> on the host's own.
|
|
518
|
+
if (pathOnly(handle))
|
|
519
|
+
return `${this.origin().replace(/\/+$/, "")}/@${handle}`;
|
|
520
|
+
return (this.o.instanceOrigin ?? "http://{handle}.localhost:{port}").replace("{handle}", handle).replace("{port}", String(this.port || this.o.port || 0));
|
|
521
|
+
}
|
|
522
|
+
/** 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
|
+
async start() {
|
|
524
|
+
// #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
|
+
if (this.o.headersFeed) {
|
|
526
|
+
void this.image.start();
|
|
527
|
+
this.feeds.listen(this.o.headersFeed, (raws) => { void this.image.headers(raws); });
|
|
528
|
+
}
|
|
529
|
+
for (const row of this.o.db.list("enabled")) {
|
|
530
|
+
try {
|
|
531
|
+
await this.hydrate(row.handle);
|
|
532
|
+
}
|
|
533
|
+
catch (e) {
|
|
534
|
+
this.say(row.handle, `not started: ${e.message}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Stop everything this router started (#61), once: the providers' (the
|
|
540
|
+
* waker's, the cron provider's), the idle reaper's and the ledger's timers
|
|
541
|
+
* (the owed fuel written first), its
|
|
542
|
+
* servers and their connections, the control socket (its file removed), the header feeds' and the broadcaster's
|
|
543
|
+
* SSE clients, the libp2p nodes, and every kernel process it spawned — one
|
|
544
|
+
* still hydrating included. Afterwards nothing of it keeps the process
|
|
545
|
+
* alive: `skein-host run`'s shutdown and every one-shot command that builds
|
|
546
|
+
* a router (`add --boot/--packet`) end with it.
|
|
547
|
+
*/
|
|
548
|
+
close() {
|
|
549
|
+
this.closing ??= this.shutdown();
|
|
550
|
+
return this.closing;
|
|
551
|
+
}
|
|
552
|
+
/** The same as `close()`. */
|
|
553
|
+
stop() { return this.close(); }
|
|
554
|
+
/**
|
|
555
|
+
* Listen on the control socket at `path` ($SKEIN_HOME/host.sock for
|
|
556
|
+
* `skein-host run`; mode 0600, no HTTP route): `skein-host event` sends its
|
|
557
|
+
* event here while this router runs, and it reaches the instance as a tick
|
|
558
|
+
* due now (cronEvent). Removed by `close()`.
|
|
559
|
+
*/
|
|
560
|
+
async listenControl(path) {
|
|
561
|
+
if (this.stopped)
|
|
562
|
+
throw new Error("the router is stopping");
|
|
563
|
+
const server = await listenControl(path, {
|
|
564
|
+
event: async (h, box, ev) => (await this.cronEvent(h, box, ev)).toString(),
|
|
565
|
+
reclaim: (h) => this.reclaim(h),
|
|
566
|
+
message: async (h, m, body) => (await this.messageLocal(h, m, body)).toString(),
|
|
567
|
+
});
|
|
568
|
+
this.control = { server, path };
|
|
569
|
+
this.say("router", `control socket at ${path}`);
|
|
570
|
+
}
|
|
571
|
+
async shutdown() {
|
|
572
|
+
this.stopped = true;
|
|
573
|
+
this.sync?.stop();
|
|
574
|
+
// Clients waiting on a thread are answered 503 + Retry-After before the servers close (#66).
|
|
575
|
+
this.stopNow();
|
|
576
|
+
if (this.inflight.size) {
|
|
577
|
+
await Promise.all([...this.inflight].map((p) => p.catch(() => { }))); // each answers its own client (a 503 now): this only waits for them
|
|
578
|
+
await new Promise((r) => setTimeout(r, 50)); // their 503s written before the sockets close
|
|
579
|
+
}
|
|
580
|
+
clearInterval(this.idleTimer);
|
|
581
|
+
clearInterval(this.ledgerTimer);
|
|
582
|
+
clearInterval(this.tickTimer);
|
|
583
|
+
this.flushLedger();
|
|
584
|
+
const closed = this.servers.map((s) => new Promise((r) => { s.close(() => r()); s.closeAllConnections(); }));
|
|
585
|
+
this.servers = [];
|
|
586
|
+
if (this.control)
|
|
587
|
+
closed.push(closeControl(this.control.server, this.control.path));
|
|
588
|
+
this.control = undefined;
|
|
589
|
+
this.providers.stop();
|
|
590
|
+
this.events.stop();
|
|
591
|
+
await Promise.all([this.cron.stop(), this.feeds.stop(), this.arc?.stop(), this.p2p?.stop()]);
|
|
592
|
+
// A kernel still hydrating: its load finishes (or fails) first, then it is stopped with the rest.
|
|
593
|
+
await Promise.all([...this.loading.values()].map((p) => p.catch(() => { }))); // a failed load was reported to whoever asked for it
|
|
594
|
+
await Promise.all([...this.loaded.values()].map((l) => l.kernel.stop()));
|
|
595
|
+
this.loaded.clear();
|
|
596
|
+
// #158: the one-process kernels released (the last holder's release stops the process: its database flushed).
|
|
597
|
+
await Promise.all([...this.kernelHosts.values()].map((h) => h.release()));
|
|
598
|
+
this.kernelHosts.clear();
|
|
599
|
+
await Promise.all([...this.wasmHosts.values()].map((h) => h.release()));
|
|
600
|
+
this.wasmHosts.clear();
|
|
601
|
+
await Promise.all(closed);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* `skein-host event` (#69): a message from the cron provider into `box`, as
|
|
605
|
+
* a tick due now would be — {...event, kind: event.kind ?? "cron", due:
|
|
606
|
+
* now}, signed by the provider and appended as a `local` request; the
|
|
607
|
+
* instance takes it by its subscription to the box (from the cron provider,
|
|
608
|
+
* or from anyone). The entry's CID.
|
|
609
|
+
*/
|
|
610
|
+
async cronEvent(handle, box, event) {
|
|
611
|
+
const row = this.o.db.get(handle);
|
|
612
|
+
if (!row || row.status !== "enabled")
|
|
613
|
+
throw new Error(`no enabled instance ${handle}`);
|
|
614
|
+
const l = await this.hydrate(handle);
|
|
615
|
+
return await this.providers.send(handle, "cron", keyBytes(l.identity), box, { ...event, kind: event.kind ?? "cron", due: stampMs(this.now()) });
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* A plain entry (#29, #65): a self-validating event from the host's wiring
|
|
619
|
+
* (a header feed, the broadcaster's proof), admitted into the instance in
|
|
620
|
+
* `box` with its record beside the entry (#164: the kernel puts it — after
|
|
621
|
+
* the door, when `box`'s event route names kernel.beef: its BEEFs decoded
|
|
622
|
+
* and checked, the bytes never stored; a BUMP that does not check, or no
|
|
623
|
+
* BEEF, is turned away: DoorAnswered, thrown, nothing written). The kernel
|
|
624
|
+
* routes it by its `subject` to the thread awaiting that record, else to
|
|
625
|
+
* `box`'s event route.
|
|
626
|
+
*/
|
|
627
|
+
async admitEvent(handle, box, event) {
|
|
628
|
+
return await this.serial(handle, async () => {
|
|
629
|
+
const l = await this.hydrate(handle);
|
|
630
|
+
const closed = this.closed(handle);
|
|
631
|
+
if (closed)
|
|
632
|
+
throw new Error(`not forwarded (#130): ${closed}`);
|
|
633
|
+
const e = await l.kernel.admit({ box, event: cidOf(event) }, { event }, this.now());
|
|
634
|
+
this.settle(l);
|
|
635
|
+
return e;
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* #157: a `deliver` intention from `handle`, carried (deliver.ts: BRC-103/104 with the instance's
|
|
640
|
+
* key) and its answer admitted in box `deliver` — it stands on its own at the door (the server's
|
|
641
|
+
* signature). No answer: nothing admitted, the thread rests (offered again at its next start).
|
|
642
|
+
*/
|
|
643
|
+
async deliver(handle, id, rec) {
|
|
644
|
+
const l = await this.hydrate(handle);
|
|
645
|
+
const mail = await l.kernel.store.get(rec.message);
|
|
646
|
+
const body = dagCbor.encode(await l.kernel.store.get(mail.body));
|
|
647
|
+
const ans = await this.deliverer.carry(handle, l.wallet, id, rec, mail, body);
|
|
648
|
+
if (!ans)
|
|
649
|
+
return false;
|
|
650
|
+
try {
|
|
651
|
+
await this.admitEvent(handle, "deliver", ans);
|
|
652
|
+
this.say(handle, `deliver → ${String(rec.url)}: HTTP ${ans.status}, the signed answer admitted`);
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
this.say(handle, `deliver → ${String(rec.url)}: the answer turned away: ${e.message}`);
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* A message for an instance (#70): a provider's signed answer, a forwarded
|
|
662
|
+
* claim, or the loopback's own emit, appended as received — a `local`
|
|
663
|
+
* request entry, {kind: "message", message, body}; the front door checks it
|
|
664
|
+
* (the signature, or for the loopback the record in the store) and routes
|
|
665
|
+
* it. Nobody waits on it.
|
|
666
|
+
*/
|
|
667
|
+
async appendLocal(handle, pkg) {
|
|
668
|
+
return await this.serial(handle, async () => {
|
|
669
|
+
const l = await this.hydrate(handle);
|
|
670
|
+
// #130: the gate lets through the instance's own messages to itself (the loopback), nothing else.
|
|
671
|
+
const closed = this.closed(handle);
|
|
672
|
+
if (closed && !isLoopback(pkg, l.identity))
|
|
673
|
+
throw new Error(`not forwarded (#130): ${closed}`);
|
|
674
|
+
const e = await appendRequest(l.kernel, "local", pkg, this.now());
|
|
675
|
+
this.settle(l);
|
|
676
|
+
return e;
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* #142: a message signed outside the host (the client `skein`, with the operator's key), delivered on
|
|
681
|
+
* the host machine over the control socket: its mail record (dag-cbor) and body, appended as a `local`
|
|
682
|
+
* request (appendLocal — the path a forwarded claim takes). The front door checks the signature and
|
|
683
|
+
* admits it by the dispatch table as it would any signed message; this waits for its verdict and
|
|
684
|
+
* throws on a refusal. The host signs nothing. The entry.
|
|
685
|
+
*/
|
|
686
|
+
async messageLocal(handle, messageBytes, body) {
|
|
687
|
+
if (!this.o.db.get(handle))
|
|
688
|
+
throw new Error(`no instance ${handle}`);
|
|
689
|
+
let message;
|
|
690
|
+
try {
|
|
691
|
+
message = dagCbor.decode(messageBytes);
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
throw new Error("the message is not dag-cbor");
|
|
695
|
+
}
|
|
696
|
+
if (!message || typeof message !== "object" || Array.isArray(message) || !message.signature)
|
|
697
|
+
throw new Error("not a signed message (a mail record with its signature)");
|
|
698
|
+
// #143: turned away at the door (a filter, the transport's check) writes no entry: DoorAnswered, thrown.
|
|
699
|
+
const entry = await this.appendLocal(handle, { kind: "message", message, body });
|
|
700
|
+
const l = await this.hydrate(handle);
|
|
701
|
+
const a = await l.kernel.answer(entry, this.o.answerWaitMs ?? ANSWER_WAIT_MS);
|
|
702
|
+
if (a.state === "errored")
|
|
703
|
+
throw new Error(`the front door failed: ${a.error}`);
|
|
704
|
+
if (a.state !== "finished")
|
|
705
|
+
throw new Error(`not judged in time (${a.state}); entry ${entry}`);
|
|
706
|
+
const v = dagCbor.decode(a.answer);
|
|
707
|
+
if (v.verdict !== "accept")
|
|
708
|
+
throw new Error(`${v.verdict ?? "not accepted"}: ${v.reason ?? ""}`.trim());
|
|
709
|
+
return entry;
|
|
710
|
+
}
|
|
711
|
+
/** Sign `data` with `handle`'s signer: [2, "metanet handles envelope"] / `send` / anyone — an intention request's signature (providers.ts), a beacon's beat (p2p.ts beaconFrame). */
|
|
712
|
+
async signAs(handle, data) {
|
|
713
|
+
const row = this.o.db.get(handle);
|
|
714
|
+
if (!row)
|
|
715
|
+
throw new Error(`no instance ${handle}`);
|
|
716
|
+
const { signature } = await (await this.o.walletFor(row)).createSignature({ protocolID: MESSAGE_PROTOCOL, keyID: MESSAGE_KEY_ID, counterparty: "anyone", data: [...data] });
|
|
717
|
+
return Uint8Array.from(signature);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* An inbound libp2p message or stream frame (#51, #68): appended as
|
|
721
|
+
* received (the message with its signature; the frame), and the verdict
|
|
722
|
+
* — or the frame's answer — read off the thread the front door is stepped
|
|
723
|
+
* on, once it comes to rest (#66). The front door verifies, routes, and
|
|
724
|
+
* admits what an accept carries (the message's `p2p` event first); a
|
|
725
|
+
* redelivered message it accepted before is `ignore`. Its thread failing,
|
|
726
|
+
* or not answering within the wait, is `ignore` (no penalty). A frame a
|
|
727
|
+
* route's filter answers at the door (a read, #143: nothing written) is
|
|
728
|
+
* answered with the filter's body (#154: the overlay's sync protocol).
|
|
729
|
+
*/
|
|
730
|
+
async p2pInbound(handle, call) {
|
|
731
|
+
const l = await this.hydrate(handle);
|
|
732
|
+
const closed = this.closed(handle);
|
|
733
|
+
if (closed)
|
|
734
|
+
return { verdict: "ignore", reason: `not forwarded (#130): ${closed}` };
|
|
735
|
+
const source = `libp2p:${call.topic ?? call.protocol ?? "?"}`;
|
|
736
|
+
let from = "";
|
|
737
|
+
try {
|
|
738
|
+
from = peerIdFromMultihash(Digest.decode(call.from)).toString();
|
|
739
|
+
}
|
|
740
|
+
catch { /* not a peer ID: only this log line's name; the front door judges the record */ }
|
|
741
|
+
// The settled sync design (David 2026-10-30, skein-overlay 0.20.0): an `admit` announcement on an overlay
|
|
742
|
+
// topic is the host's, never the instance's — fetched from the announcer unless held (sync.ts), nothing
|
|
743
|
+
// logged; GossipSub's verdict ignore (no forward: each node announces its own admission).
|
|
744
|
+
if (call.topic !== undefined && this.sync) {
|
|
745
|
+
const app = this.loaded.get(handle)?.beacons.find((b) => b.topic === `${call.topic}-live`)?.app;
|
|
746
|
+
const txid = app ? admitOf(call.topic, call.body) : undefined;
|
|
747
|
+
if (app && txid && from) {
|
|
748
|
+
this.sync.admit(handle, app, call.topic, txid, from);
|
|
749
|
+
return { verdict: "ignore", reason: "an announcement: the host fetches it" };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const record = call.topic !== undefined
|
|
753
|
+
? { kind: "p2p", topic: call.topic, from: call.from, seqno: call.seqno ?? new Uint8Array(), signature: call.signature ?? new Uint8Array(), body: call.body }
|
|
754
|
+
: { kind: "p2p-frame", protocol: call.protocol ?? "", from: call.from, body: call.body };
|
|
755
|
+
let entry;
|
|
756
|
+
try {
|
|
757
|
+
entry = await appendRequest(l.kernel, "libp2p", record, this.now());
|
|
758
|
+
}
|
|
759
|
+
catch (e) {
|
|
760
|
+
if (!(e instanceof DoorAnswered))
|
|
761
|
+
throw e;
|
|
762
|
+
// #154: a frame on a stream answered by a filter (a read: nothing written) — its body is the answer frame.
|
|
763
|
+
if (call.topic === undefined && e.answer.kind === "answer")
|
|
764
|
+
return { verdict: "accept", body: e.answer.body ?? new Uint8Array() };
|
|
765
|
+
// #121, #143: turned away at the door (nothing written). A bad signature or a bad BEEF is the sender's fault
|
|
766
|
+
// (reject: GossipSub penalises the forwarder); what this instance cannot judge (no chain state, a failing check) is ignore.
|
|
767
|
+
const reason = e.answer.reason ?? "turned away";
|
|
768
|
+
const verdict = e.answer.status < 500 && !/no chain state/.test(reason) ? "reject" : "ignore";
|
|
769
|
+
this.say(handle, `${source} from ${from}: ${verdict} (turned away at the door: ${reason})`);
|
|
770
|
+
return { verdict, reason };
|
|
771
|
+
}
|
|
772
|
+
const a = await l.kernel.answer(entry, this.o.answerWaitMs ?? ANSWER_WAIT_MS);
|
|
773
|
+
this.settle(l);
|
|
774
|
+
if (a.state !== "finished") {
|
|
775
|
+
const why = a.state === "errored" ? `the front door failed: ${a.error}` : `not answered in time (${a.state})`;
|
|
776
|
+
this.say(handle, `${source} from ${from}: ${why}`);
|
|
777
|
+
return { verdict: "ignore", reason: why };
|
|
778
|
+
}
|
|
779
|
+
const r = dagCbor.decode(a.answer);
|
|
780
|
+
const verdict = r.verdict === "accept" || r.verdict === "reject" ? r.verdict : "ignore";
|
|
781
|
+
if (verdict !== "accept" || process.env.SKEIN_LIBP2P_VERBOSE)
|
|
782
|
+
this.say(handle, `${source} from ${from}: ${verdict}${r.reason ? ` (${r.reason})` : ""}`);
|
|
783
|
+
return { verdict, ...(r.body instanceof Uint8Array ? { body: r.body } : {}), ...(r.close ? { close: true } : {}), ...(r.reason ? { reason: r.reason } : {}) };
|
|
784
|
+
}
|
|
785
|
+
/** Until nothing is queued and every loaded kernel has processed what it was given (tests, corpus). */
|
|
786
|
+
async settled() {
|
|
787
|
+
for (let i = 0; i < 1000; i++) {
|
|
788
|
+
await Promise.all([...this.queues.values()]);
|
|
789
|
+
await Promise.all([...this.loaded.values()].map((l) => l.kernel.idle().catch(() => { }))); // a kernel that exited is reported by its exit (hydrate's `exited`): this only waits
|
|
790
|
+
await this.providers.idle(); // what the providers carry now (#70): its answers are entries
|
|
791
|
+
await this.arc?.idle(); // the broadcaster's posts (#65): their answers are statuses to route
|
|
792
|
+
await Promise.all([...this.syncing]); // the libp2p nodes following their dispatch tables (#72, #77)
|
|
793
|
+
await new Promise((r) => setImmediate(r));
|
|
794
|
+
if (!this.syncing.size && !this.queues.size && !this.providers.busy() && !this.arc?.busy() && [...this.loaded.values()].every((l) => l.kernel.busy === 0))
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Run `f` after everything else queued for this instance: the host's own admissions (events from its
|
|
800
|
+
* wiring, `local` messages: the waker's, the providers', the loopback, a forwarded claim) reach the
|
|
801
|
+
* kernel in the order the host made them, each after its hydrate and `closed` check, and `settled`
|
|
802
|
+
* waits on them. Not the log's order: the kernel assigns every entry's place (#163), so HTTP and
|
|
803
|
+
* libp2p arrivals, which do not queue here, need no queue.
|
|
804
|
+
*/
|
|
805
|
+
serial(handle, f) {
|
|
806
|
+
const prev = this.queues.get(handle) ?? Promise.resolve();
|
|
807
|
+
// f's failure goes to its caller through `next`; the queue only keeps the order.
|
|
808
|
+
const next = prev.catch(() => { }).then(f);
|
|
809
|
+
const tail = next.catch(() => { });
|
|
810
|
+
this.queues.set(handle, tail);
|
|
811
|
+
void tail.then(() => { if (this.queues.get(handle) === tail)
|
|
812
|
+
this.queues.delete(handle); });
|
|
813
|
+
return next;
|
|
814
|
+
}
|
|
815
|
+
// ---------------------------------------------------------------- the host chain (#156)
|
|
816
|
+
/**
|
|
817
|
+
* The network the host chain is on: SKEIN_NETWORK (`network`), else the genesis defaults'
|
|
818
|
+
* `walletNetwork` (a test host's), else the kernel's default (main).
|
|
819
|
+
*/
|
|
820
|
+
chainNetwork() {
|
|
821
|
+
const d = this.o.genesis?.defaults;
|
|
822
|
+
return this.o.network ?? (typeof d?.walletNetwork === "string" ? d.walletNetwork : undefined);
|
|
823
|
+
}
|
|
824
|
+
/** The kernels' environment: the configured one, and the host chain's network (#156). */
|
|
825
|
+
kernelEnv() {
|
|
826
|
+
const net = this.chainNetwork();
|
|
827
|
+
return net ? { ...this.o.kernel?.env, SKEIN_NETWORK: net } : this.o.kernel?.env;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* A run of headers from a feed for `handle` (#156): into the host chain — the one kernel
|
|
831
|
+
* process's for a RocksDB set (an instance not running is not hydrated for it: it gets the
|
|
832
|
+
* chain at its next start), the instance's own serve kernel's otherwise — then, when the
|
|
833
|
+
* record changed for it, the `chain` event.
|
|
834
|
+
*/
|
|
835
|
+
async chainHeaders(handle, box, raws) {
|
|
836
|
+
const row = this.o.db.get(handle);
|
|
837
|
+
if (!row)
|
|
838
|
+
return;
|
|
839
|
+
const rocks = parseRocksSpec(storeSpec(row));
|
|
840
|
+
const sq = parseSqliteSpec(storeSpec(row));
|
|
841
|
+
let tip;
|
|
842
|
+
if (rocks || sq) {
|
|
843
|
+
tip = rocks ? await this.kernelHost(rocks.dir).chainHeaders(raws) : await this.wasmHost(sq.db).chainHeaders(raws);
|
|
844
|
+
const l = this.loaded.get(handle);
|
|
845
|
+
if (!l || l.kernel.gone)
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
tip = await (await this.hydrate(handle)).kernel.chainHeaders(raws);
|
|
850
|
+
}
|
|
851
|
+
await this.chainEvent(handle, box, tip);
|
|
852
|
+
}
|
|
853
|
+
/** The `chain` event (#156) for `handle` in `box`, unless it was sent that record already since its start. */
|
|
854
|
+
async chainEvent(handle, box, tip) {
|
|
855
|
+
const key = tip.root.toString();
|
|
856
|
+
if (this.sentChain.get(handle) === key)
|
|
857
|
+
return;
|
|
858
|
+
this.sentChain.set(handle, key);
|
|
859
|
+
try {
|
|
860
|
+
await this.admitEvent(handle, box, { kind: "chain", root: tip.root, height: tip.height });
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
if (this.sentChain.get(handle) === key)
|
|
864
|
+
this.sentChain.delete(handle);
|
|
865
|
+
throw e;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Fresh-host bootstrap (#156): the host chain empty and the instance's tree (its head `main`,
|
|
870
|
+
* an image's) carrying `chain/headers`: those imported into the host chain before the
|
|
871
|
+
* instance's first event. A tree with none, or a host chain with a tip: nothing.
|
|
872
|
+
*/
|
|
873
|
+
async chainBootstrap(handle, kernel) {
|
|
874
|
+
if (await kernel.chainTip())
|
|
875
|
+
return;
|
|
876
|
+
const tree = await kernel.call("head", "main");
|
|
877
|
+
if (!tree || tree.code !== 0x78)
|
|
878
|
+
return;
|
|
879
|
+
try {
|
|
880
|
+
const t0 = Date.now();
|
|
881
|
+
const got = await kernel.chainImport(tree);
|
|
882
|
+
this.say("router", `host chain: ${got.added} headers imported from ${handle}'s image (tip ${got.height}, ${Date.now() - t0} ms)`);
|
|
883
|
+
}
|
|
884
|
+
catch (e) {
|
|
885
|
+
const m = e.message;
|
|
886
|
+
if (!/NoChain|NotEmpty/.test(m))
|
|
887
|
+
this.say(handle, `host chain: the image's chain not imported: ${m}`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
/** At start (and once an app takes box `chain` — an install): the host chain's current record, as the `chain` event. */
|
|
891
|
+
async chainBirth(handle) {
|
|
892
|
+
const l = this.loaded.get(handle);
|
|
893
|
+
if (!l || l.kernel.gone)
|
|
894
|
+
return;
|
|
895
|
+
const rows = l.rows ?? (await l.kernel.dispatch()).rows;
|
|
896
|
+
if (!rows.some((r) => r.address === DEFAULT_BOX && takesEvent(r, DEFAULT_BOX)))
|
|
897
|
+
return;
|
|
898
|
+
const tip = await l.kernel.chainTip();
|
|
899
|
+
if (tip)
|
|
900
|
+
await this.chainEvent(handle, DEFAULT_BOX, tip);
|
|
901
|
+
}
|
|
902
|
+
// ---------------------------------------------------------------- hydration
|
|
903
|
+
/** The instance's kernel, started (and its genesis written) if it is not loaded. */
|
|
904
|
+
hydrate(handle) {
|
|
905
|
+
const l = this.loaded.get(handle);
|
|
906
|
+
if (l && !l.kernel.gone)
|
|
907
|
+
return Promise.resolve(l);
|
|
908
|
+
let p = this.loading.get(handle);
|
|
909
|
+
if (!p) {
|
|
910
|
+
p = this.load(handle).finally(() => this.loading.delete(handle));
|
|
911
|
+
this.loading.set(handle, p);
|
|
912
|
+
}
|
|
913
|
+
return p;
|
|
914
|
+
}
|
|
915
|
+
async load(handle) {
|
|
916
|
+
if (this.stopped)
|
|
917
|
+
throw new Error("the router is stopping");
|
|
918
|
+
const row = this.o.db.get(handle);
|
|
919
|
+
if (!row || (row.status !== "enabled" && !this.unpublished.has(handle)))
|
|
920
|
+
throw new Error(`no enabled instance ${handle}`);
|
|
921
|
+
const wallet = await this.o.walletFor(row);
|
|
922
|
+
const identity = await rootIdentity(wallet);
|
|
923
|
+
if (row.identity && row.identity !== identity)
|
|
924
|
+
throw new Error(`its signer is ${short(identity)}, not the recorded identity ${short(row.identity)}`);
|
|
925
|
+
const ko = {
|
|
926
|
+
db: storeSpec(row), handle: row.handle, domain: row.domain, wallet, command: this.o.kernel?.command, env: this.kernelEnv(),
|
|
927
|
+
log: (line) => this.say(handle, line),
|
|
928
|
+
emit: (o) => this.providers.deliver(handle, o),
|
|
929
|
+
exited: (code, signal) => {
|
|
930
|
+
if (this.loaded.get(handle)?.kernel === kernel)
|
|
931
|
+
this.loaded.delete(handle);
|
|
932
|
+
if (code !== 0)
|
|
933
|
+
this.say(handle, `kernel exited (${signal ?? `code ${code}`})`);
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
// #158: a RocksDB set is an instance of the host's one kernel process (opened there: hydrate); else a serve process of its own.
|
|
937
|
+
// The settled split (2026-10-31): a SQLite set's machine is the wasm kernel, loaded in this process on a worker.
|
|
938
|
+
const rocks = parseRocksSpec(ko.db);
|
|
939
|
+
const sq = parseSqliteSpec(ko.db);
|
|
940
|
+
const kernel = sq ? await this.wasmHost(sq.db).open(ko) : rocks ? await this.kernelHost(rocks.dir).open(ko) : new Kernel(ko);
|
|
941
|
+
let g;
|
|
942
|
+
try {
|
|
943
|
+
if (!(await kernel.store.log.tip())) {
|
|
944
|
+
// No tree was loaded into this store (`skein-host add --boot/--packet` does that): the stock system, through the same loader.
|
|
945
|
+
const b = await boot(kernel, { kind: "code" }, this.genesisConfig(row, identity), this.now());
|
|
946
|
+
this.say(handle, `genesis ${b.entry}`);
|
|
947
|
+
}
|
|
948
|
+
g = await kernel.genesis();
|
|
949
|
+
if (!g)
|
|
950
|
+
throw new Error("the store's log does not start with a genesis this kernel reads (format 3, #40): start a new store (re-genesis)");
|
|
951
|
+
if (keyHex(g.identity) !== identity)
|
|
952
|
+
throw new Error(`the signer (${short(identity)}) is not this instance's identity (${short(keyHex(g.identity))})`);
|
|
953
|
+
await kernel.start();
|
|
954
|
+
// #156: after the start (the genesis processed: its tree is the head `main`), before any event is admitted.
|
|
955
|
+
await this.chainBootstrap(handle, kernel);
|
|
956
|
+
await kernel.running(identity);
|
|
957
|
+
void kernel.idle().catch(() => { }); // busy until what start resumed is done; a kernel that exits meanwhile is reported by its exit
|
|
958
|
+
if (!row.identity)
|
|
959
|
+
this.o.db.add(row.handle, { identity });
|
|
960
|
+
if (row.kind !== "mailbox") {
|
|
961
|
+
const w = noOwnerMessagebox(g);
|
|
962
|
+
if (w)
|
|
963
|
+
this.say(handle, w);
|
|
964
|
+
}
|
|
965
|
+
this.feeds.declare(handle, feedsOf(g));
|
|
966
|
+
}
|
|
967
|
+
catch (e) {
|
|
968
|
+
await kernel.stop(1000);
|
|
969
|
+
throw e;
|
|
970
|
+
}
|
|
971
|
+
// #119: the apps' subscriptions, read from the log (the kernel writes nothing for them).
|
|
972
|
+
const subs = await this.foldSubscriptions(storeSpec(row)).catch((e) => { this.say(handle, `libp2p: the subscriptions not read: ${e.message}`); return []; });
|
|
973
|
+
// #126: the apps' beacons, read from the log the same way.
|
|
974
|
+
const beacons = await this.foldBeacons(storeSpec(row)).catch((e) => { this.say(handle, `libp2p: the beacons not read: ${e.message}`); return []; });
|
|
975
|
+
// #138: the apps' liveness, read from the log the same way.
|
|
976
|
+
const live = await this.readLiveness(storeSpec(row)).catch((e) => { this.say(handle, `libp2p: the liveness not read: ${e.message}`); return []; });
|
|
977
|
+
const l = { row, kernel, identity, wallet, genesis: g, subs, beacons, live };
|
|
978
|
+
// Its node (the genesis's libp2p and the dispatch table's libp2p rows), before anything it runs can publish or dial; the host's headers feed.
|
|
979
|
+
await this.syncDispatch(l, true);
|
|
980
|
+
this.loaded.set(handle, l);
|
|
981
|
+
// #156: the host chain as it stands, once at each start (not awaited: an admit made here would wait on this load).
|
|
982
|
+
this.sentChain.delete(handle);
|
|
983
|
+
void this.chainBirth(handle).catch((e) => this.say(handle, `host chain: the start event not sent: ${e.message}`));
|
|
984
|
+
// The settled sync design: the peers each overlay topic's beats came from (host.db), asked at once.
|
|
985
|
+
if (this.sync)
|
|
986
|
+
void this.askKnownPeers(l).catch((e) => this.say(handle, `sync: the known peers not asked: ${e.message}`));
|
|
987
|
+
await this.syncBilling(l).catch((e) => this.say(handle, `billing: not read: ${e.message}`));
|
|
988
|
+
this.say("router", `hydrated ${handle} (${short(identity)})`);
|
|
989
|
+
return l;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Whether a stopped instance's route table routes an entry in `box` — an event, or (given a
|
|
993
|
+
* sender) a message: the kernel's rule (#77, #143: dispatch.zig forMail/forEvent, by the box
|
|
994
|
+
* alone; who may run the function is the gate's, when it is routed). Read from its store file,
|
|
995
|
+
* read-only, with no kernel.
|
|
996
|
+
*/
|
|
997
|
+
async subscribes(handle, box, sender) {
|
|
998
|
+
const row = this.o.db.get(handle);
|
|
999
|
+
if (!row || !storeExists(storeSpec(row)))
|
|
1000
|
+
return false;
|
|
1001
|
+
const s = openStoreFile(storeSpec(row), { readOnly: true });
|
|
1002
|
+
try {
|
|
1003
|
+
const rows = await currentDispatch(s);
|
|
1004
|
+
return !!rows?.some((r) => sender !== undefined ? takesMail(r, box) : takesEvent(r, box));
|
|
1005
|
+
}
|
|
1006
|
+
finally {
|
|
1007
|
+
s.close();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Boot a row's empty store from a system tree or a checkpoint (issue #4,
|
|
1012
|
+
* boot.ts) before its first hydration; the row's identity is its signer's.
|
|
1013
|
+
*/
|
|
1014
|
+
async bootRow(handle, src, o = {}) {
|
|
1015
|
+
const row = this.o.db.get(handle);
|
|
1016
|
+
if (!row)
|
|
1017
|
+
throw new Error(`no instance ${handle}`);
|
|
1018
|
+
if (this.loaded.has(handle))
|
|
1019
|
+
throw new Error(`${handle} is loaded: boot only an empty store`);
|
|
1020
|
+
if (o.image && src.kind !== "tree")
|
|
1021
|
+
throw new Error("an image is a system tree");
|
|
1022
|
+
if (o.manager && !o.image)
|
|
1023
|
+
throw new Error("the instance manager is the host skein's, and the host skein is an image");
|
|
1024
|
+
if ((o.owner || o.appConfig) && !o.image)
|
|
1025
|
+
throw new Error("an owner and app config at birth are an image's (#142)");
|
|
1026
|
+
const identity = await rootIdentity(await this.o.walletFor(row));
|
|
1027
|
+
// #142: an image booted with its owner named (the host skein): the owner's rows and its apps' `$owner` rows at birth, no claim row.
|
|
1028
|
+
const c = src.kind === "checkpoint" ? { identity, root: [this.o.owner ?? identity], handle: row.handle, domain: row.domain } : o.image ? { ...this.imageConfig(row, identity, { manager: o.manager }), ...(o.owner ? { root: [o.owner] } : {}), ...(o.appConfig ? { appConfig: o.appConfig } : {}) } : this.genesisConfig(row, identity, src.kind === "code");
|
|
1029
|
+
const b = await bootStore({ db: storeSpec(row), handle: row.handle, domain: row.domain, command: this.o.kernel?.command, env: this.kernelEnv(), log: (l) => this.say(handle, l) }, src, c, this.now());
|
|
1030
|
+
this.o.db.add(row.handle, { identity, ...(b.tree ? { tree: b.tree.toString() } : {}) });
|
|
1031
|
+
return b;
|
|
1032
|
+
}
|
|
1033
|
+
/** The owner's messagebox: configured, else its mailbox instance here. */
|
|
1034
|
+
ownerMessagebox() {
|
|
1035
|
+
if (this.o.ownerMessagebox)
|
|
1036
|
+
return this.o.ownerMessagebox;
|
|
1037
|
+
const mb = this.o.owner ? this.o.db.mailboxOf(this.o.owner) : undefined;
|
|
1038
|
+
return mb ? this.originOf(mb.handle) : undefined;
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* A new agent's address book seed (#70): this host's providers — the HTTP
|
|
1042
|
+
* proxy, the waker, the cron provider (#69), the libp2p node (when it runs
|
|
1043
|
+
* one), the status provider (#65, when it has an Arcade) — and the owner's
|
|
1044
|
+
* mailbox, when it knows it.
|
|
1045
|
+
*/
|
|
1046
|
+
addressSeed(handle) {
|
|
1047
|
+
const names = ["fetch", "waker", "cron", ...(this.p2p ? ["libp2p"] : []), ...(this.arc ? ["status"] : []), ...(handle !== undefined && this.managerFor(handle) ? ["manager"] : [])];
|
|
1048
|
+
const out = this.providers.entries(names);
|
|
1049
|
+
const mb = this.ownerMessagebox();
|
|
1050
|
+
if (this.o.owner && mb)
|
|
1051
|
+
out.push({ key: keyBytes(this.o.owner), transport: "mailbox", address: mb, ...(this.o.ownerHandle ?? {}) });
|
|
1052
|
+
return out;
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* What this host brings to an image's genesis (#89): only its own facts —
|
|
1056
|
+
* the instance's identity, handle and domain, its providers in the address
|
|
1057
|
+
* book, where its domain resolves, the host's defaults. No owner, no
|
|
1058
|
+
* owner's mailbox, no inference peer, no names: the image is the same for
|
|
1059
|
+
* everyone, and the owner is the claim's sender (#127). `manager`
|
|
1060
|
+
* (#90, #113): the host skein's — the instance manager and the certifier
|
|
1061
|
+
* in its address book too (no other instance's book names them).
|
|
1062
|
+
*/
|
|
1063
|
+
imageConfig(row, identity, o = {}) {
|
|
1064
|
+
// #162: an instance SKEIN_MANAGER_FOR names gets the instance manager (not the certifier), as the host skein has it.
|
|
1065
|
+
const seed = this.providers.entries(["fetch", "waker", "cron", ...(this.p2p ? ["libp2p"] : []), ...(this.arc ? ["status"] : []), ...(o.manager ? ["manager", "certifier"] : this.managerFor(row.handle) ? ["manager"] : [])]);
|
|
1066
|
+
return {
|
|
1067
|
+
identity, handle: row.handle, domain: row.domain, resolveOrigin: this.origin(), network: this.o.network, addressBook: seed,
|
|
1068
|
+
providers: Object.fromEntries(seed.map((e) => [e.address, Buffer.from(e.key).toString("hex")])),
|
|
1069
|
+
feeds: this.o.genesis?.feeds, defaults: this.o.genesis?.defaults, overrides: this.o.fuelPerStep ? { fuelPerStep: this.o.fuelPerStep } : undefined,
|
|
1070
|
+
warn: (l) => this.say(row.handle, l),
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
/** #162: whether SKEIN_MANAGER_FOR names `handle` (the instance manager in its address book, its messages taken). */
|
|
1074
|
+
managerFor(handle) { return (this.o.managerFor ?? []).includes(handle); }
|
|
1075
|
+
/**
|
|
1076
|
+
* #162: the image a create names, as a boot source — the built-in `default` (this host's, with its
|
|
1077
|
+
* chain part), or a directory `<imagesDir>/<name>` (SKEIN_IMAGES_DIR, default $SKEIN_HOME/images;
|
|
1078
|
+
* the layout `skein-host add --image <dir>` takes: with no bin/ merged over the default image as
|
|
1079
|
+
* images/host is, with bin/ a complete image). Its path resolved first (a link to an app repository's
|
|
1080
|
+
* image/ reads `..` beside the repository's image, not beside the link).
|
|
1081
|
+
*/
|
|
1082
|
+
async namedImage(name, host) {
|
|
1083
|
+
if (host || name === undefined || name === "default")
|
|
1084
|
+
return await this.image.source({ host, ...(name ? { image: name } : {}) });
|
|
1085
|
+
const dir = this.imageDir(name);
|
|
1086
|
+
if (!dir)
|
|
1087
|
+
throw new Error(`image ${JSON.stringify(name)}: this host has the default image, mailbox instances${this.o.imagesDir ? ` and the images in ${this.o.imagesDir}` : ""}`);
|
|
1088
|
+
return isOverlayImage(dir) ? await this.image.source({ overlay: dir }) : await imageSource(dir);
|
|
1089
|
+
}
|
|
1090
|
+
/** #162: an image by name: its directory under imagesDir (resolved), or undefined. */
|
|
1091
|
+
imageDir(name) {
|
|
1092
|
+
if (!this.o.imagesDir || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name === "host" || name === "mailbox")
|
|
1093
|
+
return undefined;
|
|
1094
|
+
try {
|
|
1095
|
+
const d = realpathSync(join(this.o.imagesDir, name));
|
|
1096
|
+
return statSync(d).isDirectory() ? d : undefined;
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
return undefined;
|
|
1100
|
+
} // no such image here
|
|
1101
|
+
}
|
|
1102
|
+
/** Whether `owner` holds root on the instance (#143: the head `grants`): claimed by that key. */
|
|
1103
|
+
async claimedBy(l, owner) {
|
|
1104
|
+
const root = await l.kernel.call("head", "grants");
|
|
1105
|
+
if (!root)
|
|
1106
|
+
return false;
|
|
1107
|
+
const g = await l.kernel.store.get(root).catch(() => undefined);
|
|
1108
|
+
return (g?.roles?.root ?? []).some((k) => k instanceof Uint8Array && Buffer.from(k).toString("hex") === owner);
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* A new instance from an image, owned by `owner` (#90: the instance
|
|
1112
|
+
* manager's `create`; `skein-host init` for the host skein). In this order:
|
|
1113
|
+
* the row, disabled (no hostname: nothing reaches it); its identity
|
|
1114
|
+
* derived (the signer's, key ID = the handle) and its store booted from the
|
|
1115
|
+
* image (only `default`, the default image, so far); its kernel started
|
|
1116
|
+
* unpublished; `claim` (#127) — the owner's own signed claim, a message in
|
|
1117
|
+
* box `claim` naming no recipient (it was signed before the instance
|
|
1118
|
+
* existed), with its body — forwarded into it as a `local` request, its
|
|
1119
|
+
* first entry after the genesis: the front door checks the signature, and
|
|
1120
|
+
* the kernel takes the owner from the signer (the owner's admin rows
|
|
1121
|
+
* written, the claim row removed); only then (`publish`, default true) the
|
|
1122
|
+
* row enabled, which publishes its hostname. No request can reach the claim
|
|
1123
|
+
* row first. The host signs nothing for the owner. With no `claim` (a bare
|
|
1124
|
+
* image: `skein-host init`), the instance keeps its claim row, from anyone:
|
|
1125
|
+
* the first sender of a claim owns it. `host`: the host skein — the
|
|
1126
|
+
* instance manager and the certifier in its address book, and recorded as
|
|
1127
|
+
* the host skein (host.db). `domain`: the handle's domain, recorded with
|
|
1128
|
+
* the row (default `localhost`). A refusal throws (a bad or taken handle, a
|
|
1129
|
+
* bad key, another image, a claim not `owner`'s or refused: the row is left
|
|
1130
|
+
* disabled, its store kept for a look).
|
|
1131
|
+
*
|
|
1132
|
+
* `owned` (#164): a non-host image instance born owned as the host skein
|
|
1133
|
+
* is — its genesis's `root` is `owner`, no claim (none taken, none made:
|
|
1134
|
+
* the image's claim route is not seeded), no instance manager or
|
|
1135
|
+
* certifier in its address book (those stay the host skein's and
|
|
1136
|
+
* SKEIN_MANAGER_FOR's).
|
|
1137
|
+
*
|
|
1138
|
+
* Image `mailbox` (#113): a mailbox instance for `owner` — the front door
|
|
1139
|
+
* and the messagebox, its owner in its genesis (no claim) — published at
|
|
1140
|
+
* once (`publish`). The same owner and handle again: the same answer (it
|
|
1141
|
+
* exists, enabled again if it was not, at `domain` if given); a key with
|
|
1142
|
+
* another mailbox here is refused. One creation path: the manager's `create` (the onboarding app's
|
|
1143
|
+
* registrations) and `skein-host add --mailbox` both come here.
|
|
1144
|
+
*/
|
|
1145
|
+
async createInstance(handle, owner, o = {}) {
|
|
1146
|
+
if (!validHandle(handle))
|
|
1147
|
+
throw new Error(`handle ${JSON.stringify(handle)}: lower-case letters, digits and "-", at most 63, as a hostname label; or a token id <txid>_<vout> (path-only, #161)`);
|
|
1148
|
+
if (this.reserved(handle) && !(o.host && handle === "host"))
|
|
1149
|
+
throw new Error(`handle ${handle} is reserved`);
|
|
1150
|
+
if (!KEY.test(owner))
|
|
1151
|
+
throw new Error("owner: not an identity key (33 bytes)");
|
|
1152
|
+
if (o.image !== undefined && o.image !== "default" && o.image !== "mailbox" && !this.imageDir(o.image))
|
|
1153
|
+
throw new Error(`image ${JSON.stringify(o.image)}: this host has the default image${this.o.imagesDir ? `, the images in ${this.o.imagesDir}` : ""} and mailbox instances`);
|
|
1154
|
+
if (o.domain !== undefined && !DOMAIN.test(o.domain))
|
|
1155
|
+
throw new Error(`domain ${JSON.stringify(o.domain)}: a host name, lower case`);
|
|
1156
|
+
if (o.image === "mailbox")
|
|
1157
|
+
return await this.createMailbox(handle, owner, o);
|
|
1158
|
+
if (o.host && (o.claim !== undefined || o.needsClaim))
|
|
1159
|
+
throw new Error("the host skein is born owned (#142): no claim");
|
|
1160
|
+
// #164: an owned image instance is born owned as the host skein is: no claim.
|
|
1161
|
+
if (o.owned && (o.claim !== undefined || o.needsClaim))
|
|
1162
|
+
throw new Error("an owned instance is born owned (#164): no claim");
|
|
1163
|
+
const owned = o.host || o.owned;
|
|
1164
|
+
let claim = o.claim !== undefined ? claimOf(o.claim) : undefined;
|
|
1165
|
+
// #162: a one-key host claims what it creates itself — the claim signed by its shared key (the owner).
|
|
1166
|
+
if (!claim && !owned && this.o.claimer && o.needsClaim) {
|
|
1167
|
+
const me = (await this.o.claimer.getPublicKey({ identityKey: true })).publicKey;
|
|
1168
|
+
if (owner !== me)
|
|
1169
|
+
throw new Error(`owner: on a one-key host the claim is the host's own key's (${short(me)}): name it or no owner, or forward the owner's signed claim`);
|
|
1170
|
+
claim = claimOf(await signClaim(this.o.claimer));
|
|
1171
|
+
}
|
|
1172
|
+
if (!claim && o.needsClaim)
|
|
1173
|
+
claimOf(undefined);
|
|
1174
|
+
if (claim)
|
|
1175
|
+
claimProblem(claim, owner);
|
|
1176
|
+
if (this.o.db.get(handle))
|
|
1177
|
+
throw new Error(`handle ${handle} is taken`);
|
|
1178
|
+
const store = this.newStore(handle);
|
|
1179
|
+
if (storeExists(storeSpec({ store, handle })))
|
|
1180
|
+
throw new Error(`handle ${handle}: a store is at ${storeSpec({ store, handle })} already`);
|
|
1181
|
+
this.o.db.add(handle, { store, status: "disabled", domain: o.domain ?? await this.handleDomain(), ...(o.topics?.length ? { topics: JSON.stringify(o.topics) } : {}) }); // #137: the names it is routed by. H4: never the column's `localhost` on a host with a domain
|
|
1182
|
+
this.unpublished.add(handle);
|
|
1183
|
+
try {
|
|
1184
|
+
await this.image.ready; // #132: born with the chain as the host holds it
|
|
1185
|
+
// #142, #143: the host skein from the host image (images/host over images/default), its genesis's `root` the
|
|
1186
|
+
// operator's key, `appConfig` merged.
|
|
1187
|
+
// #162: any instance's app config at birth (a create's `config`), the host skein's owner at birth.
|
|
1188
|
+
// #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 } : {}) });
|
|
1190
|
+
const l = await this.hydrate(handle);
|
|
1191
|
+
if (claim) {
|
|
1192
|
+
// #127: the owner's own message, forwarded as signed; the host signs nothing for the owner. #143: one the door
|
|
1193
|
+
// turns away (its signature does not hold) writes no entry.
|
|
1194
|
+
let entry;
|
|
1195
|
+
try {
|
|
1196
|
+
entry = await this.appendLocal(handle, { kind: "message", message: claim.message, body: claim.body });
|
|
1197
|
+
}
|
|
1198
|
+
catch (e) {
|
|
1199
|
+
if (e instanceof DoorAnswered)
|
|
1200
|
+
throw new Error(`the claim message was refused at the door (${e.answer.reason ?? e.answer.status}); ${handle} is left disabled`);
|
|
1201
|
+
throw e;
|
|
1202
|
+
}
|
|
1203
|
+
await this.queues.get(handle);
|
|
1204
|
+
await l.kernel.idle();
|
|
1205
|
+
if (!(await this.claimedBy(l, owner)))
|
|
1206
|
+
throw new Error(`the claim ${entry} was refused (its log says why); ${handle} is left disabled`);
|
|
1207
|
+
}
|
|
1208
|
+
if (o.host)
|
|
1209
|
+
this.o.db.setSetting("host_skein", handle);
|
|
1210
|
+
if (o.publish !== false) {
|
|
1211
|
+
this.o.db.setStatus(handle, "enabled");
|
|
1212
|
+
await this.refollowHeaders(handle);
|
|
1213
|
+
}
|
|
1214
|
+
const tip = this.image.tip();
|
|
1215
|
+
this.say("router", `created ${handle} (${short(l.identity)}) from the ${o.host ? "host" : o.image ?? "default"} image${tip ? ` (its chain to ${tip.height})` : ""}, ${owned ? `owned by ${short(owner)}${o.host ? "" : " (born owned, #164)"}` : claim ? `claimed by ${short(owner)}${o.claim === undefined ? " (the host's own key, #162)" : ""}` : "unclaimed (its claim row from anyone)"}${o.publish !== false ? `, at ${this.originOf(handle)}` : ""}${o.host ? ": the host skein" : ""}`);
|
|
1216
|
+
return { handle, identity: l.identity, url: this.originOf(handle) };
|
|
1217
|
+
}
|
|
1218
|
+
finally {
|
|
1219
|
+
this.unpublished.delete(handle);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/** host.db's `store` for a new instance (#96): its set in the host's LMDB environment (SKEIN_STORE), else its own file. */
|
|
1223
|
+
newStore(handle) {
|
|
1224
|
+
if (this.o.sqliteStore)
|
|
1225
|
+
return `sqlite:${this.o.sqliteStore}`; // the settled split: the wasm kernel
|
|
1226
|
+
if (this.o.rocksStore)
|
|
1227
|
+
return `rocksdb:${this.o.rocksStore}`; // #158
|
|
1228
|
+
return this.o.lmdbStore ? `lmdb:${this.o.lmdbStore}` : join(this.o.home ?? ".", "instances", handle, "runtime.db");
|
|
1229
|
+
}
|
|
1230
|
+
/** createInstance's image `mailbox` (#113). */
|
|
1231
|
+
async createMailbox(handle, owner, o) {
|
|
1232
|
+
const had = this.o.db.get(handle);
|
|
1233
|
+
if (had) {
|
|
1234
|
+
if (had.kind !== "mailbox" || had.owner !== owner)
|
|
1235
|
+
throw new Error(`handle ${handle} is taken`);
|
|
1236
|
+
// The handle's domain is the asker's (a row registered before #113 took the request's host name).
|
|
1237
|
+
if (o.domain && had.domain !== o.domain)
|
|
1238
|
+
this.o.db.add(handle, { domain: o.domain });
|
|
1239
|
+
if (o.publish !== false && had.status !== "enabled")
|
|
1240
|
+
this.o.db.setStatus(handle, "enabled");
|
|
1241
|
+
const identity = had.identity ?? await rootIdentity(await this.o.walletFor(had));
|
|
1242
|
+
return { handle, identity, url: this.originOf(handle) };
|
|
1243
|
+
}
|
|
1244
|
+
const mine = this.o.db.list().find((r) => r.kind === "mailbox" && r.owner === owner);
|
|
1245
|
+
if (mine)
|
|
1246
|
+
throw new Error(`${short(owner)} has a mailbox instance here already: ${mine.handle}`);
|
|
1247
|
+
const store = this.newStore(handle);
|
|
1248
|
+
if (storeExists(storeSpec({ store, handle })))
|
|
1249
|
+
throw new Error(`handle ${handle}: a store is at ${storeSpec({ store, handle })} already`);
|
|
1250
|
+
this.o.db.add(handle, { store, status: "disabled", kind: "mailbox", owner, ...(o.domain ? { domain: o.domain } : {}) });
|
|
1251
|
+
await this.bootRow(handle, { kind: "code" });
|
|
1252
|
+
const identity = this.o.db.get(handle).identity;
|
|
1253
|
+
if (o.publish !== false)
|
|
1254
|
+
this.o.db.setStatus(handle, "enabled");
|
|
1255
|
+
this.say("router", `created mailbox instance ${handle}@${this.o.db.get(handle).domain} (${short(identity)}) for ${short(owner)}${o.publish !== false ? `, at ${this.originOf(handle)}` : ""}`);
|
|
1256
|
+
return { handle, identity, url: this.originOf(handle) };
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* The instance manager's work (#90; providers.ts `manager`): a message from
|
|
1260
|
+
* the host skein in box `create`, `start` or `stop` → the answer body. A
|
|
1261
|
+
* refusal throws (the provider answers {error}).
|
|
1262
|
+
*/
|
|
1263
|
+
async manage(box, b) {
|
|
1264
|
+
const handle = typeof b.handle === "string" ? b.handle : "";
|
|
1265
|
+
if (box === "create") {
|
|
1266
|
+
// #162: on a one-key host with no claim forwarded, the owner is the host's own key (named or not).
|
|
1267
|
+
const shared = this.o.claimer && b.claim == null ? (await this.o.claimer.getPublicKey({ identityKey: true })).publicKey : undefined;
|
|
1268
|
+
const owner = b.owner instanceof Uint8Array ? Buffer.from(b.owner).toString("hex") : typeof b.owner === "string" ? b.owner : shared ?? "";
|
|
1269
|
+
if (b.image != null && typeof b.image !== "string")
|
|
1270
|
+
throw new Error("image: the image's name (text)");
|
|
1271
|
+
if (b.domain != null && typeof b.domain !== "string")
|
|
1272
|
+
throw new Error("domain: a host name (text)");
|
|
1273
|
+
// #162: `config` — the new instance's app config at birth, by app name (as an install's --config), any image's.
|
|
1274
|
+
if (b.config != null && (typeof b.config !== "object" || Array.isArray(b.config) || b.config instanceof Uint8Array || Object.values(b.config).some((x) => !x || typeof x !== "object" || Array.isArray(x))))
|
|
1275
|
+
throw new Error("config: the apps' config at birth, {<app>: {…}}");
|
|
1276
|
+
// #127: a skein from the default image comes with its owner's signed claim (the registrant's, forwarded); #162: a one-key host's own.
|
|
1277
|
+
// skein-amm#4: `beef` — an Atomic BEEF (the token's deploy) handed into the new instance once it is up.
|
|
1278
|
+
if (b.beef != null && !(b.beef instanceof Uint8Array))
|
|
1279
|
+
throw new Error("beef: an Atomic BEEF (bytes)");
|
|
1280
|
+
const topics = topicsArg(b.topics);
|
|
1281
|
+
const c = await this.createInstance(handle, owner, { image: b.image ?? undefined, ...(typeof b.domain === "string" ? { domain: b.domain } : {}), ...(b.claim != null ? { claim: b.claim } : {}), ...(b.config != null ? { appConfig: b.config } : {}), ...(topics ? { topics } : {}), needsClaim: true });
|
|
1282
|
+
const answer = { handle: c.handle, identity: keyBytes(c.identity), url: c.url };
|
|
1283
|
+
if (b.beef instanceof Uint8Array)
|
|
1284
|
+
answer.delivered = await this.deliverBeef(c.handle, b.beef);
|
|
1285
|
+
return answer;
|
|
1286
|
+
}
|
|
1287
|
+
if (box !== "start" && box !== "stop")
|
|
1288
|
+
throw new Error(`the instance manager takes create, start and stop: not ${box}`);
|
|
1289
|
+
const row = this.o.db.get(handle);
|
|
1290
|
+
if (!row)
|
|
1291
|
+
throw new Error(`no instance ${handle}`);
|
|
1292
|
+
if (row.handle === this.o.db.hostSkein()?.handle)
|
|
1293
|
+
throw new Error(`${handle} is the host skein: it is not started or stopped by a message from itself`);
|
|
1294
|
+
if (box === "start") {
|
|
1295
|
+
// #137: `topics` replaces the names the row is routed by; absent keeps them.
|
|
1296
|
+
const topics = topicsArg(b.topics);
|
|
1297
|
+
if (topics)
|
|
1298
|
+
this.o.db.setTopics(handle, topics);
|
|
1299
|
+
this.o.db.setStatus(handle, "enabled");
|
|
1300
|
+
await this.hydrate(handle);
|
|
1301
|
+
await this.refollowHeaders(handle);
|
|
1302
|
+
this.say("router", `${handle}: started by the instance manager`);
|
|
1303
|
+
return { handle, started: true, url: this.originOf(handle) };
|
|
1304
|
+
}
|
|
1305
|
+
this.o.db.setStatus(handle, "disabled");
|
|
1306
|
+
await this.refollowHeaders(handle);
|
|
1307
|
+
const l = this.loaded.get(handle);
|
|
1308
|
+
if (l) {
|
|
1309
|
+
this.loaded.delete(handle);
|
|
1310
|
+
await l.kernel.stop();
|
|
1311
|
+
}
|
|
1312
|
+
// skein-amm#4: off its libp2p node too (as reclaim), so a stopped instance's beacons stop beating; a start's hydrate declares it again.
|
|
1313
|
+
await this.p2p?.declare(handle, undefined).catch(() => { });
|
|
1314
|
+
this.events.drop(handle);
|
|
1315
|
+
this.say("router", `${handle}: stopped by the instance manager`);
|
|
1316
|
+
return { handle, stopped: true };
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* A create's `beef` (skein-amm#4): the Atomic BEEF handed into the new instance's receive row
|
|
1320
|
+
* (/skein/overlay/beef/1.0.0) exactly as a catch-up's fetched BEEF is (sync.ts `deliver`), from the
|
|
1321
|
+
* host's own peer, and the answer → {verdict, reason?}. No topic is named here: the instance's engine
|
|
1322
|
+
* judges it under the topics its config declares. Turned away at the door (unmined, a bad BUMP): reported,
|
|
1323
|
+
* not retried; the instance stays created.
|
|
1324
|
+
*/
|
|
1325
|
+
async deliverBeef(handle, beef) {
|
|
1326
|
+
let from = new Uint8Array();
|
|
1327
|
+
try {
|
|
1328
|
+
if (this.p2p)
|
|
1329
|
+
from = peerIdFromString(this.p2p.peerId(handle)).toMultihash().bytes;
|
|
1330
|
+
}
|
|
1331
|
+
catch { /* no peer: the record names none */ }
|
|
1332
|
+
// The door checks the BUMPs against chain/state: the host chain's event (#156, sent at the start, not awaited there) taken first.
|
|
1333
|
+
await this.chainBirth(handle).catch(() => { });
|
|
1334
|
+
const l = this.loaded.get(handle);
|
|
1335
|
+
if (l) {
|
|
1336
|
+
await this.queues.get(handle);
|
|
1337
|
+
await l.kernel.idle().catch(() => { });
|
|
1338
|
+
}
|
|
1339
|
+
try {
|
|
1340
|
+
const r = await this.p2pInbound(handle, { transport: "libp2p", protocol: BEEF_PROTOCOL, from, body: beef });
|
|
1341
|
+
this.say("router", `${handle}: the create's BEEF delivered: ${r.verdict}${r.reason ? ` (${r.reason})` : ""}`);
|
|
1342
|
+
return { verdict: r.verdict, ...(r.reason ? { reason: r.reason } : {}) };
|
|
1343
|
+
}
|
|
1344
|
+
catch (e) {
|
|
1345
|
+
const reason = e.message;
|
|
1346
|
+
this.say("router", `${handle}: the create's BEEF not delivered: ${reason}`);
|
|
1347
|
+
return { verdict: "ignore", reason };
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
/** What this host brings to a new instance's genesis (boot.ts): the owner and its messagebox, the inference peer, their names, the host's defaults. */
|
|
1351
|
+
genesisConfig(row, identity, code = true) {
|
|
1352
|
+
if (row.kind === "mailbox") {
|
|
1353
|
+
if (!row.owner)
|
|
1354
|
+
throw new Error(`${row.handle}: a mailbox instance names its owner`);
|
|
1355
|
+
return { identity, root: [row.owner], handle: row.handle, domain: row.domain, mailbox: true, network: this.o.network };
|
|
1356
|
+
}
|
|
1357
|
+
if (!this.o.owner)
|
|
1358
|
+
throw new Error("an empty store needs the owner's identity key (SKEIN_OWNER) for its genesis");
|
|
1359
|
+
const genesisDefaults = this.o.genesis?.defaults;
|
|
1360
|
+
const hostDefaults = { ...genesisDefaults, ...(this.o.fuelPerStep ? { fuelPerStep: this.o.fuelPerStep } : {}) };
|
|
1361
|
+
const warn = (l) => this.say(row.handle, l);
|
|
1362
|
+
const facts = { ownerMessagebox: this.ownerMessagebox(), resolveOrigin: this.origin(), addressBook: this.addressSeed(row.handle), providers: Object.fromEntries(this.addressSeed(row.handle).filter((e) => e.transport === "local").map((e) => [e.address, Buffer.from(e.key).toString("hex")])) };
|
|
1363
|
+
if (!code) {
|
|
1364
|
+
// A system tree: its config wins; the host fills what it leaves unset. SKEIN_FUEL_PER_STEP stays an explicit (dev) override.
|
|
1365
|
+
return {
|
|
1366
|
+
identity, root: [this.o.owner], handle: row.handle, domain: row.domain, infer: this.o.infer, ownerHandle: this.o.ownerHandle, inferHandle: this.o.inferHandle,
|
|
1367
|
+
feeds: this.o.genesis?.feeds, defaults: genesisDefaults, overrides: this.o.fuelPerStep ? { fuelPerStep: this.o.fuelPerStep } : undefined, warn, network: this.o.network, ...facts,
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
return {
|
|
1371
|
+
identity, root: [this.o.owner], handle: row.handle, domain: row.domain, infer: this.o.infer, ownerHandle: this.o.ownerHandle, inferHandle: this.o.inferHandle,
|
|
1372
|
+
// Code genesis takes DEFAULTS under the host's.
|
|
1373
|
+
defaults: Object.keys(hostDefaults).length ? { ...DEFAULTS, ...hostDefaults } : undefined,
|
|
1374
|
+
subscriptions: this.o.genesis?.subscriptions,
|
|
1375
|
+
feeds: this.o.genesis?.feeds,
|
|
1376
|
+
libp2p: this.o.genesis?.libp2p, dispatch: this.o.genesis?.dispatch,
|
|
1377
|
+
network: this.o.network, ...facts,
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* The kernel counts as busy until it has processed what was just admitted
|
|
1382
|
+
* (its `idle` answers after the drain); then the libp2p node and the host's
|
|
1383
|
+
* headers feed follow the dispatch table (#72, #77, #102).
|
|
1384
|
+
*/
|
|
1385
|
+
settle(l) {
|
|
1386
|
+
const p = l.kernel.idle().then(() => this.syncDispatch(l)).then(() => this.syncBilling(l)).catch((e) => this.say(l.row.handle, `the dispatch table not followed: ${e.message}`));
|
|
1387
|
+
this.syncing.add(p);
|
|
1388
|
+
void p.finally(() => this.syncing.delete(p));
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* What the host does by the instance's dispatch table (#72, #77, #102):
|
|
1392
|
+
* read the table (the kernel's `dispatch` frame); when its chain's tip
|
|
1393
|
+
* moved since it was last followed (or `first`), the host's headers feed
|
|
1394
|
+
* subscribes or unsubscribes it (followHeaders), and its libp2p node is
|
|
1395
|
+
* declared again as its genesis and the table ask (p2p.ts libp2pConfig) —
|
|
1396
|
+
* started, reconfigured (topics subscribed and unsubscribed, protocols
|
|
1397
|
+
* handled and unhandled) or stopped. A failure is logged, not fatal.
|
|
1398
|
+
*/
|
|
1399
|
+
async syncDispatch(l, first = false) {
|
|
1400
|
+
if ((!this.p2p && !this.o.headersFeed && !this.events.open(l.row.handle)) || l.kernel.gone)
|
|
1401
|
+
return;
|
|
1402
|
+
const d = await l.kernel.dispatch().catch((e) => { this.say(l.row.handle, `the dispatch table not read: ${e.message}`); return undefined; });
|
|
1403
|
+
if (d === undefined)
|
|
1404
|
+
return;
|
|
1405
|
+
const key = d.tip ? d.tip.toString() : "";
|
|
1406
|
+
if (!first && key === (l.dispatchTip ?? ""))
|
|
1407
|
+
return;
|
|
1408
|
+
l.dispatchTip = key;
|
|
1409
|
+
l.rows = d.rows;
|
|
1410
|
+
this.followHeaders(l.row.handle, d.rows);
|
|
1411
|
+
// #148: an uninstalled app's event streams close.
|
|
1412
|
+
if (this.events.open(l.row.handle))
|
|
1413
|
+
this.events.keep(l.row.handle, appsOf(d.rows));
|
|
1414
|
+
// #126: an uninstalled app's beacons stop — an app with no row left in the table (an uninstall
|
|
1415
|
+
// removes every row naming the app and leaves its heads).
|
|
1416
|
+
if (l.beacons.length || l.live.length) {
|
|
1417
|
+
const apps = appsOf(d.rows);
|
|
1418
|
+
l.beacons = l.beacons.filter((b) => apps.has(b.app));
|
|
1419
|
+
l.live = l.live.filter((x) => apps.has(x.app)); // #138: and its liveness
|
|
1420
|
+
}
|
|
1421
|
+
await this.declareP2P(l, first);
|
|
1422
|
+
}
|
|
1423
|
+
/** The instance's libp2p node declared as its genesis, its table and its apps' subscriptions (#119) ask. */
|
|
1424
|
+
async declareP2P(l, first = false) {
|
|
1425
|
+
if (!this.p2p)
|
|
1426
|
+
return;
|
|
1427
|
+
const rows = (l.rows ?? []);
|
|
1428
|
+
await this.p2p.declare(l.row.handle, libp2pConfig(l.genesis, rows, subscribedTopics(l.subs), l.beacons.length > 0 || l.live.length > 0)).catch((e) => this.say(l.row.handle, `libp2p: ${first ? "not started" : "not reconfigured"}: ${e.message}`));
|
|
1429
|
+
this.p2p.beacons(l.row.handle, l.beacons);
|
|
1430
|
+
this.p2p.liveness(l.row.handle, l.live);
|
|
1431
|
+
}
|
|
1432
|
+
/** #138: the apps' liveness, folded from the instance's log (liveness.ts livenessOf), the store file read-only. */
|
|
1433
|
+
async readLiveness(store) {
|
|
1434
|
+
if (!this.p2p || !storeExists(store))
|
|
1435
|
+
return [];
|
|
1436
|
+
const s = openStoreFile(store, { readOnly: true });
|
|
1437
|
+
try {
|
|
1438
|
+
return await livenessOf(s);
|
|
1439
|
+
}
|
|
1440
|
+
finally {
|
|
1441
|
+
s.close();
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/** #126: the apps' beacons, folded from the instance's log (p2p.ts beaconsOf), the store file read-only. */
|
|
1445
|
+
async foldBeacons(store) {
|
|
1446
|
+
if (!this.p2p || !storeExists(store))
|
|
1447
|
+
return [];
|
|
1448
|
+
const s = openStoreFile(store, { readOnly: true });
|
|
1449
|
+
try {
|
|
1450
|
+
return await beaconsOf(s);
|
|
1451
|
+
}
|
|
1452
|
+
finally {
|
|
1453
|
+
s.close();
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* #119: the apps' subscriptions, folded from the instance's log — the
|
|
1458
|
+
* libp2p node's recovery at hydrate, by reading (p2p.ts subscriptionsOf;
|
|
1459
|
+
* the store file opened read-only beside the kernel, as `subscribes` reads it).
|
|
1460
|
+
*/
|
|
1461
|
+
async foldSubscriptions(store) {
|
|
1462
|
+
if (!this.p2p || !storeExists(store))
|
|
1463
|
+
return [];
|
|
1464
|
+
const s = openStoreFile(store, { readOnly: true });
|
|
1465
|
+
try {
|
|
1466
|
+
return await subscriptionsOf(s);
|
|
1467
|
+
}
|
|
1468
|
+
finally {
|
|
1469
|
+
s.close();
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* A `subscribe` / `unsubscribe` event from `handle` (#119, after its step's
|
|
1474
|
+
* commit; the kernel checked it as it was emitted): the subscriptions
|
|
1475
|
+
* change and the node is declared again — the topic subscribed, or left
|
|
1476
|
+
* when no subscription, row or genesis topic takes it any more. Which app's
|
|
1477
|
+
* program runs for a message on it is the kernel's (the subscription). An
|
|
1478
|
+
* instance still loading reads the event from its log at hydrate; it is
|
|
1479
|
+
* applied after that too (the same result).
|
|
1480
|
+
*/
|
|
1481
|
+
topicEvent(handle, rec) {
|
|
1482
|
+
if (rec.event === "beacon" || rec.event === "unbeacon")
|
|
1483
|
+
return this.beaconEvent(handle, rec);
|
|
1484
|
+
// An overlay before skein-overlay 0.20.0 emits want / unwant: nothing asks for them any more (the settled sync design).
|
|
1485
|
+
if (rec.event === "want" || rec.event === "unwant")
|
|
1486
|
+
return;
|
|
1487
|
+
if (rec.event === "liveness" || rec.event === "unliveness")
|
|
1488
|
+
return this.livenessEvent(handle, rec);
|
|
1489
|
+
const e = subscriptionEvent(rec);
|
|
1490
|
+
if ("refused" in e) {
|
|
1491
|
+
this.say(handle, `libp2p: ${String(rec.event)}: ${e.refused}`);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const topic = e.event === "subscribe" ? e.sub.topic : e.topic;
|
|
1495
|
+
const app = e.event === "subscribe" ? e.sub.app : e.app;
|
|
1496
|
+
const apply = async () => {
|
|
1497
|
+
const l = this.loaded.get(handle) ?? await this.loading.get(handle)?.catch(() => undefined);
|
|
1498
|
+
if (!l || l.kernel.gone)
|
|
1499
|
+
return;
|
|
1500
|
+
foldSubscriptions([e], l.subs);
|
|
1501
|
+
this.say(handle, `libp2p: ${e.event} ${topic} (app ${app}${e.event === "subscribe" ? ` → ${e.sub.program}.${e.sub.fn}` : ""})`);
|
|
1502
|
+
await this.declareP2P(l);
|
|
1503
|
+
};
|
|
1504
|
+
const p = apply().catch((x) => this.say(handle, `libp2p: ${e.event} ${topic}: ${x.message}`));
|
|
1505
|
+
this.syncing.add(p);
|
|
1506
|
+
void p.finally(() => this.syncing.delete(p));
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* A `liveness` / `unliveness` event from `handle` (#138, after its step's commit; the kernel
|
|
1510
|
+
* checked its shape as it was emitted): the liveness changes and the node's liveness tool follows
|
|
1511
|
+
* it (P2PHost.liveness) — the topic subscribed without admitting its messages, or left. An instance
|
|
1512
|
+
* still loading reads it from its log.
|
|
1513
|
+
*/
|
|
1514
|
+
livenessEvent(handle, rec) {
|
|
1515
|
+
const e = livenessEvent(rec);
|
|
1516
|
+
if ("refused" in e) {
|
|
1517
|
+
this.say(handle, `libp2p: ${String(rec.event)}: ${e.refused}`);
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const apply = async () => {
|
|
1521
|
+
const l = this.loaded.get(handle) ?? await this.loading.get(handle)?.catch(() => undefined);
|
|
1522
|
+
if (!l || l.kernel.gone)
|
|
1523
|
+
return;
|
|
1524
|
+
foldLiveness([e], l.live);
|
|
1525
|
+
await this.declareP2P(l);
|
|
1526
|
+
};
|
|
1527
|
+
const p = apply().catch((x) => this.say(handle, `libp2p: ${e.event}: ${x.message}`));
|
|
1528
|
+
this.syncing.add(p);
|
|
1529
|
+
void p.finally(() => this.syncing.delete(p));
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* GET /<app>/.live/<topic> at an instance's origin (#138): the liveness tool's set for the app's
|
|
1533
|
+
* liveness on the topic, served by the host — no program, no entry, nothing logged; metered as a
|
|
1534
|
+
* read (bytes served). 404 when the app keeps no liveness for the topic (or the host has no libp2p).
|
|
1535
|
+
*/
|
|
1536
|
+
async liveRead(handle, route, app, topic) {
|
|
1537
|
+
const none = () => json(404, { status: "error", code: "ERR_NOT_FOUND", description: `app ${app} keeps no liveness for ${topic}` });
|
|
1538
|
+
if (!this.p2p)
|
|
1539
|
+
return none();
|
|
1540
|
+
try {
|
|
1541
|
+
await this.hydrate(handle);
|
|
1542
|
+
}
|
|
1543
|
+
catch (e) {
|
|
1544
|
+
return json(503, { status: "error", code: "ERR_UNAVAILABLE", description: e.message });
|
|
1545
|
+
}
|
|
1546
|
+
const closed = this.closed(handle);
|
|
1547
|
+
if (closed)
|
|
1548
|
+
return json(402, { status: "error", code: "ERR_PAYMENT_REQUIRED", description: closed });
|
|
1549
|
+
const beats = this.p2p.live.read(handle, app, topic);
|
|
1550
|
+
if (!beats)
|
|
1551
|
+
return none();
|
|
1552
|
+
const r = json(200, beats.map((b) => ({ sender: b.sender, at: b.at, body: Buffer.from(b.body).toString("base64"), from: b.from })));
|
|
1553
|
+
this.meter(handle, { at: stampMs(this.now()), op: `GET ${route}`, fuel: 0, bytes: r.body.length });
|
|
1554
|
+
return r;
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* GET /<app>/.events at an instance's origin (#148, events.ts): the app's emitted events of the
|
|
1558
|
+
* asked name and topics as a Server-Sent Events stream — the backlog folded from the log, then each
|
|
1559
|
+
* as it is handed over — held open until the client goes, the app is uninstalled or the instance
|
|
1560
|
+
* stops. The host's own (no program, no entry, nothing logged); each message metered as a read.
|
|
1561
|
+
*/
|
|
1562
|
+
async eventStream(handle, route, app, url, req, res, cors) {
|
|
1563
|
+
const answer = (r) => { if (!res.headersSent) {
|
|
1564
|
+
res.writeHead(r.status, { ...cors, ...r.headers, "content-length": String(r.body.length) });
|
|
1565
|
+
res.end(r.body);
|
|
1566
|
+
} };
|
|
1567
|
+
try {
|
|
1568
|
+
const last = req.headers["last-event-id"];
|
|
1569
|
+
const q = eventsQuery(app, url.searchParams, Array.isArray(last) ? last[0] : last);
|
|
1570
|
+
if ("problem" in q)
|
|
1571
|
+
return answer(json(400, { status: "error", code: "ERR_BAD_REQUEST", description: q.problem }));
|
|
1572
|
+
if (this.stopped)
|
|
1573
|
+
return answer(json(503, { status: "error", code: "ERR_UNAVAILABLE", description: "the host is shutting down" }));
|
|
1574
|
+
let l;
|
|
1575
|
+
try {
|
|
1576
|
+
l = await this.hydrate(handle);
|
|
1577
|
+
}
|
|
1578
|
+
catch (e) {
|
|
1579
|
+
return answer(json(503, { status: "error", code: "ERR_UNAVAILABLE", description: e.message }));
|
|
1580
|
+
}
|
|
1581
|
+
const closed = this.closed(handle);
|
|
1582
|
+
if (closed)
|
|
1583
|
+
return answer(json(402, { status: "error", code: "ERR_PAYMENT_REQUIRED", description: closed }));
|
|
1584
|
+
if (!appsOf((await l.kernel.dispatch()).rows).has(app))
|
|
1585
|
+
return answer(json(404, { status: "error", code: "ERR_NOT_FOUND", description: `no app ${app} installed here` }));
|
|
1586
|
+
res.writeHead(200, { ...cors, "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
1587
|
+
res.flushHeaders();
|
|
1588
|
+
let forget;
|
|
1589
|
+
let gone = false;
|
|
1590
|
+
res.on("close", () => { gone = true; forget?.(); });
|
|
1591
|
+
const send = (d) => {
|
|
1592
|
+
const m = sseMessage(d);
|
|
1593
|
+
res.write(m);
|
|
1594
|
+
this.meter(handle, { at: stampMs(this.now()), op: `GET ${route}`, fuel: 0, bytes: Buffer.byteLength(m) });
|
|
1595
|
+
};
|
|
1596
|
+
forget = await this.events.subscribe(handle, q, send, () => res.end());
|
|
1597
|
+
if (gone)
|
|
1598
|
+
forget();
|
|
1599
|
+
}
|
|
1600
|
+
catch (e) {
|
|
1601
|
+
this.say(handle, `events: ${e.message}`);
|
|
1602
|
+
if (res.headersSent)
|
|
1603
|
+
res.end();
|
|
1604
|
+
else
|
|
1605
|
+
answer(json(500, { status: "error", code: "ERR_INTERNAL", description: e.message }));
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
/** How long a peer that beat on a topic is remembered without beating again (ms): 30 days (David to review). */
|
|
1609
|
+
static PEER_KEEP_MS = 30 * 86_400_000;
|
|
1610
|
+
/**
|
|
1611
|
+
* A beat kept from `peer` on `liveTopic` at `handle` (the settled sync design): when an overlay app
|
|
1612
|
+
* of the instance beats on that topic, the peer and its addresses (the node's peer store) upserted
|
|
1613
|
+
* into host.db `topic_peers`, keyed by the overlay topic. What a restart asks first.
|
|
1614
|
+
*/
|
|
1615
|
+
async rememberPeer(handle, liveTopic, peer) {
|
|
1616
|
+
if (!liveTopic.endsWith("-live") || !this.p2p)
|
|
1617
|
+
return;
|
|
1618
|
+
if (!this.loaded.get(handle)?.beacons.some((b) => b.topic === liveTopic))
|
|
1619
|
+
return;
|
|
1620
|
+
try {
|
|
1621
|
+
this.o.db.seenPeer(handle, liveTopic.slice(0, -"-live".length), peer, await this.p2p.peerAddrs(handle, peer), stampMs(this.now()));
|
|
1622
|
+
}
|
|
1623
|
+
catch (e) {
|
|
1624
|
+
this.say(handle, `sync: peer ${peer} not remembered: ${e.message}`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* At a start (hydrate): for each overlay topic the instance beats on, the peers host.db remembers
|
|
1629
|
+
* (rows older than PEER_KEEP_MS pruned first) dialled, a few at a time, failures dropped silently;
|
|
1630
|
+
* each that answers asked at once — the locator and the tail (sync.ts `reconnect`) — without waiting
|
|
1631
|
+
* for a beat.
|
|
1632
|
+
*/
|
|
1633
|
+
async askKnownPeers(l) {
|
|
1634
|
+
const handle = l.row.handle;
|
|
1635
|
+
if (!this.p2p || !this.sync)
|
|
1636
|
+
return;
|
|
1637
|
+
this.o.db.prunePeers(stampMs(this.now()) - Router.PEER_KEEP_MS);
|
|
1638
|
+
const jobs = [];
|
|
1639
|
+
for (const b of l.beacons) {
|
|
1640
|
+
if (!b.topic.endsWith("-live"))
|
|
1641
|
+
continue;
|
|
1642
|
+
const topic = b.topic.slice(0, -"-live".length);
|
|
1643
|
+
for (const r of this.o.db.topicPeers(handle, topic))
|
|
1644
|
+
if (r.peer !== this.p2p.peerId(handle))
|
|
1645
|
+
jobs.push({ app: b.app, topic, peer: r.peer, addrs: r.addrs });
|
|
1646
|
+
}
|
|
1647
|
+
const p2p = this.p2p, sync = this.sync;
|
|
1648
|
+
const one = async (j) => {
|
|
1649
|
+
try {
|
|
1650
|
+
await p2p.connect(handle, j.peer, j.addrs);
|
|
1651
|
+
}
|
|
1652
|
+
catch {
|
|
1653
|
+
return;
|
|
1654
|
+
} // a peer gone: dropped silently
|
|
1655
|
+
sync.reconnect(handle, j.app, j.topic, j.peer);
|
|
1656
|
+
};
|
|
1657
|
+
for (let i = 0; i < jobs.length; i += 4)
|
|
1658
|
+
await Promise.all(jobs.slice(i, i + 4).map(one));
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* shruggr/skein#154: our own overlay's read, for the sync's puller (its locator and what it lacks) —
|
|
1662
|
+
* the engine's function `fn` (`anchors`, `admitted`) by a kernel call (the `call` op; nothing
|
|
1663
|
+
* appended, nothing logged), not through the app's http table, which an app need not publish. The
|
|
1664
|
+
* program is the app record's engine (its role `overlay`); the argument is what a read route's
|
|
1665
|
+
* filter is handed (docs/APPS.md "Filters": the route handler contract's fields), `match` naming the
|
|
1666
|
+
* app, so the engine reads its configuration from the app record as it does for its read routes.
|
|
1667
|
+
* Its answer is the read route's `{status, type, body}`: the body's JSON, or a throw on another status.
|
|
1668
|
+
*/
|
|
1669
|
+
async overlayRead(handle, app, fn, query) {
|
|
1670
|
+
const l = await this.hydrate(handle);
|
|
1671
|
+
const root = await l.kernel.call("head", appHead(app));
|
|
1672
|
+
if (!root)
|
|
1673
|
+
throw new Error(`${app}: no app record`);
|
|
1674
|
+
const engine = (await l.kernel.store.get(root)).programs?.[OVERLAY_ROLE];
|
|
1675
|
+
if (!(engine instanceof CID))
|
|
1676
|
+
throw new Error(`${app}: its app record has no engine (role ${OVERLAY_ROLE})`);
|
|
1677
|
+
const qs = new URLSearchParams(query).toString();
|
|
1678
|
+
const route = `/${app}/${fn}`;
|
|
1679
|
+
const http = { method: "GET", path: route, route, query: qs ? `?${qs}` : "", headers: {}, body: new Uint8Array() };
|
|
1680
|
+
const arg = { transport: "http", request: { kind: "http", ...http }, match: { transport: "http", address: route, app }, ...http, contentType: "" };
|
|
1681
|
+
const c = await l.kernel.invoke(engine, fn, dagCbor.encode(arg), { now: stampMs(this.now()) });
|
|
1682
|
+
if (!c.ok)
|
|
1683
|
+
throw new Error(`${app}.${fn}: ${c.error}`);
|
|
1684
|
+
const a = dagCbor.decode(c.result);
|
|
1685
|
+
const text = a.body instanceof Uint8Array ? Buffer.from(a.body).toString("utf8") : "";
|
|
1686
|
+
if (a.status !== 200)
|
|
1687
|
+
throw new Error(`${app}.${fn}: ${a.status} ${text.slice(0, 200)}`);
|
|
1688
|
+
return JSON.parse(text);
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* A `beacon` / `unbeacon` event from `handle` (#126, after its step's commit;
|
|
1692
|
+
* the kernel checked its shape as it was emitted): the beacons change and
|
|
1693
|
+
* the node beats them (P2PHost.beacons) — started if a beacon needs it; the
|
|
1694
|
+
* topic is not subscribed. An instance still loading reads it from its log.
|
|
1695
|
+
*/
|
|
1696
|
+
beaconEvent(handle, rec) {
|
|
1697
|
+
const e = beaconEvent(rec);
|
|
1698
|
+
if ("refused" in e) {
|
|
1699
|
+
this.say(handle, `libp2p: ${String(rec.event)}: ${e.refused}`);
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
const apply = async () => {
|
|
1703
|
+
const l = this.loaded.get(handle) ?? await this.loading.get(handle)?.catch(() => undefined);
|
|
1704
|
+
if (!l || l.kernel.gone)
|
|
1705
|
+
return;
|
|
1706
|
+
foldBeacons([e], l.beacons);
|
|
1707
|
+
await this.declareP2P(l);
|
|
1708
|
+
};
|
|
1709
|
+
const p = apply().catch((x) => this.say(handle, `libp2p: ${e.event}: ${x.message}`));
|
|
1710
|
+
this.syncing.add(p);
|
|
1711
|
+
void p.finally(() => this.syncing.delete(p));
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* The host's headers feed (#102) for an instance whose dispatch table is
|
|
1715
|
+
* `rows`: subscribed (box `chain`) while it is enabled and a row takes
|
|
1716
|
+
* events in box `chain` by name (the chain app's `event` row; dispatch.zig
|
|
1717
|
+
* forEvent) — a catch-all `*` row (a mailbox instance's) is not one —
|
|
1718
|
+
* else not. One line when that changes.
|
|
1719
|
+
*/
|
|
1720
|
+
followHeaders(handle, rows) {
|
|
1721
|
+
const url = this.o.headersFeed;
|
|
1722
|
+
if (!url)
|
|
1723
|
+
return;
|
|
1724
|
+
const want = this.o.db.get(handle)?.status === "enabled" && rows.some((r) => r.address === DEFAULT_BOX && takesEvent(r, DEFAULT_BOX));
|
|
1725
|
+
if (want === this.feeds.hosts(handle))
|
|
1726
|
+
return;
|
|
1727
|
+
// #141: on subscribing, the headers it lacks first — from its tip (its chain state's, else its image's) to the host's.
|
|
1728
|
+
const backfill = async () => {
|
|
1729
|
+
const row = this.o.db.get(handle);
|
|
1730
|
+
if (!row || !storeExists(storeSpec(row)))
|
|
1731
|
+
return [];
|
|
1732
|
+
const s = openStoreFile(storeSpec(row), { readOnly: true });
|
|
1733
|
+
let tip;
|
|
1734
|
+
try {
|
|
1735
|
+
tip = await skeinTip(s);
|
|
1736
|
+
}
|
|
1737
|
+
finally {
|
|
1738
|
+
s.close();
|
|
1739
|
+
}
|
|
1740
|
+
return await this.image.after(tip);
|
|
1741
|
+
};
|
|
1742
|
+
void this.feeds.host(handle, want ? { kind: "headers", url, box: DEFAULT_BOX } : undefined, want ? backfill : undefined);
|
|
1743
|
+
this.say("router", `${handle}: ${want ? "subscribed to" : "unsubscribed from"} the host's headers feed ${url}`);
|
|
1744
|
+
// #156: an app that takes box `chain` installed into a running instance: the host chain as it stands, now.
|
|
1745
|
+
if (want)
|
|
1746
|
+
void this.chainBirth(handle).catch((e) => this.say(handle, `host chain: the install event not sent: ${e.message}`));
|
|
1747
|
+
}
|
|
1748
|
+
/** Follow the host's headers feed for an instance whose status changed (#102): its table read again. */
|
|
1749
|
+
async refollowHeaders(handle) {
|
|
1750
|
+
if (!this.o.headersFeed)
|
|
1751
|
+
return;
|
|
1752
|
+
const l = this.loaded.get(handle);
|
|
1753
|
+
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;
|
|
1754
|
+
this.followHeaders(handle, d?.rows ?? []);
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* The waker answers every wake-me due by this router's clock, and the cron
|
|
1758
|
+
* provider sends every tick due (#69) — what their timers do in real time,
|
|
1759
|
+
* for a clock that is not (a script clock: tests, the corpus).
|
|
1760
|
+
*/
|
|
1761
|
+
async wake() {
|
|
1762
|
+
this.providers.wakeDue();
|
|
1763
|
+
await this.cron.tick();
|
|
1764
|
+
await this.providers.idle();
|
|
1765
|
+
}
|
|
1766
|
+
/** An instance's next wake (ms), if any (the host page): the waker's earliest, or its next tick (#69). */
|
|
1767
|
+
nextWake(handle) {
|
|
1768
|
+
const xs = [this.providers.nextWake(handle), ...this.cron.of(handle).map((s) => s.next)].filter((x) => x !== undefined);
|
|
1769
|
+
return xs.length ? Math.min(...xs) : undefined;
|
|
1770
|
+
}
|
|
1771
|
+
/** Stop kernels idle for `ms` with nothing queued. Their deadlines and sleeps stay with the waker, their schedules with the cron provider. */
|
|
1772
|
+
async reap(ms) {
|
|
1773
|
+
for (const [handle, l] of this.loaded) {
|
|
1774
|
+
if (l.kernel.busy > 0 || this.queues.has(handle) || Date.now() - l.kernel.last < ms)
|
|
1775
|
+
continue;
|
|
1776
|
+
this.loaded.delete(handle);
|
|
1777
|
+
this.say("router", `${handle}: idle, stopped`);
|
|
1778
|
+
await l.kernel.stop();
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
// ---------------------------------------------------------------- the fuel ledger
|
|
1782
|
+
charge(instance, caller, op, fuel) {
|
|
1783
|
+
const k = `${instance}\0${caller}\0${op}`;
|
|
1784
|
+
const x = this.owed.get(k) ?? { calls: 0, fuel: 0 };
|
|
1785
|
+
x.calls += 1;
|
|
1786
|
+
x.fuel += fuel;
|
|
1787
|
+
this.owed.set(k, x);
|
|
1788
|
+
}
|
|
1789
|
+
/** Write what calls cost since the last flush (host.db, never the log). */
|
|
1790
|
+
flushLedger() {
|
|
1791
|
+
if (!this.owed.size)
|
|
1792
|
+
return;
|
|
1793
|
+
const rows = [...this.owed].map(([k, v]) => { const [instance, caller, op] = k.split("\0"); return { instance, caller, op, ...v }; });
|
|
1794
|
+
this.owed.clear();
|
|
1795
|
+
try {
|
|
1796
|
+
this.o.db.charge(rows);
|
|
1797
|
+
}
|
|
1798
|
+
catch (e) {
|
|
1799
|
+
this.say("router", `ledger: ${e.message}`);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
// ---------------------------------------------------------------- billing (#130, billing.ts)
|
|
1803
|
+
/** Why this host forwards nothing to `handle` (undefined: it forwards as usual). */
|
|
1804
|
+
closed(handle) {
|
|
1805
|
+
return closedBy(this.views.get(handle));
|
|
1806
|
+
}
|
|
1807
|
+
/** A billed instance as this host last read it (#130). */
|
|
1808
|
+
billingView(handle) { return this.views.get(handle); }
|
|
1809
|
+
/** A line of this host's log for `handle`'s period (#130): a request it served, a read call it made. */
|
|
1810
|
+
meter(handle, line) {
|
|
1811
|
+
if (!this.o.billing || !this.views.get(handle)?.terms)
|
|
1812
|
+
return;
|
|
1813
|
+
let p = this.periods.get(handle);
|
|
1814
|
+
if (!p)
|
|
1815
|
+
this.periods.set(handle, (p = new Period(line.at)));
|
|
1816
|
+
p.add(line);
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Read the instance's billing (#130): its host row's terms (the dispatch table) and its kernel's
|
|
1820
|
+
* billing state (the head `billing`), checked against what this host supports. A skein with a host
|
|
1821
|
+
* row this host has not ticked yet is ticked now; one whose unreported amounts would take its tally
|
|
1822
|
+
* to its allocation, early. When it went asleep (and woke) goes to host.db (the grace).
|
|
1823
|
+
*/
|
|
1824
|
+
async syncBilling(l) {
|
|
1825
|
+
const cfg = this.o.billing;
|
|
1826
|
+
if (!cfg || l.kernel.gone)
|
|
1827
|
+
return;
|
|
1828
|
+
const handle = l.row.handle;
|
|
1829
|
+
// The table as syncDispatch just read it (it follows the table when the host has libp2p or a headers feed), else read now.
|
|
1830
|
+
const rows = (this.p2p || this.o.headersFeed) && l.rows ? l.rows : (await l.kernel.dispatch()).rows;
|
|
1831
|
+
const terms = termsOf(rows);
|
|
1832
|
+
if (!terms) {
|
|
1833
|
+
if (this.views.has(handle)) {
|
|
1834
|
+
this.views.delete(handle);
|
|
1835
|
+
this.o.db.forgetBilling(handle);
|
|
1836
|
+
this.say(handle, "billing: no host row: not billed");
|
|
1837
|
+
}
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
const root = await l.kernel.call("head", BILLING_HEAD);
|
|
1841
|
+
const state = root ? stateOf(await l.kernel.store.get(root)) : undefined;
|
|
1842
|
+
const why = mismatch(cfg, this.providers.key("billing"), terms);
|
|
1843
|
+
const was = this.views.get(handle);
|
|
1844
|
+
const view = { terms, ...(state ? { state } : {}), ...(why ? { mismatch: why } : {}) };
|
|
1845
|
+
this.views.set(handle, view);
|
|
1846
|
+
const mine = state && state.host === terms.host ? state : undefined;
|
|
1847
|
+
this.o.db.setBilling(handle, { host: terms.host, ...(mine ? { tally: mine.tally, allocation: allocation(mine) } : {}), asleep: !!mine?.asleep, ...(why ? { mismatch: why } : {}) }, stampMs(this.now()));
|
|
1848
|
+
const closed = closedBy(view);
|
|
1849
|
+
if (closed !== closedBy(was))
|
|
1850
|
+
this.say(handle, closed ? `billing: closed — ${closed}` : "billing: open");
|
|
1851
|
+
if (why || mine?.asleep)
|
|
1852
|
+
return;
|
|
1853
|
+
if (mine)
|
|
1854
|
+
this.firstTicks.delete(handle);
|
|
1855
|
+
else if (this.firstTicks.has(handle))
|
|
1856
|
+
return; // sent: the timer sends it again
|
|
1857
|
+
else
|
|
1858
|
+
this.firstTicks.add(handle);
|
|
1859
|
+
const p = this.periods.get(handle);
|
|
1860
|
+
const owed = p ? priceHost(terms.rates, p.fuel, p.served) : 0n;
|
|
1861
|
+
if (!mine || (p && !p.empty && mine.tally + owed >= allocation(mine)))
|
|
1862
|
+
void this.billingTick(handle).catch((e) => this.say(handle, `billing: tick: ${e.message}`));
|
|
1863
|
+
}
|
|
1864
|
+
/** Tick every billed instance this router holds (the timer, every tickMs). */
|
|
1865
|
+
async tickAll() {
|
|
1866
|
+
for (const [handle, l] of this.loaded) {
|
|
1867
|
+
if (l.kernel.gone || !this.views.get(handle)?.terms)
|
|
1868
|
+
continue;
|
|
1869
|
+
await this.billingTick(handle).catch((e) => this.say(handle, `billing: tick: ${e.message}`));
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* The host's tick to `handle` (#130 decided 4): signed by its billing key, in the host row's box —
|
|
1874
|
+
* {kind: "tick", at, allowance, fuel, served, log} — the period since its last tick closed, its log
|
|
1875
|
+
* record kept (host.db billing_ticks: `log` is that record's CID). Not to an instance the gate
|
|
1876
|
+
* holds (asleep, other terms), nor twice at once. The tick's entry, or undefined.
|
|
1877
|
+
*/
|
|
1878
|
+
async billingTick(handle) {
|
|
1879
|
+
const cfg = this.o.billing;
|
|
1880
|
+
const v = this.views.get(handle);
|
|
1881
|
+
if (!cfg || !v?.terms || this.closed(handle) || this.ticking.has(handle))
|
|
1882
|
+
return undefined;
|
|
1883
|
+
const l = this.loaded.get(handle);
|
|
1884
|
+
if (!l || l.kernel.gone)
|
|
1885
|
+
return undefined;
|
|
1886
|
+
this.ticking.add(handle);
|
|
1887
|
+
try {
|
|
1888
|
+
const at = stampMs(this.now());
|
|
1889
|
+
const p = this.periods.get(handle) ?? new Period(at);
|
|
1890
|
+
this.periods.set(handle, new Period(at));
|
|
1891
|
+
const log = periodRecord(l.identity, p, at);
|
|
1892
|
+
this.o.db.billingTick(handle, at, log.cid.toString(), log.bytes);
|
|
1893
|
+
return await this.providers.send(handle, "billing", keyBytes(l.identity), v.terms.address, tickBody(at, cfg, p, log.cid));
|
|
1894
|
+
}
|
|
1895
|
+
finally {
|
|
1896
|
+
this.ticking.delete(handle);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* A `payment` event from `handle` (#130 decided 6: its pay step's, the kernel lets no other step
|
|
1901
|
+
* emit one): this host is the payee. Its wiring: the payment kept (host.db billing_payments, with
|
|
1902
|
+
* whether its output pays the key the BRC-29 remittance derives from this host's billing key) and
|
|
1903
|
+
* broadcast (its Arcade, once).
|
|
1904
|
+
*/
|
|
1905
|
+
payment(handle, rec) {
|
|
1906
|
+
const p = (async () => {
|
|
1907
|
+
const beef = rec.tx instanceof Uint8Array ? rec.tx : undefined;
|
|
1908
|
+
const amount = typeof rec.amount === "number" ? rec.amount : undefined;
|
|
1909
|
+
const vout = typeof rec.outputIndex === "number" ? rec.outputIndex : undefined;
|
|
1910
|
+
const rem = rec.remittance;
|
|
1911
|
+
if (!beef || amount === undefined || vout === undefined || !rem) {
|
|
1912
|
+
this.say(handle, "billing: a payment event without its transaction, amount or remittance: ignored");
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
const tx = Transaction.fromAtomicBEEF([...beef]);
|
|
1916
|
+
let ours = false;
|
|
1917
|
+
try {
|
|
1918
|
+
const key = new KeyDeriver(this.providerKey("billing")).derivePrivateKey([2, "3241645161d8"], `${rem.derivationPrefix} ${rem.derivationSuffix}`, String(rem.senderIdentityKey));
|
|
1919
|
+
const out = tx.outputs[vout];
|
|
1920
|
+
ours = !!out && out.satoshis === amount && out.lockingScript.toHex() === new P2PKH().lock(key.toPublicKey().toHash()).toHex();
|
|
1921
|
+
}
|
|
1922
|
+
catch { /* a remittance that derives nothing: kept, not ours */ }
|
|
1923
|
+
const checkpoint = CID.asCID(rec.checkpoint)?.toString();
|
|
1924
|
+
const txid = tx.id("hex");
|
|
1925
|
+
const fresh = this.o.db.billingPayment({ instance: handle, txid, amount, at: stampMs(this.now()), beef, remittance: rem, ours, ...(checkpoint ? { checkpoint } : {}) });
|
|
1926
|
+
if (fresh && this.arc)
|
|
1927
|
+
this.arc.enqueue(handle, Uint8Array.from(tx.toBinary()), beef);
|
|
1928
|
+
this.say(handle, `billing: payment ${txid.slice(0, 8)}… ${amount} sats${ours ? "" : " (its output does not pay this host's derived key)"}${checkpoint ? `, checkpoint ${checkpoint.slice(-8)}` : ""}`);
|
|
1929
|
+
})().catch((e) => this.say(handle, `billing: payment: ${e.message}`));
|
|
1930
|
+
this.syncing.add(p);
|
|
1931
|
+
void p.finally(() => this.syncing.delete(p));
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* Reclaim an instance (#130 decided 9; `skein-host reclaim`): its kernel stopped, its row and its
|
|
1935
|
+
* billing removed, its store deleted. The host's call to make, after the grace; nothing here makes
|
|
1936
|
+
* it on its own. Not the host skein.
|
|
1937
|
+
*/
|
|
1938
|
+
async reclaim(handle) {
|
|
1939
|
+
const row = this.o.db.get(handle);
|
|
1940
|
+
if (!row)
|
|
1941
|
+
throw new Error(`no instance ${handle}`);
|
|
1942
|
+
if (row.handle === this.o.db.hostSkein()?.handle)
|
|
1943
|
+
throw new Error(`${handle} is the host skein`);
|
|
1944
|
+
this.o.db.setStatus(handle, "disabled");
|
|
1945
|
+
await this.refollowHeaders(handle);
|
|
1946
|
+
const l = this.loaded.get(handle);
|
|
1947
|
+
if (l) {
|
|
1948
|
+
this.loaded.delete(handle);
|
|
1949
|
+
await l.kernel.stop();
|
|
1950
|
+
}
|
|
1951
|
+
await this.p2p?.declare(handle, undefined).catch(() => { });
|
|
1952
|
+
this.events.drop(handle);
|
|
1953
|
+
this.cron.forget(handle);
|
|
1954
|
+
this.views.delete(handle);
|
|
1955
|
+
this.periods.delete(handle);
|
|
1956
|
+
this.o.db.forgetBilling(handle);
|
|
1957
|
+
this.o.db.remove(handle);
|
|
1958
|
+
if (isSet(row.store)) {
|
|
1959
|
+
// #96, #158: the host never writes an LMDB environment or a RocksDB database; the set stays in it (its handle cannot be created again here).
|
|
1960
|
+
this.say("router", `${handle}: reclaimed (its set in ${row.store} left in place: the host writes no instance store)`);
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
await removeStore(row.store);
|
|
1964
|
+
this.say("router", `${handle}: reclaimed (its store ${row.store} deleted)`);
|
|
1965
|
+
}
|
|
1966
|
+
// ---------------------------------------------------------------- HTTP
|
|
1967
|
+
/** Whether a URL is this host's own (answered in process: no DNS, no socket). */
|
|
1968
|
+
isLocal(url) {
|
|
1969
|
+
let u;
|
|
1970
|
+
try {
|
|
1971
|
+
u = new URL(url);
|
|
1972
|
+
}
|
|
1973
|
+
catch {
|
|
1974
|
+
return false;
|
|
1975
|
+
} // a URL that does not parse is not this host's (the caller's fetch reports it)
|
|
1976
|
+
if (!this.port)
|
|
1977
|
+
return false;
|
|
1978
|
+
const mine = (o) => { try {
|
|
1979
|
+
return new URL(o).host === u.host;
|
|
1980
|
+
}
|
|
1981
|
+
catch {
|
|
1982
|
+
return false;
|
|
1983
|
+
} }; // an origin setting that is no URL matches nothing
|
|
1984
|
+
if (mine(this.origin()))
|
|
1985
|
+
return true;
|
|
1986
|
+
const port = u.port || (u.protocol === "https:" ? "443" : "80");
|
|
1987
|
+
if (port !== String(this.port))
|
|
1988
|
+
return false;
|
|
1989
|
+
const h = u.hostname.toLowerCase();
|
|
1990
|
+
return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1" || h.endsWith(".localhost");
|
|
1991
|
+
}
|
|
1992
|
+
/** Which instance a URL is for: `/@<handle>` (stripped for the routes), or a `<handle>.` host name. */
|
|
1993
|
+
target(url) {
|
|
1994
|
+
const enabled = (h) => this.o.db.get(h)?.status === "enabled";
|
|
1995
|
+
const m = /^\/@([^/]+)(\/.*)?$/.exec(url.pathname);
|
|
1996
|
+
if (m) {
|
|
1997
|
+
const h = decodeURIComponent(m[1]);
|
|
1998
|
+
return enabled(h) ? { handle: h, route: m[2] ?? "/" } : undefined;
|
|
1999
|
+
}
|
|
2000
|
+
const name = url.hostname.toLowerCase();
|
|
2001
|
+
const label = name.includes(".") ? name.split(".")[0] : "";
|
|
2002
|
+
if (label && HANDLE.test(label) && (label !== this.ownLabel() || this.ownLabelInstance(label)) && enabled(label))
|
|
2003
|
+
return { handle: label, route: url.pathname }; // #161: a path-only handle never by hostname
|
|
2004
|
+
return undefined;
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* The HTTP proxy's request (#70, the `fetch` provider): this host's own URLs in
|
|
2008
|
+
* process, the rest as configured. A delivery (`POST …/sendMessage`, from
|
|
2009
|
+
* instance `from`) is logged in one line: where it went, how, and the result.
|
|
2010
|
+
*/
|
|
2011
|
+
async http(req, from) {
|
|
2012
|
+
const local = this.isLocal(req.url);
|
|
2013
|
+
const delivery = req.method === "POST" && /\/sendMessage$/.test(new URL(req.url, "http://x").pathname);
|
|
2014
|
+
const said = (what) => { if (delivery)
|
|
2015
|
+
this.say(from ?? "router", `deliver${((d) => d ? ` ${d}` : "")(describeDelivery(req))} → ${req.url} (${local ? "local" : "remote"}): ${what}`); };
|
|
2016
|
+
try {
|
|
2017
|
+
let r;
|
|
2018
|
+
if (local) {
|
|
2019
|
+
const d = await this.dispatch({ method: req.method, url: req.url, headers: headerMap(req.headers ?? {}), body: req.body ?? new Uint8Array(), ...(from ? { from } : {}) });
|
|
2020
|
+
r = { status: d.status, headers: d.headers, body: d.body };
|
|
2021
|
+
}
|
|
2022
|
+
else {
|
|
2023
|
+
const f = this.o.http ?? (process.env.SKEIN_HTTP === "fetch" ? fetchHttp : undefined);
|
|
2024
|
+
if (!f)
|
|
2025
|
+
throw new Error(`this host answers no http beyond its own (${req.url})`);
|
|
2026
|
+
r = await f(req);
|
|
2027
|
+
}
|
|
2028
|
+
said(r.status === 200 ? "200 delivered" : `HTTP ${r.status} ${errorOf(r.body)}`.trimEnd());
|
|
2029
|
+
return r;
|
|
2030
|
+
}
|
|
2031
|
+
catch (e) {
|
|
2032
|
+
said(`failed: ${e.message}`);
|
|
2033
|
+
throw e;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
/** One request, whoever made it: a socket's, or an instance's own http. */
|
|
2037
|
+
async dispatch(req) {
|
|
2038
|
+
const url = new URL(req.url);
|
|
2039
|
+
if (req.method === "GET" && url.pathname === "/.well-known/skein-host") {
|
|
2040
|
+
// #130: the terms this host bills by — what an owner grants as the host row (`skein host`).
|
|
2041
|
+
const b = this.o.billing;
|
|
2042
|
+
const billing = b ? { key: this.providers.key("billing"), x: b.x, rates: b.rates, allowance: b.allowance, tickMs: b.tickMs, graceMs: b.graceMs } : undefined;
|
|
2043
|
+
return json(200, { origin: this.origin(), domain: await this.handleDomain(), ...(billing ? { billing } : {}) });
|
|
2044
|
+
}
|
|
2045
|
+
const t = this.target(url);
|
|
2046
|
+
// The router's own paths at its own hostname stay the router's when an instance takes that label (ownLabelInstance).
|
|
2047
|
+
if (t && !/^\/@/.test(url.pathname) && this.ownLabelInstance(t.handle)) {
|
|
2048
|
+
if (req.method === "GET" && url.pathname === "/skeins")
|
|
2049
|
+
return await this.skeinsHeldBy(url.searchParams.get("key") ?? "");
|
|
2050
|
+
if (url.pathname === `${ARC_ROUTE}/callback`)
|
|
2051
|
+
return await this.arcRequest(req, url.pathname);
|
|
2052
|
+
}
|
|
2053
|
+
// #161: an overlay request routed by its topic goes to the token instance it names, at an instance's
|
|
2054
|
+
// hostname too (one public name for the pages and the wallets' provider URL, 2026-10-28).
|
|
2055
|
+
if (req.method === "POST" && (url.pathname === "/submit" || url.pathname === "/lookup") && (!t || t.route === url.pathname)) {
|
|
2056
|
+
const o = this.overlayTarget(url.pathname, req);
|
|
2057
|
+
if ("problem" in o)
|
|
2058
|
+
return json(400, { status: "error", code: "ERR_BAD_REQUEST", description: o.problem });
|
|
2059
|
+
if (o.handle)
|
|
2060
|
+
return await this.held(this.forward(o.handle, url.pathname, url, req));
|
|
2061
|
+
}
|
|
2062
|
+
const serve = async (x) => {
|
|
2063
|
+
// #138: the liveness tool's read, the host's own (no program).
|
|
2064
|
+
const live = req.method === "GET" ? /^\/([^/]+)\/\.live\/([^/]+)$/.exec(x.route) : null;
|
|
2065
|
+
if (live)
|
|
2066
|
+
return await this.held(this.liveRead(x.handle, x.route, decodeURIComponent(live[1]), decodeURIComponent(live[2])));
|
|
2067
|
+
// #148: a stream, served on a socket only (handler).
|
|
2068
|
+
if (req.method === "GET" && EVENTS_ROUTE.test(x.route))
|
|
2069
|
+
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" });
|
|
2070
|
+
return await this.held(this.forward(x.handle, x.route, url, req));
|
|
2071
|
+
};
|
|
2072
|
+
if (t)
|
|
2073
|
+
return await serve(t);
|
|
2074
|
+
const path = url.pathname;
|
|
2075
|
+
if (req.method === "GET" && path === "/skeins")
|
|
2076
|
+
return await this.skeinsHeldBy(url.searchParams.get("key") ?? "");
|
|
2077
|
+
// #113: the host's own origin's BRC-169 requests are the host skein's (its onboarding app's routes).
|
|
2078
|
+
const route = DISCOVERY[`${req.method} ${path}`] ?? (req.method === "GET" && path.startsWith(PAYMAIL_PREFIX) ? `/${ONBOARD_APP}${path}` : undefined);
|
|
2079
|
+
if (route) {
|
|
2080
|
+
const host = this.o.db.hostSkein();
|
|
2081
|
+
if (host?.status === "enabled")
|
|
2082
|
+
return await this.held(this.forward(host.handle, route, url, req));
|
|
2083
|
+
const f = await this.o.discovery?.(req, url);
|
|
2084
|
+
if (f)
|
|
2085
|
+
return f;
|
|
2086
|
+
return json(404, { status: "error", code: "ERR_NOT_FOUND", description: "this host has no host skein (skein-host init): no BRC-169 here" });
|
|
2087
|
+
}
|
|
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
|
+
return json(404, { status: "error", code: "ERR_NOT_FOUND", description: "no instance here: an instance is at http://<handle>.localhost:<port>/ or /@<handle>/" });
|
|
2096
|
+
}
|
|
2097
|
+
/**
|
|
2098
|
+
* #161, #137: an overlay request at the bare host — POST /submit (the topics its X-Topics names: a
|
|
2099
|
+
* comma list or a JSON array, as the overlay reads it) or POST /lookup (its JSON body's `service`,
|
|
2100
|
+
* and a `topic` beside it or in its query) — routed to the instance that serves the names:
|
|
2101
|
+
*
|
|
2102
|
+
* - a token's name, `tm_color_<id>` / `ls_color_<id>` whose `<id>` is an enabled path-only
|
|
2103
|
+
* instance here: that instance (#161, the handle naming convention; checked first);
|
|
2104
|
+
* - any other name (`tm_color`, `ls_color`, …): the enabled instance whose `topics` (the
|
|
2105
|
+
* names the instance manager's create or start named, host.db) contain it. Whether the instance
|
|
2106
|
+
* has run yet does not matter. Two instances taking the same name: a problem (400, "N instances
|
|
2107
|
+
* here take <name>").
|
|
2108
|
+
*
|
|
2109
|
+
* Every name that maps must map to the same instance (else a problem: 400); a name mapping to no
|
|
2110
|
+
* instance here beside one that does is a problem too. Nothing maps: no handle (the hostname's
|
|
2111
|
+
* instance, or the bare host, as before).
|
|
2112
|
+
*/
|
|
2113
|
+
overlayTarget(path, req) {
|
|
2114
|
+
const names = [];
|
|
2115
|
+
if (path === "/submit") {
|
|
2116
|
+
const h = (req.headers["x-topics"] ?? "").trim();
|
|
2117
|
+
if (!h)
|
|
2118
|
+
return {};
|
|
2119
|
+
if (h.startsWith("[")) {
|
|
2120
|
+
let j;
|
|
2121
|
+
try {
|
|
2122
|
+
j = JSON.parse(h);
|
|
2123
|
+
}
|
|
2124
|
+
catch {
|
|
2125
|
+
return {};
|
|
2126
|
+
} // the overlay's own refusal, at no instance: the bare host's
|
|
2127
|
+
if (!Array.isArray(j))
|
|
2128
|
+
return {};
|
|
2129
|
+
for (const t of j)
|
|
2130
|
+
if (typeof t === "string")
|
|
2131
|
+
names.push(t);
|
|
2132
|
+
}
|
|
2133
|
+
else
|
|
2134
|
+
names.push(...h.split(",").map((x) => x.trim()).filter(Boolean));
|
|
2135
|
+
}
|
|
2136
|
+
else {
|
|
2137
|
+
let j;
|
|
2138
|
+
try {
|
|
2139
|
+
j = JSON.parse(new TextDecoder().decode(req.body));
|
|
2140
|
+
}
|
|
2141
|
+
catch {
|
|
2142
|
+
return {};
|
|
2143
|
+
}
|
|
2144
|
+
if (!j || typeof j !== "object")
|
|
2145
|
+
return {};
|
|
2146
|
+
const q = j.query;
|
|
2147
|
+
for (const t of [j.service, j.topic, q && typeof q === "object" ? q.topic : undefined])
|
|
2148
|
+
if (typeof t === "string")
|
|
2149
|
+
names.push(t);
|
|
2150
|
+
}
|
|
2151
|
+
const enabled = (h) => this.o.db.get(h)?.status === "enabled";
|
|
2152
|
+
const rows = this.o.db.list("enabled");
|
|
2153
|
+
const mapped = [];
|
|
2154
|
+
for (const name of names) {
|
|
2155
|
+
const id = /^(?:tm|ls)_color_(.+)$/.exec(name)?.[1];
|
|
2156
|
+
if (id && TOKEN_HANDLE.test(id) && enabled(id)) {
|
|
2157
|
+
mapped.push(id);
|
|
2158
|
+
continue;
|
|
2159
|
+
}
|
|
2160
|
+
const by = rows.filter((r) => topicsOf(r).includes(name)).map((r) => r.handle);
|
|
2161
|
+
if (by.length > 1)
|
|
2162
|
+
return { problem: `${by.length} instances here take ${name} (${by.join(", ")}): send it to one of them (/@<handle>/${path.slice(1)})` };
|
|
2163
|
+
mapped.push(by[0]);
|
|
2164
|
+
}
|
|
2165
|
+
const hit = [...new Set(mapped.filter((x) => !!x))];
|
|
2166
|
+
if (!hit.length)
|
|
2167
|
+
return {};
|
|
2168
|
+
if (hit.length > 1)
|
|
2169
|
+
return { problem: `the names map to ${hit.length} instances here (${hit.join(", ")}): one request goes to one; send one per instance` };
|
|
2170
|
+
const stray = names.filter((_, i) => !mapped[i]);
|
|
2171
|
+
if (stray.length)
|
|
2172
|
+
return { problem: `${stray.join(", ")}: no instance here takes ${stray.length > 1 ? "these" : "it"} beside ${hit[0]}: send one request per instance` };
|
|
2173
|
+
return { handle: hit[0] };
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* GET /skeins?key=<hex> at the host's own origin: the skeins on this host whose head `grants`
|
|
2177
|
+
* (#143) names `key` among root's holders — each enabled agent row's store file read read-only,
|
|
2178
|
+
* no kernel — as JSON [{handle, identity, url}]. A key's skeins are the host's to say (no token
|
|
2179
|
+
* in a wallet tracks them). Unsigned, anyone; mailbox instances are not listed.
|
|
2180
|
+
*/
|
|
2181
|
+
async skeinsHeldBy(key) {
|
|
2182
|
+
if (!/^0[23][0-9a-f]{64}$/.test(key))
|
|
2183
|
+
return json(400, { status: "error", code: "ERR_BAD_REQUEST", description: "key: an identity key (33 bytes, hex)" });
|
|
2184
|
+
const out = [];
|
|
2185
|
+
for (const row of this.o.db.list("enabled")) {
|
|
2186
|
+
if (row.kind === "mailbox" || !row.identity || !storeExists(storeSpec(row)))
|
|
2187
|
+
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))
|
|
2200
|
+
out.push({ handle: row.handle, identity: row.identity, url: this.originOf(row.handle) });
|
|
2201
|
+
}
|
|
2202
|
+
return json(200, out);
|
|
2203
|
+
}
|
|
2204
|
+
/** A forwarded request, held among the in-flight ones (shutdown answers them 503). */
|
|
2205
|
+
async held(p) {
|
|
2206
|
+
this.inflight.add(p);
|
|
2207
|
+
try {
|
|
2208
|
+
return await p;
|
|
2209
|
+
}
|
|
2210
|
+
finally {
|
|
2211
|
+
this.inflight.delete(p);
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* The handle domain (#113): the host skein's onboarding app's
|
|
2216
|
+
* `config.onboard.domain` (the app record, a kernel read), the one setting
|
|
2217
|
+
* every handle here is at. With no host skein or no app: this router's
|
|
2218
|
+
* origin's host name as a domain.
|
|
2219
|
+
*/
|
|
2220
|
+
async handleDomain() {
|
|
2221
|
+
const fallback = this.domain();
|
|
2222
|
+
const host = this.o.db.hostSkein();
|
|
2223
|
+
if (host?.status !== "enabled")
|
|
2224
|
+
return fallback;
|
|
2225
|
+
try {
|
|
2226
|
+
const l = await this.hydrate(host.handle);
|
|
2227
|
+
const root = await l.kernel.call("head", `${ONBOARD_APP}/app`);
|
|
2228
|
+
if (!root)
|
|
2229
|
+
return fallback;
|
|
2230
|
+
const rec = await l.kernel.store.get(root);
|
|
2231
|
+
const d = rec.config?.[ONBOARD_APP]?.domain;
|
|
2232
|
+
return typeof d === "string" && d ? d.toLowerCase() : "localhost";
|
|
2233
|
+
}
|
|
2234
|
+
catch (e) {
|
|
2235
|
+
this.say("router", `the handle domain: not read from ${host.handle}: ${e.message}`);
|
|
2236
|
+
return fallback;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
/** Arcade's webhook (#58, arc.ts); 404 when the host has no Arcade. */
|
|
2240
|
+
async arcRequest(req, path) {
|
|
2241
|
+
if (!this.arc)
|
|
2242
|
+
return json(404, { status: "error", code: "ERR_NOT_FOUND", description: "this host has no Arcade" });
|
|
2243
|
+
if (req.method === "POST")
|
|
2244
|
+
return await this.arc.callback(req.headers, req.body);
|
|
2245
|
+
return json(404, { status: "error", code: "ERR_NOT_FOUND", description: `no ${req.method} ${path}` });
|
|
2246
|
+
}
|
|
2247
|
+
/**
|
|
2248
|
+
* A request for an instance (#68, #66): appended as received, and the
|
|
2249
|
+
* client's connection held until the thread it launched comes to rest —
|
|
2250
|
+
* then its answer, signed on the session inside. Past `answerWaitMs`, or
|
|
2251
|
+
* when the router shuts down, 503 + Retry-After (the thread goes on). A
|
|
2252
|
+
* read's call fuel (the explorer's) is charged to the ledger.
|
|
2253
|
+
*/
|
|
2254
|
+
async forward(handle, route, url, req) {
|
|
2255
|
+
if (this.stopped)
|
|
2256
|
+
return unavailable("the host is shutting down");
|
|
2257
|
+
let l;
|
|
2258
|
+
try {
|
|
2259
|
+
l = await this.hydrate(handle);
|
|
2260
|
+
}
|
|
2261
|
+
catch (e) {
|
|
2262
|
+
return json(503, { status: "error", code: "ERR_UNAVAILABLE", description: e.message });
|
|
2263
|
+
}
|
|
2264
|
+
// #130: asleep, or terms this host does not serve: nothing is forwarded.
|
|
2265
|
+
const closed = this.closed(handle);
|
|
2266
|
+
if (closed)
|
|
2267
|
+
return json(402, { status: "error", code: "ERR_PAYMENT_REQUIRED", description: closed });
|
|
2268
|
+
// #135: two doors — a read by a call, a signed request through the door (serveHttp).
|
|
2269
|
+
const a = await serveHttp(l.kernel, { method: req.method, path: url.pathname, route, query: url.search, headers: req.headers, body: req.body }, { now: this.now(), waitMs: this.o.answerWaitMs, stop: this.stopping });
|
|
2270
|
+
this.settle(l);
|
|
2271
|
+
// Charged to the identity the front door verified (H13), never to a header as the client sent it.
|
|
2272
|
+
if (a.fuel !== undefined)
|
|
2273
|
+
this.charge(handle, a.caller ?? "", `${route} (read)`, a.fuel);
|
|
2274
|
+
// #130: what never reaches the log — the read call's fuel, the bytes served — for its next tick.
|
|
2275
|
+
this.meter(handle, { at: stampMs(this.now()), op: `${req.method} ${route}`, ...(a.caller ? { caller: a.caller } : {}), fuel: a.fuel ?? 0, bytes: a.body.length });
|
|
2276
|
+
return { status: a.status, headers: a.headers, body: a.body };
|
|
2277
|
+
}
|
|
2278
|
+
/** The HTTP handler: CORS, then dispatch. */
|
|
2279
|
+
handler() {
|
|
2280
|
+
return (req, res) => {
|
|
2281
|
+
const cors = {
|
|
2282
|
+
"access-control-allow-origin": "*", "access-control-allow-headers": "*", "access-control-allow-methods": "*", "access-control-expose-headers": "*",
|
|
2283
|
+
};
|
|
2284
|
+
if (req.method === "OPTIONS") {
|
|
2285
|
+
res.writeHead(200, cors).end();
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
// #148: an event stream is held open, not buffered.
|
|
2289
|
+
if (req.method === "GET") {
|
|
2290
|
+
const url = new URL(`http://${req.headers.host ?? `127.0.0.1:${this.port}`}${req.url ?? "/"}`);
|
|
2291
|
+
const t = this.target(url);
|
|
2292
|
+
const m = t ? EVENTS_ROUTE.exec(t.route) : null;
|
|
2293
|
+
if (t && m) {
|
|
2294
|
+
void this.eventStream(t.handle, t.route, decodeURIComponent(m[1]), url, req, res, cors);
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
const chunks = [];
|
|
2299
|
+
req.on("data", (c) => chunks.push(c));
|
|
2300
|
+
req.on("end", () => {
|
|
2301
|
+
const url = `http://${req.headers.host ?? `127.0.0.1:${this.port}`}${req.url ?? "/"}`;
|
|
2302
|
+
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); })
|
|
2304
|
+
.catch((e) => { if (!res.headersSent) {
|
|
2305
|
+
res.writeHead(500, { ...cors, "content-type": "application/json" });
|
|
2306
|
+
res.end(JSON.stringify({ status: "error", code: "ERR_INTERNAL", description: e.message }));
|
|
2307
|
+
} });
|
|
2308
|
+
});
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Listen on `port` (0: any) at each of `hosts` (default the IPv4 and IPv6
|
|
2313
|
+
* loopbacks: `<handle>.localhost` resolves to either).
|
|
2314
|
+
*/
|
|
2315
|
+
async listen(port, hosts = ["127.0.0.1", "::1"]) {
|
|
2316
|
+
const list = Array.isArray(hosts) ? hosts : [hosts];
|
|
2317
|
+
for (const [i, host] of list.entries()) {
|
|
2318
|
+
const server = createServer(this.handler());
|
|
2319
|
+
// A client's idle socket is the client's to close (undici: 4 s): a server that closes first races a
|
|
2320
|
+
// client whose event loop was busy past the server's 5 s default — a large install message (#83:
|
|
2321
|
+
// a module whole) is seconds of the SDK's per-byte checks — and the client's next request is reset.
|
|
2322
|
+
server.keepAliveTimeout = 65_000;
|
|
2323
|
+
server.headersTimeout = 66_000;
|
|
2324
|
+
try {
|
|
2325
|
+
await new Promise((resolve, reject) => {
|
|
2326
|
+
server.once("error", reject);
|
|
2327
|
+
server.listen(i === 0 ? port : this.port, host, () => { server.off("error", reject); resolve(); });
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
catch (e) {
|
|
2331
|
+
if (i === 0)
|
|
2332
|
+
throw e;
|
|
2333
|
+
this.say("router", `not listening on ${host}: ${e.message}`);
|
|
2334
|
+
continue;
|
|
2335
|
+
}
|
|
2336
|
+
if (i === 0)
|
|
2337
|
+
this.port = server.address().port;
|
|
2338
|
+
this.servers.push(server);
|
|
2339
|
+
}
|
|
2340
|
+
// The host is up: its one status subscription (#58), resumed where it left off.
|
|
2341
|
+
this.arc?.start();
|
|
2342
|
+
return this.servers[0];
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
/** A create's `claim` ({message, body}), checked for its shape (throws; undefined: missing). */
|
|
2346
|
+
/**
|
|
2347
|
+
* The instance manager's `topics` (#137): the overlay names (gossip topics and lookup services alike)
|
|
2348
|
+
* the host routes to the instance at the bare host — plain non-empty strings, no duplicates; absent → undefined.
|
|
2349
|
+
*/
|
|
2350
|
+
function topicsArg(v) {
|
|
2351
|
+
if (v == null)
|
|
2352
|
+
return undefined;
|
|
2353
|
+
if (!Array.isArray(v) || v.some((t) => typeof t !== "string" || !t) || new Set(v).size !== v.length)
|
|
2354
|
+
throw new Error("topics: the overlay names the host routes to it, [text, …], each non-empty, no duplicates");
|
|
2355
|
+
return v;
|
|
2356
|
+
}
|
|
2357
|
+
function claimOf(v) {
|
|
2358
|
+
const c = v;
|
|
2359
|
+
if (!c || typeof c !== "object" || !c.message || typeof c.message !== "object" || !(c.body instanceof Uint8Array)) {
|
|
2360
|
+
throw new Error("claim: the owner's signed claim {message, body} (#127: a message in box claim, signed by the owner, naming no recipient)");
|
|
2361
|
+
}
|
|
2362
|
+
return { message: c.message, body: c.body };
|
|
2363
|
+
}
|
|
2364
|
+
/** Why a claim cannot be forwarded for `owner` (throws): not a claim, another sender, a recipient named. The signature is the front door's to check. */
|
|
2365
|
+
function claimProblem(c, owner) {
|
|
2366
|
+
const m = c.message;
|
|
2367
|
+
if (m.kind !== "mail" || m.op !== "put" || m.box !== "claim")
|
|
2368
|
+
throw new Error("claim: not a message in box claim");
|
|
2369
|
+
if (!(m.sender instanceof Uint8Array) || Buffer.from(m.sender).toString("hex") !== owner)
|
|
2370
|
+
throw new Error("claim: its sender is not the owner");
|
|
2371
|
+
if (m.recipient !== undefined)
|
|
2372
|
+
throw new Error("claim: it names a recipient (a claim is signed before the instance exists: it names none)");
|
|
2373
|
+
if (!(m.signature instanceof Uint8Array))
|
|
2374
|
+
throw new Error("claim: not signed");
|
|
2375
|
+
}
|
|
2376
|
+
/** #130: a package that is the instance's own message to itself (the loopback: unsigned, sender and recipient its identity). */
|
|
2377
|
+
function isLoopback(pkg, identity) {
|
|
2378
|
+
const m = pkg.message;
|
|
2379
|
+
const hex = (k) => k instanceof Uint8Array ? Buffer.from(k).toString("hex") : "";
|
|
2380
|
+
return !!m && m.signature === undefined && hex(m.sender) === identity && hex(m.recipient) === identity;
|
|
2381
|
+
}
|
|
2382
|
+
/** #130: a reclaimed instance's store: the file, its -wal and -shm, and its directory when that empties. */
|
|
2383
|
+
async function removeStore(store) {
|
|
2384
|
+
const fs = await import("node:fs/promises");
|
|
2385
|
+
const { dirname } = await import("node:path");
|
|
2386
|
+
for (const f of [store, `${store}-wal`, `${store}-shm`])
|
|
2387
|
+
await fs.rm(f, { force: true });
|
|
2388
|
+
await fs.rmdir(dirname(store)).catch(() => { }); // not empty, or not its own: left
|
|
2389
|
+
}
|