@raidiant/notifai 8.0.0 → 9.0.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 (145) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-doctor.d.ts +1 -0
  24. package/dist/commands-doctor.js +71 -15
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-guidance.d.ts +7 -3
  27. package/dist/commands-guidance.js +63 -28
  28. package/dist/commands-guidance.js.map +1 -1
  29. package/dist/commands-hooks.d.ts +9 -4
  30. package/dist/commands-hooks.js +430 -32
  31. package/dist/commands-hooks.js.map +1 -1
  32. package/dist/commands-init.d.ts +10 -18
  33. package/dist/commands-init.js +114 -32
  34. package/dist/commands-init.js.map +1 -1
  35. package/dist/commands-send-support.d.ts +17 -2
  36. package/dist/commands-send-support.js +61 -5
  37. package/dist/commands-send-support.js.map +1 -1
  38. package/dist/commands-send.js +60 -11
  39. package/dist/commands-send.js.map +1 -1
  40. package/dist/commands-setup-proof.d.ts +8 -0
  41. package/dist/commands-setup-proof.js +31 -2
  42. package/dist/commands-setup-proof.js.map +1 -1
  43. package/dist/commands-skill.d.ts +11 -1
  44. package/dist/commands-skill.js +61 -11
  45. package/dist/commands-skill.js.map +1 -1
  46. package/dist/commands.d.ts +1 -1
  47. package/dist/commands.js.map +1 -1
  48. package/dist/config-schema.d.ts +1 -1
  49. package/dist/config-schema.js +25 -0
  50. package/dist/config-schema.js.map +1 -1
  51. package/dist/config.d.ts +24 -3
  52. package/dist/config.js +37 -5
  53. package/dist/config.js.map +1 -1
  54. package/dist/credentials.d.ts +10 -1
  55. package/dist/credentials.js +25 -12
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/guidance-content.d.ts +20 -0
  58. package/dist/guidance-content.js +73 -8
  59. package/dist/guidance-content.js.map +1 -1
  60. package/dist/guidance.d.ts +21 -0
  61. package/dist/guidance.js +73 -18
  62. package/dist/guidance.js.map +1 -1
  63. package/dist/hook-adapter.js +37 -3
  64. package/dist/hook-adapter.js.map +1 -1
  65. package/dist/hooks.d.ts +68 -7
  66. package/dist/hooks.js +231 -85
  67. package/dist/hooks.js.map +1 -1
  68. package/dist/install-hooks.d.ts +10 -18
  69. package/dist/install-hooks.js +110 -23
  70. package/dist/install-hooks.js.map +1 -1
  71. package/dist/interactive.d.ts +1 -1
  72. package/dist/interactive.js +1 -1
  73. package/dist/interactive.js.map +1 -1
  74. package/dist/logging.d.ts +8 -1
  75. package/dist/logging.js +43 -2
  76. package/dist/logging.js.map +1 -1
  77. package/dist/main.js +2 -13
  78. package/dist/main.js.map +1 -1
  79. package/dist/native-skills.d.ts +19 -0
  80. package/dist/native-skills.js +30 -8
  81. package/dist/native-skills.js.map +1 -1
  82. package/dist/opencode-plugin.js +14 -1
  83. package/dist/opencode-plugin.js.map +1 -1
  84. package/dist/program.js +13 -8
  85. package/dist/program.js.map +1 -1
  86. package/dist/send.d.ts +8 -1
  87. package/dist/send.js +17 -1
  88. package/dist/send.js.map +1 -1
  89. package/dist/session-activation.d.ts +15 -0
  90. package/dist/session-activation.js +35 -0
  91. package/dist/session-activation.js.map +1 -0
  92. package/dist/url-policy.d.ts +85 -0
  93. package/dist/url-policy.js +369 -0
  94. package/dist/url-policy.js.map +1 -0
  95. package/package.json +3 -3
  96. package/src/atomic-file.ts +36 -2
  97. package/src/cli-bin.test.ts +38 -0
  98. package/src/cli-bin.ts +67 -0
  99. package/src/cli-release.test.ts +46 -0
  100. package/src/cli-release.ts +78 -0
  101. package/src/client.test.ts +95 -0
  102. package/src/client.ts +16 -3
  103. package/src/commands-auth.ts +32 -3
  104. package/src/commands-config.ts +25 -2
  105. package/src/commands-core.ts +38 -3
  106. package/src/commands-doctor.ts +99 -13
  107. package/src/commands-guidance.ts +75 -29
  108. package/src/commands-hooks.ts +503 -34
  109. package/src/commands-init.ts +139 -38
  110. package/src/commands-send-support.test.ts +156 -0
  111. package/src/commands-send-support.ts +67 -5
  112. package/src/commands-send.ts +73 -10
  113. package/src/commands-setup-proof.ts +36 -2
  114. package/src/commands-skill.ts +78 -15
  115. package/src/commands.test.ts +1083 -101
  116. package/src/commands.ts +1 -0
  117. package/src/config-schema.ts +28 -1
  118. package/src/config.test.ts +1 -1
  119. package/src/config.ts +49 -5
  120. package/src/credentials.test.ts +71 -3
  121. package/src/credentials.ts +29 -15
  122. package/src/guidance-content.ts +74 -8
  123. package/src/guidance.test.ts +42 -11
  124. package/src/guidance.ts +100 -18
  125. package/src/hook-adapter.test.ts +113 -1
  126. package/src/hook-adapter.ts +37 -3
  127. package/src/hooks.test.ts +746 -165
  128. package/src/hooks.ts +299 -124
  129. package/src/install-hooks.test.ts +135 -16
  130. package/src/install-hooks.ts +113 -25
  131. package/src/interactive.ts +1 -1
  132. package/src/logging.test.ts +66 -3
  133. package/src/logging.ts +45 -2
  134. package/src/main.ts +2 -14
  135. package/src/native-skills.test.ts +41 -1
  136. package/src/native-skills.ts +40 -6
  137. package/src/opencode-plugin.ts +14 -1
  138. package/src/program.test.ts +37 -2
  139. package/src/program.ts +40 -12
  140. package/src/send.test.ts +28 -0
  141. package/src/send.ts +28 -2
  142. package/src/session-activation.ts +45 -0
  143. package/src/skill.test.ts +120 -4
  144. package/src/trust-policy.test.ts +701 -0
  145. package/src/url-policy.ts +399 -0
