approval-md 0.3.0 → 0.4.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 +83 -914
- package/SPEC.md +98 -13
- package/cli.js +66 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +19 -0
- package/dist/src/channels/contract.js +105 -1
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.js +6 -1
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +271 -1
- package/dist/src/channels/telegram.js +403 -45
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +350 -4
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
- package/dist/src/cli/channel-telegram-webhook.js +709 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +272 -26
- package/dist/src/cli/channel-telegram.js +478 -41
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +341 -20
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +9 -1
- package/dist/src/cli/help.js +246 -38
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +371 -3
- package/dist/src/cli/hook.js +1926 -44
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +51 -0
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/scaffold.js +6 -0
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/up.js +21 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +337 -39
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +55 -3
- package/dist/src/core/audit.js +52 -5
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +760 -8
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/gate.d.ts +306 -5
- package/dist/src/core/gate.js +772 -24
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +50 -21
- package/dist/src/core/log.js +197 -19
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +23 -0
- package/dist/src/core/policy-proposal.js +8 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sender-identity.d.ts +35 -10
- package/dist/src/core/sender-identity.js +48 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +298 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +22 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +158 -0
- package/dist/src/serve/archive.js +354 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +766 -0
- package/docs/cli-reference.md +1633 -43
- package/package.json +4 -2
- package/schema/event.schema.json +59 -2
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +15 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/policy.schema.json +50 -6
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authenticated relay channel: its HTTP receiver (SPEC.md §10.3, APRV-455).
|
|
3
|
+
*
|
|
4
|
+
* The sibling of `channels/telegram-webhook.ts`, and deliberately the same
|
|
5
|
+
* shape: one header, one path, one method, one bounded body, a loopback bind by
|
|
6
|
+
* default with the operator's proxy terminating TLS, a refusal carried as a
|
|
7
|
+
* body with a frozen code, and no refusal ever written to the log. What differs
|
|
8
|
+
* is what arrives. A Telegram delivery is an Update the channel parses; a relay
|
|
9
|
+
* post is a gesture whose shape `channels/relay.ts` closes, whose freshness and
|
|
10
|
+
* nonce this file checks, and which `applyRelayGesture` turns into a log event
|
|
11
|
+
* through `recordChannelDecision`, the one place a reported gesture becomes one.
|
|
12
|
+
*
|
|
13
|
+
* ## The secret first
|
|
14
|
+
*
|
|
15
|
+
* `x-approval-relay-secret` is compared before the URL is parsed, the method is
|
|
16
|
+
* read or a byte of the body is consumed. A caller without it learns only that
|
|
17
|
+
* something refused. The comparison is over SHA-256 digests under
|
|
18
|
+
* `timingSafeEqual`, constant-time and length-independent, and a request
|
|
19
|
+
* carrying the header twice is refused rather than resolved, because Node joins
|
|
20
|
+
* repeats and the value compared would not be the value a reader names. These
|
|
21
|
+
* are the webhook's three rules, for the webhook's reasons.
|
|
22
|
+
*
|
|
23
|
+
* ## Why this is its own port and its own secret
|
|
24
|
+
*
|
|
25
|
+
* `approval serve` answers two bearer credentials, the agent's and the
|
|
26
|
+
* tenant's, and NEITHER may reach a decision: grant, reject and attest are
|
|
27
|
+
* human-only verbs that serve does not publish. This receiver answers a third
|
|
28
|
+
* credential held by a third party (the operator's control plane), and it is
|
|
29
|
+
* reachable only from the process `approval channel relay` starts. Nothing in
|
|
30
|
+
* `src/serve/` or `src/mcp/` imports this module, the verb registry marks the
|
|
31
|
+
* verb `human_only` so no wrapper publishes it, and `tests/channel-relay.test.ts`
|
|
32
|
+
* pins both.
|
|
33
|
+
*
|
|
34
|
+
* ## Replay
|
|
35
|
+
*
|
|
36
|
+
* Every gesture carries a nonce and an `issued_at`. The nonce is claimed in a
|
|
37
|
+
* ledger of exclusively linked files under the gate's `daemon/` directory before the
|
|
38
|
+
* gesture is applied, so a replayed post is refused `relay-nonce-replayed`
|
|
39
|
+
* whether it arrives at this process, after its restart, or at a second relay
|
|
40
|
+
* on the same gate; a post older than the window is refused
|
|
41
|
+
* `relay-gesture-stale` after the ledger has forgotten it. The gate is the
|
|
42
|
+
* belt: a decision a replay did reach would be refused `already-decided`.
|
|
43
|
+
*/
|
|
44
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
45
|
+
import { createServer } from "node:http";
|
|
46
|
+
import { RELAY_DEFAULT_HOST, RELAY_DEFAULT_PORT, RELAY_DRAIN_LIMIT_BYTES, RELAY_MAX_BODY_BYTES, RELAY_PATH, RELAY_REFUSAL_CODES, RELAY_SECRET_HEADER, applyRelayGesture, parseRelayGesture, relayGestureFresh, relayRefusal, } from "./relay.js";
|
|
47
|
+
/** How long {@link RelayHandle.close} waits for requests in flight. */
|
|
48
|
+
export const RELAY_CLOSE_TIMEOUT_MS = 10_000;
|
|
49
|
+
function digest(value) {
|
|
50
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
51
|
+
}
|
|
52
|
+
/** Is `offered` the secret? Constant-time over equal-length digests. */
|
|
53
|
+
export function relaySecretMatches(expected, offered) {
|
|
54
|
+
if (offered === null)
|
|
55
|
+
return false;
|
|
56
|
+
return timingSafeEqual(digest(expected), digest(offered));
|
|
57
|
+
}
|
|
58
|
+
/** The one secret header offered, `null` for none, `"duplicate"` for more than one. */
|
|
59
|
+
export function relaySecretHeaderOf(rawHeaders) {
|
|
60
|
+
const values = [];
|
|
61
|
+
for (let index = 0; index + 1 < rawHeaders.length; index += 2) {
|
|
62
|
+
const name = rawHeaders[index];
|
|
63
|
+
if (name !== undefined && name.toLowerCase() === RELAY_SECRET_HEADER) {
|
|
64
|
+
values.push(rawHeaders[index + 1] ?? "");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (values.length === 0)
|
|
68
|
+
return null;
|
|
69
|
+
if (values.length > 1)
|
|
70
|
+
return "duplicate";
|
|
71
|
+
return values[0] ?? "";
|
|
72
|
+
}
|
|
73
|
+
async function readBody(req) {
|
|
74
|
+
const chunks = [];
|
|
75
|
+
let size = 0;
|
|
76
|
+
let oversized = false;
|
|
77
|
+
const tooLarge = {
|
|
78
|
+
ok: false,
|
|
79
|
+
code: "relay-body-too-large",
|
|
80
|
+
message: `request body exceeds ${String(RELAY_MAX_BODY_BYTES)} bytes`,
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
for await (const chunk of req) {
|
|
84
|
+
const buffer = chunk;
|
|
85
|
+
size += buffer.length;
|
|
86
|
+
if (size > RELAY_MAX_BODY_BYTES) {
|
|
87
|
+
// Drained rather than answered mid-upload, for the webhook's reason: a
|
|
88
|
+
// response written while the client is still writing closes the socket
|
|
89
|
+
// under it, and the caller sees a transport error instead of a code.
|
|
90
|
+
oversized = true;
|
|
91
|
+
chunks.length = 0;
|
|
92
|
+
if (size > RELAY_DRAIN_LIMIT_BYTES) {
|
|
93
|
+
req.destroy();
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
chunks.push(buffer);
|
|
99
|
+
}
|
|
100
|
+
if (oversized)
|
|
101
|
+
return tooLarge;
|
|
102
|
+
}
|
|
103
|
+
catch (cause) {
|
|
104
|
+
if (oversized)
|
|
105
|
+
return tooLarge;
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
code: "relay-body-unreadable",
|
|
109
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, text: Buffer.concat(chunks).toString("utf8") };
|
|
113
|
+
}
|
|
114
|
+
function emptyRefusals() {
|
|
115
|
+
const counts = {};
|
|
116
|
+
for (const code of RELAY_REFUSAL_CODES)
|
|
117
|
+
counts[code] = 0;
|
|
118
|
+
return counts;
|
|
119
|
+
}
|
|
120
|
+
/** Start the receiver. Resolves once bound; rejects when the bind fails. */
|
|
121
|
+
export async function serveRelay(options) {
|
|
122
|
+
const host = options.host ?? RELAY_DEFAULT_HOST;
|
|
123
|
+
const now = options.now ?? (() => Date.now());
|
|
124
|
+
const complain = options.log ??
|
|
125
|
+
((message) => {
|
|
126
|
+
process.stderr.write(`${message}\n`);
|
|
127
|
+
});
|
|
128
|
+
const stats = { requests: 0, gestures: 0, refusals: emptyRefusals() };
|
|
129
|
+
const sockets = new Set();
|
|
130
|
+
let inFlight = 0;
|
|
131
|
+
let closed = null;
|
|
132
|
+
function send(res, response) {
|
|
133
|
+
const bytes = Buffer.from(JSON.stringify(response.body), "utf8");
|
|
134
|
+
res.writeHead(response.status, {
|
|
135
|
+
"content-type": "application/json",
|
|
136
|
+
"content-length": bytes.length,
|
|
137
|
+
"cache-control": "no-store",
|
|
138
|
+
});
|
|
139
|
+
res.end(bytes);
|
|
140
|
+
}
|
|
141
|
+
/** Counted, said on stderr, answered with its code. Never an event. */
|
|
142
|
+
function refuse(res, status, code, message) {
|
|
143
|
+
stats.refusals[code] += 1;
|
|
144
|
+
complain(`approval: relay refused a post (${code}): ${message}`);
|
|
145
|
+
send(res, relayRefusal(status, code, message));
|
|
146
|
+
}
|
|
147
|
+
const http = createServer((req, res) => {
|
|
148
|
+
void (async () => {
|
|
149
|
+
stats.requests += 1;
|
|
150
|
+
inFlight += 1;
|
|
151
|
+
try {
|
|
152
|
+
const offered = relaySecretHeaderOf(req.rawHeaders);
|
|
153
|
+
if (offered === "duplicate") {
|
|
154
|
+
refuse(res, 401, "relay-duplicate-secret-header", `this request carries more than one ${RELAY_SECRET_HEADER} header; exactly one is accepted`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!relaySecretMatches(options.secret, offered)) {
|
|
158
|
+
refuse(res, 401, "relay-secret-mismatch", offered === null
|
|
159
|
+
? `no ${RELAY_SECRET_HEADER} header. A post without the relay's secret decides nothing and is not written to the log`
|
|
160
|
+
: `the ${RELAY_SECRET_HEADER} header is not this relay's secret. Nothing reached the gate and nothing was appended`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let url;
|
|
164
|
+
try {
|
|
165
|
+
url = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
refuse(res, 400, "relay-malformed-request", "the request line and Host header do not form a URL");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (url.pathname !== RELAY_PATH) {
|
|
172
|
+
refuse(res, 404, "relay-unknown-path", `no endpoint at ${url.pathname}; the relay answers ${RELAY_PATH}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const method = req.method ?? "GET";
|
|
176
|
+
if (method !== "POST") {
|
|
177
|
+
refuse(res, 405, "relay-method-not-allowed", `${RELAY_PATH} answers POST, not ${method}`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const body = await readBody(req);
|
|
181
|
+
if (!body.ok) {
|
|
182
|
+
refuse(res, body.code === "relay-body-too-large" ? 413 : 400, body.code, body.message);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
let decoded;
|
|
186
|
+
try {
|
|
187
|
+
decoded = JSON.parse(body.text);
|
|
188
|
+
}
|
|
189
|
+
catch (cause) {
|
|
190
|
+
refuse(res, 400, "relay-body-unreadable", `request body is not JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const parsed = parseRelayGesture(decoded);
|
|
194
|
+
if (!parsed.ok) {
|
|
195
|
+
refuse(res, 400, parsed.code, parsed.message);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const gesture = parsed.gesture;
|
|
199
|
+
const nowMs = now();
|
|
200
|
+
if (!relayGestureFresh(gesture.issuedAtMs, nowMs)) {
|
|
201
|
+
refuse(res, 400, "relay-gesture-stale", `issued_at ${new Date(gesture.issuedAtMs).toISOString()} is more than five minutes from this relay's clock (${new Date(nowMs).toISOString()}); a gesture is carried while the person who made it is still there, and an old one is refused rather than honoured`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Claimed BEFORE the gesture is applied, so a replay racing the
|
|
205
|
+
// original loses at the file system rather than at the gate. A claim
|
|
206
|
+
// that cannot be recorded fails closed: replay protection that could
|
|
207
|
+
// not be promised is not offered as though it were.
|
|
208
|
+
const claim = options.ledger.claim(gesture.nonce, nowMs);
|
|
209
|
+
if (claim === "replayed") {
|
|
210
|
+
refuse(res, 409, "relay-nonce-replayed", "this nonce was already used. Each gesture carries its own; a retry is a new post with a new nonce, and the gate answers a repeated decision already-decided");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (claim !== "claimed") {
|
|
214
|
+
refuse(res, 503, "relay-nonce-unavailable", `the nonce ledger could not record this gesture (${claim.error}), so a replay of it could not be refused later; nothing was decided`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
let response;
|
|
218
|
+
try {
|
|
219
|
+
response = applyRelayGesture(options.logPath, gesture, options);
|
|
220
|
+
}
|
|
221
|
+
catch (cause) {
|
|
222
|
+
refuse(res, 500, "relay-handler-failed", `applying the gesture failed (${cause instanceof Error ? cause.message : String(cause)}); read the log for what, if anything, was appended`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
stats.gestures += 1;
|
|
226
|
+
if (options.afterGesture !== undefined) {
|
|
227
|
+
try {
|
|
228
|
+
options.afterGesture(response);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// A reporting hook that throws must not turn an answered gesture
|
|
232
|
+
// into a failed request.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
send(res, response);
|
|
236
|
+
}
|
|
237
|
+
catch (cause) {
|
|
238
|
+
if (!res.headersSent) {
|
|
239
|
+
refuse(res, 500, "relay-handler-failed", `the request failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
res.end();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
inFlight -= 1;
|
|
247
|
+
}
|
|
248
|
+
})();
|
|
249
|
+
});
|
|
250
|
+
http.on("connection", (socket) => {
|
|
251
|
+
sockets.add(socket);
|
|
252
|
+
socket.on("close", () => sockets.delete(socket));
|
|
253
|
+
});
|
|
254
|
+
await new Promise((settle, fail) => {
|
|
255
|
+
const onError = (cause) => fail(cause);
|
|
256
|
+
http.once("error", onError);
|
|
257
|
+
http.listen(options.port ?? RELAY_DEFAULT_PORT, host, () => {
|
|
258
|
+
http.off("error", onError);
|
|
259
|
+
settle();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
const address = http.address();
|
|
263
|
+
const boundPort = typeof address === "object" && address !== null ? address.port : (options.port ?? RELAY_DEFAULT_PORT);
|
|
264
|
+
const boundHost = typeof address === "object" && address !== null ? address.address : host;
|
|
265
|
+
async function stop() {
|
|
266
|
+
const accepted = new Promise((settle) => {
|
|
267
|
+
http.close(() => settle());
|
|
268
|
+
});
|
|
269
|
+
const deadline = Date.now() + (options.closeTimeoutMs ?? RELAY_CLOSE_TIMEOUT_MS);
|
|
270
|
+
while (inFlight > 0 && Date.now() < deadline) {
|
|
271
|
+
await new Promise((settle) => {
|
|
272
|
+
setTimeout(settle, 5).unref?.();
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (inFlight > 0) {
|
|
276
|
+
complain(`approval: relay: ${String(inFlight)} request(s) still in flight at the close deadline; closing anyway`);
|
|
277
|
+
}
|
|
278
|
+
for (const socket of sockets)
|
|
279
|
+
socket.destroy();
|
|
280
|
+
sockets.clear();
|
|
281
|
+
await accepted;
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
host: boundHost,
|
|
285
|
+
port: boundPort,
|
|
286
|
+
path: RELAY_PATH,
|
|
287
|
+
stats: () => ({ ...stats, refusals: { ...stats.refusals } }),
|
|
288
|
+
inFlight: () => inFlight,
|
|
289
|
+
close: () => {
|
|
290
|
+
closed ??= stop();
|
|
291
|
+
return closed;
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
//# sourceMappingURL=relay-server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-server.js","sourceRoot":"","sources":["../../../src/channels/relay-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,YAAY,EAA0D,MAAM,WAAW,CAAC;AAGjG,OAAO,EACL,kBAAkB,EAClB,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,UAAU,EACV,mBAAmB,EACnB,mBAAmB,EACnB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,EACjB,YAAY,GAKb,MAAM,YAAY,CAAC;AAEpB,uEAAuE;AACvE,MAAM,CAAC,MAAM,sBAAsB,GAAG,MAAM,CAAC;AA2C7C,SAAS,MAAM,CAAC,KAAa;IAC3B,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,MAAM,EAAE,CAAC;AAC7D,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,kBAAkB,CAAC,QAAgB,EAAE,OAAsB;IACzE,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IACnC,OAAO,eAAe,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC;AAC5D,CAAC;AAED,uFAAuF;AACvF,MAAM,UAAU,mBAAmB,CAAC,UAA6B;IAC/D,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,GAAG,UAAU,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QAC9D,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;QAC/B,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,WAAW,EAAE,KAAK,mBAAmB,EAAE,CAAC;YACrE,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,KAAK,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;QAC3C,CAAC;IACH,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrC,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,WAAW,CAAC;IAC1C,OAAO,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;AACzB,CAAC;AAID,KAAK,UAAU,QAAQ,CAAC,GAAoB;IAC1C,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,IAAI,SAAS,GAAG,KAAK,CAAC;IACtB,MAAM,QAAQ,GAAa;QACzB,EAAE,EAAE,KAAK;QACT,IAAI,EAAE,sBAAsB;QAC5B,OAAO,EAAE,wBAAwB,MAAM,CAAC,oBAAoB,CAAC,QAAQ;KACtE,CAAC;IACF,IAAI,CAAC;QACH,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,EAAE,CAAC;YAC9B,MAAM,MAAM,GAAG,KAAe,CAAC;YAC/B,IAAI,IAAI,MAAM,CAAC,MAAM,CAAC;YACtB,IAAI,IAAI,GAAG,oBAAoB,EAAE,CAAC;gBAChC,uEAAuE;gBACvE,uEAAuE;gBACvE,qEAAqE;gBACrE,SAAS,GAAG,IAAI,CAAC;gBACjB,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;gBAClB,IAAI,IAAI,GAAG,uBAAuB,EAAE,CAAC;oBACnC,GAAG,CAAC,OAAO,EAAE,CAAC;oBACd,MAAM;gBACR,CAAC;gBACD,SAAS;YACX,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACtB,CAAC;QACD,IAAI,SAAS;YAAE,OAAO,QAAQ,CAAC;IACjC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,SAAS;YAAE,OAAO,QAAQ,CAAC;QAC/B,OAAO;YACL,EAAE,EAAE,KAAK;YACT,IAAI,EAAE,uBAAuB;YAC7B,OAAO,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;SAChE,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;AACpE,CAAC;AAED,SAAS,aAAa;IACpB,MAAM,MAAM,GAAG,EAAsC,CAAC;IACtD,KAAK,MAAM,IAAI,IAAI,mBAAmB;QAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACzD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,4EAA4E;AAC5E,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,OAA2B;IAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,kBAAkB,CAAC;IAChD,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAW,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IACtD,MAAM,QAAQ,GACZ,OAAO,CAAC,GAAG;QACX,CAAC,CAAC,OAAe,EAAQ,EAAE;YACzB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,OAAO,IAAI,CAAC,CAAC;QACvC,CAAC,CAAC,CAAC;IACL,MAAM,KAAK,GAAe,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,EAAE,aAAa,EAAE,EAAE,CAAC;IAClF,MAAM,OAAO,GAAG,IAAI,GAAG,EAAU,CAAC;IAClC,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,IAAI,MAAM,GAAyB,IAAI,CAAC;IAExC,SAAS,IAAI,CAAC,GAAmB,EAAE,QAAuB;QACxD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC;QACjE,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE;YAC7B,cAAc,EAAE,kBAAkB;YAClC,gBAAgB,EAAE,KAAK,CAAC,MAAM;YAC9B,eAAe,EAAE,UAAU;SAC5B,CAAC,CAAC;QACH,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IACjB,CAAC;IAED,uEAAuE;IACvE,SAAS,MAAM,CAAC,GAAmB,EAAE,MAAc,EAAE,IAAsB,EAAE,OAAe;QAC1F,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC1B,QAAQ,CAAC,mCAAmC,IAAI,MAAM,OAAO,EAAE,CAAC,CAAC;QACjE,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;IACjD,CAAC;IAED,MAAM,IAAI,GAAW,YAAY,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;QAC7C,KAAK,CAAC,KAAK,IAAI,EAAE;YACf,KAAK,CAAC,QAAQ,IAAI,CAAC,CAAC;YACpB,QAAQ,IAAI,CAAC,CAAC;YACd,IAAI,CAAC;gBACH,MAAM,OAAO,GAAG,mBAAmB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;gBACpD,IAAI,OAAO,KAAK,WAAW,EAAE,CAAC;oBAC5B,MAAM,CACJ,GAAG,EACH,GAAG,EACH,+BAA+B,EAC/B,sCAAsC,mBAAmB,kCAAkC,CAC5F,CAAC;oBACF,OAAO;gBACT,CAAC;gBACD,IAAI,CAAC,kBAAkB,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;oBACjD,MAAM,CACJ,GAAG,EACH,GAAG,EACH,uBAAuB,EACvB,OAAO,KAAK,IAAI;wBACd,CAAC,CAAC,MAAM,mBAAmB,0FAA0F;wBACrH,CAAC,CAAC,OAAO,mBAAmB,uFAAuF,CACtH,CAAC;oBACF,OAAO;gBACT,CAAC;gBAED,IAAI,GAAQ,CAAC;gBACb,IAAI,CAAC;oBACH,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,EAAE,UAAU,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,IAAI,EAAE,CAAC,CAAC;gBACtE,CAAC;gBAAC,MAAM,CAAC;oBACP,MAAM,CAAC,GAAG,EAAE,GAAG,EAAE,yBAAyB,EAAE,oDAAoD,CAAC,CAAC;oBAClG,OAAO;gBACT,CAAC;gBACD,IAAI,GAAG,CAAC,QAAQ,KAAK,UAAU,EAAE,CAAC;oBAChC,MAAM,CAAC,GAAG,EAAE,GAAG,EAAE,oBAAoB,EAAE,kBAAkB,GAAG,CAAC,QAAQ,uBAAuB,UAAU,EAAE,CAAC,CAAC;oBAC1G,OAAO;gBACT,CAAC;gBACD,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,IAAI,KAAK,CAAC;gBACnC,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;oBACtB,MAAM,CAAC,GAAG,EAAE,GAAG,EAAE,0BAA0B,EAAE,GAAG,UAAU,sBAAsB,MAAM,EAAE,CAAC,CAAC;oBAC1F,OAAO;gBACT,CAAC;gBAED,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC;gBACjC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;oBACb,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,KAAK,sBAAsB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;oBACvF,OAAO;gBACT,CAAC;gBACD,IAAI,OAAgB,CAAC;gBACrB,IAAI,CAAC;oBACH,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBAClC,CAAC;gBAAC,OAAO,KAAK,EAAE,CAAC;oBACf,MAAM,CACJ,GAAG,EACH,GAAG,EACH,uBAAuB,EACvB,6BAA6B,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CACtF,CAAC;oBACF,OAAO;gBACT,CAAC;gBACD,MAAM,MAAM,GAAG,iBAAiB,CAAC,OAAO,CAAC,CAAC;gBAC1C,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;oBACf,MAAM,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;oBAC9C,OAAO;gBACT,CAAC;gBACD,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;gBAE/B,MAAM,KAAK,GAAG,GAAG,EAAE,CAAC;gBACpB,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC,UAAU,EAAE,KAAK,CAAC,EAAE,CAAC;oBAClD,MAAM,CACJ,GAAG,EACH,GAAG,EACH,qBAAqB,EACrB,aAAa,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,WAAW,EAAE,uDAAuD,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,qHAAqH,CACjQ,CAAC;oBACF,OAAO;gBACT,CAAC;gBAED,gEAAgE;gBAChE,qEAAqE;gBACrE,qEAAqE;gBACrE,oDAAoD;gBACpD,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;gBACzD,IAAI,KAAK,KAAK,UAAU,EAAE,CAAC;oBACzB,MAAM,CACJ,GAAG,EACH,GAAG,EACH,sBAAsB,EACtB,6JAA6J,CAC9J,CAAC;oBACF,OAAO;gBACT,CAAC;gBACD,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;oBACxB,MAAM,CACJ,GAAG,EACH,GAAG,EACH,yBAAyB,EACzB,mDAAmD,KAAK,CAAC,KAAK,sEAAsE,CACrI,CAAC;oBACF,OAAO;gBACT,CAAC;gBAED,IAAI,QAAuB,CAAC;gBAC5B,IAAI,CAAC;oBACH,QAAQ,GAAG,iBAAiB,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;gBAClE,CAAC;gBAAC,OAAO,KAAK,EAAE,CAAC;oBACf,MAAM,CACJ,GAAG,EACH,GAAG,EACH,sBAAsB,EACtB,gCAAgC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,qDAAqD,CAC5I,CAAC;oBACF,OAAO;gBACT,CAAC;gBACD,KAAK,CAAC,QAAQ,IAAI,CAAC,CAAC;gBACpB,IAAI,OAAO,CAAC,YAAY,KAAK,SAAS,EAAE,CAAC;oBACvC,IAAI,CAAC;wBACH,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;oBACjC,CAAC;oBAAC,MAAM,CAAC;wBACP,iEAAiE;wBACjE,yBAAyB;oBAC3B,CAAC;gBACH,CAAC;gBACD,IAAI,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;YACtB,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC;oBACrB,MAAM,CACJ,GAAG,EACH,GAAG,EACH,sBAAsB,EACtB,uBAAuB,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAChF,CAAC;gBACJ,CAAC;qBAAM,CAAC;oBACN,GAAG,CAAC,GAAG,EAAE,CAAC;gBACZ,CAAC;YACH,CAAC;oBAAS,CAAC;gBACT,QAAQ,IAAI,CAAC,CAAC;YAChB,CAAC;QACH,CAAC,CAAC,EAAE,CAAC;IACP,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,MAAc,EAAE,EAAE;QACvC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACpB,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;IACnD,CAAC,CAAC,CAAC;IAEH,MAAM,IAAI,OAAO,CAAO,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE;QACvC,MAAM,OAAO,GAAG,CAAC,KAAY,EAAQ,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACpD,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC5B,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,kBAAkB,EAAE,IAAI,EAAE,GAAG,EAAE;YACzD,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC3B,MAAM,EAAE,CAAC;QACX,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;IAC/B,MAAM,SAAS,GACb,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,IAAI,kBAAkB,CAAC,CAAC;IACxG,MAAM,SAAS,GAAG,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;IAE3F,KAAK,UAAU,IAAI;QACjB,MAAM,QAAQ,GAAG,IAAI,OAAO,CAAO,CAAC,MAAM,EAAE,EAAE;YAC5C,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC;QAC7B,CAAC,CAAC,CAAC;QACH,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,OAAO,CAAC,cAAc,IAAI,sBAAsB,CAAC,CAAC;QACjF,OAAO,QAAQ,GAAG,CAAC,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,QAAQ,EAAE,CAAC;YAC7C,MAAM,IAAI,OAAO,CAAO,CAAC,MAAM,EAAE,EAAE;gBACjC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC;YAClC,CAAC,CAAC,CAAC;QACL,CAAC;QACD,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;YACjB,QAAQ,CAAC,oBAAoB,MAAM,CAAC,QAAQ,CAAC,mEAAmE,CAAC,CAAC;QACpH,CAAC;QACD,KAAK,MAAM,MAAM,IAAI,OAAO;YAAE,MAAM,CAAC,OAAO,EAAE,CAAC;QAC/C,OAAO,CAAC,KAAK,EAAE,CAAC;QAChB,MAAM,QAAQ,CAAC;IACjB,CAAC;IAED,OAAO;QACL,IAAI,EAAE,SAAS;QACf,IAAI,EAAE,SAAS;QACf,IAAI,EAAE,UAAU;QAChB,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,QAAQ,EAAE,EAAE,GAAG,KAAK,CAAC,QAAQ,EAAE,EAAE,CAAC;QAC5D,QAAQ,EAAE,GAAG,EAAE,CAAC,QAAQ;QACxB,KAAK,EAAE,GAAG,EAAE;YACV,MAAM,KAAK,IAAI,EAAE,CAAC;YAClB,OAAO,MAAM,CAAC;QAChB,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authenticated relay channel: its protocol (SPEC.md §10.3, APRV-455).
|
|
3
|
+
*
|
|
4
|
+
* Agent Village residents review their approval.md settings in the Edge City
|
|
5
|
+
* onboarding app, and that review is meant to be the attestation of the policy
|
|
6
|
+
* in force. The app signs the resident in through EdgeOS, and the operator's
|
|
7
|
+
* control plane is what holds that session. This module is the daemon's side
|
|
8
|
+
* of the hop from there: a gesture the control plane carries, attributed to the
|
|
9
|
+
* EdgeOS `/humans/me` id of the session that made it, arriving over an
|
|
10
|
+
* authenticated HTTP post (`channels/relay-server.ts`) and turned into a log
|
|
11
|
+
* event by the SAME function a Telegram tap goes through.
|
|
12
|
+
*
|
|
13
|
+
* ## What the relay is trusted for, and what it is not
|
|
14
|
+
*
|
|
15
|
+
* The daemon trusts the relay's report of WHO acted, exactly as it trusts the
|
|
16
|
+
* Telegram Bot API's report of `callback_query.from.id`. That is operator
|
|
17
|
+
* trust (APRV-422: no hosted service has authority over decisions), and it is
|
|
18
|
+
* the whole of what the shared secret buys: a post that carries it is a report
|
|
19
|
+
* from the operator's relay, and a post that does not is nothing. Every
|
|
20
|
+
* question of AUTHORITY is answered here, by the runtime, from the verified log
|
|
21
|
+
* and the attested policy:
|
|
22
|
+
*
|
|
23
|
+
* - which approver the account is, by `core/sender-identity.ts` against the
|
|
24
|
+
* attested mapping, exactly as a tap is resolved;
|
|
25
|
+
* - whether that approver may decide this request, attest this policy, or
|
|
26
|
+
* neither, by `core/gate.ts` and `core/policy-proposal.ts`, unchanged;
|
|
27
|
+
* - whether the request is still answerable (expiry, policy drift, an earlier
|
|
28
|
+
* answer), by the same gate, with the same codes.
|
|
29
|
+
*
|
|
30
|
+
* The relay holds NO human identity of its own. A Telegram listener launched
|
|
31
|
+
* `--as human:carter` falls back to that identity for a channel the policy maps
|
|
32
|
+
* no sender for; this surface does not, ever (`requireSenderMapping`). A post
|
|
33
|
+
* naming an account the attested policy does not map is refused
|
|
34
|
+
* `sender-unmapped` with one `audit.decision_refused`, and a policy that maps
|
|
35
|
+
* nobody on `edgeos` refuses every gesture the same way.
|
|
36
|
+
*
|
|
37
|
+
* ## The body, closed
|
|
38
|
+
*
|
|
39
|
+
* ```json
|
|
40
|
+
* { "gesture": "attest" | "decline", "policy_sha256": "<64 hex>",
|
|
41
|
+
* "sender": { "channel": "edgeos", "id": "<EdgeOS /humans/me id>" },
|
|
42
|
+
* "nonce": "<16..128 of A-Z a-z 0-9 _ ->", "issued_at": "<RFC 3339 UTC>" }
|
|
43
|
+
* { "gesture": "grant" | "reject", "action_key": "<the request's key>",
|
|
44
|
+
* "sender": { ... }, "nonce": "...", "issued_at": "..." }
|
|
45
|
+
* { "gesture": "propose", "policy_sha256": "<64 hex>",
|
|
46
|
+
* "nonce": "...", "issued_at": "..." }
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* Unknown keys are refused rather than ignored, so a field that looks like it
|
|
50
|
+
* carries authority (an `actor`, a second sender, a `ts`) cannot ride along on
|
|
51
|
+
* a valid post and be read by something later. `issued_at` is used for one
|
|
52
|
+
* thing only, the freshness window that bounds how long a nonce must be
|
|
53
|
+
* remembered; it is never recorded, and every record's `ts` is still the
|
|
54
|
+
* runtime's own (SPEC.md §11.1 invariant 2).
|
|
55
|
+
*
|
|
56
|
+
* ## Why `propose` exists here
|
|
57
|
+
*
|
|
58
|
+
* The control plane renders the resident's policy and writes it into the
|
|
59
|
+
* store; somebody then has to append the `policy.proposed` an acceptance
|
|
60
|
+
* answers. The existing route for that is `approval policy amend`, which is a
|
|
61
|
+
* git-aware terminal ceremony that blocks for its own `--wait`. `propose` is the
|
|
62
|
+
* same `core/policy-proposal.ts` call, under the same secret, recorded under the
|
|
63
|
+
* actor the operator started the relay with (`--proposer`), and it returns at
|
|
64
|
+
* once. It binds the hash the control plane rendered: bytes on disk that are not
|
|
65
|
+
* the bytes it names are refused, so the prompt the resident answers is the file
|
|
66
|
+
* the control plane showed them.
|
|
67
|
+
*
|
|
68
|
+
* It also admits the one proposal `policy amend` refuses: bytes that are
|
|
69
|
+
* ALREADY the policy in force (`ProposeInput.reaffirm`). The onboarding review
|
|
70
|
+
* most residents make is "accept, unchanged", and the operator attested those
|
|
71
|
+
* bytes at provisioning (APRV-449). The reaffirmation changes the attester of
|
|
72
|
+
* record and nothing else: same hash, a diff that reads "no semantic change",
|
|
73
|
+
* and an acceptance resolved against the policy in force like any other.
|
|
74
|
+
*
|
|
75
|
+
* ## No token leaves this surface
|
|
76
|
+
*
|
|
77
|
+
* A grant may mint a single-use execution token for the deciding surface
|
|
78
|
+
* (manual delivery). A relay is not a place a token can go: the control plane
|
|
79
|
+
* is not the requester and the HTTP response is not a secrets channel. The
|
|
80
|
+
* token `recordChannelDecision` returns is dropped here, unread, and the
|
|
81
|
+
* response says only whether one was minted. The deployments this exists for
|
|
82
|
+
* mint none (a harness-executed request) or seal it to the requester's own
|
|
83
|
+
* key, so nothing is lost in practice, and the residual is stated rather than
|
|
84
|
+
* hidden: a manual-delivery request granted through the relay has a token
|
|
85
|
+
* nobody holds, and its requester asks again.
|
|
86
|
+
*/
|
|
87
|
+
import type { DecideOptions } from "../core/gate.js";
|
|
88
|
+
import { type ChannelSender } from "../core/sender-identity.js";
|
|
89
|
+
/** The channel name every relay gesture is recorded under, and its senders' namespace. */
|
|
90
|
+
export declare const RELAY_CHANNEL = "edgeos";
|
|
91
|
+
/** The header the shared secret travels in, lowercased as Node presents it. */
|
|
92
|
+
export declare const RELAY_SECRET_HEADER = "x-approval-relay-secret";
|
|
93
|
+
/** The one path this transport answers. */
|
|
94
|
+
export declare const RELAY_PATH = "/relay/gesture";
|
|
95
|
+
/** The interface it binds when the operator names none. Loopback, always. */
|
|
96
|
+
export declare const RELAY_DEFAULT_HOST = "127.0.0.1";
|
|
97
|
+
/** The port it binds when the operator names none: `serve` is 4682, the webhook 4683. */
|
|
98
|
+
export declare const RELAY_DEFAULT_PORT = 4684;
|
|
99
|
+
/** Largest body accepted. A gesture is a handful of short strings. */
|
|
100
|
+
export declare const RELAY_MAX_BODY_BYTES: number;
|
|
101
|
+
/** How much of an oversized body is drained before the socket is dropped. */
|
|
102
|
+
export declare const RELAY_DRAIN_LIMIT_BYTES: number;
|
|
103
|
+
/**
|
|
104
|
+
* How far `issued_at` may sit from this process's clock, either side.
|
|
105
|
+
*
|
|
106
|
+
* Five minutes: a control plane and a sandbox on the same platform agree to
|
|
107
|
+
* well under a second, and a gesture older than this was not made by a person
|
|
108
|
+
* who is still looking at the screen that made it.
|
|
109
|
+
*/
|
|
110
|
+
export declare const RELAY_GESTURE_WINDOW_MS: number;
|
|
111
|
+
/**
|
|
112
|
+
* How long a claimed nonce is remembered.
|
|
113
|
+
*
|
|
114
|
+
* Longer than the whole window a fresh gesture can occupy (issued up to one
|
|
115
|
+
* window in the future, accepted up to one window late), with a minute of
|
|
116
|
+
* margin, so a replay is refused by the nonce while it is still fresh and by
|
|
117
|
+
* the window once the nonce is forgotten. There is no instant at which neither
|
|
118
|
+
* holds.
|
|
119
|
+
*/
|
|
120
|
+
export declare const RELAY_NONCE_RETENTION_MS: number;
|
|
121
|
+
/**
|
|
122
|
+
* How long a relay proposal stays open (APRV-455 refuter L2).
|
|
123
|
+
*
|
|
124
|
+
* The onboarding review is answered in the screen that rendered it, so an hour
|
|
125
|
+
* is generous; past it the prompt retires from every channel queue by
|
|
126
|
+
* derivation, and a control plane whose resident came back later proposes
|
|
127
|
+
* again. Until a proposal is attested the bytes on disk are not the policy in
|
|
128
|
+
* force, so the control plane restores the in-force bytes when a proposal is
|
|
129
|
+
* declined or lapses.
|
|
130
|
+
*/
|
|
131
|
+
export declare const RELAY_PROPOSAL_TTL_MS: number;
|
|
132
|
+
/** The actor a proposal is recorded under when the operator names none. */
|
|
133
|
+
export declare const RELAY_DEFAULT_PROPOSER = "agent:edgeos-relay";
|
|
134
|
+
/** The gestures, closed. */
|
|
135
|
+
export declare const RELAY_GESTURES: readonly ["propose", "attest", "decline", "grant", "reject"];
|
|
136
|
+
export type RelayGestureName = (typeof RELAY_GESTURES)[number];
|
|
137
|
+
/**
|
|
138
|
+
* Every refusal THIS transport makes, and nothing else. Frozen, per SPEC.md
|
|
139
|
+
* §11.1 invariant 6, and pinned by `conformance/vectors/refusal-unions.v1.json`
|
|
140
|
+
* because the control plane on the other side of the hop is a second
|
|
141
|
+
* implementation that branches on these strings.
|
|
142
|
+
*
|
|
143
|
+
* Refusals the GATE or the decision surface makes (`sender-unmapped`,
|
|
144
|
+
* `expired`, `policy-drift`, `already-decided`, `proposal-stale`,
|
|
145
|
+
* `proposal-not-found`, ...) are not here: they arrive verbatim under
|
|
146
|
+
* `refusal` in the response body, from their own unions, exactly as a Telegram
|
|
147
|
+
* tap would surface them.
|
|
148
|
+
*
|
|
149
|
+
* None of these appends to the log. A post the relay refuses is a fact about
|
|
150
|
+
* the transport and is counted and reported on stderr, because an endpoint
|
|
151
|
+
* that wrote its refusals into the approval log would let anyone who can reach
|
|
152
|
+
* it grow the record a human is asked to trust.
|
|
153
|
+
*/
|
|
154
|
+
export declare const RELAY_REFUSAL_CODES: readonly [
|
|
155
|
+
/** No secret header, or one that is not the launch environment's secret. */
|
|
156
|
+
"relay-secret-mismatch",
|
|
157
|
+
/** More than one secret header. Malformed, and not resolvable by choosing. */
|
|
158
|
+
"relay-duplicate-secret-header",
|
|
159
|
+
/** The request line and the Host header do not form a URL. */
|
|
160
|
+
"relay-malformed-request",
|
|
161
|
+
/** A path this transport does not serve. */
|
|
162
|
+
"relay-unknown-path",
|
|
163
|
+
/** The right path, a method other than POST. */
|
|
164
|
+
"relay-method-not-allowed",
|
|
165
|
+
/** Larger than {@link RELAY_MAX_BODY_BYTES}. */
|
|
166
|
+
"relay-body-too-large",
|
|
167
|
+
/** The body could not be read off the socket, or is not JSON. */
|
|
168
|
+
"relay-body-unreadable",
|
|
169
|
+
/** JSON that is not a gesture of the closed shape. */
|
|
170
|
+
"relay-body-invalid",
|
|
171
|
+
/**
|
|
172
|
+
* The sender is not an EdgeOS account this transport can report: a channel
|
|
173
|
+
* other than `edgeos`, or an id outside the grammar `senders.edgeos` pins.
|
|
174
|
+
*/
|
|
175
|
+
"relay-sender-invalid",
|
|
176
|
+
/** `issued_at` is further than {@link RELAY_GESTURE_WINDOW_MS} from now. */
|
|
177
|
+
"relay-gesture-stale",
|
|
178
|
+
/** This nonce was already used within {@link RELAY_NONCE_RETENTION_MS}. */
|
|
179
|
+
"relay-nonce-replayed",
|
|
180
|
+
/**
|
|
181
|
+
* The nonce ledger could not be written, so a replay could not be refused.
|
|
182
|
+
* Fails closed: nothing is decided without it.
|
|
183
|
+
*/
|
|
184
|
+
"relay-nonce-unavailable",
|
|
185
|
+
/**
|
|
186
|
+
* `propose` named a hash that is not the hash of the policy file on disk.
|
|
187
|
+
* Distinct from the gate's `proposal-stale`, which is the same fact found at
|
|
188
|
+
* ACCEPTANCE: this one is found before anything is proposed.
|
|
189
|
+
*/
|
|
190
|
+
"relay-policy-mismatch",
|
|
191
|
+
/** The gesture threw while being applied. Nothing is claimed about the log. */
|
|
192
|
+
"relay-handler-failed"];
|
|
193
|
+
export type RelayRefusalCode = (typeof RELAY_REFUSAL_CODES)[number];
|
|
194
|
+
/** A parsed, shape-checked gesture. */
|
|
195
|
+
export type RelayGesture = {
|
|
196
|
+
gesture: "attest" | "decline";
|
|
197
|
+
policySha256: string;
|
|
198
|
+
sender: ChannelSender;
|
|
199
|
+
nonce: string;
|
|
200
|
+
issuedAtMs: number;
|
|
201
|
+
} | {
|
|
202
|
+
gesture: "grant" | "reject";
|
|
203
|
+
actionKey: string;
|
|
204
|
+
sender: ChannelSender;
|
|
205
|
+
nonce: string;
|
|
206
|
+
issuedAtMs: number;
|
|
207
|
+
} | {
|
|
208
|
+
gesture: "propose";
|
|
209
|
+
policySha256: string;
|
|
210
|
+
nonce: string;
|
|
211
|
+
issuedAtMs: number;
|
|
212
|
+
};
|
|
213
|
+
export type RelayParse = {
|
|
214
|
+
ok: true;
|
|
215
|
+
gesture: RelayGesture;
|
|
216
|
+
} | {
|
|
217
|
+
ok: false;
|
|
218
|
+
code: RelayRefusalCode;
|
|
219
|
+
message: string;
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* Check one decoded body against the closed shape.
|
|
223
|
+
*
|
|
224
|
+
* Pure: no clock, no file, no log. Freshness and the nonce are the server's,
|
|
225
|
+
* because both need state this function deliberately does not hold.
|
|
226
|
+
*/
|
|
227
|
+
export declare function parseRelayGesture(value: unknown): RelayParse;
|
|
228
|
+
/** Is `issuedAtMs` within the window of `nowMs`? */
|
|
229
|
+
export declare function relayGestureFresh(issuedAtMs: number, nowMs: number): boolean;
|
|
230
|
+
/** What claiming one nonce came to. */
|
|
231
|
+
export type NonceClaim = "claimed" | "replayed" | {
|
|
232
|
+
error: string;
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* Where used nonces are remembered.
|
|
236
|
+
*
|
|
237
|
+
* One file per nonce, named by its SHA-256, written whole to a private temp and
|
|
238
|
+
* then hard-linked into place, under the gate's own `daemon/` directory. The
|
|
239
|
+
* file system settles the race: `link` fails with EEXIST exactly as `O_EXCL`
|
|
240
|
+
* does, so of any number of processes claiming one nonce at once exactly one
|
|
241
|
+
* wins, and a claim file never exists without the time it was claimed.
|
|
242
|
+
* That is why two relays on one gate need no lease between them for replay
|
|
243
|
+
* protection, and why a restart forgets nothing it was still obliged to
|
|
244
|
+
* remember. Files older than {@link RELAY_NONCE_RETENTION_MS} are pruned.
|
|
245
|
+
*/
|
|
246
|
+
export interface NonceLedger {
|
|
247
|
+
claim(nonce: string, nowMs: number): NonceClaim;
|
|
248
|
+
}
|
|
249
|
+
/** The file ledger described at {@link NonceLedger}. */
|
|
250
|
+
export declare function fileNonceLedger(dir: string): NonceLedger;
|
|
251
|
+
/** What applying a gesture produced: an HTTP status and the JSON body. */
|
|
252
|
+
export interface RelayResponse {
|
|
253
|
+
status: number;
|
|
254
|
+
body: Record<string, unknown>;
|
|
255
|
+
}
|
|
256
|
+
export interface ApplyRelayOptions {
|
|
257
|
+
/** The gate options a decision runs under: the policy location, the clock. */
|
|
258
|
+
gateOptions: DecideOptions;
|
|
259
|
+
/** The actor a `propose` is recorded under. */
|
|
260
|
+
proposer: string;
|
|
261
|
+
}
|
|
262
|
+
/** A transport refusal as a response. Never appended. */
|
|
263
|
+
export declare function relayRefusal(status: number, code: RelayRefusalCode, message: string): RelayResponse;
|
|
264
|
+
/**
|
|
265
|
+
* Turn one checked gesture into a log event, or into the refusal that replaces
|
|
266
|
+
* one.
|
|
267
|
+
*
|
|
268
|
+
* Decisions and attestations go through {@link recordChannelDecision}, the one
|
|
269
|
+
* place a reported gesture becomes a log event, with `requireSenderMapping` set
|
|
270
|
+
* and the channel named `edgeos`. A proposal goes through `proposeAttestation`.
|
|
271
|
+
* Nothing here decides anything those two do not already decide.
|
|
272
|
+
*/
|
|
273
|
+
export declare function applyRelayGesture(logPath: string, gesture: RelayGesture, options: ApplyRelayOptions): RelayResponse;
|