@rine-network/core 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 {
@@ -1901,6 +1906,1026 @@ async function listMyInvites(client, extraHeaders) {
1901
1906
  return (await client.get("/groups/invites", void 0, extraHeaders)).items;
1902
1907
  }
1903
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({
2105
+ fqdn,
2106
+ expectedValue: order.dnsChallengeToken
2107
+ });
2108
+ await deps.acme.validate({ handle });
2109
+ const { certPem } = await deps.acme.finalizeAndDownload({
2110
+ handle,
2111
+ csrPem
2112
+ });
2113
+ return {
2114
+ certPem,
2115
+ keyPem,
2116
+ meta: {
2117
+ directory_url: directoryUrl,
2118
+ not_after: certNotAfter(certPem, deps.now()),
2119
+ delegation_id: delegationId,
2120
+ last_attempt_ts: deps.now(),
2121
+ consecutive_failures: 0
2122
+ }
2123
+ };
2124
+ } finally {
2125
+ if (txtSet) await deps.dnsPost("clear");
2126
+ }
2127
+ }
2128
+ //#endregion
2129
+ //#region src/funnel/cert.ts
2130
+ /**
2131
+ * Cold-start readiness gate (REQ-CERT-32) + cache reuse + persisted backoff
2132
+ * (REQ-CERT-13). A valid, fresh cached cert opens the gate immediately with NO
2133
+ * ACME round-trip. Otherwise issue, persist atomically, and reset the failure
2134
+ * counter on success. On failure the persisted `consecutive_failures` is bumped
2135
+ * (read from the prior meta so a crash-loop never resets it to zero) and the
2136
+ * error re-thrown — a failed cold start installs NOTHING (gate stays closed).
2137
+ */
2138
+ async function ensureCert(args) {
2139
+ const { handle, agentId, delegationId, configDir, options, deps } = args;
2140
+ const directoryUrl = acmeDirectoryUrl(options);
2141
+ const cached = loadCachedCert(configDir, handle, options);
2142
+ if (cached && !isRenewalDue(cached.meta, deps.now())) return {
2143
+ ready: true,
2144
+ fromCache: true,
2145
+ ...cached
2146
+ };
2147
+ const priorFailures = loadMeta(configDir, handle, options)?.consecutive_failures ?? 0;
2148
+ try {
2149
+ const issued = await issueCert({
2150
+ handle,
2151
+ agentId,
2152
+ delegationId,
2153
+ directoryUrl,
2154
+ deps
2155
+ });
2156
+ saveCert(configDir, handle, options, issued);
2157
+ return {
2158
+ ready: true,
2159
+ fromCache: false,
2160
+ ...issued
2161
+ };
2162
+ } catch (err) {
2163
+ persistFailure(configDir, handle, options, {
2164
+ delegationId,
2165
+ directoryUrl,
2166
+ priorFailures,
2167
+ nowMs: deps.now()
2168
+ });
2169
+ throw isTerminalAcmeError(err) ? new Error(`terminal ACME error (LE rate limit): ${describe(err)}`, { cause: err }) : err;
2170
+ }
2171
+ }
2172
+ /**
2173
+ * Graceful teardown (REQ-CERT-16): best-effort ACME revoke (relay-local — the
2174
+ * server holds no key and cannot revoke, REQ-CERT-15), then a SCOPED wipe of
2175
+ * just this handle's cache dir. A revoke failure (offline) is swallowed; the
2176
+ * local wipe is the meaningful local effect and always runs.
2177
+ */
2178
+ async function revokeAndWipeCache(args) {
2179
+ const { handle, configDir, options, deps } = args;
2180
+ const cached = loadCachedCert(configDir, handle, options);
2181
+ if (cached) try {
2182
+ await deps.acme.revoke({
2183
+ certPem: cached.certPem,
2184
+ directoryUrl: cached.meta.directory_url
2185
+ });
2186
+ } catch {}
2187
+ wipeHandleCache(configDir, handle, options);
2188
+ }
2189
+ function persistFailure(configDir, handle, options, ctx) {
2190
+ const existing = loadMeta(configDir, handle, options);
2191
+ saveMeta(configDir, handle, options, {
2192
+ directory_url: existing?.directory_url ?? ctx.directoryUrl,
2193
+ not_after: existing?.not_after ?? new Date(ctx.nowMs).toISOString(),
2194
+ delegation_id: existing?.delegation_id ?? ctx.delegationId,
2195
+ last_attempt_ts: ctx.nowMs,
2196
+ consecutive_failures: ctx.priorFailures + 1
2197
+ });
2198
+ }
2199
+ function describe(err) {
2200
+ return err instanceof Error ? err.message : String(err);
2201
+ }
2202
+ //#endregion
2203
+ //#region src/funnel/propagation.ts
2204
+ const INITIAL_PROPAGATION_DELAY_MS = 45e3;
2205
+ const PROPAGATION_TIMEOUT_MS = 12e4;
2206
+ const PROPAGATION_INTERVAL_MS = 5e3;
2207
+ function sleep(ms) {
2208
+ return new Promise((resolve) => setTimeout(resolve, ms));
2209
+ }
2210
+ /**
2211
+ * The authoritative NS hostnames for the zone that contains `fqdn`. The
2212
+ * challenge name (`_acme-challenge.<label>.hook.rine.network`) is BELOW the zone
2213
+ * apex, where neither SOA nor NS records live, so we walk UP one label at a time
2214
+ * asking for NS until a parent answers (the apex — e.g. `rine.network`). A plain
2215
+ * `resolveSoa(fqdn)` returns ENODATA at a sub-apex name, which is why the prior
2216
+ * SOA->NS approach silently fell through to the host resolver every time.
2217
+ * Exported (with an injectable `nsResolve`) so the climb is unit-tested directly.
2218
+ */
2219
+ async function nsHostsForZoneOf(fqdn, nsResolve = resolveNs) {
2220
+ let name = fqdn.replace(/\.$/, "");
2221
+ while (name.includes(".")) {
2222
+ try {
2223
+ const ns = await nsResolve(name);
2224
+ if (ns.length > 0) return ns;
2225
+ } catch {}
2226
+ name = name.slice(name.indexOf(".") + 1);
2227
+ }
2228
+ throw new Error(`no authoritative NS found for any parent of ${fqdn}`);
2229
+ }
2230
+ /**
2231
+ * Build a resolver pinned to the AUTHORITATIVE nameservers of `fqdn`'s zone
2232
+ * (NS -> A, then `Resolver.setServers`). One flaky NS-host A lookup must NOT cost
2233
+ * us the others, so `Promise.allSettled` keeps every address that did resolve.
2234
+ * Falls back to the system resolver only if NS discovery fails or NO address
2235
+ * resolves (best-effort — the bounded poll still backstops). Exported (with
2236
+ * injectable resolvers) so the NS-walk path is unit-tested directly.
2237
+ */
2238
+ async function authoritativeResolver(fqdn, nsResolve = resolveNs, a4Resolve = resolve4) {
2239
+ const resolver = new Resolver();
2240
+ try {
2241
+ const nsHosts = await nsHostsForZoneOf(fqdn, nsResolve);
2242
+ const addresses = (await Promise.allSettled(nsHosts.map((ns) => a4Resolve(ns)))).flatMap((r) => r.status === "fulfilled" ? r.value : []).filter(Boolean);
2243
+ if (addresses.length > 0) resolver.setServers(addresses);
2244
+ } catch {}
2245
+ return resolver;
2246
+ }
2247
+ /** A TXT answer satisfies the gate iff it carries THIS order's key-authorization. */
2248
+ function recordsMatch(records, expectedValue) {
2249
+ if (records.length === 0) return false;
2250
+ if (!expectedValue) return true;
2251
+ return records.some((chunks) => chunks.join("").includes(expectedValue));
2252
+ }
2253
+ /**
2254
+ * Poll the authoritative DNS for the challenge TXT at the server-returned
2255
+ * `_acme-challenge.<cert-domain>.hook.rine.network` until the record carrying
2256
+ * `expectedValue` (this order's key-authorization) resolves (bounded) before
2257
+ * telling LE to validate, so a slow zone never burns a validation attempt. The
2258
+ * FQDN is the one the backend wrote the TXT under (the dns-challenge `set`
2259
+ * response's `fqdn`) — never derived from a relay-side guess.
2260
+ *
2261
+ * Order: build the authoritative resolver (NS-walk) -> hold the grace delay ->
2262
+ * poll TXT. The NS-walk overlaps the grace window and the grace delay keeps the
2263
+ * first TXT query from preceding propagation (avoids poisoning the negative
2264
+ * cache — see the module header).
2265
+ */
2266
+ async function waitForTxtPropagation(args, opts = {}) {
2267
+ const { fqdn, expectedValue } = args;
2268
+ const initialDelayMs = opts.initialDelayMs ?? INITIAL_PROPAGATION_DELAY_MS;
2269
+ const timeoutMs = opts.timeoutMs ?? PROPAGATION_TIMEOUT_MS;
2270
+ const intervalMs = opts.intervalMs ?? PROPAGATION_INTERVAL_MS;
2271
+ const doSleep = opts.sleep ?? sleep;
2272
+ const resolver = await (opts.resolverFactory ?? ((name) => authoritativeResolver(name, opts.nsResolve, opts.a4Resolve)))(fqdn);
2273
+ await doSleep(initialDelayMs);
2274
+ const deadline = Date.now() + timeoutMs;
2275
+ while (Date.now() < deadline) {
2276
+ try {
2277
+ if (recordsMatch(await resolver.resolveTxt(fqdn), expectedValue)) return;
2278
+ } catch {}
2279
+ await doSleep(intervalMs);
2280
+ }
2281
+ throw new Error(`DNS propagation timeout for ${fqdn}`);
2282
+ }
2283
+ //#endregion
2284
+ //#region src/funnel/acme.ts
2285
+ /**
2286
+ * Tag an LE rate-limit failure so backoff.ts classifies it as TERMINAL. Matches
2287
+ * the stable LE stem `too many certificates` so the modern parenthetical-count
2288
+ * wording (`too many certificates (5) already issued ...`) is still caught —
2289
+ * see backoff.ts isTerminalAcmeError, which uses the same stems.
2290
+ */
2291
+ function tagLeRateLimit(err) {
2292
+ const msg = err.message.toLowerCase();
2293
+ if (msg.includes("too many certificates")) err.leRateLimit = "duplicate-certificate";
2294
+ else if (msg.includes("too many failed authorizations")) err.leRateLimit = "failed-validation";
2295
+ return err;
2296
+ }
2297
+ /**
2298
+ * POST to the backend dns-challenge endpoint, honoring a 429 + Retry-After
2299
+ * (the server-side rate-limit backstop, REQ-CERT-07). The body carries ONLY
2300
+ * {action, value?} — never the FQDN, never key material.
2301
+ */
2302
+ function makeDnsPost(http, agentId, agentHeaders = {}) {
2303
+ const path = `/agents/${agentId}/funnel/dns-challenge`;
2304
+ return async (action, value) => {
2305
+ const body = action === "set" ? {
2306
+ action,
2307
+ value
2308
+ } : { action };
2309
+ try {
2310
+ return await http.post(path, body, agentHeaders);
2311
+ } catch (err) {
2312
+ if (err instanceof RineApiError && err.status === 429) {
2313
+ const retryAfter = retryAfterMs(err);
2314
+ if (retryAfter > 0) await sleep(retryAfter);
2315
+ return await http.post(path, body, agentHeaders);
2316
+ }
2317
+ throw err;
2318
+ }
2319
+ };
2320
+ }
2321
+ function retryAfterMs(err) {
2322
+ const res = err.raw;
2323
+ if (res instanceof Response) {
2324
+ const header = res.headers.get("retry-after");
2325
+ const secs = header ? Number(header) : NaN;
2326
+ if (Number.isFinite(secs) && secs > 0) return secs * 1e3;
2327
+ }
2328
+ return 0;
2329
+ }
2330
+ /**
2331
+ * Build the production ACME deps. The acme-client `Client` is the order driver;
2332
+ * a per-issuance closure holds the order/challenge between newOrder→validate→
2333
+ * download so the orchestrator's step boundaries map onto the RFC 8555 flow.
2334
+ */
2335
+ function makeAcmeClientDeps(client) {
2336
+ let pending;
2337
+ return { acme: {
2338
+ async newOrder({ handle }) {
2339
+ const fqdn = hookFqdn(handle);
2340
+ try {
2341
+ const order = await client.createOrder({ identifiers: [{
2342
+ type: "dns",
2343
+ value: fqdn
2344
+ }] });
2345
+ const authz = (await client.getAuthorizations(order))[0];
2346
+ if (!authz) throw new Error("no authorization on order");
2347
+ const dns = authz.challenges.find((c) => c.type === "dns-01");
2348
+ const offered = authz.challenges.map((c) => c.type);
2349
+ if (dns) {
2350
+ const keyAuth = await client.getChallengeKeyAuthorization(dns);
2351
+ pending = {
2352
+ order,
2353
+ authz,
2354
+ challenge: dns
2355
+ };
2356
+ return {
2357
+ dnsChallengeToken: keyAuth,
2358
+ challengeTypesOffered: offered
2359
+ };
2360
+ }
2361
+ return {
2362
+ dnsChallengeToken: "",
2363
+ challengeTypesOffered: offered
2364
+ };
2365
+ } catch (err) {
2366
+ throw tagLeRateLimit(err);
2367
+ }
2368
+ },
2369
+ async validate() {
2370
+ if (!pending) throw new Error("validate called before newOrder");
2371
+ await client.completeChallenge(pending.challenge);
2372
+ await client.waitForValidStatus(pending.authz);
2373
+ },
2374
+ async finalizeAndDownload({ csrPem }) {
2375
+ if (!pending) throw new Error("finalize called before newOrder");
2376
+ const order = await client.finalizeOrder(pending.order, csrPem);
2377
+ return { certPem: await client.getCertificate(order) };
2378
+ },
2379
+ async revoke({ certPem }) {
2380
+ await client.revokeCertificate(certPem);
2381
+ }
2382
+ } };
2383
+ }
2384
+ /**
2385
+ * Load (or mint + persist 0600) the ONE shared ACME account key for this relay,
2386
+ * then build an `acme-client` Client against the selected LE directory. One LE
2387
+ * account is reused across all of the relay's hooks (REQ-CERT-12), so repeated
2388
+ * issuances don't churn accounts. The account key never leaves the box.
2389
+ */
2390
+ async function buildAcmeClient(configDir, options) {
2391
+ const keyPath = accountKeyPath(configDir, options);
2392
+ let accountKey;
2393
+ try {
2394
+ accountKey = fs.readFileSync(keyPath, "utf-8");
2395
+ } catch {
2396
+ accountKey = (await crypto$1.createPrivateEcdsaKey("P-256")).toString();
2397
+ fs.mkdirSync(dirname(keyPath), {
2398
+ recursive: true,
2399
+ mode: 448
2400
+ });
2401
+ const tmp = `${keyPath}.tmp`;
2402
+ fs.writeFileSync(tmp, accountKey, { mode: 384 });
2403
+ fs.renameSync(tmp, keyPath);
2404
+ fs.chmodSync(keyPath, 384);
2405
+ }
2406
+ const client = new Client({
2407
+ directoryUrl: acmeDirectoryUrl(options),
2408
+ accountKey
2409
+ });
2410
+ await client.createAccount({ termsOfServiceAgreed: true });
2411
+ return client;
2412
+ }
2413
+ /**
2414
+ * Assemble the full production `IssueCertDeps` the relay (Track E) injects into
2415
+ * `ensureCert` / `revokeAndWipeCache`. Pure wiring: the ACME client drives the
2416
+ * order, the HttpClient brokers the TXT, node:dns confirms propagation.
2417
+ */
2418
+ async function buildIssueCertDeps(args) {
2419
+ return {
2420
+ ...makeAcmeClientDeps(await buildAcmeClient(args.configDir, args.options)),
2421
+ dnsPost: makeDnsPost(args.http, args.agentId, args.agentHeaders),
2422
+ waitForPropagation: waitForTxtPropagation,
2423
+ now: () => Date.now()
2424
+ };
2425
+ }
2426
+ //#endregion
2427
+ //#region src/funnel/mux-codec.ts
2428
+ /** Fixed mux header size: type(1) + conn_id(4) + len(4). */
2429
+ const HEADER_LEN = 9;
2430
+ /** conn_id 0 is reserved for the control plane (BIND and keepalive). */
2431
+ const CONTROL_CONN_ID = 0;
2432
+ /** Frame type discriminants. WINDOW (0x07) is intentionally absent. */
2433
+ const FrameType = {
2434
+ OPEN: 1,
2435
+ DATA: 2,
2436
+ CLOSE: 3,
2437
+ RESET: 4,
2438
+ BIND: 16,
2439
+ BIND_ACK: 17,
2440
+ BIND_NAK: 18
2441
+ };
2442
+ const KNOWN_TYPES = new Set(Object.values(FrameType));
2443
+ function isControl(t) {
2444
+ return t === FrameType.BIND || t === FrameType.BIND_ACK || t === FrameType.BIND_NAK;
2445
+ }
2446
+ /** A framing/protocol fault. The broker maps these to WS close codes (1002/1009). */
2447
+ var FrameError = class extends Error {
2448
+ constructor(message) {
2449
+ super(message);
2450
+ this.name = "FrameError";
2451
+ }
2452
+ };
2453
+ /**
2454
+ * Encode a frame into one WS-binary message body. Rejects an over-`maxFrameBytes`
2455
+ * payload and any type the codec does not know (so the relay can never put a
2456
+ * WINDOW/0x07 frame on the wire) before allocating.
2457
+ */
2458
+ function encodeFrame(frame, maxFrameBytes) {
2459
+ if (!KNOWN_TYPES.has(frame.frameType)) throw new FrameError(`refusing to encode unknown frame type 0x${frame.frameType.toString(16)}`);
2460
+ const len = frame.payload.length;
2461
+ if (len > maxFrameBytes) throw new FrameError(`frame payload ${len} exceeds max_frame_bytes ${maxFrameBytes}`);
2462
+ const out = new Uint8Array(9 + len);
2463
+ const view = new DataView(out.buffer);
2464
+ out[0] = frame.frameType;
2465
+ view.setUint32(1, frame.connId, false);
2466
+ view.setUint32(5, len, false);
2467
+ out.set(frame.payload, 9);
2468
+ return out;
2469
+ }
2470
+ /**
2471
+ * Decode one WS-binary message body into a Frame, enforcing every REQ-TUN-05
2472
+ * rule: unknown/reserved type (incl. WINDOW 0x07), short header, declared-len
2473
+ * mismatch, conn_id=0 on a data frame, non-zero conn_id on a control frame, and
2474
+ * an over-`maxFrameBytes` payload.
2475
+ */
2476
+ function decodeFrame(buf, maxFrameBytes) {
2477
+ if (buf.length < 9) throw new FrameError(`frame shorter than 9-byte header: ${buf.length} bytes`);
2478
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
2479
+ const typeByte = buf[0];
2480
+ if (!KNOWN_TYPES.has(typeByte)) throw new FrameError(`unknown frame type byte: 0x${typeByte.toString(16)}`);
2481
+ const frameType = typeByte;
2482
+ const connId = view.getUint32(1, false);
2483
+ const declared = view.getUint32(5, false);
2484
+ const body = buf.subarray(9);
2485
+ if (declared !== body.length) throw new FrameError(`declared len ${declared} != actual payload ${body.length}`);
2486
+ if (isControl(frameType)) {
2487
+ if (connId !== 0) throw new FrameError(`control frame must use conn_id=0, got ${connId}`);
2488
+ } else if (connId === 0) throw new FrameError("conn_id=0 reserved for control plane, not valid on a data-plane frame");
2489
+ if (body.length > maxFrameBytes) throw new FrameError(`frame payload ${body.length} exceeds max_frame_bytes ${maxFrameBytes}`);
2490
+ return {
2491
+ frameType,
2492
+ connId,
2493
+ payload: body.slice()
2494
+ };
2495
+ }
2496
+ //#endregion
2497
+ //#region src/funnel/mux-client.ts
2498
+ /** Per-conn in-flight cap before the relay pauses reading from the broker. */
2499
+ const PER_CONN_INFLIGHT_CAP = 256 * 1024;
2500
+ const DEFAULT_MAX_FRAME$1 = 65536;
2501
+ /**
2502
+ * Build a mux client over `ws`. `start()` wires the WS binary + close callbacks;
2503
+ * `handleFrame` is exposed so a caller (and the unit tests) can drive a single
2504
+ * decoded frame directly.
2505
+ */
2506
+ function createMuxClient(args) {
2507
+ const { ws, openLocalPipe } = args;
2508
+ const maxFrame = args.maxFrameBytes ?? DEFAULT_MAX_FRAME$1;
2509
+ const conns = /* @__PURE__ */ new Map();
2510
+ function sendData(connId, bytes) {
2511
+ ws.send(encodeFrame({
2512
+ frameType: FrameType.DATA,
2513
+ connId,
2514
+ payload: bytes
2515
+ }, maxFrame));
2516
+ }
2517
+ function teardown(connId) {
2518
+ const state = conns.get(connId);
2519
+ if (!state) return;
2520
+ conns.delete(connId);
2521
+ state.pipe.close();
2522
+ }
2523
+ function openConn(frame) {
2524
+ const pipe = openLocalPipe(frame);
2525
+ const state = {
2526
+ pipe,
2527
+ inflight: 0,
2528
+ paused: false
2529
+ };
2530
+ conns.set(frame.connId, state);
2531
+ pipe.onData((bytes) => {
2532
+ sendData(frame.connId, bytes);
2533
+ state.inflight = Math.max(0, state.inflight - bytes.length);
2534
+ if (state.paused && state.inflight < 262144) {
2535
+ state.paused = false;
2536
+ state.pipe.resume?.();
2537
+ }
2538
+ });
2539
+ }
2540
+ function onData(frame) {
2541
+ const state = conns.get(frame.connId);
2542
+ if (!state) return;
2543
+ state.pipe.write(frame.payload);
2544
+ state.inflight += frame.payload.length;
2545
+ if (state.inflight > 262144 && !state.paused) {
2546
+ state.paused = true;
2547
+ state.pipe.pause?.();
2548
+ }
2549
+ }
2550
+ function handleFrame(frame) {
2551
+ switch (frame.frameType) {
2552
+ case FrameType.OPEN:
2553
+ openConn(frame);
2554
+ break;
2555
+ case FrameType.DATA:
2556
+ onData(frame);
2557
+ break;
2558
+ case FrameType.CLOSE:
2559
+ case FrameType.RESET:
2560
+ teardown(frame.connId);
2561
+ break;
2562
+ default: break;
2563
+ }
2564
+ }
2565
+ function resetAllConns() {
2566
+ for (const connId of [...conns.keys()]) teardown(connId);
2567
+ }
2568
+ return {
2569
+ start() {
2570
+ ws.onBinary((data) => {
2571
+ const frame = decodeFrame(data, maxFrame);
2572
+ if (frame.connId === 0) return;
2573
+ handleFrame(frame);
2574
+ });
2575
+ ws.onClose?.(() => resetAllConns());
2576
+ },
2577
+ handleFrame
2578
+ };
2579
+ }
2580
+ /**
2581
+ * Reconnect backoff progression, identical to stream.ts: double, capped at 30s.
2582
+ * `1 → 2 → 4 → 8 → 16 → 30 → 30`.
2583
+ */
2584
+ function nextReconnectBackoffSeconds(backoffSeconds) {
2585
+ return Math.min(backoffSeconds * 2, 30);
2586
+ }
2587
+ /** Full jitter over the current backoff window — identical to stream.ts. */
2588
+ function jitteredDelayMs(backoffSeconds) {
2589
+ return Math.round(backoffSeconds * (.5 + Math.random()) * 1e3);
2590
+ }
2591
+ //#endregion
2592
+ //#region src/funnel/tunnel.ts
2593
+ const SUBPROTOCOL = "rine.funnel.v1";
2594
+ const DEFAULT_MAX_FRAME = 65536;
2595
+ const CLEAN_CLOSE_CODES = new Set([
2596
+ 0,
2597
+ 1e3,
2598
+ 1001,
2599
+ 1005
2600
+ ]);
2601
+ function classifyClose(ev) {
2602
+ const code = ev.code ?? 1005;
2603
+ return {
2604
+ code,
2605
+ clean: ev.wasClean === true || CLEAN_CLOSE_CODES.has(code)
2606
+ };
2607
+ }
2608
+ /** A bind rejection (BIND_NAK / ownership failure). `revoked` stops the relay. */
2609
+ var TunnelBindError = class extends Error {
2610
+ revoked = true;
2611
+ constructor(reason) {
2612
+ super(`funnel binding rejected: ${reason}`);
2613
+ this.name = "TunnelBindError";
2614
+ }
2615
+ };
2616
+ /**
2617
+ * Adapt a Node 24 global WebSocket to the minimal MuxWs surface. The close
2618
+ * callback receives the classified close (code + clean flag); the mux driver
2619
+ * (MuxWs.onClose) ignores the arg and just resets its conns.
2620
+ */
2621
+ function adaptWs(ws) {
2622
+ ws.binaryType = "arraybuffer";
2623
+ return {
2624
+ send: (data) => ws.send(data),
2625
+ onBinary: (cb) => {
2626
+ ws.addEventListener("message", (ev) => {
2627
+ if (ev.data instanceof ArrayBuffer) cb(new Uint8Array(ev.data));
2628
+ });
2629
+ },
2630
+ onClose: (cb) => ws.addEventListener("close", (ev) => cb(classifyClose(ev))),
2631
+ close: () => ws.close()
2632
+ };
2633
+ }
2634
+ function sendBind(ws, agentId, hostname) {
2635
+ const payload = new TextEncoder().encode(JSON.stringify({
2636
+ v: 1,
2637
+ agent_id: agentId,
2638
+ hostname
2639
+ }));
2640
+ ws.send(encodeFrame({
2641
+ frameType: FrameType.BIND,
2642
+ connId: 0,
2643
+ payload
2644
+ }, DEFAULT_MAX_FRAME));
2645
+ }
2646
+ /**
2647
+ * Open the control WS (Bearer at the upgrade — Node 24's global WebSocket takes
2648
+ * a `{protocols, headers}` options object), perform the BIND handshake, and wire
2649
+ * the mux driver. The returned handle resolves once BIND_ACK arrives; a BIND_NAK
2650
+ * rejects with a TunnelBindError (the relay treats it as revoked and stops).
2651
+ */
2652
+ function connectTunnel(args) {
2653
+ const { controlWsUrl, token, agentId, hostname, openLocalPipe } = args;
2654
+ return new Promise((resolve, reject) => {
2655
+ const ws = new WebSocket(controlWsUrl, {
2656
+ protocols: [SUBPROTOCOL],
2657
+ headers: { Authorization: `Bearer ${token}` }
2658
+ });
2659
+ const adapted = adaptWs(ws);
2660
+ let bound = false;
2661
+ ws.addEventListener("open", () => sendBind(adapted, agentId, hostname));
2662
+ ws.addEventListener("error", () => {
2663
+ if (!bound) reject(/* @__PURE__ */ new Error("control WS connect failed"));
2664
+ });
2665
+ adapted.onBinary((data) => {
2666
+ const frame = decodeFrame(data, DEFAULT_MAX_FRAME);
2667
+ if (frame.connId !== 0) return;
2668
+ if (frame.frameType === FrameType.BIND_ACK) {
2669
+ bound = true;
2670
+ const caps = JSON.parse(new TextDecoder().decode(frame.payload)).caps;
2671
+ createMuxClient({
2672
+ ws: adapted,
2673
+ openLocalPipe,
2674
+ maxFrameBytes: caps.max_frame_bytes
2675
+ }).start();
2676
+ resolve({
2677
+ caps,
2678
+ close: () => ws.close(),
2679
+ onClose: (cb) => adapted.onClose(cb)
2680
+ });
2681
+ } else if (frame.frameType === FrameType.BIND_NAK) {
2682
+ const reason = JSON.parse(new TextDecoder().decode(frame.payload)).reason;
2683
+ ws.close();
2684
+ reject(new TunnelBindError(reason ?? "unknown"));
2685
+ }
2686
+ });
2687
+ });
2688
+ }
2689
+ //#endregion
2690
+ //#region src/funnel/tls-listener.ts
2691
+ /**
2692
+ * Stand up the loopback TLS listener. Each accepted socket is one webhook
2693
+ * delivery: parse the HTTP request head + body, hand the RAW body to `onRequest`,
2694
+ * then write a minimal 204/400 response and close (GitHub only needs a 2xx).
2695
+ */
2696
+ async function startTlsListener(args) {
2697
+ const { certPem, keyPem, onRequest } = args;
2698
+ const server = createServer({
2699
+ cert: certPem,
2700
+ key: keyPem
2701
+ }, (socket) => handleConnection(socket, onRequest));
2702
+ const port = await new Promise((resolve, reject) => {
2703
+ server.once("error", reject);
2704
+ server.listen(args.port, "127.0.0.1", () => {
2705
+ const addr = server.address();
2706
+ resolve(typeof addr === "object" && addr ? addr.port : args.port);
2707
+ });
2708
+ });
2709
+ return {
2710
+ port,
2711
+ close: () => server.close(),
2712
+ openLocalPipe: () => openLoopbackPipe(port)
2713
+ };
2714
+ }
2715
+ /**
2716
+ * Buffer one HTTP request off the TLS socket and dispatch as soon as the full
2717
+ * body has arrived. A real HTTP/1.1 client (GitHub) sends the request then BLOCKS
2718
+ * on the response WITHOUT half-closing, so triggering on the socket `"end"` (EOF)
2719
+ * deadlocks until the client's own timeout. Instead we dispatch the moment we have
2720
+ * the head plus a `Content-Length`-worth of body; `"end"` stays a fallback for the
2721
+ * no-body / chunked / EOF-terminated cases. `handled` guards single dispatch.
2722
+ */
2723
+ function handleConnection(socket, onRequest) {
2724
+ const chunks = [];
2725
+ let handled = false;
2726
+ const dispatch = async (eof) => {
2727
+ if (handled) return;
2728
+ const raw = Buffer.concat(chunks);
2729
+ const sep = raw.indexOf("\r\n\r\n");
2730
+ if (sep < 0) {
2731
+ if (eof) {
2732
+ handled = true;
2733
+ socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
2734
+ }
2735
+ return;
2736
+ }
2737
+ const headers = parseHeaders(raw.subarray(0, sep).toString("latin1"));
2738
+ const body = raw.subarray(sep + 4);
2739
+ const declared = Number(headers["content-length"]);
2740
+ if (Number.isInteger(declared) && declared >= 0) {
2741
+ if (body.length < declared && !eof) return;
2742
+ } else if (!eof) return;
2743
+ handled = true;
2744
+ const rawBody = Number.isInteger(declared) && declared >= 0 ? body.subarray(0, declared) : body;
2745
+ try {
2746
+ await onRequest({
2747
+ headers,
2748
+ rawBody
2749
+ });
2750
+ socket.end("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n");
2751
+ } catch {
2752
+ socket.end("HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n");
2753
+ }
2754
+ };
2755
+ socket.on("data", (c) => {
2756
+ chunks.push(c);
2757
+ dispatch(false);
2758
+ });
2759
+ socket.on("error", () => socket.destroy());
2760
+ socket.on("end", () => void dispatch(true));
2761
+ }
2762
+ /** Parse a CRLF-delimited HTTP head into a lowercased header map (skip the request line). */
2763
+ function parseHeaders(head) {
2764
+ const out = {};
2765
+ const lines = head.split("\r\n");
2766
+ for (const line of lines.slice(1)) {
2767
+ const idx = line.indexOf(":");
2768
+ if (idx > 0) out[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
2769
+ }
2770
+ return out;
2771
+ }
2772
+ /** One mux conn → one loopback TCP socket bridging broker bytes ↔ TLS listener. */
2773
+ function openLoopbackPipe(port) {
2774
+ const sock = connect(port, "127.0.0.1");
2775
+ let dataCb;
2776
+ sock.on("data", (c) => dataCb?.(new Uint8Array(c)));
2777
+ sock.on("error", () => sock.destroy());
2778
+ return {
2779
+ write: (bytes) => sock.write(bytes),
2780
+ onData: (cb) => {
2781
+ dataCb = cb;
2782
+ },
2783
+ close: () => sock.destroy(),
2784
+ pause: () => sock.pause(),
2785
+ resume: () => sock.resume()
2786
+ };
2787
+ }
2788
+ //#endregion
2789
+ //#region src/funnel/relay-adapter.ts
2790
+ const WEBHOOK_MESSAGE_TYPE = "rine.v1.webhook";
2791
+ /** Parse `sha256=<hex>` (X-Hub-Signature-256) or a bare hex (X-Hook-Signature). */
2792
+ function parseSignature(headers) {
2793
+ const raw = headers["x-hub-signature-256"] ?? headers["x-hook-signature"];
2794
+ if (!raw) return void 0;
2795
+ const hex = raw.startsWith("sha256=") ? raw.slice(7) : raw;
2796
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) return void 0;
2797
+ return Uint8Array.from(Buffer.from(hex, "hex"));
2798
+ }
2799
+ /**
2800
+ * Constant-time HMAC-SHA-256 verification over the EXACT raw bytes (no JSON
2801
+ * re-serialization). A length-mismatched signature fails SAFELY: timingSafeEqual
2802
+ * throws on unequal buffers, so we length-guard first.
2803
+ */
2804
+ function verifyHmac(secret, rawBody, headers) {
2805
+ const received = parseSignature(headers);
2806
+ if (!received) return false;
2807
+ const computed = createHmac("sha256", secret).update(rawBody).digest();
2808
+ if (received.length !== computed.length) return false;
2809
+ return timingSafeEqual(received, computed);
2810
+ }
2811
+ /**
2812
+ * Verify → encrypt → self-send one webhook. Returns the outcome; never throws out
2813
+ * (the caller's tunnel must survive a single bad request). A verify failure drops
2814
+ * the conn with no post and no encrypt; a post/key-fetch failure drops the conn
2815
+ * after verify with `relayed:false`.
2816
+ */
2817
+ async function handleWebhookRequest(deps, req) {
2818
+ const { configDir, agentId, hookName, secret, client, emitLifecycle } = deps;
2819
+ if (!verifyHmac(secret, req.rawBody, req.headers)) {
2820
+ emitLifecycle?.({
2821
+ event: "lifecycle",
2822
+ data: {
2823
+ state: "verify_failed",
2824
+ hook_name: hookName
2825
+ }
2826
+ });
2827
+ return {
2828
+ verified: false,
2829
+ relayed: false
2830
+ };
2831
+ }
2832
+ try {
2833
+ const recipientKeys = await fetchRecipientKeys(client, agentId);
2834
+ const payload = decodePayload(req.rawBody);
2835
+ const encrypted = await encryptMessage(configDir, agentId, recipientKeys.encryption, payload, recipientKeys.pqEncryption);
2836
+ const body = {
2837
+ to_agent_id: agentId,
2838
+ type: WEBHOOK_MESSAGE_TYPE,
2839
+ metadata: { "rine.hook_name": hookName },
2840
+ encrypted_payload: encrypted.encrypted_payload,
2841
+ encryption_version: encrypted.encryption_version,
2842
+ sender_signing_kid: encrypted.sender_signing_kid
2843
+ };
2844
+ const result = await client.post("/messages", body);
2845
+ emitLifecycle?.({
2846
+ event: "lifecycle",
2847
+ data: {
2848
+ state: "webhook_relayed",
2849
+ hook_name: hookName,
2850
+ message_id: result?.id
2851
+ }
2852
+ });
2853
+ return {
2854
+ verified: true,
2855
+ relayed: true
2856
+ };
2857
+ } catch {
2858
+ emitLifecycle?.({
2859
+ event: "lifecycle",
2860
+ data: {
2861
+ state: "relay_error",
2862
+ hook_name: hookName
2863
+ }
2864
+ });
2865
+ return {
2866
+ verified: true,
2867
+ relayed: false
2868
+ };
2869
+ }
2870
+ }
2871
+ /** Best-effort JSON decode; fall back to the raw text so any body still relays. */
2872
+ function decodePayload(rawBody) {
2873
+ const text = new TextDecoder("utf-8").decode(rawBody);
2874
+ try {
2875
+ return JSON.parse(text);
2876
+ } catch {
2877
+ return text;
2878
+ }
2879
+ }
2880
+ //#endregion
2881
+ //#region src/funnel/secret-store.ts
2882
+ /** DNS-label-safe hook name (mirrors the backend HOOK_NAME_PATTERN). */
2883
+ const HOOK_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
2884
+ function isValidHookName(name) {
2885
+ return HOOK_NAME_RE.test(name);
2886
+ }
2887
+ /** Generate a 32-byte HMAC secret as 64 hex chars (client-side only). */
2888
+ function generateHookSecret() {
2889
+ return randomBytes(32).toString("hex");
2890
+ }
2891
+ /** Directory holding one agent's funnel secrets. */
2892
+ function funnelSecretDir(configDir, agentId) {
2893
+ return join(configDir, "funnel", agentId);
2894
+ }
2895
+ /** Absolute path of a hook's secret file. */
2896
+ function hookSecretPath(configDir, agentId, name) {
2897
+ return join(funnelSecretDir(configDir, agentId), `${name}.secret`);
2898
+ }
2899
+ /**
2900
+ * Persist a hook secret atomically with file mode 0600 in a 0700 dir. Returns the
2901
+ * path it wrote so callers can tell the operator where the secret lives.
2902
+ */
2903
+ function saveHookSecret(configDir, agentId, name, secret) {
2904
+ const dir = funnelSecretDir(configDir, agentId);
2905
+ fs.mkdirSync(dir, {
2906
+ recursive: true,
2907
+ mode: 448
2908
+ });
2909
+ const path = hookSecretPath(configDir, agentId, name);
2910
+ const tmp = `${path}.tmp`;
2911
+ fs.writeFileSync(tmp, secret, { mode: 384 });
2912
+ fs.renameSync(tmp, path);
2913
+ fs.chmodSync(path, 384);
2914
+ return path;
2915
+ }
2916
+ /** Read a hook secret, or undefined if it is not stored locally. */
2917
+ function loadHookSecret(configDir, agentId, name) {
2918
+ try {
2919
+ return fs.readFileSync(hookSecretPath(configDir, agentId, name), "utf-8").trim();
2920
+ } catch {
2921
+ return;
2922
+ }
2923
+ }
2924
+ /** Remove a hook secret (no error if it is already absent). */
2925
+ function deleteHookSecret(configDir, agentId, name) {
2926
+ fs.rmSync(hookSecretPath(configDir, agentId, name), { force: true });
2927
+ }
2928
+ //#endregion
1904
2929
  //#region src/mls-ops.ts
1905
2930
  /**
1906
2931
  * Generate and upload MLS key packages for an agent, enabling other agents
@@ -2083,4 +3108,4 @@ async function performAgentCreation(client, configDir, profile, params) {
2083
3108
  return agent;
2084
3109
  }
2085
3110
  //#endregion
2086
- export { DEFAULT_API_URL, GROUP_INVITE_TYPE, 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, listMyInvites, 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, sendGroupInviteNotification, signPayload, signingPublicKeyToJWK, solveTimeLock, solveTimeLockWithProgress, submitProposal, syncMlsGroup, toBase64Url, uploadKeyPackages, uuidToBytes, validateEncryptionKey, validatePathId, validateSigningKey, validateSlug, verifySignature };
3111
+ 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 };