@@ -0,0 +1,399 @@
1
+ import { lookup as dnsLookup } from 'node:dns/promises'
2
+ import { request as httpRequest, type IncomingMessage } from 'node:http'
3
+ import { request as httpsRequest } from 'node:https'
4
+ import { BlockList, isIP } from 'node:net'
5
+ import { Readable } from 'node:stream'
6
+
7
+ /**
8
+ * The URL trust policy: which network destinations this CLI will touch on
9
+ * input it did not author. `docs/TRUST.md` is the written policy; this module
10
+ * is its enforcement, and the two must change together.
11
+ *
12
+ * The threat is not a hostile user — it is hostile *input* reaching a
13
+ * trusted user's CLI: a cloned repository, a compromised server response, or
14
+ * an agent following text it found in a working tree. Every rule here is
15
+ * therefore fail-closed, and every escape hatch resolves only from
16
+ * configuration layers the User owns — never from a repository.
17
+ */
18
+
19
+ /** The one dashboard origin the shipped service uses for pairing approval. */
20
+ export const CANONICAL_DASHBOARD_ORIGIN = 'https://app.notifai.sh'
21
+
22
+ /** Redirect hops a remote image fetch may follow, each re-validated. */
23
+ export const MEDIA_REDIRECT_LIMIT = 3
24
+
25
+ /** One address attempt must not hold an agent command indefinitely. */
26
+ export const MEDIA_FETCH_TIMEOUT_MS = 15_000
27
+
28
+ export type UrlRefusal = { ok: false; reason: string }
29
+
30
+ /**
31
+ * Normalize one configured origin entry to `scheme://host[:port]`, or null
32
+ * when the entry is not a bare http(s) origin. Paths, queries, fragments, and
33
+ * userinfo are rejected rather than stripped: a config entry that carries
34
+ * them says the author expected them to matter, and silently ignoring parts
35
+ * of a trust rule is how allowlists rot.
36
+ */
37
+ export function normalizeOrigin(entry: string): string | null {
38
+ let url: URL
39
+ try {
40
+ url = new URL(entry)
41
+ } catch {
42
+ return null
43
+ }
44
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
45
+ if (url.username !== '' || url.password !== '') return null
46
+ if (url.pathname !== '/' || url.search !== '' || url.hash !== '') return null
47
+ // `https://*.example` parses, and would then match exactly one impossible
48
+ // host while reading like a wildcard. Refusing it is how someone finds out
49
+ // that origins are exact before they trust one that never matches.
50
+ if (!/^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:.]+\])$/i.test(url.hostname)) return null
51
+ return url.origin
52
+ }
53
+
54
+ /**
55
+ * Addresses that are not publicly routable: loopback, RFC 1918, link-local
56
+ * (including cloud metadata at 169.254.169.254), CGNAT, benchmark,
57
+ * documentation, multicast, and reserved space; for IPv6 also unspecified,
58
+ * unique-local, site-local, and discard prefixes. IPv4-mapped IPv6 addresses
59
+ * are classified as the IPv4 address they carry.
60
+ */
61
+ const NON_PUBLIC = new BlockList()
62
+ const PUBLIC_IPV6 = new BlockList()
63
+ PUBLIC_IPV6.addSubnet('2000::', 3, 'ipv6')
64
+ for (const [subnet, prefix] of [
65
+ ['0.0.0.0', 8],
66
+ ['10.0.0.0', 8],
67
+ ['100.64.0.0', 10],
68
+ ['127.0.0.0', 8],
69
+ ['169.254.0.0', 16],
70
+ ['172.16.0.0', 12],
71
+ ['192.0.0.0', 24],
72
+ ['192.0.2.0', 24],
73
+ ['192.88.99.0', 24],
74
+ ['192.168.0.0', 16],
75
+ ['198.18.0.0', 15],
76
+ ['198.51.100.0', 24],
77
+ ['203.0.113.0', 24],
78
+ ['224.0.0.0', 4],
79
+ ['240.0.0.0', 4],
80
+ ] as const) {
81
+ NON_PUBLIC.addSubnet(subnet, prefix, 'ipv4')
82
+ }
83
+ for (const [subnet, prefix] of [
84
+ ['::', 128],
85
+ ['::1', 128],
86
+ ['64:ff9b::', 96], // NAT64 — fail closed rather than trust the embedded address
87
+ ['64:ff9b:1::', 48],
88
+ ['100::', 64],
89
+ ['2001::', 32], // Teredo embeds an IPv4 destination
90
+ ['2001:2::', 48],
91
+ ['2001:10::', 28],
92
+ ['2001:20::', 28],
93
+ ['2001:db8::', 32],
94
+ ['2002::', 16], // 6to4 embeds an IPv4 address; fail closed
95
+ ['3fff::', 20],
96
+ ['fc00::', 7],
97
+ ['fe80::', 10],
98
+ ['fec0::', 10],
99
+ ['ff00::', 8],
100
+ // Deliberately no `::ffff:0:0/96` rule: Node checks an IPv4 address against
101
+ // IPv6 rules through its own IPv4-mapped form, so that entry would classify
102
+ // every address on the internet as private. `mappedIpv4` handles the mapped
103
+ // spellings instead.
104
+ ] as const) {
105
+ NON_PUBLIC.addSubnet(subnet, prefix, 'ipv6')
106
+ }
107
+
108
+ /**
109
+ * The IPv4 address an IPv4-mapped IPv6 address carries, in either spelling:
110
+ * `::ffff:127.0.0.1` and `::ffff:7f00:1` are the same address, and only the
111
+ * first would survive being classified as IPv6.
112
+ */
113
+ function mappedIpv4(address: string): string | null {
114
+ const lower = address.toLowerCase()
115
+ if (!lower.startsWith('::ffff:')) return null
116
+ const rest = lower.slice('::ffff:'.length)
117
+ if (isIP(rest) === 4) return rest
118
+ const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest)
119
+ if (hex === null) return null
120
+ const high = Number.parseInt(hex[1]!, 16)
121
+ const low = Number.parseInt(hex[2]!, 16)
122
+ return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`
123
+ }
124
+
125
+ /** Whether one IP literal is publicly routable. */
126
+ export function isPublicAddress(address: string): boolean {
127
+ const candidate = mappedIpv4(address) ?? address
128
+ const family = isIP(candidate)
129
+ if (family === 0) return false
130
+ // Fail closed outside the currently allocated global-unicast block. This
131
+ // catches reserved IPv6 space without depending on an inevitably stale
132
+ // enumeration; explicit exclusions below then carve special-use prefixes
133
+ // out of global unicast itself.
134
+ if (family === 6 && !PUBLIC_IPV6.check(candidate, 'ipv6')) return false
135
+ return !NON_PUBLIC.check(candidate, family === 6 ? 'ipv6' : 'ipv4')
136
+ }
137
+
138
+ /** A URL hostname as an address-or-name: IPv6 hosts arrive bracketed. */
139
+ function hostnameAddress(hostname: string): string {
140
+ return hostname.startsWith('[') && hostname.endsWith(']')
141
+ ? hostname.slice(1, -1)
142
+ : hostname
143
+ }
144
+
145
+ export function isLoopbackHostname(hostname: string): boolean {
146
+ const host = hostnameAddress(hostname).toLowerCase()
147
+ if (host === 'localhost' || host.endsWith('.localhost')) return true
148
+ const family = isIP(host)
149
+ if (family === 4) return host.startsWith('127.')
150
+ if (family === 6) return host === '::1' || /^::ffff:127\./.test(host)
151
+ return false
152
+ }
153
+
154
+ export interface MediaUrlPolicy {
155
+ /**
156
+ * Extra origins remote media may use beyond public HTTPS — the self-host
157
+ * and intranet escape hatch. Entries are normalized origins from
158
+ * User-owned configuration (`media_origins`); a repository cannot supply
159
+ * them.
160
+ */
161
+ allowOrigins: readonly string[]
162
+ /** Test seam; production resolves with the system resolver. */
163
+ lookup?: (hostname: string) => Promise<string[]>
164
+ }
165
+
166
+ type MediaUrlApproval = { ok: true; addresses: readonly string[] }
167
+
168
+ async function resolveAddresses(
169
+ hostname: string,
170
+ policy: MediaUrlPolicy,
171
+ ): Promise<string[] | null> {
172
+ if (policy.lookup !== undefined) {
173
+ try {
174
+ return await policy.lookup(hostname)
175
+ } catch {
176
+ return null
177
+ }
178
+ }
179
+ try {
180
+ const results = await dnsLookup(hostname, { all: true, verbatim: true })
181
+ return results.map((entry) => entry.address)
182
+ } catch {
183
+ return null
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Whether one URL is a destination remote media may be fetched from.
189
+ *
190
+ * Default policy: `https` to a host every resolved address of which is
191
+ * publicly routable. A User-allowed origin skips the scheme and address
192
+ * checks — the User said that exact origin is theirs to trust — but nothing
193
+ * skips the userinfo refusal, and every redirect hop is checked again.
194
+ *
195
+ * The approved addresses come back with the verdict rather than being
196
+ * rediscovered later: `fetchMediaUrl` connects to one of exactly these, so a
197
+ * resolver that would answer differently on a second lookup cannot rebind the
198
+ * request onto a private address between the check and the connection.
199
+ */
200
+ export async function checkMediaUrl(
201
+ target: URL,
202
+ policy: MediaUrlPolicy,
203
+ ): Promise<MediaUrlApproval | UrlRefusal> {
204
+ if (target.username !== '' || target.password !== '') {
205
+ return { ok: false, reason: 'Image URLs with embedded credentials are never fetched.' }
206
+ }
207
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') {
208
+ return { ok: false, reason: `Only http(s) image URLs are supported, not ${target.protocol.replace(/:$/, '')}.` }
209
+ }
210
+ const userAllowed = policy.allowOrigins.includes(target.origin)
211
+ const escapeHatch =
212
+ 'Remote images use public HTTPS; for a self-hosted or intranet image server, allow its exact origin with `notifai config set media_origins <origin>`.'
213
+ if (!userAllowed && target.protocol !== 'https:') {
214
+ return { ok: false, reason: `${target.origin} is http. ${escapeHatch}` }
215
+ }
216
+ const literal = hostnameAddress(target.hostname)
217
+ if (isIP(literal) !== 0) {
218
+ return userAllowed || isPublicAddress(literal)
219
+ ? { ok: true, addresses: [literal] }
220
+ : { ok: false, reason: `${target.hostname} is not a public address. ${escapeHatch}` }
221
+ }
222
+ const addresses = await resolveAddresses(target.hostname, policy)
223
+ if (addresses === null || addresses.length === 0) {
224
+ return { ok: false, reason: `Could not resolve ${target.hostname}.` }
225
+ }
226
+ if (addresses.some((address) => isIP(address) === 0)) {
227
+ return { ok: false, reason: `${target.hostname} resolved to an invalid address.` }
228
+ }
229
+ if (!userAllowed && !addresses.every((address) => isPublicAddress(address))) {
230
+ return { ok: false, reason: `${target.hostname} resolves to a non-public address. ${escapeHatch}` }
231
+ }
232
+ return { ok: true, addresses }
233
+ }
234
+
235
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
236
+
237
+ function responseHeaders(response: IncomingMessage): Headers {
238
+ const headers = new Headers()
239
+ for (const [name, value] of Object.entries(response.headers)) {
240
+ if (Array.isArray(value)) {
241
+ for (const entry of value) headers.append(name, entry)
242
+ } else if (value !== undefined) {
243
+ headers.set(name, value)
244
+ }
245
+ }
246
+ return headers
247
+ }
248
+
249
+ /**
250
+ * Fetch through one already-vetted address while retaining the URL hostname
251
+ * for Host and TLS verification. This closes the DNS-rebinding gap between
252
+ * policy resolution and the connection: the request cannot resolve the name
253
+ * a second time to a private address.
254
+ */
255
+ async function fetchPinned(target: URL, addresses: readonly string[]): Promise<Response> {
256
+ let lastError: unknown = new Error(`No address was available for ${target.hostname}.`)
257
+ for (const address of addresses) {
258
+ try {
259
+ return await new Promise<Response>((resolve, reject) => {
260
+ const family = isIP(address)
261
+ const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)(
262
+ target,
263
+ {
264
+ method: 'GET',
265
+ headers: { 'accept-encoding': 'identity' },
266
+ signal: AbortSignal.timeout(MEDIA_FETCH_TIMEOUT_MS),
267
+ // Node asks for every address at once when it is racing address
268
+ // families, and for a single one otherwise. Answering in the wrong
269
+ // shape throws inside the connect path rather than failing the
270
+ // request, so both forms are answered explicitly.
271
+ lookup: (_hostname, options, callback) => {
272
+ if (options.all === true) {
273
+ callback(null, [{ address, family }])
274
+ return
275
+ }
276
+ callback(null, address, family)
277
+ },
278
+ },
279
+ (incoming) => {
280
+ const status = incoming.statusCode ?? 500
281
+ const hasBody = ![101, 204, 205, 304].includes(status)
282
+ const body = hasBody
283
+ ? (Readable.toWeb(incoming) as ReadableStream<Uint8Array>)
284
+ : null
285
+ resolve(
286
+ new Response(body, {
287
+ status,
288
+ ...(incoming.statusMessage === undefined
289
+ ? {}
290
+ : { statusText: incoming.statusMessage }),
291
+ headers: responseHeaders(incoming),
292
+ }),
293
+ )
294
+ },
295
+ )
296
+ request.once('error', reject)
297
+ request.end()
298
+ })
299
+ } catch (error) {
300
+ lastError = error
301
+ }
302
+ }
303
+ throw lastError
304
+ }
305
+
306
+ /**
307
+ * Fetch a remote image under the media URL policy, following at most
308
+ * `MEDIA_REDIRECT_LIMIT` redirects and re-validating every hop — a public
309
+ * HTTPS URL redirecting into an intranet is the exact attack the per-hop
310
+ * check exists for.
311
+ */
312
+ export async function fetchMediaUrl(
313
+ source: string,
314
+ policy: MediaUrlPolicy,
315
+ fetchImpl?: typeof fetch,
316
+ ): Promise<{ ok: true; response: Response } | UrlRefusal> {
317
+ let target: URL
318
+ try {
319
+ target = new URL(source)
320
+ } catch {
321
+ return { ok: false, reason: `"${source}" is not a valid URL.` }
322
+ }
323
+ for (let hop = 0; ; hop += 1) {
324
+ const allowed = await checkMediaUrl(target, policy)
325
+ if (!allowed.ok) return allowed
326
+ const response =
327
+ fetchImpl === undefined
328
+ ? await fetchPinned(target, allowed.addresses)
329
+ : await fetchImpl(target, { redirect: 'manual' })
330
+ if (!REDIRECT_STATUSES.has(response.status)) return { ok: true, response }
331
+ const location = response.headers.get('location')
332
+ await response.body?.cancel()
333
+ if (location === null) {
334
+ return { ok: false, reason: `${target.origin} redirected without a destination.` }
335
+ }
336
+ if (hop >= MEDIA_REDIRECT_LIMIT) {
337
+ return { ok: false, reason: `${source} redirected more than ${MEDIA_REDIRECT_LIMIT} times.` }
338
+ }
339
+ try {
340
+ target = new URL(location, target)
341
+ } catch {
342
+ return { ok: false, reason: `${target.origin} redirected to an invalid URL.` }
343
+ }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Whether a server-supplied pairing approval URL may be shown and opened.
349
+ *
350
+ * The server chooses `approve_url`, so a compromised or misconfigured one
351
+ * must not be able to drive this machine's browser to an arbitrary
352
+ * destination. Allowed: the canonical dashboard, the exact origin the user
353
+ * pointed this pairing at, loopback for local development, and origins the
354
+ * User allowed (`approve_origins`) for a self-host whose dashboard lives on
355
+ * a different origin than its API.
356
+ */
357
+ export function checkApproveUrl(
358
+ raw: string,
359
+ baseUrl: string,
360
+ allowOrigins: readonly string[],
361
+ ): { ok: true } | UrlRefusal {
362
+ let target: URL
363
+ try {
364
+ target = new URL(raw)
365
+ } catch {
366
+ return { ok: false, reason: `the server sent an invalid approval URL.` }
367
+ }
368
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') {
369
+ return {
370
+ ok: false,
371
+ reason: `the server sent a ${target.protocol.replace(/:$/, '')} approval URL; only http(s) is ever opened.`,
372
+ }
373
+ }
374
+ if (target.username !== '' || target.password !== '') {
375
+ return { ok: false, reason: 'the server sent an approval URL with embedded credentials.' }
376
+ }
377
+ if (target.origin === CANONICAL_DASHBOARD_ORIGIN) return { ok: true }
378
+ if (isLoopbackHostname(target.hostname)) return { ok: true }
379
+ let pairingOrigin: string | null = null
380
+ try {
381
+ pairingOrigin = new URL(baseUrl).origin
382
+ } catch {
383
+ pairingOrigin = null
384
+ }
385
+ if (allowOrigins.includes(target.origin)) return { ok: true }
386
+ if (
387
+ pairingOrigin !== null &&
388
+ target.origin === pairingOrigin &&
389
+ target.protocol === 'https:'
390
+ ) {
391
+ return { ok: true }
392
+ }
393
+ return {
394
+ ok: false,
395
+ reason:
396
+ `the server sent an approval URL on ${target.origin}, which is not the Notifai dashboard, ` +
397
+ 'the origin being paired with, loopback, or an origin in `approve_origins`.',
398
+ }
399
+ }