@raidiant/notifai 10.1.0 → 10.1.2

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 (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
package/src/ui/theme.ts DELETED
@@ -1,265 +0,0 @@
1
- /**
2
- * One visual language for every human-facing surface: help, `config show`,
3
- * `doctor`, and the interactive app.
4
- *
5
- * Two rules hold everything here together.
6
- *
7
- * Colour is decoration, never information. Every state that colour marks also
8
- * carries a glyph and a word, because the output is read through pipes, on
9
- * monochrome terminals, by screen readers, and by people who cannot separate
10
- * red from green. `picocolors` resolves to identity functions when stdout is
11
- * not a TTY or `NO_COLOR` is set, so the plain path is the same code, not a
12
- * second implementation that can drift.
13
- *
14
- * And an agent's bytes are not ours to restyle. Machine-readable output
15
- * (`--json`) never routes through this module, and every non-TTY invocation
16
- * lands on the uncoloured branch by construction.
17
- */
18
- import pc from 'picocolors'
19
-
20
- /** True when the terminal can render the box-drawing and status glyphs below. */
21
- export const unicode: boolean =
22
- process.platform !== 'win32' ||
23
- process.env['WT_SESSION'] !== undefined ||
24
- process.env['TERM_PROGRAM'] === 'vscode' ||
25
- process.env['ConEmuTask'] === '{cmd::Cmder}'
26
-
27
- /** True when 24-bit colour is available, which the banner gradient needs. */
28
- function supportsTrueColor(env: NodeJS.ProcessEnv = process.env): boolean {
29
- if (!pc.isColorSupported) return false
30
- const colorterm = env['COLORTERM'] ?? ''
31
- if (/^(truecolor|24bit)$/i.test(colorterm)) return true
32
- return env['TERM_PROGRAM'] === 'iTerm.app' || env['TERM_PROGRAM'] === 'vscode'
33
- }
34
-
35
- export const color = pc
36
-
37
- /**
38
- * Brand ramp, shared with the companion app icon: pink → coral → rose →
39
- * magenta. Used for the wordmark and for accenting the surfaces a human sees.
40
- */
41
- export const BRAND_RAMP = ['#FF9EC0', '#FF816B', '#EC5A73', '#B84E93'] as const
42
-
43
- interface Rgb {
44
- r: number
45
- g: number
46
- b: number
47
- }
48
-
49
- function hexToRgb(hex: string): Rgb {
50
- const value = Number.parseInt(hex.slice(1), 16)
51
- return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 }
52
- }
53
-
54
- function mix(a: Rgb, b: Rgb, t: number): Rgb {
55
- return {
56
- r: Math.round(a.r + (b.r - a.r) * t),
57
- g: Math.round(a.g + (b.g - a.g) * t),
58
- b: Math.round(a.b + (b.b - a.b) * t),
59
- }
60
- }
61
-
62
- /**
63
- * Sample the brand ramp at `t` in [0, 1]. Multi-stop so the gradient keeps the
64
- * icon's coral midtones instead of washing straight from pink to magenta.
65
- */
66
- function rampAt(t: number, ramp: readonly string[] = BRAND_RAMP): string {
67
- const clamped = Math.min(1, Math.max(0, t))
68
- const span = ramp.length - 1
69
- const scaled = clamped * span
70
- const index = Math.min(span - 1, Math.floor(scaled))
71
- const { r, g, b } = mix(hexToRgb(ramp[index]!), hexToRgb(ramp[index + 1]!), scaled - index)
72
- return `[38;2;${r};${g};${b}m`
73
- }
74
-
75
- const RESET = ''
76
-
77
- /**
78
- * SGR escape sequences, in the three shapes the helpers below need: stripping
79
- * them to measure, splitting around them to slice, and recognising one on its
80
- * own. Kept together so a change to what counts as an escape lands once.
81
- */
82
- const ANSI_GLOBAL = /\[[0-9;]*m/g
83
- const ANSI_SPLIT = /(\[[0-9;]*m)/
84
- const ANSI_ONLY = /^\[[0-9;]*m$/
85
-
86
-
87
- /**
88
- * Paint each line of a block so the gradient runs left-to-right across the
89
- * whole block rather than restarting per line. The wordmark is one image, and
90
- * per-line gradients make it read as several.
91
- */
92
- export function gradientBlock(lines: readonly string[], ramp: readonly string[] = BRAND_RAMP): string[] {
93
- if (!pc.isColorSupported) return [...lines]
94
- const width = Math.max(1, ...lines.map((line) => [...line].length))
95
- if (!supportsTrueColor()) return lines.map((line) => pc.magenta(line))
96
- return lines.map((line) =>
97
- [...line]
98
- .map((char, i) => (char === ' ' ? char : `${rampAt(i / Math.max(1, width - 1), ramp)}${char}`))
99
- .join('') + RESET,
100
- )
101
- }
102
-
103
- // ---------------------------------------------------------------------------
104
- // Semantic styles
105
- // ---------------------------------------------------------------------------
106
-
107
- export const style = {
108
- /** A heading that opens a section. */
109
- heading: (text: string): string => pc.bold(text),
110
- /** The brand voice — command names, the product name, the selected thing. */
111
- accent: (text: string): string => pc.magenta(text),
112
- /** Secondary text: hints, provenance, units, defaults. */
113
- dim: (text: string): string => pc.dim(text),
114
- /** Something the reader can type. */
115
- code: (text: string): string => pc.cyan(text),
116
- ok: (text: string): string => pc.green(text),
117
- warn: (text: string): string => pc.yellow(text),
118
- bad: (text: string): string => pc.red(text),
119
- /** A value as it currently stands. */
120
- value: (text: string): string => pc.bold(pc.white(text)),
121
- /** A value that is not set, or is inherited rather than chosen. */
122
- unset: (text: string): string => pc.dim(pc.italic(text)),
123
- } as const
124
-
125
- /**
126
- * Status marks. The glyph and the colour say the same thing twice on purpose;
127
- * either alone is enough to read the line.
128
- */
129
- export const glyph = {
130
- ok: unicode ? '●' : '*',
131
- warn: unicode ? '▲' : '!',
132
- bad: unicode ? '✕' : 'x',
133
- pending: unicode ? '○' : 'o',
134
- bullet: unicode ? '•' : '-',
135
- arrow: unicode ? '→' : '->',
136
- chevron: unicode ? '›' : '>',
137
- bar: unicode ? '│' : '|',
138
- } as const
139
-
140
- export type Tone = 'ok' | 'warn' | 'bad' | 'pending'
141
-
142
- /** A coloured glyph for a tone; the caller supplies the words. */
143
- export function mark(tone: Tone): string {
144
- switch (tone) {
145
- case 'ok':
146
- return style.ok(glyph.ok)
147
- case 'warn':
148
- return style.warn(glyph.warn)
149
- case 'bad':
150
- return style.bad(glyph.bad)
151
- case 'pending':
152
- return style.dim(glyph.pending)
153
- }
154
- }
155
-
156
- /** Visible width, ignoring ANSI escapes, for alignment maths. */
157
- export function width(text: string): number {
158
- return [...text.replace(ANSI_GLOBAL, '')].length
159
- }
160
-
161
- /** Pad to `size` using visible width, so colour never breaks a column. */
162
- export function pad(text: string, size: number): string {
163
- return text + ' '.repeat(Math.max(0, size - width(text)))
164
- }
165
-
166
- /**
167
- * Usable terminal width: the real one, capped so paragraphs do not run to 200
168
- * characters on a maximised window.
169
- *
170
- * No lower clamp. Raising a narrow terminal to a comfortable minimum sounds
171
- * harmless and is not — every box drawn to that invented width overflows the
172
- * real one and wraps, which looks far worse than a cramped box that fits.
173
- */
174
- export function terminalWidth(fallback = 80): number {
175
- const columns = process.stdout.columns
176
- if (typeof columns !== 'number' || columns <= 0) return fallback
177
- return Math.min(100, columns)
178
- }
179
-
180
- /**
181
- * A titled box around `rows`.
182
- *
183
- * Hand-drawn rather than delegated, because the obvious library call sizes
184
- * itself from `process.stdout.columns` and divides by it — a pty that reports
185
- * zero columns (some CI runners, some `script` invocations) makes the padding
186
- * negative and throws, taking the whole screen down with it. A status card is
187
- * decoration; it may degrade, but it must never be what crashes the app.
188
- *
189
- * Rows are measured with `width`, so colour inside them does not skew the
190
- * border, and anything too wide for the terminal is clipped rather than
191
- * wrapped — a status line that wraps stops being a status line.
192
- */
193
- export function card(title: string, rows: readonly string[], columns = terminalWidth()): string[] {
194
- const [tl, tr, bl, br, h, v] = unicode
195
- ? ['╭', '╮', '╰', '╯', '─', '│']
196
- : ['+', '+', '+', '+', '-', '|']
197
- const inner = Math.max(20, columns - 4)
198
- const content = rows.map((row) => (width(row) > inner ? clip(row, inner) : row))
199
- const bodyWidth = Math.max(width(title) + 4, ...content.map((row) => width(row)))
200
- const boxWidth = Math.min(inner, bodyWidth)
201
- const heading = `${h} ${style.heading(title)} `
202
- const lines: string[] = []
203
- lines.push(style.dim(tl) + heading + style.dim(h!.repeat(Math.max(0, boxWidth - width(heading) + 2))) + style.dim(tr))
204
- for (const row of content) {
205
- lines.push(`${style.dim(v)} ${pad(row, boxWidth)} ${style.dim(v)}`)
206
- }
207
- lines.push(style.dim(bl! + h!.repeat(boxWidth + 2) + br))
208
- return lines
209
- }
210
-
211
- /**
212
- * Truncate to a visible width, carrying escape sequences through untouched.
213
- *
214
- * Counting only printable characters is the whole point: slicing a coloured
215
- * string by `String.length` cuts through an escape sequence and spills raw
216
- * `[38;2` into the terminal.
217
- */
218
- export function clip(text: string, limit: number): string {
219
- if (width(text) <= limit) return text
220
- const parts = text.split(ANSI_SPLIT)
221
- let out = ''
222
- let visible = 0
223
- for (const part of parts) {
224
- if (part === undefined || part === '') continue
225
- if (ANSI_ONLY.test(part)) {
226
- out += part
227
- continue
228
- }
229
- for (const char of part) {
230
- if (visible >= limit - 1) return `${out}\u2026${RESET}`
231
- out += char
232
- visible += 1
233
- }
234
- }
235
- return `${out}${RESET}`
236
- }
237
-
238
- /**
239
- * Wrap `text` to `columns`, returning bare lines.
240
- *
241
- * Indentation is the caller's, deliberately: an earlier version indented
242
- * continuation lines itself, callers indented every line as well, and each
243
- * wrapped paragraph stepped further right down the page.
244
- */
245
- export function wrap(text: string, columns: number): string[] {
246
- // Honour the width asked for. A floor here would silently ignore small
247
- // values and hand back lines wider than the caller reserved room for; a
248
- // width of zero or less is the only nonsense, and that falls back.
249
- const limit = columns > 0 ? columns : 80
250
- const words = text.split(/\s+/).filter(Boolean)
251
- if (words.length === 0) return ['']
252
- const lines: string[] = []
253
- let line = ''
254
- for (const word of words) {
255
- const candidate = line === '' ? word : `${line} ${word}`
256
- if (width(candidate) > limit && line !== '') {
257
- lines.push(line)
258
- line = word
259
- } else {
260
- line = candidate
261
- }
262
- }
263
- if (line !== '') lines.push(line)
264
- return lines
265
- }
package/src/url-policy.ts DELETED
@@ -1,399 +0,0 @@
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
- }
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "dist",
5
- "rootDir": "src",
6
- "types": ["node"]
7
- },
8
- "include": ["src"],
9
- "exclude": ["src/**/*.test.ts"]
10
- }