@huynle/hub 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1029 -2
  5. package/bin/hub.ts +20 -0
  6. package/docker/hub-deps.ts +42 -0
  7. package/package.json +85 -4
  8. package/rpc.ts +2 -0
  9. package/scripts/hub-local.sh +28 -0
  10. package/scripts/io.github.huynle.hub.plist +42 -0
  11. package/server.ts +3 -0
  12. package/src/central/assets.ts +74 -0
  13. package/src/central/auth.ts +271 -0
  14. package/src/central/config.ts +310 -0
  15. package/src/central/http.ts +1247 -0
  16. package/src/central/main.ts +136 -0
  17. package/src/central/oidc.ts +295 -0
  18. package/src/central/push-target.ts +97 -0
  19. package/src/central/push.ts +285 -0
  20. package/src/central/registry.ts +1153 -0
  21. package/src/central/speech/models.ts +89 -0
  22. package/src/central/speech/openai.ts +66 -0
  23. package/src/central/speech/openrouter.ts +71 -0
  24. package/src/central/speech/provider.ts +74 -0
  25. package/src/central/speech/service.ts +168 -0
  26. package/src/central/speech/settings.ts +375 -0
  27. package/src/central/spoke-ws.ts +300 -0
  28. package/src/central/store.ts +653 -0
  29. package/src/config.ts +170 -0
  30. package/src/env.ts +63 -0
  31. package/src/index.ts +236 -0
  32. package/src/key.ts +17 -0
  33. package/src/loopback.ts +41 -0
  34. package/src/migrate.ts +105 -0
  35. package/src/openrouter.ts +3 -0
  36. package/src/options.ts +33 -0
  37. package/src/protocol.ts +650 -0
  38. package/src/reply.ts +27 -0
  39. package/src/rpc.ts +98 -0
  40. package/src/setup.ts +124 -0
  41. package/src/speakable.ts +35 -0
  42. package/src/spoke/client.ts +272 -0
  43. package/src/spoke/conversation.ts +93 -0
  44. package/src/spoke/enroll.ts +79 -0
  45. package/src/spoke/herdr.ts +346 -0
  46. package/src/spoke/listing.ts +108 -0
  47. package/src/spoke/localhub.ts +287 -0
  48. package/src/spoke/locations.ts +34 -0
  49. package/src/spoke/own-server.ts +32 -0
  50. package/src/spoke/presence.ts +45 -0
  51. package/src/spoke/singleton.ts +103 -0
  52. package/src/spoke/spoke.ts +1212 -0
  53. package/src/spoke/summary.ts +117 -0
  54. package/src/spoke/timeline.ts +93 -0
  55. package/src/spoke/tracker.ts +345 -0
  56. package/src/spoke/watch.ts +78 -0
  57. package/src/status.ts +232 -0
  58. package/src/tui-commands.ts +53 -0
  59. package/src/tui-forms.ts +77 -0
  60. package/src/tui-presence.ts +75 -0
  61. package/src/tui.ts +479 -0
  62. package/src/turns.ts +94 -0
  63. package/src/voice-command.ts +65 -0
  64. package/tui.ts +2 -0
  65. package/web/dist/apple-touch-icon.png +0 -0
  66. package/web/dist/assets/index-DClBvfLL.css +1 -0
  67. package/web/dist/assets/index-Djo_yF0a.js +12 -0
  68. package/web/dist/favicon.ico +0 -0
  69. package/web/dist/icon-192.png +0 -0
  70. package/web/dist/icon-512.png +0 -0
  71. package/web/dist/icon-maskable-512.png +0 -0
  72. package/web/dist/icon.svg +18 -0
  73. package/web/dist/index.html +25 -0
  74. package/web/dist/manifest.webmanifest +18 -0
  75. package/web/dist/sw.js +151 -0
  76. package/web/dist/vad-worklet.js +27 -0
