@rine-network/core 0.5.1 → 0.6.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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core";
5
5
  import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
6
6
  import { ed25519, x25519 } from "@noble/curves/ed25519.js";
@@ -9,6 +9,11 @@ import { sha256 } from "@noble/hashes/sha2.js";
9
9
  import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
10
10
  import { hmac } from "@noble/hashes/hmac.js";
11
11
  import { MlsError, acceptAll, clientStateDecoder, clientStateEncoder, createApplicationMessage, createCommit, createGroup, createGroupInfoWithExternalPubAndRatchetTree, decode, defaultCredentialTypes, encode, generateKeyPackageWithKey, getCiphersuiteImpl, joinGroup, joinGroupExternal, keyPackageDecoder, keyPackageEncoder, makeKeyPackageRef, mlsMessageDecoder, mlsMessageEncoder, privateKeyPackageDecoder, privateKeyPackageEncoder, processMessage, processPrivateMessage, protocolVersions, unsafeTestingAuthenticationService, wireformats, zeroOutUint8Array } from "ts-mls";
12
+ import { Client, crypto as crypto$1 } from "acme-client";
13
+ import { createHmac, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
14
+ import { Resolver, resolve4, resolveNs } from "node:dns/promises";
15
+ import { connect } from "node:net";
16
+ import { createServer } from "node:tls";
12
17
  //#region src/errors.ts
13
18
  var RineApiError = class extends Error {
14
19
  constructor(status, detail, raw, code) {
@@ -1312,7 +1317,7 @@ function isPendingConfirmation(err) {
1312
1317
  const CIPHER_SUITE = 1;
1313
1318
  const PENDING_RETRY_LIMIT = 3;
1314
1319
  const PENDING_RETRY_DELAY_MS = 400;
1315
- function sleep(ms) {
1320
+ function sleep$1(ms) {
1316
1321
  return new Promise((resolve) => setTimeout(resolve, ms));
1317
1322
  }
1318
1323
  /**
@@ -1363,7 +1368,7 @@ async function externalJoinMlsGroup(configDir, agentId, groupId, client, extraHe
1363
1368
  break;
1364
1369
  }
1365
1370
  if (i >= PENDING_RETRY_LIMIT - 1) throw new Error(`Cannot self-join MLS group ${groupId}: GroupInfo is still pending peer confirmation after a brief retry. Try again shortly.`, { cause: err });
1366
- await sleep(PENDING_RETRY_DELAY_MS);
1371
+ await sleep$1(PENDING_RETRY_DELAY_MS);
1367
1372
  }
1368
1373
  if (!isEpochConflict(firstErr)) throw firstErr;
1369
1374
  try {
@@ -1857,6 +1862,1045 @@ async function fetchAndIngestPendingSKDistributions(client, configDir, agentId,
1857
1862
  return ingested;
1858
1863
  }
1859
1864
  //#endregion
1865
+ //#region src/group-invite-ops.ts
1866
+ /**
1867
+ * Message type for the best-effort group-invite notification DM.
1868
+ *
1869
+ * Underscore-namespaced like `rine.v1.sender_key_distribution`. Sealed by the
1870
+ * inviter and delivered to the invitee's normal inbox; it is a convenience
1871
+ * nudge — `GET /groups/invites` is the authoritative source of truth.
1872
+ */
1873
+ const GROUP_INVITE_TYPE = "rine.v1.group_invite";
1874
+ /**
1875
+ * Seal a `rine.v1.group_invite` notification to the invitee and POST it.
1876
+ *
1877
+ * Reuses the 1:1 DM crypto path (`fetchRecipientKeys` + `encryptMessage`), so
1878
+ * hybrid PQ is auto-negotiated when the invitee publishes an ML-KEM key. The
1879
+ * sealed payload carries `invited_by = inviterAgentId` (also cryptographically
1880
+ * bound via the sender signature). `X-Rine-Agent` selects the inviter as sender.
1881
+ *
1882
+ * Best-effort by contract: callers wrap this so a failure (e.g. groups_only
1883
+ * 403, missing keys) warns but never fails the invite.
1884
+ */
1885
+ async function sendGroupInviteNotification(client, configDir, inviterAgentId, inviteeAgentId, invite) {
1886
+ const recipientKeys = await fetchRecipientKeys(client, inviteeAgentId);
1887
+ const payload = {
1888
+ group_id: invite.group_id,
1889
+ group_handle: invite.group_handle,
1890
+ invited_by: inviterAgentId,
1891
+ message: invite.message
1892
+ };
1893
+ const encrypted = await encryptMessage(configDir, inviterAgentId, recipientKeys.encryption, payload, recipientKeys.pqEncryption);
1894
+ await client.post("/messages", {
1895
+ to_agent_id: inviteeAgentId,
1896
+ type: GROUP_INVITE_TYPE,
1897
+ ...encrypted
1898
+ }, { "X-Rine-Agent": inviterAgentId });
1899
+ }
1900
+ /**
1901
+ * List the calling agent's open group invites via the authoritative pull
1902
+ * endpoint `GET /groups/invites`. `extraHeaders` lets multi-agent orgs pass
1903
+ * `X-Rine-Agent` to disambiguate which agent's invites to list.
1904
+ */
1905
+ async function listMyInvites(client, extraHeaders) {
1906
+ return (await client.get("/groups/invites", void 0, extraHeaders)).items;
1907
+ }
1908
+ //#endregion
1909
+ //#region src/funnel/backoff.ts
1910
+ const BACKOFF_BASE_MS = 3e4;
1911
+ const BACKOFF_FACTOR = 2;
1912
+ const BACKOFF_CAP_MS = 36e5;
1913
+ const RENEWAL_WINDOW_MS = 30 * 864e5;
1914
+ /**
1915
+ * Full-jitter exponential backoff: a uniform random delay in `[0, ceiling]`
1916
+ * where `ceiling = base * factor^(n-1)`, capped at 1h. Full jitter (not just a
1917
+ * jittered fixed delay) is what de-correlates a fleet of crash-looping relays.
1918
+ * `n` is the 1-based consecutive-failure count (n<=0 is treated as the first).
1919
+ */
1920
+ function nextBackoffDelayMs(consecutiveFailures) {
1921
+ const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** (Math.max(1, consecutiveFailures) - 1));
1922
+ return Math.floor(Math.random() * (ceiling + 1));
1923
+ }
1924
+ /**
1925
+ * A cert is due for renewal when `not_after - now <= 30 days` (boundary
1926
+ * inclusive). An already-expired cert is due (and forces a cold issuance).
1927
+ */
1928
+ function isRenewalDue(meta, nowMs) {
1929
+ const notAfterMs = Date.parse(meta.not_after);
1930
+ if (Number.isNaN(notAfterMs)) return true;
1931
+ return notAfterMs - nowMs <= RENEWAL_WINDOW_MS;
1932
+ }
1933
+ /**
1934
+ * TERMINAL = an LE rate-limit ceiling that no amount of retrying can clear in
1935
+ * the near term (the duplicate-certificate limit: 5 identical FQDN sets / 7
1936
+ * days, and the per-account failed-validation limit). On a terminal error the
1937
+ * relay stops attempting and surfaces it rather than burning the LE budget.
1938
+ * Ordinary transient failures (timeouts, propagation, provider 502) are NOT
1939
+ * terminal — they retry on the backoff schedule.
1940
+ */
1941
+ function isTerminalAcmeError(err) {
1942
+ if (typeof err !== "object" || err === null) return false;
1943
+ const tag = err.leRateLimit;
1944
+ if (tag === "duplicate-certificate" || tag === "failed-validation") return true;
1945
+ const msg = err instanceof Error ? err.message.toLowerCase() : "";
1946
+ return msg.includes("too many certificates") || msg.includes("too many failed authorizations");
1947
+ }
1948
+ //#endregion
1949
+ //#region src/funnel/cert-cache.ts
1950
+ const LE_PROD = "https://acme-v02.api.letsencrypt.org/directory";
1951
+ const LE_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory";
1952
+ /**
1953
+ * Resolve the ACME directory URL. Production by default; staging when either
1954
+ * `opts.staging` is set or `RINE_ACME_STAGING=1` is in the environment, so dev
1955
+ * never burns the production LE rate limits. Shares the single `isStaging`
1956
+ * decision with the cache-path split so a cert's directory_url and its cache
1957
+ * subtree are always derived from the same flag.
1958
+ */
1959
+ function acmeDirectoryUrl(opts) {
1960
+ return isStaging(opts) ? LE_STAGING : LE_PROD;
1961
+ }
1962
+ const CERT_FILE = "cert.pem";
1963
+ const KEY_FILE = "key.pem";
1964
+ const META_FILE = "meta.json";
1965
+ function isStaging(opts) {
1966
+ return opts.staging === true || process.env.RINE_ACME_STAGING === "1";
1967
+ }
1968
+ /** `${configDir}/funnel` (prod) or `${configDir}/funnel/staging` (staging). */
1969
+ function funnelCacheDir(configDir, opts) {
1970
+ const root = join(configDir, "funnel");
1971
+ return isStaging(opts) ? join(root, "staging") : root;
1972
+ }
1973
+ /** The per-handle cache dir under the (staging-segregated) funnel root. */
1974
+ function handleCacheDir(configDir, handle, opts) {
1975
+ return join(funnelCacheDir(configDir, opts), handle);
1976
+ }
1977
+ /** One LE account key per relay, shared across handles, at the funnel root. */
1978
+ function accountKeyPath(configDir, opts) {
1979
+ return join(funnelCacheDir(configDir, opts), "acme-account.key");
1980
+ }
1981
+ function writeSecret(path, content) {
1982
+ const tmp = `${path}.tmp`;
1983
+ fs.writeFileSync(tmp, content, { mode: 384 });
1984
+ fs.renameSync(tmp, path);
1985
+ fs.chmodSync(path, 384);
1986
+ }
1987
+ function ensureHandleDir(configDir, handle, opts) {
1988
+ const dir = handleCacheDir(configDir, handle, opts);
1989
+ fs.mkdirSync(dir, {
1990
+ recursive: true,
1991
+ mode: 448
1992
+ });
1993
+ fs.chmodSync(dir, 448);
1994
+ return dir;
1995
+ }
1996
+ function saveMeta(configDir, handle, opts, meta) {
1997
+ writeSecret(join(ensureHandleDir(configDir, handle, opts), META_FILE), `${JSON.stringify(meta, null, 2)}\n`);
1998
+ }
1999
+ function loadMeta(configDir, handle, opts) {
2000
+ const path = join(handleCacheDir(configDir, handle, opts), META_FILE);
2001
+ try {
2002
+ return JSON.parse(fs.readFileSync(path, "utf-8"));
2003
+ } catch {
2004
+ return null;
2005
+ }
2006
+ }
2007
+ function saveCert(configDir, handle, opts, cert) {
2008
+ const dir = ensureHandleDir(configDir, handle, opts);
2009
+ writeSecret(join(dir, KEY_FILE), cert.keyPem);
2010
+ writeSecret(join(dir, CERT_FILE), cert.certPem);
2011
+ saveMeta(configDir, handle, opts, cert.meta);
2012
+ }
2013
+ /**
2014
+ * Returns the cached cert ONLY if cert+key+meta are all present AND the meta's
2015
+ * directory_url matches the requested mode (staging cert never served in prod).
2016
+ * Any partial/corrupt cache reads back as `null` — never a half-cert.
2017
+ */
2018
+ function loadCachedCert(configDir, handle, opts) {
2019
+ const dir = handleCacheDir(configDir, handle, opts);
2020
+ try {
2021
+ const certPem = fs.readFileSync(join(dir, CERT_FILE), "utf-8");
2022
+ const keyPem = fs.readFileSync(join(dir, KEY_FILE), "utf-8");
2023
+ const meta = JSON.parse(fs.readFileSync(join(dir, META_FILE), "utf-8"));
2024
+ if (!certPem || !keyPem || !meta.not_after) return null;
2025
+ if (meta.directory_url !== acmeDirectoryUrl(opts)) return null;
2026
+ return {
2027
+ certPem,
2028
+ keyPem,
2029
+ meta
2030
+ };
2031
+ } catch {
2032
+ return null;
2033
+ }
2034
+ }
2035
+ /** Scoped wipe of one handle's cache dir (REQ-CERT-16). Other handles untouched. */
2036
+ function wipeHandleCache(configDir, handle, opts) {
2037
+ fs.rmSync(handleCacheDir(configDir, handle, opts), {
2038
+ recursive: true,
2039
+ force: true
2040
+ });
2041
+ }
2042
+ //#endregion
2043
+ //#region src/funnel/csr.ts
2044
+ const HOOK_ZONE = "hook.rine.network";
2045
+ /** The publicly-trusted hostname this relay terminates TLS for. */
2046
+ function hookFqdn(handle) {
2047
+ return `${handle}.${HOOK_ZONE}`;
2048
+ }
2049
+ /**
2050
+ * Generate a fresh EC P-256 keypair (node:crypto) and a CSR for
2051
+ * `<handle>.hook.rine.network` whose CN and single SAN are that FQDN. A fresh,
2052
+ * unique key is minted on every call — no key reuse across issuances/renewals.
2053
+ */
2054
+ async function generateCertKeypairAndCsr(handle) {
2055
+ const fqdn = hookFqdn(handle);
2056
+ const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
2057
+ const keyPem = privateKey.export({
2058
+ type: "pkcs8",
2059
+ format: "pem"
2060
+ }).toString();
2061
+ const [, csrBuf] = await crypto$1.createCsr({
2062
+ commonName: fqdn,
2063
+ altNames: [fqdn]
2064
+ }, keyPem);
2065
+ return {
2066
+ keyPem,
2067
+ csrPem: csrBuf.toString(),
2068
+ commonName: fqdn,
2069
+ subjectAltNames: [fqdn],
2070
+ keyAlgorithm: "EC",
2071
+ keyCurve: "P-256"
2072
+ };
2073
+ }
2074
+ //#endregion
2075
+ //#region src/funnel/issue.ts
2076
+ const DEFAULT_CERT_LIFETIME_MS = 90 * 864e5;
2077
+ function certNotAfter(certPem, nowMs) {
2078
+ try {
2079
+ return crypto$1.readCertificateInfo(certPem).notAfter.toISOString();
2080
+ } catch {
2081
+ return new Date(nowMs + DEFAULT_CERT_LIFETIME_MS).toISOString();
2082
+ }
2083
+ }
2084
+ /**
2085
+ * Run one ACME DNS-01 issuance. Sequence (REQ-CERT-10):
2086
+ * newOrder -> dns:set -> waitForPropagation -> validate -> download -> dns:clear
2087
+ * The TXT is cleared in a `finally` so a FAILED order still cleans up its record
2088
+ * (no lingering challenge). Propagation is awaited BEFORE asking LE to validate
2089
+ * so a slow zone never burns a validation attempt.
2090
+ */
2091
+ async function issueCert(args) {
2092
+ const { handle, delegationId, directoryUrl, deps } = args;
2093
+ const { keyPem, csrPem } = await generateCertKeypairAndCsr(handle);
2094
+ const order = await deps.acme.newOrder({
2095
+ handle,
2096
+ directoryUrl
2097
+ });
2098
+ if (!order.challengeTypesOffered.includes("dns-01")) throw new Error(`DNS-01 unavailable: order offered only [${order.challengeTypesOffered.join(", ")}]`);
2099
+ let txtSet = false;
2100
+ try {
2101
+ const setResult = await deps.dnsPost("set", order.dnsChallengeToken);
2102
+ txtSet = true;
2103
+ const fqdn = setResult.fqdn ?? `_acme-challenge.${delegationId}.hook.rine.network`;
2104
+ await deps.waitForPropagation({ fqdn });
2105
+ await deps.acme.validate({ handle });
2106
+ const { certPem } = await deps.acme.finalizeAndDownload({
2107
+ handle,
2108
+ csrPem
2109
+ });
2110
+ return {
2111
+ certPem,
2112
+ keyPem,
2113
+ meta: {
2114
+ directory_url: directoryUrl,
2115
+ not_after: certNotAfter(certPem, deps.now()),
2116
+ delegation_id: delegationId,
2117
+ last_attempt_ts: deps.now(),
2118
+ consecutive_failures: 0
2119
+ }
2120
+ };
2121
+ } finally {
2122
+ if (txtSet) await deps.dnsPost("clear");
2123
+ }
2124
+ }
2125
+ //#endregion
2126
+ //#region src/funnel/cert.ts
2127
+ /**
2128
+ * Cold-start readiness gate (REQ-CERT-32) + cache reuse + persisted backoff
2129
+ * (REQ-CERT-13). A valid, fresh cached cert opens the gate immediately with NO
2130
+ * ACME round-trip. Otherwise issue, persist atomically, and reset the failure
2131
+ * counter on success. On failure the persisted `consecutive_failures` is bumped
2132
+ * (read from the prior meta so a crash-loop never resets it to zero) and the
2133
+ * error re-thrown — a failed cold start installs NOTHING (gate stays closed).
2134
+ */
2135
+ async function ensureCert(args) {
2136
+ const { handle, agentId, delegationId, configDir, options, deps } = args;
2137
+ const directoryUrl = acmeDirectoryUrl(options);
2138
+ const cached = loadCachedCert(configDir, handle, options);
2139
+ if (cached && !isRenewalDue(cached.meta, deps.now())) return {
2140
+ ready: true,
2141
+ fromCache: true,
2142
+ ...cached
2143
+ };
2144
+ const priorFailures = loadMeta(configDir, handle, options)?.consecutive_failures ?? 0;
2145
+ try {
2146
+ const issued = await issueCert({
2147
+ handle,
2148
+ agentId,
2149
+ delegationId,
2150
+ directoryUrl,
2151
+ deps
2152
+ });
2153
+ saveCert(configDir, handle, options, issued);
2154
+ return {
2155
+ ready: true,
2156
+ fromCache: false,
2157
+ ...issued
2158
+ };
2159
+ } catch (err) {
2160
+ persistFailure(configDir, handle, options, {
2161
+ delegationId,
2162
+ directoryUrl,
2163
+ priorFailures,
2164
+ nowMs: deps.now()
2165
+ });
2166
+ throw isTerminalAcmeError(err) ? new Error(`terminal ACME error (LE rate limit): ${describe(err)}`, { cause: err }) : err;
2167
+ }
2168
+ }
2169
+ /**
2170
+ * Graceful teardown (REQ-CERT-16): best-effort ACME revoke (relay-local — the
2171
+ * server holds no key and cannot revoke, REQ-CERT-15), then a SCOPED wipe of
2172
+ * just this handle's cache dir. A revoke failure (offline) is swallowed; the
2173
+ * local wipe is the meaningful local effect and always runs.
2174
+ */
2175
+ async function revokeAndWipeCache(args) {
2176
+ const { handle, configDir, options, deps } = args;
2177
+ const cached = loadCachedCert(configDir, handle, options);
2178
+ if (cached) try {
2179
+ await deps.acme.revoke({
2180
+ certPem: cached.certPem,
2181
+ directoryUrl: cached.meta.directory_url
2182
+ });
2183
+ } catch {}
2184
+ wipeHandleCache(configDir, handle, options);
2185
+ }
2186
+ function persistFailure(configDir, handle, options, ctx) {
2187
+ const existing = loadMeta(configDir, handle, options);
2188
+ saveMeta(configDir, handle, options, {
2189
+ directory_url: existing?.directory_url ?? ctx.directoryUrl,
2190
+ not_after: existing?.not_after ?? new Date(ctx.nowMs).toISOString(),
2191
+ delegation_id: existing?.delegation_id ?? ctx.delegationId,
2192
+ last_attempt_ts: ctx.nowMs,
2193
+ consecutive_failures: ctx.priorFailures + 1
2194
+ });
2195
+ }
2196
+ function describe(err) {
2197
+ return err instanceof Error ? err.message : String(err);
2198
+ }
2199
+ //#endregion
2200
+ //#region src/funnel/propagation.ts
2201
+ const PROPAGATION_TIMEOUT_MS = 12e4;
2202
+ const PROPAGATION_INTERVAL_MS = 5e3;
2203
+ function sleep(ms) {
2204
+ return new Promise((resolve) => setTimeout(resolve, ms));
2205
+ }
2206
+ /**
2207
+ * The authoritative NS hostnames for the zone that contains `fqdn`. The
2208
+ * challenge name (`_acme-challenge.<label>.hook.rine.network`) is BELOW the zone
2209
+ * apex, where neither SOA nor NS records live, so we walk UP one label at a time
2210
+ * asking for NS until a parent answers (the apex — e.g. `rine.network`). A plain
2211
+ * `resolveSoa(fqdn)` returns ENODATA at a sub-apex name, which is why the prior
2212
+ * SOA->NS approach silently fell through to the host resolver every time.
2213
+ */
2214
+ async function nsHostsForZoneOf(fqdn) {
2215
+ let name = fqdn.replace(/\.$/, "");
2216
+ while (name.includes(".")) {
2217
+ try {
2218
+ const ns = await resolveNs(name);
2219
+ if (ns.length > 0) return ns;
2220
+ } catch {}
2221
+ name = name.slice(name.indexOf(".") + 1);
2222
+ }
2223
+ throw new Error(`no authoritative NS found for any parent of ${fqdn}`);
2224
+ }
2225
+ /**
2226
+ * Build a resolver pinned to the AUTHORITATIVE nameservers of `fqdn`'s zone
2227
+ * (NS -> A, then `Resolver.setServers`). Falls back to the system resolver if NS
2228
+ * discovery fails (best-effort — the bounded poll still backstops).
2229
+ */
2230
+ async function authoritativeResolver(fqdn) {
2231
+ const resolver = new Resolver();
2232
+ try {
2233
+ const nsHosts = await nsHostsForZoneOf(fqdn);
2234
+ const addresses = (await Promise.all(nsHosts.map((ns) => resolve4(ns)))).flat().filter(Boolean);
2235
+ if (addresses.length > 0) resolver.setServers(addresses);
2236
+ } catch {}
2237
+ return resolver;
2238
+ }
2239
+ /**
2240
+ * Poll the authoritative DNS for the challenge TXT at the server-returned
2241
+ * `_acme-challenge.<cert-domain>.hook.rine.network` until it resolves (bounded,
2242
+ * ≤120s) before telling LE to validate, so a slow zone never burns a validation
2243
+ * attempt. The FQDN is the one the backend wrote the TXT under (the
2244
+ * dns-challenge `set` response's `fqdn`) — never derived from a relay-side guess.
2245
+ */
2246
+ async function waitForTxtPropagation(args) {
2247
+ const { fqdn } = args;
2248
+ const resolver = await authoritativeResolver(fqdn);
2249
+ const deadline = Date.now() + PROPAGATION_TIMEOUT_MS;
2250
+ while (Date.now() < deadline) {
2251
+ try {
2252
+ if ((await resolver.resolveTxt(fqdn)).length > 0) return;
2253
+ } catch {}
2254
+ await sleep(PROPAGATION_INTERVAL_MS);
2255
+ }
2256
+ throw new Error(`DNS propagation timeout for ${fqdn}`);
2257
+ }
2258
+ //#endregion
2259
+ //#region src/funnel/acme.ts
2260
+ /**
2261
+ * Tag an LE rate-limit failure so backoff.ts classifies it as TERMINAL. Matches
2262
+ * the stable LE stem `too many certificates` so the modern parenthetical-count
2263
+ * wording (`too many certificates (5) already issued ...`) is still caught —
2264
+ * see backoff.ts isTerminalAcmeError, which uses the same stems.
2265
+ */
2266
+ function tagLeRateLimit(err) {
2267
+ const msg = err.message.toLowerCase();
2268
+ if (msg.includes("too many certificates")) err.leRateLimit = "duplicate-certificate";
2269
+ else if (msg.includes("too many failed authorizations")) err.leRateLimit = "failed-validation";
2270
+ return err;
2271
+ }
2272
+ /**
2273
+ * POST to the backend dns-challenge endpoint, honoring a 429 + Retry-After
2274
+ * (the server-side rate-limit backstop, REQ-CERT-07). The body carries ONLY
2275
+ * {action, value?} — never the FQDN, never key material.
2276
+ */
2277
+ function makeDnsPost(http, agentId, agentHeaders = {}) {
2278
+ const path = `/agents/${agentId}/funnel/dns-challenge`;
2279
+ return async (action, value) => {
2280
+ const body = action === "set" ? {
2281
+ action,
2282
+ value
2283
+ } : { action };
2284
+ try {
2285
+ return await http.post(path, body, agentHeaders);
2286
+ } catch (err) {
2287
+ if (err instanceof RineApiError && err.status === 429) {
2288
+ const retryAfter = retryAfterMs(err);
2289
+ if (retryAfter > 0) await sleep(retryAfter);
2290
+ return await http.post(path, body, agentHeaders);
2291
+ }
2292
+ throw err;
2293
+ }
2294
+ };
2295
+ }
2296
+ function retryAfterMs(err) {
2297
+ const res = err.raw;
2298
+ if (res instanceof Response) {
2299
+ const header = res.headers.get("retry-after");
2300
+ const secs = header ? Number(header) : NaN;
2301
+ if (Number.isFinite(secs) && secs > 0) return secs * 1e3;
2302
+ }
2303
+ return 0;
2304
+ }
2305
+ /**
2306
+ * Build the production ACME deps. The acme-client `Client` is the order driver;
2307
+ * a per-issuance closure holds the order/challenge between newOrder→validate→
2308
+ * download so the orchestrator's step boundaries map onto the RFC 8555 flow.
2309
+ */
2310
+ function makeAcmeClientDeps(client) {
2311
+ let pending;
2312
+ return { acme: {
2313
+ async newOrder({ handle }) {
2314
+ const fqdn = hookFqdn(handle);
2315
+ try {
2316
+ const order = await client.createOrder({ identifiers: [{
2317
+ type: "dns",
2318
+ value: fqdn
2319
+ }] });
2320
+ const authz = (await client.getAuthorizations(order))[0];
2321
+ if (!authz) throw new Error("no authorization on order");
2322
+ const dns = authz.challenges.find((c) => c.type === "dns-01");
2323
+ const offered = authz.challenges.map((c) => c.type);
2324
+ if (dns) {
2325
+ const keyAuth = await client.getChallengeKeyAuthorization(dns);
2326
+ pending = {
2327
+ order,
2328
+ authz,
2329
+ challenge: dns
2330
+ };
2331
+ return {
2332
+ dnsChallengeToken: keyAuth,
2333
+ challengeTypesOffered: offered
2334
+ };
2335
+ }
2336
+ return {
2337
+ dnsChallengeToken: "",
2338
+ challengeTypesOffered: offered
2339
+ };
2340
+ } catch (err) {
2341
+ throw tagLeRateLimit(err);
2342
+ }
2343
+ },
2344
+ async validate() {
2345
+ if (!pending) throw new Error("validate called before newOrder");
2346
+ await client.completeChallenge(pending.challenge);
2347
+ await client.waitForValidStatus(pending.authz);
2348
+ },
2349
+ async finalizeAndDownload({ csrPem }) {
2350
+ if (!pending) throw new Error("finalize called before newOrder");
2351
+ const order = await client.finalizeOrder(pending.order, csrPem);
2352
+ return { certPem: await client.getCertificate(order) };
2353
+ },
2354
+ async revoke({ certPem }) {
2355
+ await client.revokeCertificate(certPem);
2356
+ }
2357
+ } };
2358
+ }
2359
+ /**
2360
+ * Load (or mint + persist 0600) the ONE shared ACME account key for this relay,
2361
+ * then build an `acme-client` Client against the selected LE directory. One LE
2362
+ * account is reused across all of the relay's hooks (REQ-CERT-12), so repeated
2363
+ * issuances don't churn accounts. The account key never leaves the box.
2364
+ */
2365
+ async function buildAcmeClient(configDir, options) {
2366
+ const keyPath = accountKeyPath(configDir, options);
2367
+ let accountKey;
2368
+ try {
2369
+ accountKey = fs.readFileSync(keyPath, "utf-8");
2370
+ } catch {
2371
+ accountKey = (await crypto$1.createPrivateEcdsaKey("P-256")).toString();
2372
+ fs.mkdirSync(dirname(keyPath), {
2373
+ recursive: true,
2374
+ mode: 448
2375
+ });
2376
+ const tmp = `${keyPath}.tmp`;
2377
+ fs.writeFileSync(tmp, accountKey, { mode: 384 });
2378
+ fs.renameSync(tmp, keyPath);
2379
+ fs.chmodSync(keyPath, 384);
2380
+ }
2381
+ const client = new Client({
2382
+ directoryUrl: acmeDirectoryUrl(options),
2383
+ accountKey
2384
+ });
2385
+ await client.createAccount({ termsOfServiceAgreed: true });
2386
+ return client;
2387
+ }
2388
+ /**
2389
+ * Assemble the full production `IssueCertDeps` the relay (Track E) injects into
2390
+ * `ensureCert` / `revokeAndWipeCache`. Pure wiring: the ACME client drives the
2391
+ * order, the HttpClient brokers the TXT, node:dns confirms propagation.
2392
+ */
2393
+ async function buildIssueCertDeps(args) {
2394
+ return {
2395
+ ...makeAcmeClientDeps(await buildAcmeClient(args.configDir, args.options)),
2396
+ dnsPost: makeDnsPost(args.http, args.agentId, args.agentHeaders),
2397
+ waitForPropagation: waitForTxtPropagation,
2398
+ now: () => Date.now()
2399
+ };
2400
+ }
2401
+ //#endregion
2402
+ //#region src/funnel/mux-codec.ts
2403
+ /** Fixed mux header size: type(1) + conn_id(4) + len(4). */
2404
+ const HEADER_LEN = 9;
2405
+ /** conn_id 0 is reserved for the control plane (BIND and keepalive). */
2406
+ const CONTROL_CONN_ID = 0;
2407
+ /** Frame type discriminants. WINDOW (0x07) is intentionally absent. */
2408
+ const FrameType = {
2409
+ OPEN: 1,
2410
+ DATA: 2,
2411
+ CLOSE: 3,
2412
+ RESET: 4,
2413
+ BIND: 16,
2414
+ BIND_ACK: 17,
2415
+ BIND_NAK: 18
2416
+ };
2417
+ const KNOWN_TYPES = new Set(Object.values(FrameType));
2418
+ function isControl(t) {
2419
+ return t === FrameType.BIND || t === FrameType.BIND_ACK || t === FrameType.BIND_NAK;
2420
+ }
2421
+ /** A framing/protocol fault. The broker maps these to WS close codes (1002/1009). */
2422
+ var FrameError = class extends Error {
2423
+ constructor(message) {
2424
+ super(message);
2425
+ this.name = "FrameError";
2426
+ }
2427
+ };
2428
+ /**
2429
+ * Encode a frame into one WS-binary message body. Rejects an over-`maxFrameBytes`
2430
+ * payload and any type the codec does not know (so the relay can never put a
2431
+ * WINDOW/0x07 frame on the wire) before allocating.
2432
+ */
2433
+ function encodeFrame(frame, maxFrameBytes) {
2434
+ if (!KNOWN_TYPES.has(frame.frameType)) throw new FrameError(`refusing to encode unknown frame type 0x${frame.frameType.toString(16)}`);
2435
+ const len = frame.payload.length;
2436
+ if (len > maxFrameBytes) throw new FrameError(`frame payload ${len} exceeds max_frame_bytes ${maxFrameBytes}`);
2437
+ const out = new Uint8Array(9 + len);
2438
+ const view = new DataView(out.buffer);
2439
+ out[0] = frame.frameType;
2440
+ view.setUint32(1, frame.connId, false);
2441
+ view.setUint32(5, len, false);
2442
+ out.set(frame.payload, 9);
2443
+ return out;
2444
+ }
2445
+ /**
2446
+ * Decode one WS-binary message body into a Frame, enforcing every REQ-TUN-05
2447
+ * rule: unknown/reserved type (incl. WINDOW 0x07), short header, declared-len
2448
+ * mismatch, conn_id=0 on a data frame, non-zero conn_id on a control frame, and
2449
+ * an over-`maxFrameBytes` payload.
2450
+ */
2451
+ function decodeFrame(buf, maxFrameBytes) {
2452
+ if (buf.length < 9) throw new FrameError(`frame shorter than 9-byte header: ${buf.length} bytes`);
2453
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
2454
+ const typeByte = buf[0];
2455
+ if (!KNOWN_TYPES.has(typeByte)) throw new FrameError(`unknown frame type byte: 0x${typeByte.toString(16)}`);
2456
+ const frameType = typeByte;
2457
+ const connId = view.getUint32(1, false);
2458
+ const declared = view.getUint32(5, false);
2459
+ const body = buf.subarray(9);
2460
+ if (declared !== body.length) throw new FrameError(`declared len ${declared} != actual payload ${body.length}`);
2461
+ if (isControl(frameType)) {
2462
+ if (connId !== 0) throw new FrameError(`control frame must use conn_id=0, got ${connId}`);
2463
+ } else if (connId === 0) throw new FrameError("conn_id=0 reserved for control plane, not valid on a data-plane frame");
2464
+ if (body.length > maxFrameBytes) throw new FrameError(`frame payload ${body.length} exceeds max_frame_bytes ${maxFrameBytes}`);
2465
+ return {
2466
+ frameType,
2467
+ connId,
2468
+ payload: body.slice()
2469
+ };
2470
+ }
2471
+ //#endregion
2472
+ //#region src/funnel/mux-client.ts
2473
+ /** Per-conn in-flight cap before the relay pauses reading from the broker. */
2474
+ const PER_CONN_INFLIGHT_CAP = 256 * 1024;
2475
+ const DEFAULT_MAX_FRAME$1 = 65536;
2476
+ /**
2477
+ * Build a mux client over `ws`. `start()` wires the WS binary + close callbacks;
2478
+ * `handleFrame` is exposed so a caller (and the unit tests) can drive a single
2479
+ * decoded frame directly.
2480
+ */
2481
+ function createMuxClient(args) {
2482
+ const { ws, openLocalPipe } = args;
2483
+ const maxFrame = args.maxFrameBytes ?? DEFAULT_MAX_FRAME$1;
2484
+ const conns = /* @__PURE__ */ new Map();
2485
+ function sendData(connId, bytes) {
2486
+ ws.send(encodeFrame({
2487
+ frameType: FrameType.DATA,
2488
+ connId,
2489
+ payload: bytes
2490
+ }, maxFrame));
2491
+ }
2492
+ function teardown(connId) {
2493
+ const state = conns.get(connId);
2494
+ if (!state) return;
2495
+ conns.delete(connId);
2496
+ state.pipe.close();
2497
+ }
2498
+ function openConn(frame) {
2499
+ const pipe = openLocalPipe(frame);
2500
+ const state = {
2501
+ pipe,
2502
+ inflight: 0,
2503
+ paused: false
2504
+ };
2505
+ conns.set(frame.connId, state);
2506
+ pipe.onData((bytes) => {
2507
+ sendData(frame.connId, bytes);
2508
+ state.inflight = Math.max(0, state.inflight - bytes.length);
2509
+ if (state.paused && state.inflight < 262144) {
2510
+ state.paused = false;
2511
+ state.pipe.resume?.();
2512
+ }
2513
+ });
2514
+ }
2515
+ function onData(frame) {
2516
+ const state = conns.get(frame.connId);
2517
+ if (!state) return;
2518
+ state.pipe.write(frame.payload);
2519
+ state.inflight += frame.payload.length;
2520
+ if (state.inflight > 262144 && !state.paused) {
2521
+ state.paused = true;
2522
+ state.pipe.pause?.();
2523
+ }
2524
+ }
2525
+ function handleFrame(frame) {
2526
+ switch (frame.frameType) {
2527
+ case FrameType.OPEN:
2528
+ openConn(frame);
2529
+ break;
2530
+ case FrameType.DATA:
2531
+ onData(frame);
2532
+ break;
2533
+ case FrameType.CLOSE:
2534
+ case FrameType.RESET:
2535
+ teardown(frame.connId);
2536
+ break;
2537
+ default: break;
2538
+ }
2539
+ }
2540
+ function resetAllConns() {
2541
+ for (const connId of [...conns.keys()]) teardown(connId);
2542
+ }
2543
+ return {
2544
+ start() {
2545
+ ws.onBinary((data) => {
2546
+ const frame = decodeFrame(data, maxFrame);
2547
+ if (frame.connId === 0) return;
2548
+ handleFrame(frame);
2549
+ });
2550
+ ws.onClose?.(() => resetAllConns());
2551
+ },
2552
+ handleFrame
2553
+ };
2554
+ }
2555
+ /**
2556
+ * Reconnect backoff progression, identical to stream.ts: double, capped at 30s.
2557
+ * `1 → 2 → 4 → 8 → 16 → 30 → 30`.
2558
+ */
2559
+ function nextReconnectBackoffSeconds(backoffSeconds) {
2560
+ return Math.min(backoffSeconds * 2, 30);
2561
+ }
2562
+ /** Full jitter over the current backoff window — identical to stream.ts. */
2563
+ function jitteredDelayMs(backoffSeconds) {
2564
+ return Math.round(backoffSeconds * (.5 + Math.random()) * 1e3);
2565
+ }
2566
+ //#endregion
2567
+ //#region src/funnel/tunnel.ts
2568
+ const SUBPROTOCOL = "rine.funnel.v1";
2569
+ const DEFAULT_MAX_FRAME = 65536;
2570
+ const CLEAN_CLOSE_CODES = new Set([
2571
+ 0,
2572
+ 1e3,
2573
+ 1001,
2574
+ 1005
2575
+ ]);
2576
+ function classifyClose(ev) {
2577
+ const code = ev.code ?? 1005;
2578
+ return {
2579
+ code,
2580
+ clean: ev.wasClean === true || CLEAN_CLOSE_CODES.has(code)
2581
+ };
2582
+ }
2583
+ /** A bind rejection (BIND_NAK / ownership failure). `revoked` stops the relay. */
2584
+ var TunnelBindError = class extends Error {
2585
+ revoked = true;
2586
+ constructor(reason) {
2587
+ super(`funnel binding rejected: ${reason}`);
2588
+ this.name = "TunnelBindError";
2589
+ }
2590
+ };
2591
+ /**
2592
+ * Adapt a Node 24 global WebSocket to the minimal MuxWs surface. The close
2593
+ * callback receives the classified close (code + clean flag); the mux driver
2594
+ * (MuxWs.onClose) ignores the arg and just resets its conns.
2595
+ */
2596
+ function adaptWs(ws) {
2597
+ ws.binaryType = "arraybuffer";
2598
+ return {
2599
+ send: (data) => ws.send(data),
2600
+ onBinary: (cb) => {
2601
+ ws.addEventListener("message", (ev) => {
2602
+ if (ev.data instanceof ArrayBuffer) cb(new Uint8Array(ev.data));
2603
+ });
2604
+ },
2605
+ onClose: (cb) => ws.addEventListener("close", (ev) => cb(classifyClose(ev))),
2606
+ close: () => ws.close()
2607
+ };
2608
+ }
2609
+ function sendBind(ws, agentId, hostname) {
2610
+ const payload = new TextEncoder().encode(JSON.stringify({
2611
+ v: 1,
2612
+ agent_id: agentId,
2613
+ hostname
2614
+ }));
2615
+ ws.send(encodeFrame({
2616
+ frameType: FrameType.BIND,
2617
+ connId: 0,
2618
+ payload
2619
+ }, DEFAULT_MAX_FRAME));
2620
+ }
2621
+ /**
2622
+ * Open the control WS (Bearer at the upgrade — Node 24's global WebSocket takes
2623
+ * a `{protocols, headers}` options object), perform the BIND handshake, and wire
2624
+ * the mux driver. The returned handle resolves once BIND_ACK arrives; a BIND_NAK
2625
+ * rejects with a TunnelBindError (the relay treats it as revoked and stops).
2626
+ */
2627
+ function connectTunnel(args) {
2628
+ const { controlWsUrl, token, agentId, hostname, openLocalPipe } = args;
2629
+ return new Promise((resolve, reject) => {
2630
+ const ws = new WebSocket(controlWsUrl, {
2631
+ protocols: [SUBPROTOCOL],
2632
+ headers: { Authorization: `Bearer ${token}` }
2633
+ });
2634
+ const adapted = adaptWs(ws);
2635
+ let bound = false;
2636
+ ws.addEventListener("open", () => sendBind(adapted, agentId, hostname));
2637
+ ws.addEventListener("error", () => {
2638
+ if (!bound) reject(/* @__PURE__ */ new Error("control WS connect failed"));
2639
+ });
2640
+ adapted.onBinary((data) => {
2641
+ const frame = decodeFrame(data, DEFAULT_MAX_FRAME);
2642
+ if (frame.connId !== 0) return;
2643
+ if (frame.frameType === FrameType.BIND_ACK) {
2644
+ bound = true;
2645
+ const caps = JSON.parse(new TextDecoder().decode(frame.payload)).caps;
2646
+ createMuxClient({
2647
+ ws: adapted,
2648
+ openLocalPipe,
2649
+ maxFrameBytes: caps.max_frame_bytes
2650
+ }).start();
2651
+ resolve({
2652
+ caps,
2653
+ close: () => ws.close(),
2654
+ onClose: (cb) => adapted.onClose(cb)
2655
+ });
2656
+ } else if (frame.frameType === FrameType.BIND_NAK) {
2657
+ const reason = JSON.parse(new TextDecoder().decode(frame.payload)).reason;
2658
+ ws.close();
2659
+ reject(new TunnelBindError(reason ?? "unknown"));
2660
+ }
2661
+ });
2662
+ });
2663
+ }
2664
+ //#endregion
2665
+ //#region src/funnel/tls-listener.ts
2666
+ /**
2667
+ * Stand up the loopback TLS listener. Each accepted socket is one webhook
2668
+ * delivery: parse the HTTP request head + body, hand the RAW body to `onRequest`,
2669
+ * then write a minimal 204/400 response and close (GitHub only needs a 2xx).
2670
+ */
2671
+ async function startTlsListener(args) {
2672
+ const { certPem, keyPem, onRequest } = args;
2673
+ const server = createServer({
2674
+ cert: certPem,
2675
+ key: keyPem
2676
+ }, (socket) => handleConnection(socket, onRequest));
2677
+ const port = await new Promise((resolve, reject) => {
2678
+ server.once("error", reject);
2679
+ server.listen(args.port, "127.0.0.1", () => {
2680
+ const addr = server.address();
2681
+ resolve(typeof addr === "object" && addr ? addr.port : args.port);
2682
+ });
2683
+ });
2684
+ return {
2685
+ port,
2686
+ close: () => server.close(),
2687
+ openLocalPipe: () => openLoopbackPipe(port)
2688
+ };
2689
+ }
2690
+ /**
2691
+ * Buffer one HTTP request off the TLS socket and dispatch as soon as the full
2692
+ * body has arrived. A real HTTP/1.1 client (GitHub) sends the request then BLOCKS
2693
+ * on the response WITHOUT half-closing, so triggering on the socket `"end"` (EOF)
2694
+ * deadlocks until the client's own timeout. Instead we dispatch the moment we have
2695
+ * the head plus a `Content-Length`-worth of body; `"end"` stays a fallback for the
2696
+ * no-body / chunked / EOF-terminated cases. `handled` guards single dispatch.
2697
+ */
2698
+ function handleConnection(socket, onRequest) {
2699
+ const chunks = [];
2700
+ let handled = false;
2701
+ const dispatch = async (eof) => {
2702
+ if (handled) return;
2703
+ const raw = Buffer.concat(chunks);
2704
+ const sep = raw.indexOf("\r\n\r\n");
2705
+ if (sep < 0) {
2706
+ if (eof) {
2707
+ handled = true;
2708
+ socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
2709
+ }
2710
+ return;
2711
+ }
2712
+ const headers = parseHeaders(raw.subarray(0, sep).toString("latin1"));
2713
+ const body = raw.subarray(sep + 4);
2714
+ const declared = Number(headers["content-length"]);
2715
+ if (Number.isInteger(declared) && declared >= 0) {
2716
+ if (body.length < declared && !eof) return;
2717
+ } else if (!eof) return;
2718
+ handled = true;
2719
+ const rawBody = Number.isInteger(declared) && declared >= 0 ? body.subarray(0, declared) : body;
2720
+ try {
2721
+ await onRequest({
2722
+ headers,
2723
+ rawBody
2724
+ });
2725
+ socket.end("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n");
2726
+ } catch {
2727
+ socket.end("HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n");
2728
+ }
2729
+ };
2730
+ socket.on("data", (c) => {
2731
+ chunks.push(c);
2732
+ dispatch(false);
2733
+ });
2734
+ socket.on("error", () => socket.destroy());
2735
+ socket.on("end", () => void dispatch(true));
2736
+ }
2737
+ /** Parse a CRLF-delimited HTTP head into a lowercased header map (skip the request line). */
2738
+ function parseHeaders(head) {
2739
+ const out = {};
2740
+ const lines = head.split("\r\n");
2741
+ for (const line of lines.slice(1)) {
2742
+ const idx = line.indexOf(":");
2743
+ if (idx > 0) out[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
2744
+ }
2745
+ return out;
2746
+ }
2747
+ /** One mux conn → one loopback TCP socket bridging broker bytes ↔ TLS listener. */
2748
+ function openLoopbackPipe(port) {
2749
+ const sock = connect(port, "127.0.0.1");
2750
+ let dataCb;
2751
+ sock.on("data", (c) => dataCb?.(new Uint8Array(c)));
2752
+ sock.on("error", () => sock.destroy());
2753
+ return {
2754
+ write: (bytes) => sock.write(bytes),
2755
+ onData: (cb) => {
2756
+ dataCb = cb;
2757
+ },
2758
+ close: () => sock.destroy(),
2759
+ pause: () => sock.pause(),
2760
+ resume: () => sock.resume()
2761
+ };
2762
+ }
2763
+ //#endregion
2764
+ //#region src/funnel/relay-adapter.ts
2765
+ const WEBHOOK_MESSAGE_TYPE = "rine.v1.webhook";
2766
+ /** Parse `sha256=<hex>` (X-Hub-Signature-256) or a bare hex (X-Hook-Signature). */
2767
+ function parseSignature(headers) {
2768
+ const raw = headers["x-hub-signature-256"] ?? headers["x-hook-signature"];
2769
+ if (!raw) return void 0;
2770
+ const hex = raw.startsWith("sha256=") ? raw.slice(7) : raw;
2771
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) return void 0;
2772
+ return Uint8Array.from(Buffer.from(hex, "hex"));
2773
+ }
2774
+ /**
2775
+ * Constant-time HMAC-SHA-256 verification over the EXACT raw bytes (no JSON
2776
+ * re-serialization). A length-mismatched signature fails SAFELY: timingSafeEqual
2777
+ * throws on unequal buffers, so we length-guard first.
2778
+ */
2779
+ function verifyHmac(secret, rawBody, headers) {
2780
+ const received = parseSignature(headers);
2781
+ if (!received) return false;
2782
+ const computed = createHmac("sha256", secret).update(rawBody).digest();
2783
+ if (received.length !== computed.length) return false;
2784
+ return timingSafeEqual(received, computed);
2785
+ }
2786
+ /**
2787
+ * Verify → encrypt → self-send one webhook. Returns the outcome; never throws out
2788
+ * (the caller's tunnel must survive a single bad request). A verify failure drops
2789
+ * the conn with no post and no encrypt; a post/key-fetch failure drops the conn
2790
+ * after verify with `relayed:false`.
2791
+ */
2792
+ async function handleWebhookRequest(deps, req) {
2793
+ const { configDir, agentId, hookName, secret, client, emitLifecycle } = deps;
2794
+ if (!verifyHmac(secret, req.rawBody, req.headers)) {
2795
+ emitLifecycle?.({
2796
+ event: "lifecycle",
2797
+ data: {
2798
+ state: "verify_failed",
2799
+ hook_name: hookName
2800
+ }
2801
+ });
2802
+ return {
2803
+ verified: false,
2804
+ relayed: false
2805
+ };
2806
+ }
2807
+ try {
2808
+ const recipientKeys = await fetchRecipientKeys(client, agentId);
2809
+ const payload = decodePayload(req.rawBody);
2810
+ const encrypted = await encryptMessage(configDir, agentId, recipientKeys.encryption, payload, recipientKeys.pqEncryption);
2811
+ const body = {
2812
+ to_agent_id: agentId,
2813
+ type: WEBHOOK_MESSAGE_TYPE,
2814
+ metadata: { "rine.hook_name": hookName },
2815
+ encrypted_payload: encrypted.encrypted_payload,
2816
+ encryption_version: encrypted.encryption_version,
2817
+ sender_signing_kid: encrypted.sender_signing_kid
2818
+ };
2819
+ const result = await client.post("/messages", body);
2820
+ emitLifecycle?.({
2821
+ event: "lifecycle",
2822
+ data: {
2823
+ state: "webhook_relayed",
2824
+ hook_name: hookName,
2825
+ message_id: result?.id
2826
+ }
2827
+ });
2828
+ return {
2829
+ verified: true,
2830
+ relayed: true
2831
+ };
2832
+ } catch {
2833
+ emitLifecycle?.({
2834
+ event: "lifecycle",
2835
+ data: {
2836
+ state: "relay_error",
2837
+ hook_name: hookName
2838
+ }
2839
+ });
2840
+ return {
2841
+ verified: true,
2842
+ relayed: false
2843
+ };
2844
+ }
2845
+ }
2846
+ /** Best-effort JSON decode; fall back to the raw text so any body still relays. */
2847
+ function decodePayload(rawBody) {
2848
+ const text = new TextDecoder("utf-8").decode(rawBody);
2849
+ try {
2850
+ return JSON.parse(text);
2851
+ } catch {
2852
+ return text;
2853
+ }
2854
+ }
2855
+ //#endregion
2856
+ //#region src/funnel/secret-store.ts
2857
+ /** DNS-label-safe hook name (mirrors the backend HOOK_NAME_PATTERN). */
2858
+ const HOOK_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
2859
+ function isValidHookName(name) {
2860
+ return HOOK_NAME_RE.test(name);
2861
+ }
2862
+ /** Generate a 32-byte HMAC secret as 64 hex chars (client-side only). */
2863
+ function generateHookSecret() {
2864
+ return randomBytes(32).toString("hex");
2865
+ }
2866
+ /** Directory holding one agent's funnel secrets. */
2867
+ function funnelSecretDir(configDir, agentId) {
2868
+ return join(configDir, "funnel", agentId);
2869
+ }
2870
+ /** Absolute path of a hook's secret file. */
2871
+ function hookSecretPath(configDir, agentId, name) {
2872
+ return join(funnelSecretDir(configDir, agentId), `${name}.secret`);
2873
+ }
2874
+ /**
2875
+ * Persist a hook secret atomically with file mode 0600 in a 0700 dir. Returns the
2876
+ * path it wrote so callers can tell the operator where the secret lives.
2877
+ */
2878
+ function saveHookSecret(configDir, agentId, name, secret) {
2879
+ const dir = funnelSecretDir(configDir, agentId);
2880
+ fs.mkdirSync(dir, {
2881
+ recursive: true,
2882
+ mode: 448
2883
+ });
2884
+ const path = hookSecretPath(configDir, agentId, name);
2885
+ const tmp = `${path}.tmp`;
2886
+ fs.writeFileSync(tmp, secret, { mode: 384 });
2887
+ fs.renameSync(tmp, path);
2888
+ fs.chmodSync(path, 384);
2889
+ return path;
2890
+ }
2891
+ /** Read a hook secret, or undefined if it is not stored locally. */
2892
+ function loadHookSecret(configDir, agentId, name) {
2893
+ try {
2894
+ return fs.readFileSync(hookSecretPath(configDir, agentId, name), "utf-8").trim();
2895
+ } catch {
2896
+ return;
2897
+ }
2898
+ }
2899
+ /** Remove a hook secret (no error if it is already absent). */
2900
+ function deleteHookSecret(configDir, agentId, name) {
2901
+ fs.rmSync(hookSecretPath(configDir, agentId, name), { force: true });
2902
+ }
2903
+ //#endregion
1860
2904
  //#region src/mls-ops.ts
1861
2905
  /**
1862
2906
  * Generate and upload MLS key packages for an agent, enabling other agents
@@ -2039,4 +3083,4 @@ async function performAgentCreation(client, configDir, profile, params) {
2039
3083
  return agent;
2040
3084
  }
2041
3085
  //#endregion
2042
- export { DEFAULT_API_URL, HttpClient, MLKEM_CT_SIZE, MLS_CIPHER_SUITE_ID, RineApiError, UUID_RE, VERSION_HYBRID, VERSION_MLS, addMemberToMlsGroup, addMlsGroupMember, advanceChain, agentIdFromKid, agentKeysExist, bytesToUuid, cacheMlsSelfRead, cacheToken, claimKeyPackages, createMlsGroup, decodeEnvelope, decryptGroupMessage, decryptMessage, decryptMlsAppMessage, deleteMlsState, deletePrivateKeyPackage, deriveMessageKey, distributeSenderKey, encodeEnvelope, encryptGroupMessage, encryptMessage, encryptMlsAppMessage, encryptMlsGroupMessage, encryptionPublicKeyToJWK, externalJoinMlsGroup, fetchAgents, fetchAndIngestPendingSKDistributions, fetchGroupInfo, fetchHandshakes, fetchOAuthToken, fetchRecipientKeys, fetchWelcomes, formatError, fromBase64Url, generateAgentKeys, generateEncryptionKeyPair, generateMlsKeyPackage, generatePqKeyPair, generateSenderKey, generateSigningKeyPair, getAgentPublicKeys, getCredentialEntry, getMlsCipherSuite, getMlsContext, getOrCreateSenderKey, getOrRefreshToken, ingestSenderKeyDistribution, initMlsGroup, isBareAgentName, jwkToPqPublicKey, jwkToPublicKey, listPrivateKeyPackages, loadAgentKeys, loadCredentials, loadMlsState, loadPrivateKeyPackage, loadSenderKeyStates, loadTokenCache, lookupMlsSelfRead, mlsAck, mlsCommit, fromBase64 as mlsFromBase64, mlsInit, mlsNack, toBase64 as mlsToBase64, needsRotation, normalizeHandle, open, openGroup, openHybrid, performAgentCreation, performRegistration, pqPublicKeyToJWK, processMlsCommit, processMlsWelcome, processMlsWelcomes, publishMlsKeyPackages, removeMemberFromMlsGroup, removeMlsGroupMember, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, saveAgentKeys, saveCredentials, saveMlsState, savePqEncryptionKey, savePrivateKeyPackages, saveSenderKeyState, saveTokenCache, seal, sealGroup, sealHybrid, signPayload, signingPublicKeyToJWK, solveTimeLock, solveTimeLockWithProgress, submitProposal, syncMlsGroup, toBase64Url, uploadKeyPackages, uuidToBytes, validateEncryptionKey, validatePathId, validateSigningKey, validateSlug, verifySignature };
3086
+ export { CONTROL_CONN_ID, DEFAULT_API_URL, FrameError, FrameType, GROUP_INVITE_TYPE, HEADER_LEN, HttpClient, MLKEM_CT_SIZE, MLS_CIPHER_SUITE_ID, PER_CONN_INFLIGHT_CAP, RineApiError, TunnelBindError, UUID_RE, VERSION_HYBRID, VERSION_MLS, WEBHOOK_MESSAGE_TYPE, accountKeyPath, acmeDirectoryUrl, addMemberToMlsGroup, addMlsGroupMember, advanceChain, agentIdFromKid, agentKeysExist, buildAcmeClient, buildIssueCertDeps, bytesToUuid, cacheMlsSelfRead, cacheToken, claimKeyPackages, connectTunnel, createMlsGroup, createMuxClient, decodeEnvelope, decodeFrame, decryptGroupMessage, decryptMessage, decryptMlsAppMessage, deleteHookSecret, deleteMlsState, deletePrivateKeyPackage, deriveMessageKey, distributeSenderKey, encodeEnvelope, encodeFrame, encryptGroupMessage, encryptMessage, encryptMlsAppMessage, encryptMlsGroupMessage, encryptionPublicKeyToJWK, ensureCert, externalJoinMlsGroup, fetchAgents, fetchAndIngestPendingSKDistributions, fetchGroupInfo, fetchHandshakes, fetchOAuthToken, fetchRecipientKeys, fetchWelcomes, formatError, fromBase64Url, funnelCacheDir, funnelSecretDir, generateAgentKeys, generateCertKeypairAndCsr, generateEncryptionKeyPair, generateHookSecret, generateMlsKeyPackage, generatePqKeyPair, generateSenderKey, generateSigningKeyPair, getAgentPublicKeys, getCredentialEntry, getMlsCipherSuite, getMlsContext, getOrCreateSenderKey, getOrRefreshToken, handleCacheDir, handleConnection, handleWebhookRequest, hookFqdn, hookSecretPath, ingestSenderKeyDistribution, initMlsGroup, isBareAgentName, isRenewalDue, isTerminalAcmeError, isValidHookName, issueCert, jitteredDelayMs, jwkToPqPublicKey, jwkToPublicKey, listMyInvites, listPrivateKeyPackages, loadAgentKeys, loadCachedCert, loadCredentials, loadHookSecret, loadMeta, loadMlsState, loadPrivateKeyPackage, loadSenderKeyStates, loadTokenCache, lookupMlsSelfRead, makeAcmeClientDeps, makeDnsPost, mlsAck, mlsCommit, fromBase64 as mlsFromBase64, mlsInit, mlsNack, toBase64 as mlsToBase64, needsRotation, nextBackoffDelayMs, nextReconnectBackoffSeconds, normalizeHandle, open, openGroup, openHybrid, performAgentCreation, performRegistration, pqPublicKeyToJWK, processMlsCommit, processMlsWelcome, processMlsWelcomes, publishMlsKeyPackages, removeMemberFromMlsGroup, removeMlsGroupMember, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, revokeAndWipeCache, saveAgentKeys, saveCert, saveCredentials, saveHookSecret, saveMeta, saveMlsState, savePqEncryptionKey, savePrivateKeyPackages, saveSenderKeyState, saveTokenCache, seal, sealGroup, sealHybrid, sendGroupInviteNotification, signPayload, signingPublicKeyToJWK, solveTimeLock, solveTimeLockWithProgress, startTlsListener, submitProposal, syncMlsGroup, tagLeRateLimit, toBase64Url, uploadKeyPackages, uuidToBytes, validateEncryptionKey, validatePathId, validateSigningKey, validateSlug, verifySignature, waitForTxtPropagation, wipeHandleCache };