@cruba/crubal-host 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -2
- package/dist/bin/crubal-host.d.ts +2 -0
- package/dist/bin/crubal-host.js +52 -0
- package/dist/bin/crubal-host.js.map +1 -0
- package/dist/src/credentials.d.ts +58 -0
- package/dist/src/credentials.js +161 -0
- package/dist/src/credentials.js.map +1 -0
- package/dist/src/emergency.d.ts +53 -0
- package/dist/src/emergency.js +91 -0
- package/dist/src/emergency.js.map +1 -0
- package/dist/src/host.d.ts +302 -0
- package/dist/src/host.js +808 -0
- package/dist/src/host.js.map +1 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.js +10 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/indexer.d.ts +41 -0
- package/dist/src/indexer.js +220 -0
- package/dist/src/indexer.js.map +1 -0
- package/dist/src/oidc.d.ts +29 -0
- package/dist/src/oidc.js +74 -0
- package/dist/src/oidc.js.map +1 -0
- package/dist/src/schema.sql +125 -0
- package/dist/src/secrets.d.ts +21 -0
- package/dist/src/secrets.js +36 -0
- package/dist/src/secrets.js.map +1 -0
- package/dist/src/server.d.ts +41 -0
- package/dist/src/server.js +520 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/signatures.d.ts +30 -0
- package/dist/src/signatures.js +53 -0
- package/dist/src/signatures.js.map +1 -0
- package/dist/src/terms.d.ts +21 -0
- package/dist/src/terms.js +55 -0
- package/dist/src/terms.js.map +1 -0
- package/package.json +48 -4
package/dist/src/host.js
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
// host.ts: the reference host (docs/design/host.md). It does what the spec hands the host and nothing a
|
|
2
|
+
// business requires: who a caller is, secrets by name, acting through adapters, the clock, signing, package
|
|
3
|
+
// install, the loops. It owns no model: everything goes through PgStore and PgBorder.
|
|
4
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign as edSign } from "node:crypto";
|
|
5
|
+
import { ChainedSecrets, EnvSecrets, FunctionSecrets } from "./secrets.js";
|
|
6
|
+
import { readBundle, restamp, verifyBorder, versionGate } from "@cruba/crubal";
|
|
7
|
+
import { Access } from "@cruba/crubal";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { keyFor, publish } from "@cruba/crubal";
|
|
10
|
+
import { PgBorder, borderOptions } from "@cruba/crubal-postgres";
|
|
11
|
+
import { migrate as migrateStore, transaction } from "@cruba/crubal-postgres";
|
|
12
|
+
import { PgStore } from "@cruba/crubal-postgres";
|
|
13
|
+
import { Credentials, secretsKeyFrom } from "./credentials.js";
|
|
14
|
+
import { verifyRequest, signRequest } from "./signatures.js";
|
|
15
|
+
const SCHEMA_SQL = readFileSync(new URL("./schema.sql", import.meta.url), "utf8");
|
|
16
|
+
export const hashKey = (key) => createHash("sha256").update(key).digest("hex");
|
|
17
|
+
export class Host {
|
|
18
|
+
pool;
|
|
19
|
+
o;
|
|
20
|
+
credentials;
|
|
21
|
+
constructor(pool, o) {
|
|
22
|
+
this.pool = pool;
|
|
23
|
+
this.o = o;
|
|
24
|
+
this.credentials = new Credentials(pool, () => this.now(), secretsKeyFrom(o.secretsKey ?? process.env.CRUBAL_SECRETS_KEY));
|
|
25
|
+
}
|
|
26
|
+
now() {
|
|
27
|
+
return this.o.now ? this.o.now() : new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
28
|
+
}
|
|
29
|
+
/** The store's tables and the host's. */
|
|
30
|
+
async migrate() {
|
|
31
|
+
await transaction(this.pool, async (c) => {
|
|
32
|
+
await migrateStore(c);
|
|
33
|
+
await c.query(SCHEMA_SQL);
|
|
34
|
+
});
|
|
35
|
+
// Rows stored before sealing existed are sealed once the key is at hand.
|
|
36
|
+
await this.credentials.sealAll();
|
|
37
|
+
}
|
|
38
|
+
// ─── Connections (docs/design/host.md, "Credentials"; Cashierfu brief 4) ────
|
|
39
|
+
/**
|
|
40
|
+
* Connect a service for an owner: the material sealed in the credentials store, and a `system` record of
|
|
41
|
+
* the fact within the owner's root record, written as the host's action naming the person. The person,
|
|
42
|
+
* when given, must hold admin on the root or the `connections` item; the owner's own key needs none.
|
|
43
|
+
*/
|
|
44
|
+
async connect(owner, name, c) {
|
|
45
|
+
const store = await this.storeFor(owner);
|
|
46
|
+
if (c.person)
|
|
47
|
+
await this.requireConnections(store, owner, c.person);
|
|
48
|
+
const connection = await this.credentials.connect(owner, name, c.scheme, c.material, c.scopes ?? [], c.expires);
|
|
49
|
+
const record = await this.writeSystem(store, owner, name, { scheme: c.scheme, scopes: c.scopes ?? [], connection: "connected", connectedAt: this.now().slice(0, 16).replace("T", " "), ...(c.values ?? {}) }, c.person);
|
|
50
|
+
return { connection, record };
|
|
51
|
+
}
|
|
52
|
+
/** Revoke a connection: the material gone, the record marked revoked. */
|
|
53
|
+
async disconnect(owner, name, person) {
|
|
54
|
+
const store = await this.storeFor(owner);
|
|
55
|
+
if (person)
|
|
56
|
+
await this.requireConnections(store, owner, person);
|
|
57
|
+
await this.credentials.disconnect(owner, name);
|
|
58
|
+
const existing = await this.systemFor(store, name);
|
|
59
|
+
if (!existing)
|
|
60
|
+
return {};
|
|
61
|
+
const record = await this.writeSystem(store, owner, name, { connection: "revoked" }, person);
|
|
62
|
+
return { record };
|
|
63
|
+
}
|
|
64
|
+
/** Is a service connected for the owner: material held and not expired, and the record not revoked? */
|
|
65
|
+
async connected(owner, name) {
|
|
66
|
+
const c = await this.credentials.connection(owner, name);
|
|
67
|
+
if (!c || c.status !== "connected")
|
|
68
|
+
return false;
|
|
69
|
+
const r = await this.systemFor(await this.storeFor(owner), name);
|
|
70
|
+
return r?.values.connection !== "revoked";
|
|
71
|
+
}
|
|
72
|
+
async requireConnections(store, owner, person) {
|
|
73
|
+
const [p, state] = await Promise.all([store.definitions(), store.state()]);
|
|
74
|
+
const access = new Access(p.dict, p.perms, state.records, [...state.edges.values()], owner);
|
|
75
|
+
const eff = access.effective(person, this.o.rootRecord ?? "ROOT");
|
|
76
|
+
if (eff.mode !== "admin" && !eff.items.has("connections"))
|
|
77
|
+
throw Object.assign(new Error(`${person} may not manage ${owner}'s connections`), { status: 403 });
|
|
78
|
+
}
|
|
79
|
+
async systemFor(store, name) {
|
|
80
|
+
return (await store.state()).records.all().find((r) => r.kind === "system" && r.values.name === name);
|
|
81
|
+
}
|
|
82
|
+
/** The system record for a service, within ROOT, made or updated; the host's action names the person. */
|
|
83
|
+
async writeSystem(store, owner, name, values, person) {
|
|
84
|
+
const state = await store.state();
|
|
85
|
+
const existing = state.records.all().find((r) => r.kind === "system" && r.values.name === name);
|
|
86
|
+
const n = state.records.all().filter((r) => /^S-\d+$/.test(r.id)).map((r) => Number(r.id.slice(2))).reduce((a, b) => Math.max(a, b), 0);
|
|
87
|
+
const id = existing?.id ?? `S-${n + 1}`;
|
|
88
|
+
const at = this.now();
|
|
89
|
+
await store.commit({ platform: "connection", ...(person ? { person } : {}) }, (d) => {
|
|
90
|
+
const root = this.o.rootRecord ?? "ROOT";
|
|
91
|
+
const base = existing ?? { id, kind: "system", level: "system", owner, ...(state.records.get(root) ? { within: root } : {}), values: { name, systemKind: "partner" }, sources: { "*": { origin: "observed", via: "host" } } };
|
|
92
|
+
const sources = { ...(base.sources ?? {}) };
|
|
93
|
+
for (const k of Object.keys(values))
|
|
94
|
+
if (!sources[k])
|
|
95
|
+
sources[k] = { origin: "observed", via: "host" };
|
|
96
|
+
d.records.set(id, { ...base, values: { ...base.values, ...values }, sources });
|
|
97
|
+
}, at);
|
|
98
|
+
return id;
|
|
99
|
+
}
|
|
100
|
+
// ─── Owners and keys ───────────────────────────────────────────────────────
|
|
101
|
+
/** Make an owner: a domain or a hosted person, with an Ed25519 key the host signs its chain with. */
|
|
102
|
+
async createOwner(owner) {
|
|
103
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
104
|
+
const pub = publicKey.export({ format: "jwk" }).x;
|
|
105
|
+
const priv = privateKey.export({ format: "jwk" }).d;
|
|
106
|
+
await this.pool.query("insert into host_owners (owner, public, private, created) values ($1, $2, $3, $4) on conflict (owner) do nothing", [owner, pub, priv, this.now()]);
|
|
107
|
+
const { rows } = await this.pool.query("select public from host_owners where owner = $1", [owner]);
|
|
108
|
+
return { owner, public: String(rows[0].public) };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Rotate an owner's key (8.5, 11.7): a new key signs from now; the well-known file lists the old with
|
|
112
|
+
* `until` and the new with `from`, so the chain verifies across the rotation.
|
|
113
|
+
*/
|
|
114
|
+
async rotateKey(owner, now = this.now()) {
|
|
115
|
+
this.forgetStore(owner);
|
|
116
|
+
const old = await this.keyOf(owner);
|
|
117
|
+
if (!old)
|
|
118
|
+
throw new Error(`${owner} has no key to rotate`);
|
|
119
|
+
const { rows } = await this.pool.query("select created from host_owners where owner = $1", [owner]);
|
|
120
|
+
const since = (await this.pool.query("select max(until) as until from host_keys where owner = $1", [owner])).rows[0];
|
|
121
|
+
await this.pool.query("insert into host_keys (owner, public, \"from\", until) values ($1, $2, $3, $4) on conflict do nothing", [owner, old.public, since.until ?? String(rows[0].created), now]);
|
|
122
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
123
|
+
const pub = publicKey.export({ format: "jwk" }).x;
|
|
124
|
+
const priv = privateKey.export({ format: "jwk" }).d;
|
|
125
|
+
await this.pool.query("update host_owners set public = $2, private = $3, created = $4 where owner = $1", [owner, pub, priv, now]);
|
|
126
|
+
return { owner, public: pub, retired: old.public };
|
|
127
|
+
}
|
|
128
|
+
/** The signer for a hosted person (14.1): their key, made on first use. */
|
|
129
|
+
async hostedSigner(owner) {
|
|
130
|
+
await this.createOwner(owner);
|
|
131
|
+
const key = await this.keyOf(owner);
|
|
132
|
+
return key ? (hash) => edSign(null, Buffer.from(hash, "hex"), createPrivateKey({ key: { kty: "OKP", crv: "Ed25519", x: key.public, d: key.private }, format: "jwk" })).toString("base64url") : undefined;
|
|
133
|
+
}
|
|
134
|
+
async owners() {
|
|
135
|
+
return (await this.pool.query("select owner, public from host_owners order by created")).rows;
|
|
136
|
+
}
|
|
137
|
+
async keyOf(owner) {
|
|
138
|
+
const { rows } = await this.pool.query("select public, private from host_owners where owner = $1", [owner]);
|
|
139
|
+
const r = rows[0];
|
|
140
|
+
return r?.private ? { public: r.public, private: r.private } : undefined;
|
|
141
|
+
}
|
|
142
|
+
/** Issue an API key for a caller of an owner; the raw key is returned once and only its hash is kept. */
|
|
143
|
+
async issueKey(owner, caller, label) {
|
|
144
|
+
const raw = `ck_${randomBytes(24).toString("base64url")}`;
|
|
145
|
+
await this.pool.query("insert into host_api_keys (key_hash, owner, caller, label, created) values ($1, $2, $3, $4, $5)", [hashKey(raw), owner, caller, label ?? null, this.now()]);
|
|
146
|
+
return raw;
|
|
147
|
+
}
|
|
148
|
+
async revokeKey(raw) {
|
|
149
|
+
await this.pool.query("delete from host_api_keys where key_hash = $1", [hashKey(raw)]);
|
|
150
|
+
}
|
|
151
|
+
/** Link an identity provider's subject to a caller, for the OIDC verifier. */
|
|
152
|
+
async linkIdentity(provider, subject, owner, caller) {
|
|
153
|
+
await this.pool.query("insert into host_identities (provider, subject, owner, caller) values ($1, $2, $3, $4) on conflict (provider, subject) do update set owner = excluded.owner, caller = excluded.caller", [provider, subject, owner, caller]);
|
|
154
|
+
}
|
|
155
|
+
/** Remove an identity link (a person deleting themselves): the subject signs in as nobody from now on. */
|
|
156
|
+
async unlinkIdentity(provider, subject) {
|
|
157
|
+
const r = await this.pool.query("delete from host_identities where provider = $1 and subject = $2", [provider, subject]);
|
|
158
|
+
return (r.rowCount ?? 0) > 0;
|
|
159
|
+
}
|
|
160
|
+
/** Every identity link that names a caller, for export and delete. */
|
|
161
|
+
async identitiesOf(owner, caller) {
|
|
162
|
+
const { rows } = await this.pool.query("select provider, subject from host_identities where owner = $1 and caller = $2", [owner, caller]);
|
|
163
|
+
return rows;
|
|
164
|
+
}
|
|
165
|
+
async identity(provider, subject) {
|
|
166
|
+
const { rows } = await this.pool.query("select owner, caller from host_identities where provider = $1 and subject = $2", [provider, subject]);
|
|
167
|
+
return rows[0] ?? null;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Who is calling when the request is signed by another owner (RFC 9421 with its domain key): the signer's
|
|
171
|
+
* domain, as a caller of the owner addressed. The signer's key comes from its well-known file.
|
|
172
|
+
*/
|
|
173
|
+
async whoSigned(owner, req) {
|
|
174
|
+
const v = await verifyRequest({ ...req, publicX: async (keyid) => keyFor(await this.resolveKeys(keyid), keyid, this.now())?.public, now: Math.floor(Date.parse(this.now()) / 1000) });
|
|
175
|
+
// The signing owner acts as the caller `domain:owner`: a caller of that domain (14.1), never a bare ID here.
|
|
176
|
+
return v ? { owner, caller: `${v.keyid}:owner`, via: "signature" } : null;
|
|
177
|
+
}
|
|
178
|
+
/** Who is calling: an API key, a token a verifier knows, or nobody. The store sees only the caller ID. */
|
|
179
|
+
async whoIs(authorization) {
|
|
180
|
+
if (!authorization)
|
|
181
|
+
return null;
|
|
182
|
+
const token = authorization.replace(/^Bearer\s+/i, "").trim();
|
|
183
|
+
if (token.startsWith("ck_")) {
|
|
184
|
+
const { rows } = await this.pool.query("select owner, caller from host_api_keys where key_hash = $1", [hashKey(token)]);
|
|
185
|
+
const c = rows[0];
|
|
186
|
+
return c ? { owner: c.owner, caller: c.caller, via: "key" } : null;
|
|
187
|
+
}
|
|
188
|
+
for (const v of this.o.verifiers ?? []) {
|
|
189
|
+
const c = await v(token);
|
|
190
|
+
if (c)
|
|
191
|
+
return { ...c, via: c.via ?? "identity" };
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
/** Does this host hold the owner (a domain, or a hosted person `domain:id`)? */
|
|
196
|
+
async hosts(owner) {
|
|
197
|
+
const { rows } = await this.pool.query("select 1 from host_owners where owner = $1", [owner]);
|
|
198
|
+
return rows.length > 0;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The caller as an owner on this host sees them (14.8): their own ID at the owner that hosts them, or
|
|
202
|
+
* themselves as that owner when they address their own chain; elsewhere on the host, a person proven by
|
|
203
|
+
* identity is `person:<domain:id>`, vouched for without a signature; a system or a stranger is nobody.
|
|
204
|
+
*/
|
|
205
|
+
async callerFor(owner, who) {
|
|
206
|
+
if (!who)
|
|
207
|
+
return undefined;
|
|
208
|
+
if (who.owner === owner)
|
|
209
|
+
return who.caller;
|
|
210
|
+
const at = owner.indexOf(":");
|
|
211
|
+
if (at > 0 && who.owner === owner.slice(0, at) && who.caller === owner.slice(at + 1))
|
|
212
|
+
return owner;
|
|
213
|
+
if (who.via === "identity" && (await this.hosts(who.owner)))
|
|
214
|
+
return `person:${who.owner}:${who.caller}`;
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
// ─── Acting for a person (14.9) ────────────────────────────────────────────
|
|
218
|
+
/** A person lets a system act for them: narrowed to items, until a moment, gated until they lift it. */
|
|
219
|
+
async allowActing(person, systemOwner, system, o = {}) {
|
|
220
|
+
const { rows } = await this.pool.query("insert into host_acting_for (system_owner, system, person, items, until, gated, created) values ($1, $2, $3, $4, $5, $6, $7) returning id", [systemOwner, system, person, o.items ?? [], o.until ?? null, o.gated ?? true, this.now()]);
|
|
221
|
+
return { id: Number(rows[0].id), systemOwner, system, person, items: o.items ?? [], ...(o.until ? { until: o.until } : {}), gated: o.gated ?? true, created: this.now() };
|
|
222
|
+
}
|
|
223
|
+
/** The person revokes it: nothing is deleted, the row keeps when. */
|
|
224
|
+
async revokeActing(person, id) {
|
|
225
|
+
const r = await this.pool.query("update host_acting_for set revoked = $3 where person = $1 and id = $2 and revoked is null", [person, id, this.now()]);
|
|
226
|
+
return (r.rowCount ?? 0) > 0;
|
|
227
|
+
}
|
|
228
|
+
async actingFor(person) {
|
|
229
|
+
const { rows } = await this.pool.query("select id, system_owner, system, person, items, until, gated, created, revoked from host_acting_for where person = $1 order by id", [person]);
|
|
230
|
+
return rows.map((r) => ({ id: Number(r.id), systemOwner: r.system_owner, system: r.system, person: r.person, items: r.items, ...(r.until ? { until: r.until } : {}), gated: r.gated, created: r.created, ...(r.revoked ? { revoked: r.revoked } : {}) }));
|
|
231
|
+
}
|
|
232
|
+
/** The live permission for this system to act for this person now, or none. */
|
|
233
|
+
async mayActFor(who, person, now = this.now()) {
|
|
234
|
+
if (who.via !== "key")
|
|
235
|
+
return null;
|
|
236
|
+
const live = (await this.actingFor(person)).filter((a) => a.systemOwner === who.owner && a.system === who.caller && !a.revoked && (!a.until || a.until > now));
|
|
237
|
+
return live.at(-1) ?? null;
|
|
238
|
+
}
|
|
239
|
+
// ─── The platform index (14.8, docs/design/host.md) ───────────────────────
|
|
240
|
+
/**
|
|
241
|
+
* Every live edge a caller holds across every owner on this host, grouped by owner: who they are to
|
|
242
|
+
* each. A hosted person is named by their bare ID at home and `person:<domain:id>` elsewhere.
|
|
243
|
+
*/
|
|
244
|
+
async whoami(who, now = this.now()) {
|
|
245
|
+
const names = [who.caller, `person:${who.owner}:${who.caller}`];
|
|
246
|
+
const { rows } = await this.pool.query("select owner, json from edges where json->>'who' = any($1) order by owner, ord", [names]);
|
|
247
|
+
const byOwner = {};
|
|
248
|
+
for (const r of rows) {
|
|
249
|
+
const e = r.json;
|
|
250
|
+
// Lapsed edges don't count (14.4); whether the grantor still stands is the owner's border's call at the write.
|
|
251
|
+
if (typeof e.until === "string" && e.until <= now)
|
|
252
|
+
continue;
|
|
253
|
+
if (r.owner !== who.owner && e.who === who.caller)
|
|
254
|
+
continue; // a bare ID at another owner is someone else
|
|
255
|
+
const list = byOwner[r.owner] ?? (byOwner[r.owner] = []);
|
|
256
|
+
list.push({ edge: e.edge, relation: e.relation, mode: e.mode, on: e.on, ...(Array.isArray(e.grants) && e.grants.length ? { grants: e.grants } : {}), ...(typeof e.until === "string" ? { until: e.until } : {}), ...(e.gate ? { gate: e.gate } : {}) });
|
|
257
|
+
}
|
|
258
|
+
const owners = {};
|
|
259
|
+
for (const [owner, edges] of Object.entries(byOwner)) {
|
|
260
|
+
const modes = ["none", "view", "consume", "control", "admin"];
|
|
261
|
+
const mode = edges.map((e) => String(e.mode)).sort((a, b) => modes.indexOf(b) - modes.indexOf(a))[0] ?? "none";
|
|
262
|
+
owners[owner] = { mode, relations: [...new Set(edges.map((e) => String(e.relation)))], grants: [...new Set(edges.flatMap((e) => e.grants ?? []))], edges };
|
|
263
|
+
}
|
|
264
|
+
return { caller: who.caller, home: who.owner, owners };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The rows the caller stands in a relation to, across every owner on this host (14.10, 12.6): the index
|
|
268
|
+
* rows whose record, or an ancestor, is a live edge of theirs with that relation. The feed's primitive.
|
|
269
|
+
*/
|
|
270
|
+
async rowsFor(who, relation, o = {}) {
|
|
271
|
+
const me = await this.whoami(who, o.now);
|
|
272
|
+
const ons = [];
|
|
273
|
+
for (const [owner, v] of Object.entries(me.owners))
|
|
274
|
+
for (const e of v.edges)
|
|
275
|
+
if (e.relation === relation)
|
|
276
|
+
ons.push({ owner, on: String(e.on) });
|
|
277
|
+
if (!ons.length)
|
|
278
|
+
return [];
|
|
279
|
+
const owners = [...new Set(ons.map((x) => x.owner))];
|
|
280
|
+
const targets = [...new Set(ons.map((x) => x.on))];
|
|
281
|
+
const params = [owners, targets];
|
|
282
|
+
let where = "owner = any($1) and (record = any($2) or ancestors && $2::text[])";
|
|
283
|
+
if (o.view) {
|
|
284
|
+
params.push(o.view);
|
|
285
|
+
where += ` and view = $${params.length}`;
|
|
286
|
+
}
|
|
287
|
+
params.push(o.limit ?? 100);
|
|
288
|
+
const { rows } = await this.pool.query(`select owner, view, record, row from index_rows where ${where} order by seq desc limit $${params.length}`, params);
|
|
289
|
+
// Only rows an edge of that owner covers: the same record or one of its ancestors.
|
|
290
|
+
return rows.map((r) => ({ owner: r.owner, view: r.view, record: r.record, row: r.row }));
|
|
291
|
+
}
|
|
292
|
+
// ─── Secrets ───────────────────────────────────────────────────────────────
|
|
293
|
+
/** A credential's secret: connected under the name (refreshed when OAuth and expired), a configured source, or the environment. */
|
|
294
|
+
async secret(owner, name) {
|
|
295
|
+
const connected = await this.credentials.secret(owner, name, this.o.oauth?.[name]);
|
|
296
|
+
if (connected !== undefined)
|
|
297
|
+
return connected;
|
|
298
|
+
const store = this.o.secretStore ?? new ChainedSecrets([...(this.o.secrets ? [new FunctionSecrets(this.o.secrets)] : []), new EnvSecrets()]);
|
|
299
|
+
return store.get(owner, name);
|
|
300
|
+
}
|
|
301
|
+
/** Health (docs/design/completion.md O): the database reachable, and every owner's chain head. */
|
|
302
|
+
async health() {
|
|
303
|
+
try {
|
|
304
|
+
await this.pool.query("select 1");
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
return { ok: false, database: e instanceof Error ? e.message : String(e) };
|
|
308
|
+
}
|
|
309
|
+
const chains = (await this.pool.query("select owner, chain, head_seq from chains order by owner, chain")).rows;
|
|
310
|
+
const heads = {};
|
|
311
|
+
for (const c of chains)
|
|
312
|
+
heads[c.chain === "main" ? c.owner : `${c.owner}/${c.chain}`] = Number(c.head_seq);
|
|
313
|
+
return { ok: true, domain: this.o.domain, now: this.now(), chains: heads };
|
|
314
|
+
}
|
|
315
|
+
// ─── Stores and borders ────────────────────────────────────────────────────
|
|
316
|
+
/** An owner's store, signing with the owner's key when the host holds one. */
|
|
317
|
+
/** One store per owner for the life of the host, so its definitions and state caches outlive a call (G6). */
|
|
318
|
+
async storeFor(owner) {
|
|
319
|
+
const had = this.stores.get(owner);
|
|
320
|
+
if (had)
|
|
321
|
+
return had;
|
|
322
|
+
const key = await this.keyOf(owner);
|
|
323
|
+
const signer = key ? (hash) => edSign(null, Buffer.from(hash, "hex"), createPrivateKey({ key: { kty: "OKP", crv: "Ed25519", x: key.public, d: key.private }, format: "jwk" })).toString("base64url") : undefined;
|
|
324
|
+
const store = new PgStore(this.pool, owner, { ...(signer ? { signer } : {}), hostedSigner: (o) => this.hostedSigner(o), ...(this.o.fresh ? { fresh: this.o.fresh } : {}), ...(this.o.allowUnsigned ? { allowUnsigned: true } : {}) });
|
|
325
|
+
if (key)
|
|
326
|
+
this.stores.set(owner, store);
|
|
327
|
+
return store;
|
|
328
|
+
}
|
|
329
|
+
stores = new Map();
|
|
330
|
+
/** Drop an owner's kept store: after a key rotation (its signer changes) or a rewrite of its history. */
|
|
331
|
+
forgetStore(owner) {
|
|
332
|
+
this.stores.delete(owner);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The built-in `http` adapter: a contract with the host's part `host:url` is POSTed its payload as JSON,
|
|
336
|
+
* with the credential the contract names resolved to a secret and sent as its `auth` scheme says.
|
|
337
|
+
*/
|
|
338
|
+
httpAdapter(owner) {
|
|
339
|
+
return async (c, payload) => {
|
|
340
|
+
const url = c.parts["host:url"];
|
|
341
|
+
if (!url)
|
|
342
|
+
return { acted: true };
|
|
343
|
+
const headers = { "content-type": "application/json" };
|
|
344
|
+
const credential = c.parts.credential ? await this.secret(owner, c.parts.credential) : undefined;
|
|
345
|
+
if (c.parts.credential && credential === undefined)
|
|
346
|
+
return { failed: `credential: no secret for ${c.parts.credential}` };
|
|
347
|
+
if (credential && (c.parts.auth === "apikey" || c.parts.auth === "oauth2"))
|
|
348
|
+
headers.authorization = `Bearer ${credential}`;
|
|
349
|
+
if (credential && c.parts.auth === "basic")
|
|
350
|
+
headers.authorization = `Basic ${Buffer.from(credential).toString("base64")}`;
|
|
351
|
+
const res = await fetch(url, { method: c.parts["host:method"] ?? "POST", headers, body: JSON.stringify(payload) });
|
|
352
|
+
if (res.status === 401 || res.status === 403)
|
|
353
|
+
return { failed: `credential: ${res.status}` };
|
|
354
|
+
if (!res.ok)
|
|
355
|
+
return { failed: `http ${res.status}` };
|
|
356
|
+
const body = await res.text();
|
|
357
|
+
return { acted: true, ...(body ? { body } : {}) };
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
// ─── Between owners (7.8, 12.6.2) ─────────────────────────────────────────
|
|
361
|
+
/** Another owner's host, by domain. */
|
|
362
|
+
hostOf(domain) {
|
|
363
|
+
return this.o.hostOf ? this.o.hostOf(domain) : `https://${domain}`;
|
|
364
|
+
}
|
|
365
|
+
/** A request to another owner's host, signed with this owner's domain key (RFC 9421). */
|
|
366
|
+
async signedFetch(owner, method, url, body) {
|
|
367
|
+
const key = await this.keyOf(owner);
|
|
368
|
+
if (!key)
|
|
369
|
+
throw new Error(`${owner} has no key to sign with`);
|
|
370
|
+
const headers = signRequest({ method, url, ...(body !== undefined ? { body } : {}), keyid: owner, privateD: key.private, publicX: key.public, created: Math.floor(Date.parse(this.now()) / 1000) });
|
|
371
|
+
return fetch(url, { method, headers: { ...headers, ...(body !== undefined ? { "content-type": "application/json" } : {}) }, ...(body !== undefined ? { body } : {}) });
|
|
372
|
+
}
|
|
373
|
+
/** May this caller read an offer (7.8 `to`)? Public to anyone; link by token; followers and named parties by edge or signature. */
|
|
374
|
+
async mayTake(owner, c, callerId, link) {
|
|
375
|
+
const to = (c.to ?? "private").split(",").map((s) => s.trim()).filter(Boolean);
|
|
376
|
+
if (callerId === owner)
|
|
377
|
+
return true;
|
|
378
|
+
if (to.includes("public"))
|
|
379
|
+
return true;
|
|
380
|
+
if (to.includes("link") && link) {
|
|
381
|
+
const { rows } = await this.pool.query("select 1 from host_offer_links where owner = $1 and contract = $2 and token = $3 and not revoked", [owner, c.name, link]);
|
|
382
|
+
return rows.length > 0;
|
|
383
|
+
}
|
|
384
|
+
if (!callerId)
|
|
385
|
+
return false;
|
|
386
|
+
const domain = callerId.replace(/:owner$/, "");
|
|
387
|
+
if (to.some((p) => p !== "public" && p !== "link" && p !== "followers" && p !== "private" && (p === domain || p === callerId)))
|
|
388
|
+
return true;
|
|
389
|
+
if (to.includes("followers")) {
|
|
390
|
+
const store = await this.storeFor(owner);
|
|
391
|
+
const edges = [...(await store.state()).edges.values()];
|
|
392
|
+
return edges.some((e) => (e.who === callerId || e.who === domain) && /^follow/.test(String(e.relation)));
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
/** Publish an offer by link (7.8): the token, minted once and kept across republishing. */
|
|
397
|
+
async offerLink(owner, contract) {
|
|
398
|
+
const { rows } = await this.pool.query("select token from host_offer_links where owner = $1 and contract = $2", [owner, contract]);
|
|
399
|
+
if (rows[0]) {
|
|
400
|
+
await this.pool.query("update host_offer_links set revoked = false where owner = $1 and contract = $2", [owner, contract]);
|
|
401
|
+
return String(rows[0].token);
|
|
402
|
+
}
|
|
403
|
+
const token = randomBytes(24).toString("base64url");
|
|
404
|
+
await this.pool.query("insert into host_offer_links (owner, contract, token) values ($1, $2, $3)", [owner, contract, token]);
|
|
405
|
+
return token;
|
|
406
|
+
}
|
|
407
|
+
/** Unpublish a link offer: the token stops working, nothing is deleted. */
|
|
408
|
+
async unpublishOffer(owner, contract) {
|
|
409
|
+
await this.pool.query("update host_offer_links set revoked = true where owner = $1 and contract = $2", [owner, contract]);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* A take across hosts (7.8, 8.5.1): fetch the presenter's offer with a signed request, verify the wrapper
|
|
413
|
+
* against the presenter's keys, then take each offered record into ours: the one bound to it, or a new
|
|
414
|
+
* record bound from now on. Each take is one entry naming the presenter's.
|
|
415
|
+
*/
|
|
416
|
+
async take(owner, takeName, at = this.now()) {
|
|
417
|
+
const store = await this.storeFor(owner);
|
|
418
|
+
const border = await this.borderFor(owner, store);
|
|
419
|
+
const t = border.contracts.get(takeName);
|
|
420
|
+
if (!t || t.kind !== "take")
|
|
421
|
+
throw new Error(`no take contract ${takeName}`);
|
|
422
|
+
const from = t.from;
|
|
423
|
+
if (!from)
|
|
424
|
+
throw new Error(`${takeName} names no presenter (from)`);
|
|
425
|
+
const offerName = t.parts.offer;
|
|
426
|
+
const names = offerName ? [offerName] : (await (await this.signedFetch(owner, "GET", `${this.hostOf(from)}/o/${from}/offer`)).json()).offers?.filter((o) => o.gives.some((g) => t.reads.some((m) => m.from === g))).map((o) => String(o.contract)) ?? [];
|
|
427
|
+
const taken = [];
|
|
428
|
+
for (const name of names) {
|
|
429
|
+
const res = await this.signedFetch(owner, "GET", `${this.hostOf(from)}/o/${from}/offer/${name}`);
|
|
430
|
+
if (!res.ok) {
|
|
431
|
+
taken.push({ offer: name, stopped: `http ${res.status}` });
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const offer = (await res.json());
|
|
435
|
+
const verified = verifyBorder({ records: offer.records, ...(offer.log ? { log: offer.log } : {}) }, await this.resolveKeys(from));
|
|
436
|
+
if (verified.verified !== true) {
|
|
437
|
+
taken.push({ offer: name, stopped: "unverified", ...verified });
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
// The version gate (10.1): newer than ours is held until we upgrade; older is restamped through the recipes between.
|
|
441
|
+
const gate = versionGate(offer.package, (await store.state()).installed.map((i) => ({ package: String(i.package), version: String(i.version) })));
|
|
442
|
+
if (gate.kind === "hold") {
|
|
443
|
+
const key = `version:${gate.package}`;
|
|
444
|
+
const open = (await store.review()).some((it) => it.key === key && it.from === from && it.offer === name);
|
|
445
|
+
if (!open)
|
|
446
|
+
await store.commit({ take: takeName, from }, (d) => { d.review.push({ key, from, offer: name, package: gate.package, theirs: gate.theirs, ours: gate.ours, reason: `written under ${gate.package} /${gate.theirs}; this store holds /${gate.ours}` }); }, at);
|
|
447
|
+
taken.push({ offer: name, stopped: "version", package: gate.package, theirs: gate.theirs, ours: gate.ours });
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (gate.kind === "restamp") {
|
|
451
|
+
const rows = (await this.pool.query("select version, json from bundles where owner = $1 and package = $2 and version > $3 and version <= $4 order by version", [owner, gate.package, gate.theirs, gate.ours])).rows;
|
|
452
|
+
const defs = await store.definitions();
|
|
453
|
+
for (const row of rows)
|
|
454
|
+
for (const r of readBundle(row.json).recipes)
|
|
455
|
+
offer.records = restamp(offer.records, r, defs.dict).records;
|
|
456
|
+
}
|
|
457
|
+
for (const presenter of offer.records) {
|
|
458
|
+
const key = `take|${from}|${presenter.id}`;
|
|
459
|
+
let recordId = (await store.state()).bindings.get(key);
|
|
460
|
+
if (!recordId) {
|
|
461
|
+
// A record of ours for theirs: minted empty, bound by the presenter's id from now on.
|
|
462
|
+
const n = (await this.pool.query("select count(*) as n from bindings where owner = $1 and key like 'take|%'", [owner])).rows[0];
|
|
463
|
+
recordId = `T-${Number(n.n) + 1}`;
|
|
464
|
+
const id = recordId;
|
|
465
|
+
await store.commit({ take: takeName, from }, (d) => {
|
|
466
|
+
d.records.set(id, { id, kind: presenter.kind, level: presenter.level, owner, values: {}, sources: {} });
|
|
467
|
+
d.bindings.set(key, id);
|
|
468
|
+
}, at);
|
|
469
|
+
}
|
|
470
|
+
const r = await border.take(takeName, offer, recordId, presenter, undefined, at);
|
|
471
|
+
taken.push({ offer: name, presenter: presenter.id, record: recordId, ...r });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { contract: takeName, from, taken };
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Push streams (12.6.2): every stream contract with `when: entry` sends its entries since the cursor to
|
|
478
|
+
* the party it names (its adapter, else a signed POST to that host's inbox), and the acknowledgement
|
|
479
|
+
* moves the cursor.
|
|
480
|
+
*/
|
|
481
|
+
async push(now = this.now(), owner) {
|
|
482
|
+
const out = [];
|
|
483
|
+
for (const o of owner ? [{ owner }] : await this.owners()) {
|
|
484
|
+
const store = await this.storeFor(o.owner);
|
|
485
|
+
const border = await this.borderFor(o.owner, store);
|
|
486
|
+
for (const c of border.contracts.values()) {
|
|
487
|
+
if (!c.stream.length || c.when !== "entry")
|
|
488
|
+
continue;
|
|
489
|
+
const r = await border.streamRun(c.name, undefined, undefined, now);
|
|
490
|
+
const entries = (r.entries ?? {});
|
|
491
|
+
if (!Object.values(entries).some((l) => l.length))
|
|
492
|
+
continue;
|
|
493
|
+
const adapter = this.o.adapters?.[c.name];
|
|
494
|
+
const payload = { contract: c.name, from: o.owner, entries: entries };
|
|
495
|
+
let acted = false;
|
|
496
|
+
let failed;
|
|
497
|
+
if (adapter) {
|
|
498
|
+
const a = await adapter(c, payload, String(r.log ?? ""));
|
|
499
|
+
acted = "acted" in a && a.acted === true;
|
|
500
|
+
if ("failed" in a)
|
|
501
|
+
failed = a.failed;
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
const url = c.parts["host:url"] ?? (c.to ? `${this.hostOf(c.to)}/o/${c.to}/inbox/${c.name}` : undefined);
|
|
505
|
+
if (!url) {
|
|
506
|
+
failed = "nowhere to push: no to and no host:url";
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
const res = await this.signedFetch(o.owner, "POST", url, JSON.stringify(payload));
|
|
510
|
+
acted = res.ok;
|
|
511
|
+
if (!res.ok)
|
|
512
|
+
failed = `http ${res.status}`;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (acted)
|
|
516
|
+
await border.ack(c.name, r.cursor);
|
|
517
|
+
out.push({ owner: o.owner, contract: c.name, ...(acted ? { acted: true, cursor: r.cursor } : { failed: failed ?? "not acted" }) });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
/** Taken values past their terms (7.8), for every owner or one. */
|
|
523
|
+
async expire(now = this.now(), owner) {
|
|
524
|
+
const out = [];
|
|
525
|
+
for (const o of owner ? [{ owner }] : await this.owners()) {
|
|
526
|
+
const border = await this.borderFor(o.owner);
|
|
527
|
+
const ids = await border.expire(now, now);
|
|
528
|
+
if (ids.length)
|
|
529
|
+
out.push({ owner: o.owner, expired: ids });
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
/** An owner's border over its own definitions, with the adapters. */
|
|
534
|
+
async borderFor(owner, store) {
|
|
535
|
+
const s = store ?? (await this.storeFor(owner));
|
|
536
|
+
const o = { ...(await borderOptions(s)), ...(this.o.idPrefixes ? { idPrefixes: this.o.idPrefixes } : {}) };
|
|
537
|
+
const http = this.httpAdapter(owner);
|
|
538
|
+
const adapters = {};
|
|
539
|
+
for (const c of o.contracts)
|
|
540
|
+
if (c.kind === "send" || c.kind === "form")
|
|
541
|
+
adapters[c.name] = this.o.adapters?.[c.name] ?? http;
|
|
542
|
+
return new PgBorder(s, { ...o, adapters });
|
|
543
|
+
}
|
|
544
|
+
// ─── Packages ──────────────────────────────────────────────────────────────
|
|
545
|
+
async resolveKeys(domain) {
|
|
546
|
+
if (this.o.resolveKeys)
|
|
547
|
+
return this.o.resolveKeys(domain);
|
|
548
|
+
const res = await fetch(`https://${domain}/.well-known/crubal.json`);
|
|
549
|
+
if (!res.ok)
|
|
550
|
+
throw new Error(`${domain}: no well-known file (${res.status})`);
|
|
551
|
+
const j = (await res.json());
|
|
552
|
+
return j.keys ?? {};
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Install into one owner what another owner on this host already holds (brief 1: a person's chain takes
|
|
556
|
+
* the person package from the platform's own log): the bundle and what it extends, in order, each skipped
|
|
557
|
+
* when the target already has that version. The source's bundles were verified when it installed them.
|
|
558
|
+
*/
|
|
559
|
+
async installFromOwner(target, source, pkg, version, at = this.now()) {
|
|
560
|
+
const have = (await (await this.storeFor(target)).state()).installed.map((i) => `${i.package} /${i.version}`);
|
|
561
|
+
const rows = (await this.pool.query("select package, version, json from bundles where owner = $1", [source])).rows;
|
|
562
|
+
const find = (id, v) => {
|
|
563
|
+
const mine = rows.filter((r) => r.package === id || r.package === id.split("/").pop());
|
|
564
|
+
const pick = v ? mine.find((r) => r.version === v) : mine.sort((a, b) => (a.version < b.version ? 1 : -1))[0];
|
|
565
|
+
if (!pick)
|
|
566
|
+
throw new Error(`${source} holds no ${id}${v ? ` /${v}` : ""} to install into ${target}`);
|
|
567
|
+
return pick;
|
|
568
|
+
};
|
|
569
|
+
const out = [];
|
|
570
|
+
const store = await this.storeFor(target);
|
|
571
|
+
const seen = new Set();
|
|
572
|
+
const put = async (id, v) => {
|
|
573
|
+
const b = find(id, v);
|
|
574
|
+
const key = `${b.json.id} /${b.version}`;
|
|
575
|
+
if (seen.has(key))
|
|
576
|
+
return;
|
|
577
|
+
seen.add(key);
|
|
578
|
+
for (const e of b.json.extends ?? []) {
|
|
579
|
+
const dep = String(e.package).includes("/") ? String(e.package) : `${b.json.owner}/${e.package}`;
|
|
580
|
+
await put(dep, String(e.version));
|
|
581
|
+
}
|
|
582
|
+
if (have.includes(key))
|
|
583
|
+
return;
|
|
584
|
+
const entry = await store.install(b.json, undefined, at);
|
|
585
|
+
out.push({ installed: String(b.json.id), version: b.version, entry: String(entry.log) });
|
|
586
|
+
};
|
|
587
|
+
await put(pkg, version);
|
|
588
|
+
return out;
|
|
589
|
+
}
|
|
590
|
+
/** Install a published version for an owner: the publisher's keys from its well-known file, then the store. */
|
|
591
|
+
async install(owner, bundle) {
|
|
592
|
+
const store = await this.storeFor(owner);
|
|
593
|
+
const keys = bundle.sig ? await this.resolveKeys(String(bundle.owner)) : undefined;
|
|
594
|
+
const entry = await store.install(bundle, keys, this.now());
|
|
595
|
+
return { installed: String(bundle.id), version: String(bundle.version), entry: String(entry.log) };
|
|
596
|
+
}
|
|
597
|
+
/** A JSON document by URL. */
|
|
598
|
+
async fetchJson(url) {
|
|
599
|
+
if (this.o.fetchJson)
|
|
600
|
+
return this.o.fetchJson(url);
|
|
601
|
+
const res = await fetch(url);
|
|
602
|
+
if (!res.ok)
|
|
603
|
+
throw new Error(`${url}: ${res.status}`);
|
|
604
|
+
return (await res.json());
|
|
605
|
+
}
|
|
606
|
+
/** A publisher's well-known file (11.7), by its domain. */
|
|
607
|
+
async wellKnownOf(domain) {
|
|
608
|
+
return this.fetchJson(`${this.hostOf(domain)}/.well-known/crubal.json`);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Install a package by name from its publisher's well-known file (11.7, release.md): the version asked
|
|
612
|
+
* for or the latest, its bundle fetched from where the file says, what it extends installed first
|
|
613
|
+
* (from their own publishers), every bundle verified against its publisher's published keys.
|
|
614
|
+
*/
|
|
615
|
+
async installFrom(owner, pkg, version, seen = new Set()) {
|
|
616
|
+
const store = await this.storeFor(owner);
|
|
617
|
+
// A dependency pinned to a version the owner already holds needs no publisher at all (the standard
|
|
618
|
+
// packages a deployment took at provisioning, for one).
|
|
619
|
+
if (version && (await store.state()).installed.some((i) => i.package === pkg && String(i.version) === version))
|
|
620
|
+
return [];
|
|
621
|
+
const domain = pkg.split("/")[0];
|
|
622
|
+
const wk = await this.wellKnownOf(domain);
|
|
623
|
+
const listed = (wk.packages ?? []).find((p) => p.package === pkg);
|
|
624
|
+
if (!listed)
|
|
625
|
+
throw new Error(`${domain} doesn't publish ${pkg}`);
|
|
626
|
+
const v = version ?? String(listed.latest);
|
|
627
|
+
const url = listed.versions?.[v];
|
|
628
|
+
if (!url)
|
|
629
|
+
throw new Error(`${pkg} has no version ${v} at ${domain}`);
|
|
630
|
+
const key = `${pkg} /${v}`;
|
|
631
|
+
if (seen.has(key))
|
|
632
|
+
throw new Error(`${key} extends itself through its dependencies`);
|
|
633
|
+
seen.add(key);
|
|
634
|
+
const out = [];
|
|
635
|
+
const installed = (await store.state()).installed;
|
|
636
|
+
if (installed.some((i) => i.package === pkg && String(i.version) === v))
|
|
637
|
+
return out;
|
|
638
|
+
const bundle = await this.fetchJson(url);
|
|
639
|
+
// A bundle names what it extends by short name within its own publisher, or by id across publishers.
|
|
640
|
+
for (const e of bundle.extends ?? []) {
|
|
641
|
+
const dep = String(e.package).includes("/") ? String(e.package) : `${bundle.owner}/${e.package}`;
|
|
642
|
+
out.push(...(await this.installFrom(owner, dep, String(e.version), seen)));
|
|
643
|
+
}
|
|
644
|
+
const keys = wk.keys ?? (await this.resolveKeys(domain));
|
|
645
|
+
const entry = await store.install(bundle, bundle.sig ? keys : undefined, this.now());
|
|
646
|
+
out.push({ installed: String(bundle.id), version: String(bundle.version), from: url, entry: String(entry.log) });
|
|
647
|
+
return out;
|
|
648
|
+
}
|
|
649
|
+
// ─── Publishing packages (11.5, 11.7) ─────────────────────────────────────
|
|
650
|
+
/** Where this host serves what it publishes: the public base URL (`https://<domain>` otherwise). */
|
|
651
|
+
publicBase() {
|
|
652
|
+
return (this.o.publicBase ?? `https://${this.o.domain}`).replace(/\/$/, "");
|
|
653
|
+
}
|
|
654
|
+
/** The URL of a published version's bundle; the text sits beside it as package.crubal. */
|
|
655
|
+
bundleUrl(owner, pkg, version) {
|
|
656
|
+
return `${this.publicBase()}/packages/${owner}/${pkg}/${version}/package.json`;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Publish a package version for an owner this host holds the key of: the text becomes a bundle signed
|
|
660
|
+
* with that key, versioned against the owner's last published version of the package (so unchanged
|
|
661
|
+
* members keep their versions and a breaking change needs its recipe), kept here and listed in the
|
|
662
|
+
* well-known file. A version already published is returned as it is; a published version never changes.
|
|
663
|
+
*/
|
|
664
|
+
async publishPackage(owner, source, o = {}) {
|
|
665
|
+
const key = await this.keyOf(owner);
|
|
666
|
+
if (!key)
|
|
667
|
+
throw new Error(`${owner} has no key on this host to publish with`);
|
|
668
|
+
const head = source.match(/^package: (\S+) \/(\S+)/m);
|
|
669
|
+
if (!head)
|
|
670
|
+
throw new Error("the text has no package: line");
|
|
671
|
+
const [, name, version] = head;
|
|
672
|
+
const date = o.date ?? version.split(".")[0] ?? this.now().slice(0, 10);
|
|
673
|
+
const have = (await this.pool.query("select version, json from host_published where owner = $1 and package = $2 order by version desc", [owner, name])).rows;
|
|
674
|
+
const same = have.find((r) => r.version === version || r.version.startsWith(`${date}`));
|
|
675
|
+
if (same)
|
|
676
|
+
return { bundle: same.json, changes: [], already: true };
|
|
677
|
+
const previous = have[0]?.json;
|
|
678
|
+
const { bundle, changes } = publish(source, { date, ...(previous ? { previous } : {}), ...(o.records?.length ? { records: o.records } : {}), key: { d: key.private, x: key.public } });
|
|
679
|
+
await this.pool.query("insert into host_published (owner, package, version, hash, json, text, published) values ($1, $2, $3, $4, $5, $6, $7) on conflict (owner, package, version) do nothing", [owner, name, String(bundle.version), String(bundle.hash), bundle, source, this.now()]);
|
|
680
|
+
return { bundle, changes: changes.map((c) => ({ member: c.member, kind: c.kind })), already: false };
|
|
681
|
+
}
|
|
682
|
+
/** One published version: its signed bundle and its text, or null. */
|
|
683
|
+
async publishedBundle(owner, pkg, version) {
|
|
684
|
+
const { rows } = await this.pool.query("select json, text from host_published where owner = $1 and package = $2 and version = $3", [owner, pkg, version]);
|
|
685
|
+
const r = rows[0];
|
|
686
|
+
return r ? { bundle: r.json, text: r.text } : null;
|
|
687
|
+
}
|
|
688
|
+
/** What the well-known file lists: every published package with its versions and where each bundle is. */
|
|
689
|
+
async publishedPackages(owner) {
|
|
690
|
+
const { rows } = await this.pool.query(owner ? "select owner, package, version from host_published where owner = $1 order by owner, package, version" : "select owner, package, version from host_published order by owner, package, version", owner ? [owner] : []);
|
|
691
|
+
const byId = new Map();
|
|
692
|
+
for (const r of rows) {
|
|
693
|
+
const id = `${r.owner}/${r.package}`;
|
|
694
|
+
const e = byId.get(id) ?? { owner: r.owner, pkg: r.package, versions: [] };
|
|
695
|
+
e.versions.push(r.version);
|
|
696
|
+
byId.set(id, e);
|
|
697
|
+
}
|
|
698
|
+
return [...byId.entries()].map(([id, e]) => ({ package: id, latest: e.versions[e.versions.length - 1], versions: Object.fromEntries(e.versions.map((v) => [v, this.bundleUrl(e.owner, e.pkg, v)])) }));
|
|
699
|
+
}
|
|
700
|
+
// ─── The well-known file ───────────────────────────────────────────────────
|
|
701
|
+
async wellKnown() {
|
|
702
|
+
const keys = {};
|
|
703
|
+
const retired = (await this.pool.query("select owner, public, \"from\", until from host_keys order by until")).rows;
|
|
704
|
+
for (const o of await this.owners()) {
|
|
705
|
+
const old = retired.filter((k) => k.owner === o.owner).map((k) => ({ alg: "Ed25519", public: k.public, ...(k.from ? { from: k.from } : {}), until: k.until }));
|
|
706
|
+
if (!old.length)
|
|
707
|
+
keys[o.owner] = { alg: "Ed25519", public: o.public };
|
|
708
|
+
else
|
|
709
|
+
keys[o.owner] = [...old, { alg: "Ed25519", public: o.public, from: old.at(-1).until }];
|
|
710
|
+
}
|
|
711
|
+
return { crubal: "2", owner: this.o.domain, keys, packages: await this.publishedPackages(), ...(this.o.emergency ? { emergency: this.o.emergency } : {}) };
|
|
712
|
+
}
|
|
713
|
+
async notice(owner, notice) {
|
|
714
|
+
if (this.o.notify)
|
|
715
|
+
await this.o.notify(owner, notice);
|
|
716
|
+
else
|
|
717
|
+
console.warn(`[notice ${owner}] ${JSON.stringify(notice)}`);
|
|
718
|
+
}
|
|
719
|
+
// ─── The loops ─────────────────────────────────────────────────────────────
|
|
720
|
+
/** Publish every owner's stale rows. */
|
|
721
|
+
async project(owner, now = this.now()) {
|
|
722
|
+
const out = {};
|
|
723
|
+
for (const o of owner ? [{ owner }] : await this.owners()) {
|
|
724
|
+
const store = await this.storeFor(o.owner);
|
|
725
|
+
const p = await store.projecting();
|
|
726
|
+
if (!p?.views.views.size) {
|
|
727
|
+
out[o.owner] = 0;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
// A view with a delay (12.6 `fresh: 5m`) publishes when that much has passed since its last publish.
|
|
731
|
+
const last = new Map((await this.pool.query("select view, at from host_publish where owner = $1", [o.owner])).rows.map((r) => [r.view, r.at]));
|
|
732
|
+
let n = 0;
|
|
733
|
+
for (const v of p.views.views.values()) {
|
|
734
|
+
const delay = v.fresh === "write" ? 0 : durationMs(v.fresh);
|
|
735
|
+
const was = last.get(v.name);
|
|
736
|
+
if (delay && was && Date.parse(was) + delay > Date.parse(now))
|
|
737
|
+
continue;
|
|
738
|
+
n += (await store.project(v.name)).length;
|
|
739
|
+
if (delay)
|
|
740
|
+
await this.pool.query("insert into host_publish (owner, view, at) values ($1, $2, $3) on conflict (owner, view) do update set at = excluded.at", [o.owner, v.name, now]);
|
|
741
|
+
}
|
|
742
|
+
out[o.owner] = n;
|
|
743
|
+
}
|
|
744
|
+
return out;
|
|
745
|
+
}
|
|
746
|
+
/** Drain pending instructions (completion.md P) for every owner or one, at a rate per owner. */
|
|
747
|
+
async drain(rate = 100, owner, now = this.now()) {
|
|
748
|
+
const out = [];
|
|
749
|
+
for (const o of owner ? [{ owner }] : await this.owners()) {
|
|
750
|
+
const r = await (await this.storeFor(o.owner)).drain(rate, now);
|
|
751
|
+
if (r.applied.length || r.remaining)
|
|
752
|
+
out.push({ owner: o.owner, applied: r.applied, remaining: r.remaining });
|
|
753
|
+
}
|
|
754
|
+
return out;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Try failed runs again when their contract's retry interval has passed (7.5): a run still open after a
|
|
758
|
+
* failure, whose last attempt is `every` behind `now`. A run dead after its last attempt is noticed.
|
|
759
|
+
*/
|
|
760
|
+
async retry(now = this.now(), owner) {
|
|
761
|
+
const out = [];
|
|
762
|
+
for (const o of owner ? [{ owner }] : await this.owners()) {
|
|
763
|
+
const store = await this.storeFor(o.owner);
|
|
764
|
+
const border = await this.borderFor(o.owner, store);
|
|
765
|
+
const { rows } = await this.pool.query("select run, json from runs where owner = $1 and json->>'state' = 'failed'", [o.owner]);
|
|
766
|
+
for (const r of rows) {
|
|
767
|
+
const c = border.contracts.get(String(r.json.contract));
|
|
768
|
+
const m = (c?.parts.retry ?? "").match(/^(\d+)\s+every\s+(\d+)(m|h|d)$/);
|
|
769
|
+
if (!c || !m)
|
|
770
|
+
continue;
|
|
771
|
+
const allowed = 1 + Number(m[1]);
|
|
772
|
+
if (Number(r.json.attempt ?? 0) >= allowed)
|
|
773
|
+
continue;
|
|
774
|
+
const every = Number(m[2]) * (m[3] === "m" ? 60_000 : m[3] === "h" ? 3_600_000 : 86_400_000);
|
|
775
|
+
const last = (await this.pool.query("select max(at) as at from log where owner = $1 and entry->'writes' @> $2::jsonb", [o.owner, JSON.stringify([{ run: r.run, state: "failed" }])])).rows[0];
|
|
776
|
+
if (last.at && Date.parse(last.at) + every > Date.parse(now))
|
|
777
|
+
continue;
|
|
778
|
+
const result = await border.retry(r.run, undefined, now);
|
|
779
|
+
out.push({ owner: o.owner, run: r.run, ...result });
|
|
780
|
+
if (result.dead)
|
|
781
|
+
await this.notice(o.owner, { dead: r.run, contract: c.name, review: String(result.review ?? "") });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
/** Run every scheduled contract's due ticks, for one owner or all. */
|
|
787
|
+
async tick(now = this.now(), owner) {
|
|
788
|
+
const out = [];
|
|
789
|
+
for (const o of owner ? [{ owner }] : await this.owners()) {
|
|
790
|
+
const store = await this.storeFor(o.owner);
|
|
791
|
+
const border = await this.borderFor(o.owner, store);
|
|
792
|
+
for (const c of border.contracts.values()) {
|
|
793
|
+
if (!c.when || !/^every /.test(c.when))
|
|
794
|
+
continue;
|
|
795
|
+
out.push({ owner: o.owner, ...(await border.tick(c.name, now)) });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/** A delay such as `5m`, `2h`, `1d` or `30s` in milliseconds; 0 when it isn't one. */
|
|
802
|
+
function durationMs(s) {
|
|
803
|
+
const m = s.trim().match(/^(\d+)\s*(s|m|h|d)$/);
|
|
804
|
+
if (!m)
|
|
805
|
+
return 0;
|
|
806
|
+
return Number(m[1]) * { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
|
|
807
|
+
}
|
|
808
|
+
//# sourceMappingURL=host.js.map
|