@@ -0,0 +1,136 @@
1
+ // The hub process: command line, config, startup log and shutdown. Shared by bin/hub.ts (web/ from
2
+ // disk) and the compiled binaries (web/ embedded, see scripts/gen-web-entry.ts).
3
+ import type { WebAssets } from "./assets"
4
+ import { PAIR_TTL_MS } from "./auth"
5
+ import { isLoopbackURL } from "../loopback"
6
+ import { loadCentralConfig, type CentralConfig } from "./config"
7
+ import { startCentral } from "./http"
8
+
9
+ export type HubArgs = { readonly action: "run" | "version" | "help" } | { readonly action: "error"; readonly message: string }
10
+
11
+ export function parseHubArgs(argv: readonly string[]): HubArgs {
12
+ if (argv.length === 0) return { action: "run" }
13
+ const [first] = argv
14
+ if (argv.length === 1 && (first === "--version" || first === "-v")) return { action: "version" }
15
+ if (argv.length === 1 && (first === "--help" || first === "-h")) return { action: "help" }
16
+ return { action: "error", message: `Unknown argument: ${argv.join(" ")}. The hub is configured with env vars and hub.json; see --help.` }
17
+ }
18
+
19
+ export function hubHelp(version: string): string {
20
+ return `hub-server ${version}
21
+
22
+ Hub: the central hub your AI agent harnesses connect to (OpenCode first). Serves the web console,
23
+ pairs phones and browsers, enrolls machines, and does push notifications and (optional) Voice speech.
24
+
25
+ Usage: hub-server [--help | --version] (also installed as opencode-hub and opencode-voice-hub)
26
+
27
+ Configuration: environment variables over ~/.config/hub/hub.json ($HUB_CONFIG).
28
+ HUB_HOST / HUB_PORT listen address (default 127.0.0.1 / 4097)
29
+ HUB_PUBLIC_URL origin used in links and cookies (default http://<host>:<port>)
30
+ HUB_STATE_DIR machines, devices, keys (default ~/.local/state/hub)
31
+ OPENROUTER_API_KEY default speech key; or set speech providers in the web app (Settings → Speech)
32
+ HUB_CONFIG path of hub.json
33
+
34
+ Multi-user (sign in with Google or another OpenID Connect provider):
35
+ HUB_AUTH=oidc default "pairing" (one owner)
36
+ HUB_OIDC_ISSUER default https://accounts.google.com
37
+ HUB_OIDC_CLIENT_ID OAuth client ID (required)
38
+ HUB_OIDC_CLIENT_SECRET OAuth client secret (required; never logged)
39
+ HUB_OIDC_ALLOWED who may sign in: emails and/or @domain entries, comma separated (required)
40
+ HUB_ADMIN_EMAILS admin emails, comma separated (at least one)
41
+ Register <public URL>/auth/callback as the redirect URI.
42
+
43
+ The former VOICE_* names (opencode-voice) still work, with a deprecation warning; so do the
44
+ opencode-hub and opencode-voice config and state directories (copied once on first start).
45
+ While no device is paired, the log shows a pairing code for the first one (open <public URL>/pair).
46
+ Docs: https://github.com/huynle/hub#running-the-hub
47
+ `
48
+ }
49
+
50
+ /** Startup log lines about sign-in (never the client secret). Pairing mode has none. */
51
+ export function authStartupLines(config: CentralConfig): { info: string[]; warnings: string[] } {
52
+ const auth = config.auth
53
+ if (auth.mode !== "oidc") return { info: [], warnings: [] }
54
+ const entries = auth.allowed.length
55
+ const admins = auth.admins.length
56
+ const info = [
57
+ `multi-user: sign-in with ${auth.issuer} (client ${auth.clientId}); redirect URI ${config.publicURL}/auth/callback`,
58
+ `multi-user: ${entries} allow-list ${entries === 1 ? "entry" : "entries"}, ${admins} admin${admins === 1 ? "" : "s"}`,
59
+ ]
60
+ const warnings: string[] = []
61
+ if (!config.publicURL.startsWith("https://") && !isLoopbackURL(config.publicURL)) {
62
+ warnings.push(`HUB_PUBLIC_URL ${config.publicURL} is not https: sign-in cookies travel unencrypted and most providers refuse the redirect URI`)
63
+ }
64
+ if (auth.insecureIssuer) warnings.push(`HUB_OIDC_INSECURE_ISSUER is set: plain-http issuer ${auth.issuer} allowed (development only)`)
65
+ return { info, warnings }
66
+ }
67
+
68
+ export interface RunHubOptions {
69
+ readonly web: WebAssets
70
+ readonly version: string
71
+ readonly argv?: readonly string[]
72
+ }
73
+
74
+ /** Runs the hub until SIGINT/SIGTERM (or answers --help / --version). */
75
+ export function runHub(options: RunHubOptions): void {
76
+ const args = parseHubArgs(options.argv ?? process.argv.slice(2))
77
+ if (args.action === "version") {
78
+ console.log(options.version)
79
+ return
80
+ }
81
+ if (args.action === "help") {
82
+ console.log(hubHelp(options.version))
83
+ return
84
+ }
85
+ if (args.action === "error") {
86
+ console.error(`[hub] ${args.message}`)
87
+ process.exit(2)
88
+ }
89
+
90
+ let loaded: ReturnType<typeof loadCentralConfig>
91
+ try {
92
+ loaded = loadCentralConfig()
93
+ } catch (cause) {
94
+ console.error(`[hub] ${cause instanceof Error ? cause.message : cause}`)
95
+ process.exit(1)
96
+ }
97
+ for (const warning of loaded.warnings) console.warn(`[hub] warning: ${warning}`)
98
+ const config = loaded.config
99
+ const central = startCentral({ config, web: options.web, version: options.version })
100
+
101
+ console.log(`[hub] Hub ${options.version} listening on ${central.url} (public URL ${central.publicURL})`)
102
+ console.log(`[hub] state: ${config.stateDir}`)
103
+ console.log(`[hub] config: ${loaded.loaded ? loaded.path : `${loaded.path} (not found; using env and defaults)`}`)
104
+ const authLines = authStartupLines(config)
105
+ for (const line of authLines.info) console.log(`[hub] ${line}`)
106
+ for (const line of authLines.warnings) console.warn(`[hub] warning: ${line}`)
107
+ const speech = central.speechSettings.view()
108
+ if (speech.ready.reason) {
109
+ console.log(`[hub] speech is not set up yet: ${speech.ready.reason} (or OPENROUTER_API_KEY, or speech.stt/tts in hub.json)`)
110
+ }
111
+
112
+ // While no device is paired, keep a fresh bootstrap pairing code in the log.
113
+ const bootstrap = () => {
114
+ const code = central.auth.bootstrapCode()
115
+ if (code) console.log(`[hub] Pair your first device at ${central.publicURL}/pair with code ${code.code}`)
116
+ return code !== undefined
117
+ }
118
+ let timer: ReturnType<typeof setInterval> | undefined
119
+ if (bootstrap()) {
120
+ timer = setInterval(() => {
121
+ if (!bootstrap()) clearInterval(timer)
122
+ }, PAIR_TTL_MS - 5_000)
123
+ }
124
+
125
+ let stopping = false
126
+ const shutdown = async (signal: string) => {
127
+ if (stopping) return
128
+ stopping = true
129
+ console.log(`[hub] ${signal}: shutting down`)
130
+ clearInterval(timer)
131
+ await central.stop()
132
+ process.exit(0)
133
+ }
134
+ process.on("SIGTERM", () => void shutdown("SIGTERM"))
135
+ process.on("SIGINT", () => void shutdown("SIGINT"))
136
+ }
@@ -0,0 +1,295 @@
1
+ // OpenID Connect sign-in (authorization code flow with PKCE S256, state and nonce) for multi-user hubs.
2
+ // The ID token comes straight from the token endpoint over TLS (confidential client, client_secret_post),
3
+ // so its signature is not checked (OIDC Core 3.1.3.7, item 6); its claims are. Pending sign-ins live in
4
+ // memory only (a restart invalidates them) and are single use.
5
+ import { createHash, randomBytes } from "node:crypto"
6
+ import type { OidcConfig } from "./config"
7
+
8
+ export const OIDC_COOKIE = "voice_oidc"
9
+ export const OIDC_PENDING_TTL_MS = 10 * 60_000
10
+ /** Discovery documents are re-fetched after this long. */
11
+ export const DISCOVERY_CACHE_MS = 60 * 60_000
12
+ /** Clock skew tolerated on `exp`. */
13
+ const SKEW_SECONDS = 60
14
+ /** Most pending sign-ins kept (the oldest are dropped): /auth/login is public. */
15
+ const MAX_PENDING = 1000
16
+ const FETCH_TIMEOUT_MS = 10_000
17
+ const MAX_NEXT_CHARS = 1024
18
+
19
+ /** A failed sign-in step; `message` is safe to show, `reason` goes to the audit log. */
20
+ export class OidcError extends Error {
21
+ constructor(
22
+ readonly status: number,
23
+ message: string,
24
+ readonly reason: string,
25
+ /** The state was valid: the attempt is worth an audit entry. */
26
+ readonly audited = false,
27
+ ) {
28
+ super(message)
29
+ this.name = "OidcError"
30
+ }
31
+ }
32
+
33
+ export interface OidcIdentity {
34
+ readonly sub: string
35
+ /** Lowercased, verified. */
36
+ readonly email: string
37
+ readonly name?: string
38
+ }
39
+
40
+ interface Discovery {
41
+ readonly issuer: string
42
+ readonly authorization_endpoint: string
43
+ readonly token_endpoint: string
44
+ }
45
+
46
+ interface Pending {
47
+ readonly nonce: string
48
+ readonly verifier: string
49
+ readonly next: string
50
+ readonly expiresAt: number
51
+ }
52
+
53
+ export interface OidcClientOptions {
54
+ readonly config: OidcConfig
55
+ /** Hub origin: the redirect URI is <publicURL>/auth/callback. */
56
+ readonly publicURL: string
57
+ readonly fetch?: typeof globalThis.fetch
58
+ readonly now?: () => number
59
+ }
60
+
61
+ const random = () => randomBytes(32).toString("base64url")
62
+
63
+ /** A same-origin path (with query and fragment) to land on after sign-in; anything else becomes "/". */
64
+ export function safeNext(raw: string | null | undefined): string {
65
+ if (!raw || raw.length > MAX_NEXT_CHARS) return "/"
66
+ if (!raw.startsWith("/") || raw.startsWith("//") || /[\\\x00-\x20\x7f]/.test(raw)) return "/"
67
+ const base = "http://hub.invalid"
68
+ try {
69
+ const url = new URL(raw, base)
70
+ if (url.origin !== base) return "/"
71
+ const path = `${url.pathname}${url.search}${url.hash}`
72
+ return path.startsWith("//") ? "/" : path
73
+ } catch {
74
+ return "/"
75
+ }
76
+ }
77
+
78
+ /** A short device name from a user agent, e.g. "Safari on iPhone". */
79
+ export function deviceName(userAgent: string | null | undefined): string {
80
+ const ua = userAgent ?? ""
81
+ if (!ua) return "Browser"
82
+ const os = /iPhone/.test(ua)
83
+ ? "iPhone"
84
+ : /iPad/.test(ua)
85
+ ? "iPad"
86
+ : /Android/.test(ua)
87
+ ? "Android"
88
+ : /Macintosh|Mac OS X/.test(ua)
89
+ ? "Mac"
90
+ : /Windows/.test(ua)
91
+ ? "Windows"
92
+ : /Linux|X11/.test(ua)
93
+ ? "Linux"
94
+ : undefined
95
+ const browser = /Edg\//.test(ua)
96
+ ? "Edge"
97
+ : /Firefox\/|FxiOS/.test(ua)
98
+ ? "Firefox"
99
+ : /Chrome\/|CriOS/.test(ua)
100
+ ? "Chrome"
101
+ : /Safari\//.test(ua)
102
+ ? "Safari"
103
+ : "Browser"
104
+ return os ? `${browser} on ${os}` : browser
105
+ }
106
+
107
+ /** The value of one cookie in a Cookie header. */
108
+ export function cookieValue(header: string | null, name: string): string | undefined {
109
+ for (const part of (header ?? "").split(";")) {
110
+ const [key, ...rest] = part.trim().split("=")
111
+ if (key === name) return rest.join("=")
112
+ }
113
+ return undefined
114
+ }
115
+
116
+ function decodePayload(idToken: unknown): Record<string, unknown> {
117
+ const parts = typeof idToken === "string" ? idToken.split(".") : []
118
+ if (parts.length !== 3 || !parts[1]) throw new OidcError(401, "The sign-in provider sent an invalid ID token.", "id_token malformed", true)
119
+ try {
120
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as unknown
121
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new Error("not an object")
122
+ return payload as Record<string, unknown>
123
+ } catch {
124
+ throw new OidcError(401, "The sign-in provider sent an invalid ID token.", "id_token malformed", true)
125
+ }
126
+ }
127
+
128
+ export class OidcClient {
129
+ private readonly config: OidcConfig
130
+ private readonly fetch: typeof globalThis.fetch
131
+ private readonly now: () => number
132
+ private readonly pending = new Map<string, Pending>()
133
+ private discovery?: { at: number; doc: Discovery }
134
+ readonly redirectURI: string
135
+
136
+ constructor(options: OidcClientOptions) {
137
+ this.config = options.config
138
+ this.fetch = options.fetch ?? globalThis.fetch
139
+ this.now = options.now ?? Date.now
140
+ this.redirectURI = `${options.publicURL}/auth/callback`
141
+ }
142
+
143
+ /** Starts a sign-in: the provider URL to redirect to and the state for the voice_oidc cookie. */
144
+ async login(next: string | null): Promise<{ location: string; state: string }> {
145
+ const doc = await this.discover()
146
+ this.sweep()
147
+ const state = random()
148
+ const nonce = random()
149
+ const verifier = random()
150
+ while (this.pending.size >= MAX_PENDING) this.pending.delete(this.pending.keys().next().value!)
151
+ this.pending.set(state, { nonce, verifier, next: safeNext(next), expiresAt: this.now() + OIDC_PENDING_TTL_MS })
152
+ const url = new URL(doc.authorization_endpoint)
153
+ url.searchParams.set("response_type", "code")
154
+ url.searchParams.set("client_id", this.config.clientId)
155
+ url.searchParams.set("redirect_uri", this.redirectURI)
156
+ url.searchParams.set("scope", "openid email profile")
157
+ url.searchParams.set("state", state)
158
+ url.searchParams.set("nonce", nonce)
159
+ url.searchParams.set("code_challenge", createHash("sha256").update(verifier).digest("base64url"))
160
+ url.searchParams.set("code_challenge_method", "S256")
161
+ return { location: url.toString(), state }
162
+ }
163
+
164
+ /**
165
+ * Finishes a sign-in from the callback's query and the voice_oidc cookie. Throws OidcError. The
166
+ * pending entry is consumed whatever the outcome once the state matches the cookie.
167
+ */
168
+ async callback(params: URLSearchParams, cookieState: string | undefined): Promise<{ identity: OidcIdentity; next: string }> {
169
+ this.sweep()
170
+ const state = params.get("state") ?? ""
171
+ const pending = state && cookieState === state ? this.pending.get(state) : undefined
172
+ if (!pending) {
173
+ throw new OidcError(400, "This sign-in has expired or was already used. Please sign in again.", "state invalid")
174
+ }
175
+ this.pending.delete(state)
176
+ const providerError = params.get("error")
177
+ if (providerError) {
178
+ const description = params.get("error_description")
179
+ throw new OidcError(400, `The sign-in provider reported: ${providerError}${description ? ` (${description})` : ""}`, `provider error ${providerError.slice(0, 64)}`, true)
180
+ }
181
+ const code = params.get("code")
182
+ if (!code) throw new OidcError(400, "The sign-in provider sent no authorization code.", "no code", true)
183
+
184
+ const doc = await this.discover()
185
+ let tokens: Record<string, unknown>
186
+ try {
187
+ const response = await this.fetch(doc.token_endpoint, {
188
+ method: "POST",
189
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
190
+ body: new URLSearchParams({
191
+ grant_type: "authorization_code",
192
+ code,
193
+ redirect_uri: this.redirectURI,
194
+ client_id: this.config.clientId,
195
+ client_secret: this.config.clientSecret,
196
+ code_verifier: pending.verifier,
197
+ }),
198
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
199
+ })
200
+ if (!response.ok) throw new Error(`token endpoint answered ${response.status}`)
201
+ tokens = (await response.json()) as Record<string, unknown>
202
+ } catch (cause) {
203
+ throw new OidcError(502, "Could not complete sign-in with the provider. Please try again.", `token exchange failed: ${cause instanceof Error ? cause.message : cause}`, true)
204
+ }
205
+ const identity = this.validate(decodePayload(tokens?.id_token), doc, pending.nonce)
206
+ return { identity, next: pending.next }
207
+ }
208
+
209
+ private validate(claims: Record<string, unknown>, doc: Discovery, nonce: string): OidcIdentity {
210
+ const reject = (reason: string, status = 401, message = "The sign-in could not be verified. Please sign in again."): never => {
211
+ throw new OidcError(status, message, reason, true)
212
+ }
213
+ const issuers = new Set([doc.issuer])
214
+ // Google documents both forms for `iss`.
215
+ if (doc.issuer === "https://accounts.google.com") issuers.add("accounts.google.com")
216
+ if (typeof claims.iss !== "string" || !issuers.has(claims.iss)) reject("iss mismatch")
217
+ const aud = claims.aud
218
+ const audiences = typeof aud === "string" ? [aud] : Array.isArray(aud) ? aud : []
219
+ if (!audiences.includes(this.config.clientId)) reject("aud mismatch")
220
+ if ((audiences.length > 1 || claims.azp !== undefined) && claims.azp !== this.config.clientId) reject("azp mismatch")
221
+ if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp) || claims.exp + SKEW_SECONDS <= this.now() / 1000) reject("token expired")
222
+ if (typeof claims.nonce !== "string" || claims.nonce !== nonce) reject("nonce mismatch")
223
+ if (typeof claims.sub !== "string" || !claims.sub) reject("no sub")
224
+ const email = typeof claims.email === "string" ? claims.email.trim().toLowerCase() : ""
225
+ if (!email) reject("no email", 403, "Your account did not share an email address.")
226
+ if (claims.email_verified !== true && claims.email_verified !== "true") reject("email not verified", 403, "Your email address is not verified with the sign-in provider.")
227
+ const name = typeof claims.name === "string" && claims.name.trim() ? claims.name.trim().slice(0, 100) : undefined
228
+ return { sub: claims.sub as string, email, ...(name ? { name } : {}) }
229
+ }
230
+
231
+ private async discover(): Promise<Discovery> {
232
+ if (this.discovery && this.now() - this.discovery.at < DISCOVERY_CACHE_MS) return this.discovery.doc
233
+ const issuer = this.config.issuer
234
+ let raw: Record<string, unknown>
235
+ try {
236
+ const response = await this.fetch(`${issuer}/.well-known/openid-configuration`, {
237
+ headers: { accept: "application/json" },
238
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
239
+ })
240
+ if (!response.ok) throw new Error(`answered ${response.status}`)
241
+ raw = (await response.json()) as Record<string, unknown>
242
+ } catch (cause) {
243
+ throw new OidcError(502, "The sign-in provider is not reachable right now. Please try again later.", `discovery failed: ${cause instanceof Error ? cause.message : cause}`)
244
+ }
245
+ const fail = (what: string): never => {
246
+ throw new OidcError(502, "The sign-in provider is misconfigured.", `discovery: ${what}`)
247
+ }
248
+ if (typeof raw.issuer !== "string" || raw.issuer.replace(/\/+$/, "") !== issuer) fail("issuer mismatch")
249
+ for (const key of ["authorization_endpoint", "token_endpoint"] as const) {
250
+ if (!this.endpointOK(raw[key])) fail(`${key} invalid`)
251
+ }
252
+ const doc: Discovery = {
253
+ issuer: raw.issuer as string,
254
+ authorization_endpoint: raw.authorization_endpoint as string,
255
+ token_endpoint: raw.token_endpoint as string,
256
+ }
257
+ this.discovery = { at: this.now(), doc }
258
+ return doc
259
+ }
260
+
261
+ /** https, or http on the (dev-only) insecure loopback issuer's host. */
262
+ private endpointOK(value: unknown): boolean {
263
+ if (typeof value !== "string") return false
264
+ try {
265
+ const url = new URL(value)
266
+ if (url.protocol === "https:") return true
267
+ return url.protocol === "http:" && this.config.insecureIssuer === true && url.origin === new URL(this.config.issuer).origin
268
+ } catch {
269
+ return false
270
+ }
271
+ }
272
+
273
+ private sweep(): void {
274
+ const t = this.now()
275
+ for (const [state, entry] of this.pending) if (entry.expiresAt <= t) this.pending.delete(state)
276
+ }
277
+ }
278
+
279
+ const ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }
280
+ export const escapeHTML = (text: string) => text.replace(/[&<>"']/g, (c) => ESCAPES[c]!)
281
+
282
+ /** A small standalone HTML page for sign-in results (no scripts, nothing external). */
283
+ export function authPage(status: number, title: string, message: string, headers: HeadersInit = {}): Response {
284
+ const html = `<!doctype html>
285
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
286
+ <title>${escapeHTML(title)} · Hub</title>
287
+ <style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1rem;color:#222}a{color:#0a58ca}</style>
288
+ </head><body><h1>${escapeHTML(title)}</h1><p>${escapeHTML(message)}</p><p><a href="/">Back to Hub</a></p></body></html>
289
+ `
290
+ const h = new Headers(headers)
291
+ h.set("content-type", "text/html; charset=utf-8")
292
+ h.set("cache-control", "no-store")
293
+ h.set("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'")
294
+ return new Response(html, { status, headers: h })
295
+ }
@@ -0,0 +1,97 @@
1
+ // Push endpoints come from browsers, and the hub POSTs to them: without checks a subscription could make the
2
+ // hub send requests into its own network (SSRF). An endpoint must be https on a public hostname; hosts are
3
+ // resolved at subscribe time and rejected when any address is loopback, private, link-local, CGNAT, ULA,
4
+ // unspecified or multicast. Real push services (FCM, Mozilla, Apple, WNS) are public names on public addresses.
5
+ import { lookup } from "node:dns/promises"
6
+ import { isIPv4, isIPv6 } from "node:net"
7
+
8
+ export type ResolveHost = (host: string) => Promise<readonly string[]>
9
+
10
+ /** Every address the system resolver returns for a host. */
11
+ export const resolveHost: ResolveHost = async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address)
12
+
13
+ /** IPv4 [network, prefix length] blocks that never reach a public push service. */
14
+ const V4_BLOCKED: ReadonlyArray<readonly [string, number]> = [
15
+ ["0.0.0.0", 8], // "this network", unspecified
16
+ ["10.0.0.0", 8], // private
17
+ ["100.64.0.0", 10], // CGNAT
18
+ ["127.0.0.0", 8], // loopback
19
+ ["169.254.0.0", 16], // link-local (cloud metadata)
20
+ ["172.16.0.0", 12], // private
21
+ ["192.0.0.0", 24], // IETF protocol assignments
22
+ ["192.168.0.0", 16], // private
23
+ ["198.18.0.0", 15], // benchmarking
24
+ ["224.0.0.0", 4], // multicast
25
+ ["240.0.0.0", 4], // reserved, broadcast
26
+ ]
27
+
28
+ const v4Number = (ip: string): number => ip.split(".").reduce((n, part) => n * 256 + Number(part), 0)
29
+
30
+ function privateV4(ip: string): boolean {
31
+ const n = v4Number(ip)
32
+ return V4_BLOCKED.some(([net, bits]) => Math.floor(n / 2 ** (32 - bits)) === Math.floor(v4Number(net) / 2 ** (32 - bits)))
33
+ }
34
+
35
+ /** The eight 16-bit groups of an IPv6 address (with any embedded IPv4 tail converted). */
36
+ function v6Groups(ip: string): number[] {
37
+ let text = ip.toLowerCase().replace(/%.*$/, "")
38
+ const tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(text)
39
+ if (tail) {
40
+ const n = v4Number(tail[1]!)
41
+ text = `${text.slice(0, -tail[1]!.length)}${(n >>> 16).toString(16)}:${(n & 0xffff).toString(16)}`
42
+ }
43
+ const [head, rest] = text.split("::") as [string, string | undefined]
44
+ const left = head ? head.split(":") : []
45
+ const right = rest === undefined ? [] : rest ? rest.split(":") : []
46
+ const fill = rest === undefined ? [] : Array(8 - left.length - right.length).fill("0")
47
+ return [...left, ...fill, ...right].map((g) => parseInt(g, 16))
48
+ }
49
+
50
+ /** True for addresses a push endpoint must not resolve to. Anything that is not an IP counts as private. */
51
+ export function isPrivateAddress(address: string): boolean {
52
+ const ip = address.trim().replace(/^\[(.*)\]$/, "$1")
53
+ if (isIPv4(ip)) return privateV4(ip)
54
+ if (!isIPv6(ip)) return true
55
+ const g = v6Groups(ip)
56
+ const first = g[0]!
57
+ if (g.slice(0, 5).every((x) => x === 0) && (g[5] === 0xffff || g[5] === 0)) {
58
+ // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d, which includes :: and ::1).
59
+ if (g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true
60
+ return privateV4(`${g[6]! >> 8}.${g[6]! & 0xff}.${g[7]! >> 8}.${g[7]! & 0xff}`)
61
+ }
62
+ if (first === 0x64 && g[1] === 0xff9b) return privateV4(`${g[6]! >> 8}.${g[6]! & 0xff}.${g[7]! >> 8}.${g[7]! & 0xff}`) // NAT64
63
+ return (
64
+ (first & 0xfe00) === 0xfc00 || // ULA fc00::/7
65
+ (first & 0xffc0) === 0xfe80 || // link-local fe80::/10
66
+ (first & 0xffc0) === 0xfec0 || // site-local fec0::/10 (deprecated)
67
+ (first & 0xff00) === 0xff00 // multicast
68
+ )
69
+ }
70
+
71
+ const LOCAL_SUFFIXES = [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".lan"]
72
+
73
+ /** Why an endpoint is not acceptable for Web Push, or undefined when it is. */
74
+ export async function pushEndpointProblem(endpoint: string, resolve: ResolveHost): Promise<string | undefined> {
75
+ let url: URL
76
+ try {
77
+ url = new URL(endpoint)
78
+ } catch {
79
+ return "Push endpoint is not a URL"
80
+ }
81
+ if (url.protocol !== "https:") return "Push endpoint must be https"
82
+ if (url.username || url.password) return "Push endpoint must not carry credentials"
83
+ const host = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1").replace(/\.$/, "")
84
+ if (isIPv4(host) || isIPv6(host)) return isPrivateAddress(host) ? "Push endpoint is a private or local address" : undefined
85
+ if (host === "localhost" || !host.includes(".") || LOCAL_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
86
+ return "Push endpoint is a local hostname"
87
+ }
88
+ let addresses: readonly string[]
89
+ try {
90
+ addresses = await resolve(host)
91
+ } catch {
92
+ return `Push endpoint host ${host} does not resolve`
93
+ }
94
+ if (addresses.length === 0) return `Push endpoint host ${host} does not resolve`
95
+ if (addresses.some(isPrivateAddress)) return "Push endpoint resolves to a private or local address"
96
+ return undefined
97
+ }