@critical-labs/qa-conductor 0.0.0-stage → 0.3.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.
@@ -0,0 +1,193 @@
1
+ // The Exposure seam's pure core: the public endpoints the conductor declares,
2
+ // and one reconcile pass over an adapter that makes them exist.
3
+ //
4
+ // An Exposure adapter (optional, injected by the platform; the core never
5
+ // constructs one) publishes Mounts on a front door such as tailscale serve:
6
+ // Mount = { name: 'harness'|'base'|'pr', host, port, path, target }
7
+ // Drift = { mount: Mount, actual: string | null }
8
+ // Exposure = { ensure(mounts) -> Promise<{ added: Mount[], ok: Mount[] }>,
9
+ // check(mounts) -> Promise<{ ok: boolean, drift: Drift[] }> }
10
+ // `host` and `port` are the mount's public side, from its own origin;
11
+ // `target` is where the conductor listens, on cfg.host. A mount is the
12
+ // conductor's own (port, path): an adapter never touches any other handler.
13
+ //
14
+ // mountsFor derives every mount from the origins viewers open, so the URL a
15
+ // viewer sees and the mount behind it can't disagree. No I/O here: the
16
+ // adapter owns every effect, and runExpose (the qa-conductor-expose bin's
17
+ // core) prints through the log it is given.
18
+
19
+ import { defaultExposure, defaultHarnessOrigin, EXPOSURE_MODES, HARNESS_PATH } from './config.mjs'
20
+ import { hostPort, isLoopbackHost, portOf, webOrigin } from './net.mjs'
21
+
22
+ const ORIGINS = [
23
+ ['harness', 'the harness origin (QA_HARNESS_ORIGIN)'],
24
+ ['base', 'the base pane origin (QA_BASE_ORIGIN)'],
25
+ ['pr', 'the PR pane origin (QA_PR_ORIGIN)'],
26
+ ]
27
+
28
+ const shown = value => (typeof value === 'string' ? JSON.stringify(value) : String(value))
29
+
30
+ // The three mounts for `cfg`: the harness under HARNESS_PATH at
31
+ // cfg.harnessOrigin's port, each pane at / at its origin's port. `ports` are
32
+ // the listen ports the targets point at; pass the bound ones when cfg.ports
33
+ // holds 0. Throws on a layout no adapter can publish.
34
+ export function mountsFor(cfg, { ports = cfg.ports } = {}) {
35
+ if (!cfg.harnessOrigin) throw new Error('exposure: no harness origin to mount (set QA_HARNESS_ORIGIN)')
36
+ const bind = cfg.host ?? '127.0.0.1'
37
+ const mounts = ORIGINS.map(([name, what]) => {
38
+ const value = name === 'harness' ? cfg.harnessOrigin : cfg.paneOrigins?.[name]
39
+ if (value == null || value === '') throw new Error(`exposure: ${what} is missing`)
40
+ const origin = webOrigin(value, `exposure: ${what}`)
41
+ // tailscale serve publishes https only
42
+ if (!origin.startsWith('https:')) throw new Error(`exposure: ${what} ${origin} is not https, and only an https origin can be mounted`)
43
+ // The URL parser takes port 0, and no front door can listen there.
44
+ const port = portOf(origin)
45
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`exposure: ${what} ${origin} has no port a front door can listen on`)
46
+ const listen = ports?.[name]
47
+ if (!Number.isInteger(listen) || listen < 1 || listen > 65535) {
48
+ throw new Error(`exposure: the ${name} listen port must be an integer from 1 to 65535, got ${shown(listen)} (pass the bound ports)`)
49
+ }
50
+ return {
51
+ name,
52
+ host: new URL(origin).hostname,
53
+ port,
54
+ path: name === 'harness' ? HARNESS_PATH : '/',
55
+ target: `http://${hostPort(bind, listen)}`,
56
+ }
57
+ })
58
+ // `serve --https=<port>` publishes on this node's name whatever the host, so
59
+ // two mounts on one port would share an origin, and the harness and the
60
+ // two panes must be three different origins.
61
+ for (const [i, a] of mounts.entries()) {
62
+ for (const b of mounts.slice(i + 1)) {
63
+ if (a.port === b.port) throw new Error(`exposure: the ${a.name} and ${b.name} mounts are both on port ${a.port}: each needs a port of its own`)
64
+ }
65
+ }
66
+ return mounts
67
+ }
68
+
69
+ // One pass: ensure then check, or check alone with checkOnly. Never rejects:
70
+ // exposure trouble must not take the conductor down, so an error comes back
71
+ // as { ok: false, error }, keeping whatever ensure added: all of it when the
72
+ // check fails, and the error's own `added` list when ensure fails part way.
73
+ export async function reconcileExposure(exposure, mounts, { checkOnly = false, now = Date.now } = {}) {
74
+ let added = []
75
+ try {
76
+ if (!checkOnly) {
77
+ const ensured = await exposure.ensure(mounts)
78
+ if (!Array.isArray(ensured?.added)) throw new Error('the exposure adapter\'s ensure returned no added list')
79
+ added = ensured.added
80
+ }
81
+ const checked = await exposure.check(mounts)
82
+ if (!Array.isArray(checked?.drift)) throw new Error('the exposure adapter\'s check returned no drift list')
83
+ return { ok: checked.ok === true && checked.drift.length === 0, checkedAt: now(), drift: checked.drift, added, error: null }
84
+ } catch (err) {
85
+ return { ok: false, checkedAt: now(), drift: [], added: addedOn(err) ?? added, error: messageOf(err) }
86
+ }
87
+ }
88
+
89
+ // The adapter is platform code and may throw anything: a value with no
90
+ // primitive form (Object.create(null)), or one whose getters throw, must not
91
+ // turn the result into a rejection.
92
+ function messageOf(err) {
93
+ try {
94
+ return String(err?.message ?? err)
95
+ } catch {
96
+ return 'the exposure adapter threw a value that is not an Error'
97
+ }
98
+ }
99
+
100
+ function addedOn(err) {
101
+ try {
102
+ return Array.isArray(err?.added) ? err.added : null
103
+ } catch {
104
+ return null
105
+ }
106
+ }
107
+
108
+ // The expose CLI's core: one pass for `cfg` over `exposure`, printed for an
109
+ // operator, as an exit code. It is the conductor's own pass (mountsFor, then
110
+ // reconcileExposure) on the configured ports, so the two can't disagree
111
+ // about what should be mounted. 0: nothing to do (none mode), or every mount
112
+ // in place; 1: drift remains, or the adapter failed; 2: a mode, bind host or
113
+ // mount layout the conductor would refuse or no front door can publish.
114
+ // Never rejects for anything the adapter does.
115
+ export async function runExpose({ cfg, exposure, checkOnly = false, log = console }) {
116
+ // A cfg with no exposure or harnessOrigin resolves both as startConductor
117
+ // does; loadConfig's always has both.
118
+ let host, harnessOrigin, mode, because
119
+ try {
120
+ host = cfg.host ?? '127.0.0.1'
121
+ // startConductor refuses this too: every target would be http://[[::1]]:port.
122
+ if (/^\[.*\]$/.test(host)) {
123
+ throw new Error(`the bind host (QA_BIND_HOST, cfg.host) takes an IPv6 literal without brackets, as the conductor requires: use ${JSON.stringify(host.slice(1, -1))}, not ${JSON.stringify(host)}`)
124
+ }
125
+ harnessOrigin = cfg.harnessOrigin ?? defaultHarnessOrigin({ publicHost: cfg.publicHost, host, port: cfg.ports?.harness })
126
+ // `because` names what made a defaulted mode tailscale, for the error below
127
+ const resolved = cfg.exposure != null ? { mode: cfg.exposure, because: null } : defaultExposure({
128
+ harnessOrigin, paneOrigins: cfg.paneOrigins, publicHost: cfg.publicHost, allowedHosts: cfg.allowedHosts ?? [], host,
129
+ })
130
+ mode = resolved.mode
131
+ because = resolved.because
132
+ } catch (err) {
133
+ log.error(`qa exposure: ${err.message}`)
134
+ return 2
135
+ }
136
+ if (!EXPOSURE_MODES.includes(mode)) {
137
+ log.error(`qa exposure: QA_EXPOSURE must be none or tailscale, got ${shown(mode)}`)
138
+ return 2
139
+ }
140
+ // An ungated conductor must not publish itself, so there is nothing to do.
141
+ if (mode === 'none') {
142
+ log.log('qa exposure: QA_EXPOSURE=none, nothing to do')
143
+ return 0
144
+ }
145
+ // The identity gate trusts Tailscale-User-Login only on a loopback bind, so
146
+ // loadConfig and startConductor refuse tailscale mode anywhere else, and
147
+ // the CLI must not publish a layout the conductor won't run.
148
+ if (!isLoopbackHost(host)) {
149
+ const why = because ? `QA_EXPOSURE defaults to tailscale because ${because} is not loopback` : 'QA_EXPOSURE is tailscale'
150
+ log.error(`qa exposure: ${why}, and the conductor runs tailscale mode only on a loopback bind host (QA_BIND_HOST, cfg.host), got ${JSON.stringify(host)}`)
151
+ return 2
152
+ }
153
+ let mounts
154
+ try {
155
+ mounts = mountsFor({ ...cfg, harnessOrigin })
156
+ } catch (err) {
157
+ log.error(`qa exposure: ${String(err.message).replace(/^exposure: /, '')}`)
158
+ return 2
159
+ }
160
+ const result = await reconcileExposure(exposure, mounts, { checkOnly })
161
+ try {
162
+ report(result, log)
163
+ } catch {
164
+ // The adapter is platform code: a mount whose getters throw.
165
+ log.error('qa exposure: the adapter returned a result that cannot be printed')
166
+ return 1
167
+ }
168
+ if (result.ok) {
169
+ log.log(`qa exposure: ok (harness ${webOrigin(harnessOrigin)}${HARNESS_PATH}/)`)
170
+ return 0
171
+ }
172
+ return 1
173
+ }
174
+
175
+ function report(result, log) {
176
+ for (const m of result.added) log.log(`qa exposure: mounted ${at(m)} -> ${field(m?.target)}`)
177
+ for (const { mount, actual } of result.drift) {
178
+ // A handler under the mount's path, which check names by its path: no
179
+ // ensure replaces it, since it isn't the conductor's.
180
+ if (typeof actual === 'string' && actual.startsWith('/')) {
181
+ log.error(`qa exposure: drift ${at(mount)}: the handler at ${actual} takes some of its requests; remove it`)
182
+ } else {
183
+ log.error(`qa exposure: drift ${at(mount)}: want ${field(mount?.target)}, have ${typeof actual === 'string' ? actual : 'nothing'}`)
184
+ }
185
+ }
186
+ if (result.error !== null) log.error(`qa exposure: ${result.error}`)
187
+ else if (!result.ok && result.drift.length === 0) log.error('qa exposure: the adapter reported drift but named no mount')
188
+ }
189
+
190
+ // A mount as the adapter returned it, printed: only the Mount type's own
191
+ // values (a string, an integer port) are shown, anything else as `?`.
192
+ const field = value => (typeof value === 'string' || Number.isInteger(value) ? String(value) : '?')
193
+ const at = m => `${field(m?.port)}${field(m?.path)}`
package/lib/github.mjs ADDED
@@ -0,0 +1,218 @@
1
+ // GitHub API client for the QA conductor. All effects go through the injected
2
+ // fetchFn (and sleepFn for polling) so tests never touch the network.
3
+
4
+ const API = 'https://api.github.com'
5
+
6
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
7
+
8
+ // `packageName` is the GHCR container package the GHCR helpers (tag lookup,
9
+ // RC baseline, preview-image wait) read; only those helpers need it. The
10
+ // versions endpoint is the authed-user path (`/user/...`); GHCR-under-an-org
11
+ // would need `/orgs/{owner}/...` — a follow-up when a consumer needs it.
12
+ //
13
+ // `token` authorizes the repo calls (pulls, collaborators, workflow dispatch
14
+ // and runs, issue comments and labels). `packagesToken` authorizes only the
15
+ // package versions listing, which a fine-grained PAT cannot call: pass a
16
+ // classic PAT with read:packages there (cfg.ghcrToken). It defaults to
17
+ // `token`.
18
+ export function createGithub({
19
+ token,
20
+ packagesToken = token,
21
+ repo,
22
+ fetchFn = fetch,
23
+ packageName = null,
24
+ // which GHCR tags count as release candidates for latestRcTag
25
+ rcTagPattern = /-rc\.\d+$/,
26
+ // the workflow that builds PR preview images, and the ref it is dispatched on
27
+ previewWorkflow = 'pr-preview.yml',
28
+ previewRef = 'main',
29
+ // the exclusive verdict label pair: [accept, reject]
30
+ qaLabels = ['qa-approved', 'qa-changes-requested'],
31
+ }) {
32
+ const packageVersionsPath = () => {
33
+ if (!packageName) throw new Error('createGithub: packageName is required for GHCR lookups')
34
+ return `/user/packages/container/${packageName}/versions`
35
+ }
36
+ async function request(method, path, body, auth = token) {
37
+ return fetchFn(`${API}${path}`, {
38
+ method,
39
+ headers: {
40
+ Authorization: `Bearer ${auth}`,
41
+ Accept: 'application/vnd.github+json',
42
+ 'X-GitHub-Api-Version': '2022-11-28',
43
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
44
+ },
45
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
46
+ })
47
+ }
48
+
49
+ async function requestJson(method, path, body, auth = token) {
50
+ const res = await request(method, path, body, auth)
51
+ if (!res.ok) {
52
+ const text = await res.text()
53
+ throw new Error(`github ${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`)
54
+ }
55
+ if (res.status === 204) return null
56
+ return res.json()
57
+ }
58
+
59
+ async function* packageVersions() {
60
+ for (let page = 1; ; page++) {
61
+ const versions = await requestJson('GET', `${packageVersionsPath()}?per_page=100&page=${page}`, undefined, packagesToken)
62
+ for (const version of versions) yield version
63
+ if (versions.length < 100) return
64
+ }
65
+ }
66
+
67
+ function versionTags(version) {
68
+ return version.metadata?.container?.tags ?? []
69
+ }
70
+
71
+ // Who opened a PR and where its head lives: the inputs a BuildConvention's
72
+ // trust gate judges. head.repo is null when the head repository was deleted.
73
+ function trustFields(pr) {
74
+ return {
75
+ authorAssociation: pr.author_association ?? null,
76
+ headRepo: pr.head.repo?.full_name ?? null,
77
+ headOwner: pr.head.repo?.owner?.login ?? null,
78
+ }
79
+ }
80
+
81
+ async function listOpenPrs() {
82
+ const prs = await requestJson('GET', `/repos/${repo}/pulls?state=open&per_page=50`)
83
+ return prs.map((pr) => ({
84
+ number: pr.number,
85
+ title: pr.title,
86
+ headSha: pr.head.sha,
87
+ headRef: pr.head.ref,
88
+ author: pr.user.login,
89
+ ...trustFields(pr),
90
+ }))
91
+ }
92
+
93
+ async function prHead(num) {
94
+ const pr = await requestJson('GET', `/repos/${repo}/pulls/${num}`)
95
+ return pr.head.sha
96
+ }
97
+
98
+ async function prInfo(num) {
99
+ const pr = await requestJson('GET', `/repos/${repo}/pulls/${num}`)
100
+ const { authorAssociation, headRepo, headOwner } = trustFields(pr)
101
+ return {
102
+ number: pr.number,
103
+ headSha: pr.head.sha,
104
+ author: pr.user?.login ?? null,
105
+ authorAssociation,
106
+ isDraft: Boolean(pr.draft),
107
+ headRepo,
108
+ headOwner,
109
+ }
110
+ }
111
+
112
+ // The login's effective permission on the repo: admin | write | read | none.
113
+ // author_association alone is no access check (a read-only outside
114
+ // collaborator is still COLLABORATOR), so trust gates ask this too.
115
+ async function authorPermission(login) {
116
+ const res = await requestJson('GET', `/repos/${repo}/collaborators/${encodeURIComponent(login)}/permission`)
117
+ return res.permission
118
+ }
119
+
120
+ async function ghcrTagExists(tag) {
121
+ for await (const version of packageVersions()) {
122
+ if (versionTags(version).includes(tag)) return true
123
+ }
124
+ return false
125
+ }
126
+
127
+ async function latestRcTag() {
128
+ let bestTag = null
129
+ let bestUpdated = ''
130
+ for await (const version of packageVersions()) {
131
+ const tag = versionTags(version).find((t) => rcTagPattern.test(t))
132
+ if (tag && (!bestTag || version.updated_at > bestUpdated)) {
133
+ bestTag = tag
134
+ bestUpdated = version.updated_at
135
+ }
136
+ }
137
+ return bestTag
138
+ }
139
+
140
+ async function dispatchPreviewBuild(num) {
141
+ await requestJson('POST', `/repos/${repo}/actions/workflows/${previewWorkflow}/dispatches`, {
142
+ ref: previewRef,
143
+ inputs: { pr: String(num) },
144
+ })
145
+ }
146
+
147
+ async function awaitPreviewImage(num, sha, { timeoutMs = 900000, pollMs = 15000, sleepFn = defaultSleep, signal } = {}) {
148
+ const tag = `pr-${num}-${sha.slice(0, 12)}`
149
+ let waited = 0
150
+ for (;;) {
151
+ // A cancelled boot (teardown/takeover) must stop polling promptly rather
152
+ // than hold its wait for up to timeoutMs.
153
+ if (signal?.aborted) throw Object.assign(new Error(`aborted waiting for GHCR tag ${tag}`), { name: 'AbortError' })
154
+ if (await ghcrTagExists(tag)) return tag
155
+ if (waited >= timeoutMs) throw new Error(`timed out after ${timeoutMs}ms waiting for GHCR tag ${tag}`)
156
+ await sleepFn(pollMs)
157
+ waited += pollMs
158
+ }
159
+ }
160
+
161
+ function runReferencesPr(run, num) {
162
+ const hay = `${run.display_title ?? ''} ${run.head_branch ?? ''} ${run.name ?? ''}`
163
+ return new RegExp(`(^|[^0-9])${num}([^0-9]|$)`).test(hay)
164
+ }
165
+
166
+ async function findPreviewRun(num) {
167
+ const data = await requestJson('GET', `/repos/${repo}/actions/workflows/${previewWorkflow}/runs?per_page=10`)
168
+ const runs = Array.isArray(data) ? data : (data?.workflow_runs ?? [])
169
+ if (runs.length === 0) return null
170
+ const byRecency = [...runs].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
171
+ const chosen = byRecency.find((run) => runReferencesPr(run, num)) ?? byRecency[0]
172
+ return {
173
+ url: chosen.html_url,
174
+ status: chosen.status,
175
+ conclusion: chosen.conclusion ?? null,
176
+ startedAt: chosen.run_started_at ?? chosen.created_at,
177
+ }
178
+ }
179
+
180
+ async function listPrImageTags() {
181
+ const tags = new Set()
182
+ for await (const version of packageVersions()) {
183
+ for (const tag of versionTags(version)) tags.add(tag)
184
+ }
185
+ return [...tags]
186
+ }
187
+
188
+ async function postComment(num, body) {
189
+ const comment = await requestJson('POST', `/repos/${repo}/issues/${num}/comments`, { body })
190
+ return comment.html_url
191
+ }
192
+
193
+ async function setQaLabel(num, label) {
194
+ if (!qaLabels.includes(label)) throw new Error(`unknown QA label: ${label}`)
195
+ const opposite = qaLabels.find((l) => l !== label)
196
+ await requestJson('POST', `/repos/${repo}/issues/${num}/labels`, { labels: [label] })
197
+ const res = await request('DELETE', `/repos/${repo}/issues/${num}/labels/${encodeURIComponent(opposite)}`)
198
+ if (!res.ok && res.status !== 404) {
199
+ const text = await res.text()
200
+ throw new Error(`github DELETE label ${opposite} -> ${res.status}: ${text.slice(0, 200)}`)
201
+ }
202
+ }
203
+
204
+ return {
205
+ listOpenPrs,
206
+ prHead,
207
+ prInfo,
208
+ authorPermission,
209
+ ghcrTagExists,
210
+ latestRcTag,
211
+ dispatchPreviewBuild,
212
+ awaitPreviewImage,
213
+ findPreviewRun,
214
+ listPrImageTags,
215
+ postComment,
216
+ setQaLabel,
217
+ }
218
+ }
@@ -0,0 +1,51 @@
1
+ // Tailscale identity gate for the harness and both pane proxies (#307).
2
+ //
3
+ // tailscale serve sets Tailscale-User-Login on the requests it proxies from a
4
+ // user's device, strips any copy the client sent, and sets none for tagged
5
+ // devices. The header can be trusted only because the conductor listens on
6
+ // loopback (cfg.host), so serve on the same host is the only way in.
7
+ //
8
+ // The allowlist is cfg.allowedLogins, from the comma-separated
9
+ // QA_ALLOWED_LOGINS. The names and refusal strings are homefree #329's, which
10
+ // its access-guard tests assert.
11
+
12
+ const LOGIN_HEADER = 'tailscale-user-login'
13
+
14
+ function headerValue(headers, name) {
15
+ for (const [key, value] of Object.entries(headers ?? {})) {
16
+ if (key.toLowerCase() === name) return Array.isArray(value) ? value.join(', ') : String(value)
17
+ }
18
+ return undefined
19
+ }
20
+
21
+ export function normalizeLogins(list) {
22
+ return (list ?? []).map(login => String(login).trim().toLowerCase()).filter(Boolean)
23
+ }
24
+
25
+ // Why a request must be refused, or null to serve it. An empty allowlist
26
+ // refuses everyone.
27
+ export function refusalReason(headers, allowlist) {
28
+ const login = headerValue(headers, LOGIN_HEADER)?.trim()
29
+ if (!login) return 'no Tailscale identity: open this through tailscale serve from an allowed user\'s device'
30
+ if (!normalizeLogins(allowlist).includes(login.toLowerCase())) return `${login} is not in QA_ALLOWED_LOGINS`
31
+ return null
32
+ }
33
+
34
+ export function isAllowed(headers, allowlist) {
35
+ return refusalReason(headers, allowlist) === null
36
+ }
37
+
38
+ // Wraps a request handler so that only allowed logins reach it; the rest get
39
+ // a short plain-text 403 that carries no conductor state.
40
+ export function identityGate(handler, allowlist) {
41
+ return (req, res) => {
42
+ const reason = refusalReason(req.headers, allowlist)
43
+ if (reason === null) return handler(req, res)
44
+ res.writeHead(403, {
45
+ 'content-type': 'text/plain; charset=utf-8',
46
+ 'x-content-type-options': 'nosniff',
47
+ 'cache-control': 'no-store',
48
+ })
49
+ res.end(`403: ${reason}\n`)
50
+ }
51
+ }
package/lib/net.mjs ADDED
@@ -0,0 +1,56 @@
1
+ // Address and origin helpers shared by the config loader, the servers and the
2
+ // request guards. Pure: no I/O.
3
+
4
+ import { isIPv4 } from 'node:net'
5
+
6
+ // A hostname a CSP source and a Host allowlist can both carry: DNS labels
7
+ // (letters, digits, `-`) or a bracketed IPv6 literal. The URL parser accepts
8
+ // more (`;`, `,`, `'`, `*`), and those would change a policy's meaning.
9
+ const SAFE_HOSTNAME = /^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*\.?|\[[0-9a-f:.]+\])$/
10
+
11
+ // host:port, with brackets around an IPv6 literal.
12
+ export const hostPort = (host, port) => (host.includes(':') ? `[${host}]:${port}` : `${host}:${port}`)
13
+
14
+ // True for 127.0.0.0/8, ::1 and localhost. `[]` is stripped, since
15
+ // URL.hostname keeps it on an IPv6 literal.
16
+ export function isLoopbackHost(name) {
17
+ const h = String(name ?? '').toLowerCase().replace(/^\[(.*)\]$/, '$1')
18
+ return h === '::1' || h === 'localhost' || (isIPv4(h) && h.startsWith('127.'))
19
+ }
20
+
21
+ // A web origin, normalized: scheme and host lowercased, a default port, any
22
+ // path and a trailing slash dropped. Anything that isn't an http(s) URL with
23
+ // a plain hostname throws, naming `what` (a config key or a cfg field).
24
+ // `new URL('file:///x').origin` and a data: URL's are the string 'null'.
25
+ export function webOrigin(value, what = 'an origin') {
26
+ let url
27
+ try { url = new URL(value) } catch { url = null }
28
+ if ((url?.protocol !== 'https:' && url?.protocol !== 'http:') || !SAFE_HOSTNAME.test(url.hostname)) {
29
+ const shown = typeof value === 'string' ? JSON.stringify(value) : String(value)
30
+ throw new Error(`${what} must be an origin such as https://host:8444, got ${shown}`)
31
+ }
32
+ return url.origin
33
+ }
34
+
35
+ // The port an http(s) origin is served on: its own, else the scheme's default
36
+ // (443 for https, 80 for http), which URL.port leaves empty.
37
+ export function portOf(origin) {
38
+ const url = new URL(origin)
39
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error(`portOf needs an http(s) origin, got ${JSON.stringify(String(origin))}`)
40
+ return Number(url.port || (url.protocol === 'https:' ? 443 : 80))
41
+ }
42
+
43
+ // Throws unless browsers send Fetch Metadata (Sec-Fetch-*) to `origin`, a
44
+ // webOrigin() result. They send it only to potentially trustworthy origins:
45
+ // https, and http on a loopback host. Without it, and with no Origin on a
46
+ // GET, another page's <img>, <iframe> or link looks like a non-browser
47
+ // client, so `guard` (the request guard that depends on it) would admit it.
48
+ // The message names `what` (a config key or a cfg field).
49
+ export function requireFetchMetadata(origin, what, guard) {
50
+ const url = new URL(origin)
51
+ if (url.protocol === 'https:' || isLoopbackHost(url.hostname)) return
52
+ throw new Error(
53
+ `${what} ${origin}: browsers send no Sec-Fetch-* headers to a plain-http origin off loopback, ` +
54
+ `so the ${guard} can't tell other pages apart; use https or a loopback address`,
55
+ )
56
+ }