2026-08-06 15:43:57 +00:00
|
|
|
import {execFile} from "node:child_process"
|
2026-08-28 02:31:29 +00:00
|
|
|
import {createConnection} from "node:net"
|
2026-08-20 20:33:20 +00:00
|
|
|
import {chmod, cp, readdir, rm} from "node:fs/promises"
|
|
|
|
|
import {tmpdir} from "node:os"
|
|
|
|
|
import {join} from "node:path"
|
2026-08-06 15:43:57 +00:00
|
|
|
import {fileURLToPath} from "node:url"
|
|
|
|
|
import {promisify} from "node:util"
|
2026-08-22 16:00:18 +00:00
|
|
|
import {MINUTE, int, ms, sleep} from "@welshman/lib"
|
2026-08-06 15:43:57 +00:00
|
|
|
import type {Maybe} from "@welshman/lib"
|
|
|
|
|
import {makeRelayAuth} from "@welshman/util"
|
|
|
|
|
import type {SignedEvent} from "@welshman/util"
|
|
|
|
|
import {ClientMessageType, isRelayAuth, isRelayOk} from "@welshman/net"
|
|
|
|
|
import type {ClientMessage} from "@welshman/net"
|
2026-08-20 20:33:20 +00:00
|
|
|
import {testUsersByPubkey} from "../keys"
|
|
|
|
|
import type {TestUser} from "../keys"
|
2026-08-06 15:43:57 +00:00
|
|
|
import {tenantNames, tenantUrl, tenants} from "./config"
|
|
|
|
|
import type {TenantName} from "./config"
|
|
|
|
|
import {makeTestRelay} from "./testRelay"
|
2026-08-20 20:33:20 +00:00
|
|
|
import type {PublishOptions} from "./types"
|
2026-09-02 13:50:57 +00:00
|
|
|
import {connectToZooid, port, requestZooid} from "./transport"
|
2026-08-06 15:43:57 +00:00
|
|
|
import type {ZooidConnection} from "./transport"
|
|
|
|
|
|
2026-09-18 18:18:32 +00:00
|
|
|
// The relay every test runs against, pinned by digest so a spec is checked against one zooid rather
|
|
|
|
|
// than whatever this machine last pulled. The tag is there to read; docker resolves the digest, and
|
|
|
|
|
// `docker pull` of a newer tag prints the digest to paste beside it. A zooid built from a checkout
|
|
|
|
|
// has no published digest, so ZOOID_IMAGE is how an unreleased one is tried.
|
|
|
|
|
const image =
|
|
|
|
|
process.env.ZOOID_IMAGE ??
|
|
|
|
|
"gitea.coracle.social/coracle/zooid:0.2.2@sha256:70ecf2d3ad0ac338d48f14fe8e5ab9964193d2eb30d6b6f7d8972ca179cad513"
|
2026-08-06 15:43:57 +00:00
|
|
|
|
2026-09-15 17:29:33 +00:00
|
|
|
// How long after a recreate chromium can still abort what a page has in flight. Every netlink
|
|
|
|
|
// event a create-and-destroy produces lands before `compose up --wait` returns — measured at 26 of
|
|
|
|
|
// them, all of them within a second, none after — but chromium coalesces interface changes for up
|
|
|
|
|
// to two seconds before acting on one, so the abort arrives well after the harness has moved on.
|
|
|
|
|
const SETTLE = 2500
|
|
|
|
|
|
2026-08-06 15:43:57 +00:00
|
|
|
const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url))
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
const configSource = fileURLToPath(new URL("docker/config", import.meta.url))
|
|
|
|
|
|
|
|
|
|
// What the container mounts as /app/config. zooid saves a relay's toml back whenever a nip-86 call
|
2026-09-02 13:50:57 +00:00
|
|
|
// edits that relay's name, description or icon. A read-only mount fails those calls, and mounting
|
|
|
|
|
// the repo's own directory would leave one test rewriting the fixtures the rest read, so it is
|
|
|
|
|
// handed a copy. The image is distroless, so the copy is staged here rather than in an entrypoint.
|
2026-08-20 20:33:20 +00:00
|
|
|
const configDir = join(tmpdir(), "flotilla-e2e-zooid-config")
|
2026-08-06 15:43:57 +00:00
|
|
|
|
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// execFile does not go through a shell, so a `docker` that exists only as an alias or a function is
|
|
|
|
|
// invisible to it however well it works when typed. Name the executable to drive with E2E_DOCKER in
|
|
|
|
|
// that case.
|
2026-08-06 15:43:57 +00:00
|
|
|
const dockerCommand = process.env.E2E_DOCKER ?? "docker"
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// Whether something already answers on the port the container publishes, asked before this run
|
|
|
|
|
// brings its own up.
|
2026-08-28 02:31:29 +00:00
|
|
|
const isRelayPortTaken = () =>
|
|
|
|
|
new Promise<boolean>(resolve => {
|
2026-09-02 13:50:57 +00:00
|
|
|
const socket = createConnection({port, host: "127.0.0.1"})
|
2026-08-28 02:31:29 +00:00
|
|
|
|
|
|
|
|
socket.setTimeout(1000)
|
|
|
|
|
socket.once("connect", () => {
|
|
|
|
|
socket.destroy()
|
|
|
|
|
resolve(true)
|
|
|
|
|
})
|
|
|
|
|
socket.once("timeout", () => {
|
|
|
|
|
socket.destroy()
|
|
|
|
|
resolve(false)
|
|
|
|
|
})
|
|
|
|
|
socket.once("error", () => resolve(false))
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-18 18:18:32 +00:00
|
|
|
// Every invocation carries the config directory and the image, `down` included. The compose file
|
|
|
|
|
// names both without a default, so a call that left one out fails to interpolate rather than
|
|
|
|
|
// quietly mounting something else or running a relay nothing here chose.
|
2026-08-20 20:33:20 +00:00
|
|
|
const docker = (...args: string[]) =>
|
2026-09-18 18:18:32 +00:00
|
|
|
execFileAsync(dockerCommand, args, {
|
|
|
|
|
env: {...process.env, ZOOID_CONFIG: configDir, ZOOID_IMAGE: image},
|
|
|
|
|
})
|
2026-08-06 15:43:57 +00:00
|
|
|
|
|
|
|
|
const compose = (...args: string[]) => docker("compose", "-f", composeFile, ...args)
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// Why the container cannot be driven, or undefined when it can. A cli that is not on this process's
|
|
|
|
|
// PATH and a daemon that is not running need different answers, so they are reported apart rather
|
|
|
|
|
// than as one boolean.
|
2026-08-06 15:43:57 +00:00
|
|
|
const probeDocker = async () => {
|
|
|
|
|
try {
|
|
|
|
|
await docker("compose", "version")
|
|
|
|
|
|
|
|
|
|
return undefined
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const {code, stderr} = Object(error) as {code?: string; stderr?: string}
|
|
|
|
|
|
|
|
|
|
if (code === "ENOENT") {
|
|
|
|
|
return (
|
|
|
|
|
`\`${dockerCommand}\` is not on PATH for the test runner. If it works in your shell it is ` +
|
|
|
|
|
"an alias or a function rather than an executable, which execFile cannot see: set " +
|
|
|
|
|
"E2E_DOCKER to the real command, e.g. `E2E_DOCKER=podman pnpm test`."
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
`\`${dockerCommand} compose version\` failed, so the daemon is likely not running. ` +
|
|
|
|
|
(stderr?.trim() || String(error))
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 16:00:18 +00:00
|
|
|
// How far the container's clock is from this host's, in seconds, read off the Date header of the
|
2026-09-02 13:50:57 +00:00
|
|
|
// relay's own http answer. Positive means the container is behind, so an event the harness has just
|
|
|
|
|
// signed looks like it comes from the future.
|
2026-08-22 16:00:18 +00:00
|
|
|
const getClockDrift = async (host: string) => {
|
|
|
|
|
const {headers} = await requestZooid(host, "GET", "/", {accept: "application/nostr+json"})
|
|
|
|
|
|
|
|
|
|
if (headers.date) {
|
|
|
|
|
return Math.round((Date.now() - new Date(headers.date).getTime()) / 1000)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// nip-42 accepts an auth event within ten minutes of the relay's own clock (nip42.go), and under a
|
|
|
|
|
// container runtime's vm the two clocks belong to different machines. The relay's own one-line
|
|
|
|
|
// detail says nothing about how to fix that.
|
2026-08-22 16:00:18 +00:00
|
|
|
const describeClockDrift = (drift: number) => {
|
|
|
|
|
const [minutes, direction] =
|
|
|
|
|
drift > 0 ? [drift / 60, "behind"] : [Math.abs(drift) / 60, "ahead of"]
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
`The zooid container's clock is ${Math.round(minutes)} minutes ${direction} this host. ` +
|
|
|
|
|
"nip-42 allows ten either way, so the relay refuses every event the harness signs, and " +
|
|
|
|
|
"will go on refusing them until the two agree. A container runtime's vm loses time while " +
|
|
|
|
|
"the machine is asleep; restarting it resyncs the clock:\n\n" +
|
|
|
|
|
" podman: podman machine stop && podman machine start\n" +
|
|
|
|
|
" docker: restart Docker Desktop, or " +
|
|
|
|
|
"`docker run --rm --privileged alpine hwclock -s`\n\n" +
|
|
|
|
|
"Then confirm they agree — `podman machine ssh date -u`, or " +
|
|
|
|
|
"`docker info --format '{{.SystemTime}}'`, against `date -u`."
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
const getTestUser = (pubkey: string) => {
|
|
|
|
|
const user = testUsersByPubkey.get(pubkey)
|
|
|
|
|
|
|
|
|
|
if (user) return user
|
|
|
|
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Cannot publish as ${pubkey}: zooid authenticates every write, so seeded events must be ` +
|
|
|
|
|
"signed by one of the test identities in e2e/harness/keys.ts",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 15:43:57 +00:00
|
|
|
let problem: Maybe<Promise<Maybe<string>>>
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// Asked once a worker, since docker does not come and go mid-run. The answer is also written to the
|
|
|
|
|
// terminal, because a skip reason otherwise reaches only the html report.
|
2026-08-06 15:43:57 +00:00
|
|
|
export const describeDockerProblem = () =>
|
|
|
|
|
(problem ??= probeDocker().then(reason => {
|
|
|
|
|
if (reason) {
|
|
|
|
|
console.warn(
|
|
|
|
|
`\nThe e2e suite cannot drive a container, so every test will skip:\n ${reason}\n`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return reason
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-02 13:50:57 +00:00
|
|
|
* The relay every test runs against. One zooid container on loopback serves a virtual relay per
|
|
|
|
|
* entry in `tenants`, and each relay's policy is its toml in docker/config, the only place policy
|
|
|
|
|
* is written down. A scenario describes what is on a relay, never what the relay is.
|
2026-08-06 15:43:57 +00:00
|
|
|
*/
|
|
|
|
|
export class Zooid {
|
2026-09-02 13:50:57 +00:00
|
|
|
// Every socket into the container, the client's and seeding's alike, is one this process opened,
|
|
|
|
|
// so this map is also the definition of a url that is not a leak.
|
2026-08-06 15:43:57 +00:00
|
|
|
relays = new Map(
|
|
|
|
|
tenantNames.map(name => [
|
|
|
|
|
tenantUrl(name),
|
|
|
|
|
{connect: () => connectToZooid(tenants[name]), host: tenants[name]},
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private sessions = new Map<string, ZooidConnection>()
|
|
|
|
|
|
|
|
|
|
private started = false
|
|
|
|
|
|
2026-09-15 17:29:33 +00:00
|
|
|
// Whether a container is up. `reset` is what creates one, so this stays false until it has run.
|
|
|
|
|
private running = false
|
|
|
|
|
|
|
|
|
|
// When the last recreate's interface churn stops being able to abort a request.
|
|
|
|
|
private settledAt = 0
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// Verifies docker rather than bringing the container up, which `reset` does. Repeat calls are
|
|
|
|
|
// free, so the fixture can call this per test.
|
2026-08-06 15:43:57 +00:00
|
|
|
start = async () => {
|
|
|
|
|
if (this.started) return
|
|
|
|
|
|
|
|
|
|
const problem = await describeDockerProblem()
|
|
|
|
|
|
|
|
|
|
if (problem) {
|
|
|
|
|
throw new Error(problem)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasImage = await docker("image", "inspect", image).then(
|
|
|
|
|
() => true,
|
|
|
|
|
() => false,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (!hasImage) {
|
2026-09-18 18:18:32 +00:00
|
|
|
await this.fetchImage()
|
2026-08-06 15:43:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-28 02:31:29 +00:00
|
|
|
if (await isRelayPortTaken()) {
|
|
|
|
|
throw new Error(
|
2026-09-02 13:50:57 +00:00
|
|
|
`127.0.0.1:${port} is already in use, but the zooid relay container publishes ` +
|
2026-08-28 02:31:29 +00:00
|
|
|
"exactly that port and the harness talks to whatever answers there. This is a leftover " +
|
|
|
|
|
"container from an interrupted run, or another copy of this suite running on the machine " +
|
2026-09-18 18:18:32 +00:00
|
|
|
`(under any user). Free it before running — \`${dockerCommand} rm -f ` +
|
|
|
|
|
"flotilla-e2e-zooid-relay-1` clears the one this project starts.",
|
2026-08-28 02:31:29 +00:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 15:43:57 +00:00
|
|
|
this.started = true
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
relay = (name: TenantName) =>
|
2026-08-06 15:43:57 +00:00
|
|
|
makeTestRelay({
|
|
|
|
|
name,
|
|
|
|
|
url: tenantUrl(name),
|
2026-08-20 20:33:20 +00:00
|
|
|
publish: (event, options) => this.publish(tenantUrl(name), event, options),
|
2026-08-06 15:43:57 +00:00
|
|
|
})
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
publish = async (url: string, event: SignedEvent, {as}: PublishOptions = {}) => {
|
2026-08-06 15:43:57 +00:00
|
|
|
const relay = this.relays.get(url)
|
|
|
|
|
|
|
|
|
|
if (!relay) {
|
|
|
|
|
throw new Error(`Attempted to publish to ${url}, which is not one of this container's relays`)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
const connection = await this.authenticate(relay.host, as ?? getTestUser(event.pubkey))
|
2026-08-06 15:43:57 +00:00
|
|
|
const {ok, detail} = await this.send(connection, [ClientMessageType.Event, event], event.id)
|
|
|
|
|
|
|
|
|
|
if (!ok) {
|
|
|
|
|
throw new Error(`zooid refused a kind ${event.kind} fixture: ${detail}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 17:29:33 +00:00
|
|
|
// Leaves a fresh container for whoever runs next. Recreating one churns the host's network
|
|
|
|
|
// interfaces — a veth pair torn down and rebuilt, the project's bridge losing carrier with it —
|
|
|
|
|
// and chromium aborts every request it has in flight when it acts on that, which reaches the app
|
|
|
|
|
// as a route chunk that failed to import and, with `ssr = false`, a 500 page. So this is called
|
|
|
|
|
// from teardown rather than setup, and everything after it counts towards `settle` below.
|
2026-08-06 15:43:57 +00:00
|
|
|
reset = async () => {
|
|
|
|
|
this.closeSessions()
|
|
|
|
|
|
|
|
|
|
await this.up()
|
2026-09-15 17:29:33 +00:00
|
|
|
|
|
|
|
|
this.running = true
|
|
|
|
|
this.settledAt = Date.now() + SETTLE
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The container the test about to run will use. Only the first test of a worker finds none.
|
|
|
|
|
ensure = async () => {
|
|
|
|
|
if (!this.running) {
|
|
|
|
|
await this.reset()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Waited out before a page is opened, which is as late as it can be left: teardown, the next
|
|
|
|
|
// test's fixtures and its seeding are all inside the window already, so most tests pay nothing
|
|
|
|
|
// here.
|
|
|
|
|
settle = async () => {
|
|
|
|
|
const remaining = this.settledAt - Date.now()
|
|
|
|
|
|
|
|
|
|
if (remaining > 0) {
|
|
|
|
|
await sleep(remaining)
|
|
|
|
|
}
|
2026-08-06 15:43:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stop = async () => {
|
|
|
|
|
if (this.started) {
|
|
|
|
|
this.closeSessions()
|
|
|
|
|
|
|
|
|
|
await compose("down")
|
|
|
|
|
|
|
|
|
|
this.started = false
|
2026-09-15 17:29:33 +00:00
|
|
|
this.running = false
|
2026-08-06 15:43:57 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 18:18:32 +00:00
|
|
|
// Fetching unasked is safe for a pinned reference: the pull can only produce the relay this suite
|
|
|
|
|
// was written against, and a machine that already has it never gets here.
|
|
|
|
|
private fetchImage = async () => {
|
|
|
|
|
console.warn(`\nFetching the zooid image this suite is pinned to:\n ${image}\n`)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await docker("pull", image)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const {stderr} = Object(error) as {stderr?: string}
|
|
|
|
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Could not fetch ${image}: ${stderr?.trim() || String(error)}\n\n` +
|
|
|
|
|
`Fetch it by hand with \`${dockerCommand} pull ${image}\`, or point ZOOID_IMAGE at a ` +
|
|
|
|
|
"zooid you built yourself.",
|
|
|
|
|
{cause: error},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 15:43:57 +00:00
|
|
|
// Whatever the relay wrote before it died. Compose reports only that a container exited, so
|
|
|
|
|
// without this a startup failure is a status code and no reason.
|
|
|
|
|
private logs = () =>
|
|
|
|
|
compose("logs", "--no-color", "--tail", "50").then(
|
|
|
|
|
({stdout, stderr}) => [stdout, stderr].filter(Boolean).join("\n").trim(),
|
|
|
|
|
() => "",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private fail = async (summary: string) => {
|
|
|
|
|
const logs = await this.logs()
|
|
|
|
|
|
|
|
|
|
throw new Error(logs ? `${summary}\n\nzooid said:\n${logs}` : summary)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// Storage is tmpfs with no volume mounted for it, so a new container is a new database and
|
|
|
|
|
// recreating is what makes this a reset.
|
2026-08-06 15:43:57 +00:00
|
|
|
private up = async () => {
|
2026-09-02 13:50:57 +00:00
|
|
|
// A fresh copy on every recreate is what restores relay metadata a test edited through nip-86.
|
|
|
|
|
// The modes are permissive because the container runs as uid 65532 while the copy belongs to
|
|
|
|
|
// whoever ran the suite, and a runtime that keeps host ownership leaves zooid unable to write
|
2026-08-20 20:33:20 +00:00
|
|
|
// the file it was told to save.
|
|
|
|
|
await rm(configDir, {recursive: true, force: true})
|
|
|
|
|
await cp(configSource, configDir, {recursive: true})
|
|
|
|
|
await chmod(configDir, 0o777)
|
|
|
|
|
|
|
|
|
|
for (const name of await readdir(configDir)) {
|
|
|
|
|
await chmod(join(configDir, name), 0o666)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 15:43:57 +00:00
|
|
|
try {
|
|
|
|
|
await compose("up", "-d", "--force-recreate", "--wait")
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const {stderr} = Object(error) as {stderr?: string}
|
|
|
|
|
|
|
|
|
|
await this.fail((stderr?.trim() || String(error)).split("\n").slice(-3).join("\n"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const deadline = Date.now() + ms(30)
|
|
|
|
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
// A 404 means the dispatcher has no relay bound to that host yet, so the configs are still
|
|
|
|
|
// loading. Anything a relay itself answers means every tenant is ready.
|
|
|
|
|
const isUp = await Promise.all(
|
|
|
|
|
tenantNames.map(name =>
|
|
|
|
|
requestZooid(tenants[name], "GET", "/", {accept: "application/nostr+json"}).then(
|
|
|
|
|
response => response.status === 200,
|
|
|
|
|
() => false,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
).then(results => results.every(Boolean))
|
|
|
|
|
|
|
|
|
|
if (isUp) {
|
2026-08-22 16:00:18 +00:00
|
|
|
const drift = await getClockDrift(tenants[tenantNames[0]])
|
|
|
|
|
|
|
|
|
|
if (drift !== undefined && Math.abs(drift) > int(10, MINUTE)) {
|
|
|
|
|
throw new Error(describeClockDrift(drift))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 15:43:57 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await sleep(200)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.fail(
|
|
|
|
|
`zooid did not answer as ${tenantNames.map(name => tenants[name]).join(" and ")} within 30 ` +
|
|
|
|
|
"seconds.",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NIP-42 binds an identity to a connection, so each test user seeds over its own, per relay. The
|
2026-09-02 13:50:57 +00:00
|
|
|
// url signed into the auth event is the one the client uses, since that is what khatru rebuilds
|
|
|
|
|
// from the headers transport.ts sends.
|
2026-08-20 20:33:20 +00:00
|
|
|
private authenticate = async (host: string, user: TestUser) => {
|
|
|
|
|
const key = `${host} ${user.pubkey}`
|
2026-08-06 15:43:57 +00:00
|
|
|
const session = this.sessions.get(key)
|
|
|
|
|
|
|
|
|
|
if (session) return session
|
|
|
|
|
|
|
|
|
|
const connection = connectToZooid(host)
|
|
|
|
|
const [, challenge] = await connection.wait(isRelayAuth)
|
|
|
|
|
const event = await user.signer.sign(makeRelayAuth(`wss://${host}/`, challenge))
|
|
|
|
|
const {ok, detail} = await this.send(connection, [ClientMessageType.Auth, event], event.id)
|
|
|
|
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
this.sessions.set(key, connection)
|
|
|
|
|
|
|
|
|
|
return connection
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 16:00:18 +00:00
|
|
|
const summary = `Failed to authenticate as ${user.name} on ${host}: ${detail}`
|
|
|
|
|
const drift = await getClockDrift(host).catch(() => undefined)
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// Nearly always the clock rather than the key. Every identity here is one zooid's toml names,
|
|
|
|
|
// and the signature is checked after the timestamp the relay compares against its own.
|
2026-08-22 16:00:18 +00:00
|
|
|
if (drift !== undefined && Math.abs(drift) > int(10, MINUTE)) {
|
|
|
|
|
throw new Error(`${summary}\n\n${describeClockDrift(drift)}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(summary)
|
2026-08-06 15:43:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// Both halves of seeding, the auth that opens a connection and every event written over it, are
|
|
|
|
|
// answered with an OK naming the event that was sent.
|
2026-08-06 15:43:57 +00:00
|
|
|
private send = async (connection: ZooidConnection, message: ClientMessage, id: string) => {
|
|
|
|
|
connection.send(message)
|
|
|
|
|
|
|
|
|
|
const [, , ok, detail] = await connection.wait(reply => isRelayOk(reply) && reply[1] === id)
|
|
|
|
|
|
|
|
|
|
return {ok, detail}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private closeSessions = () => {
|
|
|
|
|
for (const connection of this.sessions.values()) {
|
|
|
|
|
connection.close()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.sessions.clear()
|
|
|
|
|
}
|
|
|
|
|
}
|