zooid 0.12.0 → 0.13.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.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  buildAcpRegistry
4
- } from "./chunk-3Q4BPAZD.js";
4
+ } from "./chunk-YZ4IO5MR.js";
5
5
  export {
6
6
  buildAcpRegistry
7
7
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zooid",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "An open-source, self-hostable chat app for collaborating with AI agents alongside your team. Any model, any CLI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@
25
25
  "access": "public"
26
26
  },
27
27
  "zooid": {
28
- "webVersion": "0.10.0"
28
+ "webVersion": "0.11.0"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=22"
@@ -40,19 +40,21 @@
40
40
  "marked": "^18.0.4",
41
41
  "sanitize-html": "^2.17.4",
42
42
  "tar": "^7.5.16",
43
+ "web-push": "^3.6.7",
43
44
  "yaml": "^2.5.0",
44
- "@zooid/context-mcp": "^0.12.0",
45
- "@zooid/acp-client": "^0.12.0",
46
- "@zooid/core": "^0.12.0",
47
- "@zooid/runtime-docker": "^0.12.0",
48
- "@zooid/transport-http": "^0.12.0",
49
- "@zooid/runtime-local": "^0.12.0",
50
- "@zooid/transport-matrix": "^0.12.0"
45
+ "@zooid/acp-client": "^0.13.0",
46
+ "@zooid/transport-matrix": "^0.13.0",
47
+ "@zooid/context-mcp": "^0.13.0",
48
+ "@zooid/core": "^0.13.0",
49
+ "@zooid/runtime-local": "^0.13.0",
50
+ "@zooid/runtime-docker": "^0.13.0",
51
+ "@zooid/transport-http": "^0.13.0"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@agentclientprotocol/sdk": "^0.21.0",
54
55
  "@playwright/test": "^1.49.0",
55
56
  "@types/node": "^22.0.0",
57
+ "@types/web-push": "^3.6.4",
56
58
  "release-it": "^19.2.4",
57
59
  "tsup": "^8.5.1",
58
60
  "tsx": "^4.19.0",
@@ -0,0 +1,63 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ const HERE = dirname(fileURLToPath(import.meta.url))
7
+ const TSX = join(HERE, '..', 'node_modules', '.bin', 'tsx')
8
+ const BIN = join(HERE, 'bin.ts')
9
+
10
+ function runCli(args: string[]) {
11
+ const result = spawnSync(TSX, [BIN, ...args], { encoding: 'utf8' })
12
+ return { stdout: result.stdout, stderr: result.stderr, status: result.status }
13
+ }
14
+
15
+ describe('zooid help', () => {
16
+ it('lists every registered command with no args', () => {
17
+ const { stdout, status } = runCli([])
18
+ expect(status).toBe(1)
19
+ for (const name of ['start', 'dev', 'logs', 'status', 'init', 'help']) {
20
+ expect(stdout).toContain(name)
21
+ }
22
+ })
23
+
24
+ it('--help prints the same command list and exits 0', () => {
25
+ const { stdout, status } = runCli(['--help'])
26
+ expect(status).toBe(0)
27
+ expect(stdout).toContain('Commands:')
28
+ expect(stdout).toContain('init [dir]')
29
+ })
30
+
31
+ it('`zooid help` mirrors `zooid --help`', () => {
32
+ const { stdout, status } = runCli(['help'])
33
+ expect(status).toBe(0)
34
+ expect(stdout).toContain('Commands:')
35
+ })
36
+
37
+ it('`zooid help <command>` shows that command\'s own help', () => {
38
+ const { stdout, status } = runCli(['help', 'init'])
39
+ expect(status).toBe(0)
40
+ expect(stdout).toContain('$ zooid init [dir]')
41
+ expect(stdout).toContain('--preset <name>')
42
+ })
43
+
44
+ it('`zooid <command> --help` still works directly', () => {
45
+ const { stdout, status } = runCli(['logs', '--help'])
46
+ expect(status).toBe(0)
47
+ expect(stdout).toContain('$ zooid logs [source]')
48
+ })
49
+
50
+ it('rejects an unknown subcommand of help', () => {
51
+ const { stdout, stderr, status } = runCli(['help', 'bogus'])
52
+ expect(status).toBe(1)
53
+ expect(stderr).toContain('Unknown command: bogus')
54
+ expect(stdout).toContain('Commands:')
55
+ })
56
+
57
+ it('rejects an unknown top-level command instead of exiting silently', () => {
58
+ const { stdout, stderr, status } = runCli(['frobnicate'])
59
+ expect(status).toBe(1)
60
+ expect(stderr).toContain('Unknown command: frobnicate')
61
+ expect(stdout).toContain('Commands:')
62
+ })
63
+ })
package/src/bin.ts CHANGED
@@ -6,6 +6,7 @@ import { resolveOptions } from './commands/init/prompts.js'
6
6
  import { runLogs } from './commands/logs.js'
7
7
  import { runStart } from './commands/start.js'
8
8
  import { runStatus } from './commands/status.js'
9
+ import { CLI_VERSION } from './version.js'
9
10
 
10
11
  const cli = cac('zooid')
11
12
 
@@ -15,6 +16,8 @@ cli
15
16
  .option('--runtime <local|docker|podman>', 'Agent runtime')
16
17
  .option('--image <ref>', 'Agent container image')
17
18
  .option('--print-token', 'Print a 32-byte hex token and exit')
19
+ .example('$ zooid start --data ./data')
20
+ .example('$ zooid start --runtime docker --image ghcr.io/zooid-ai/agent:latest')
18
21
  .action(async (flags) => {
19
22
  await runStart({
20
23
  dataDir: flags.data,
@@ -35,6 +38,8 @@ cli
35
38
  '--watch-web [path]',
36
39
  'Run vite build --watch on @zooid/web. Path defaults to sibling ../zooid-clients/packages/web.',
37
40
  )
41
+ .example('$ zooid dev')
42
+ .example('$ zooid dev --engine podman --ui-port 5174')
38
43
  .action(async (flags) => {
39
44
  await runDev({
40
45
  dataDir: flags.data,
@@ -56,6 +61,9 @@ cli
56
61
  .option('--turn <id>', 'Filter ACP taps to a single turn id')
57
62
  .option('-f, --follow', 'Tail the file (not yet implemented)')
58
63
  .option('--keep <n>', 'For `logs prune`: days to retain', { default: 14 })
64
+ .example('$ zooid logs daemon')
65
+ .example('$ zooid logs agent-support.acp --turn 3f9c1a --day 2026-09-06')
66
+ .example('$ zooid logs prune --keep 7')
59
67
  .action(async (source, flags) => {
60
68
  if (source === 'prune') {
61
69
  await runLogs({
@@ -78,6 +86,7 @@ cli
78
86
  .command('status', 'Print Tuwunel + daemon health')
79
87
  .option('--data <dir>', 'Persistent data root dir', { default: './data' })
80
88
  .option('--port <n>', 'Tuwunel host port (defaults to zooid.yaml)')
89
+ .example('$ zooid status')
81
90
  .action(async (flags) => {
82
91
  await runStatus({
83
92
  dataDir: flags.data,
@@ -95,6 +104,9 @@ cli
95
104
  .option('--force', 'Allow scaffolding into a non-empty directory')
96
105
  .option('--overwrite', 'With --force, overwrite existing files')
97
106
  .option('--no-interactive', 'Disable prompts; require all flags up front')
107
+ .example('$ zooid init')
108
+ .example('$ zooid init my-workforce --preset opencode --provider anthropic')
109
+ .example('$ zooid init --preset claude --auth api-key --api-key sk-... --no-interactive')
98
110
  .action(async (dir: string | undefined, flags) => {
99
111
  const resolved = await resolveOptions({
100
112
  dir: dir ?? process.cwd(),
@@ -110,6 +122,43 @@ cli
110
122
  await runInit(resolved)
111
123
  })
112
124
 
113
- cli.help()
114
- cli.version('0.0.1')
115
- cli.parse()
125
+ cli
126
+ .command('help [command]', 'Display help for zooid, or for a specific command')
127
+ .example('$ zooid help')
128
+ .example('$ zooid help init')
129
+ .action((commandName?: string) => {
130
+ if (!commandName) {
131
+ cli.globalCommand.outputHelp()
132
+ return
133
+ }
134
+ const target = cli.commands.find((c) => c.isMatched(commandName))
135
+ if (!target) {
136
+ console.error(`Unknown command: ${commandName}\n`)
137
+ cli.globalCommand.outputHelp()
138
+ process.exitCode = 1
139
+ return
140
+ }
141
+ target.outputHelp()
142
+ })
143
+
144
+ cli.help((sections) => {
145
+ sections.push({
146
+ title: 'Docs',
147
+ body: ' https://zooid.dev/docs',
148
+ })
149
+ return sections
150
+ })
151
+ cli.version(CLI_VERSION)
152
+
153
+ cli.on('command:*', () => {
154
+ console.error(`Unknown command: ${cli.args.join(' ')}\n`)
155
+ cli.outputHelp()
156
+ process.exitCode = 1
157
+ })
158
+
159
+ if (process.argv.slice(2).length === 0) {
160
+ cli.outputHelp()
161
+ process.exitCode = 1
162
+ } else {
163
+ cli.parse()
164
+ }
@@ -15,6 +15,13 @@ describe('renderTuwunelToml', () => {
15
15
  )
16
16
  expect(toml).toContain('allow_local_presence = true')
17
17
  expect(toml).toContain('port = [8448]')
18
+ // Presence-based push suppression stays OFF — sw.js suppresses per-room
19
+ // on visibility instead, and presence lingers long past a closed tab.
20
+ expect(toml).not.toContain('suppress_push_when_active')
21
+ // DEV ONLY: Tuwunel is in Podman, the push gateway runs on the host, and
22
+ // the default ip_range_denylist (127/8, 10/8, 172.16/12, 192.168/16, ::1)
23
+ // would silently drop every pusher delivery to it.
24
+ expect(toml).toContain('ip_range_denylist = []')
18
25
  })
19
26
 
20
27
  it('honors a non-default server_name', () => {
@@ -26,6 +26,20 @@ export function renderTuwunelToml(opts: TuwunelTomlOpts): string {
26
26
  'allow_local_presence = true',
27
27
  'address = ["0.0.0.0"]',
28
28
  `port = [${TUWUNEL_INTERNAL_PORT}]`,
29
+ // NOT `suppress_push_when_active` ([[ZNC025]]). It reads Matrix presence,
30
+ // which is both too coarse and too slow for this: coarse because it is
31
+ // per-user, so reading room A kills the push for room B; slow because
32
+ // `currently_active` lingers for minutes after the last sync, so closing
33
+ // the tab and waiting for an agent to finish still delivers nothing —
34
+ // exactly the case this feature exists for. `public/sw.js` already does
35
+ // the suppression we actually want, precisely: it drops a push only when
36
+ // a *visible* window is on *that* room.
37
+ // DEV ONLY — disables an SSRF guard. Tuwunel is in Podman and the push
38
+ // gateway runs on the host, so the default ip_range_denylist (127/8,
39
+ // 10/8, 172.16/12, 192.168/16, ::1) silently drops every pusher delivery.
40
+ // Never set on a box: there the gateway is reached at the public
41
+ // hostname through Caddy.
42
+ 'ip_range_denylist = []',
29
43
  '',
30
44
  ].join('\n')
31
45
  }
@@ -248,7 +248,22 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
248
248
  t.output = msg
249
249
  },
250
250
  }))
251
- const app = webStatic({ webRoot, homeserverUrl: homeserver })
251
+ const app = webStatic({
252
+ webRoot,
253
+ homeserverUrl: homeserver,
254
+ ...(ctx.daemon?.vapidPublicKey
255
+ ? {
256
+ // Tuwunel runs in a container; `localhost` here would
257
+ // resolve to the container, not the daemon. Same shorthand
258
+ // as the AS registration url
259
+ // (bootstrap/registration-url.ts). This URL is stored in
260
+ // the pusher and fetched by the homeserver — the browser
261
+ // never requests it.
262
+ pushGatewayUrl: `http://host.docker.internal:${ctx.daemon.port}/_matrix/push/v1/notify`,
263
+ vapidPublicKey: ctx.daemon.vapidPublicKey,
264
+ }
265
+ : {}),
266
+ })
252
267
  ctx.uiServer = serve({ fetch: app.fetch, port: flags.uiPort })
253
268
  },
254
269
  },
@@ -287,13 +302,28 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
287
302
  })
288
303
 
289
304
  if (flags.installSignalHandlers !== false) {
290
- const handler = async (): Promise<void> => {
291
- process.stdout.write(chalk.dim('\nStopping…\n'))
292
- await shutdown()
293
- process.exit(0)
305
+ // buildShutdown is idempotent, so a repeat Ctrl-C used to print again and
306
+ // await the same promise, with no way to give up on a step taking too
307
+ // long. Escalate on repeats instead.
308
+ let interrupts = 0
309
+ const onSignal = (): void => {
310
+ interrupts += 1
311
+ if (interrupts === 1) {
312
+ process.stdout.write(chalk.dim('\nStopping…\n'))
313
+ void shutdown().then(() => process.exit(0))
314
+ return
315
+ }
316
+ if (interrupts === 2) {
317
+ process.stdout.write(
318
+ chalk.dim('Still stopping — press Ctrl-C again to force quit.\n'),
319
+ )
320
+ return
321
+ }
322
+ process.stdout.write(chalk.dim('Forced.\n'))
323
+ process.exit(130)
294
324
  }
295
- process.on('SIGINT', () => void handler())
296
- process.on('SIGTERM', () => void handler())
325
+ process.on('SIGINT', onSignal)
326
+ process.on('SIGTERM', onSignal)
297
327
  }
298
328
 
299
329
  process.stdout.write(
@@ -54,6 +54,21 @@ describe('collectStatus', () => {
54
54
  ])
55
55
  })
56
56
 
57
+ it('reports the VAPID public key when vapid.json exists in the data dir', async () => {
58
+ writeFileSync(join(dir, 'zooid.yaml'), yaml)
59
+ writeFileSync(join(dir, 'vapid.json'), JSON.stringify({ publicKey: 'BPk', privateKey: 'priv' }))
60
+ vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })))
61
+ const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448', dataDir: dir })
62
+ expect(s.vapidPublicKey).toBe('BPk')
63
+ })
64
+
65
+ it('omits the VAPID key when no daemon has run yet', async () => {
66
+ writeFileSync(join(dir, 'zooid.yaml'), yaml)
67
+ vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })))
68
+ const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448', dataDir: dir })
69
+ expect(s.vapidPublicKey).toBeUndefined()
70
+ })
71
+
57
72
  it('reports daemon down when the AS callback port refuses the connection', async () => {
58
73
  writeFileSync(join(dir, 'zooid.yaml'), yaml)
59
74
  vi.stubGlobal(
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from 'node:fs'
2
- import { dirname } from 'node:path'
2
+ import { dirname, join, resolve } from 'node:path'
3
3
  import chalk from 'chalk'
4
4
  import { findConfigFile, findMatrixTransport, loadZooidConfig } from '@zooid/core'
5
5
  import { deriveHomeserverShape } from '../bootstrap/derive.js'
6
+ import { VAPID_FILENAME } from '../push-gateway/vapid.js'
6
7
 
7
8
  export interface StatusFlags {
8
9
  cwd?: string
@@ -15,6 +16,24 @@ export interface StatusReport {
15
16
  tuwunel: { status: 'up' | 'down'; url: string }
16
17
  daemon: { status: 'up' | 'down'; url: string } | { status: 'unknown'; reason: string }
17
18
  agents: { name: string; userId: string; trigger: string }[]
19
+ /** VAPID public key read from `<dataDir>/vapid.json`, when it exists. */
20
+ vapidPublicKey?: string
21
+ }
22
+
23
+ /**
24
+ * Read the daemon's VAPID public key straight off disk. Operators
25
+ * hand-editing a box's config.json need it and can't start a daemon just to
26
+ * ask, so this reads the persisted file rather than starting one.
27
+ */
28
+ export function readVapidPublicKey(dataDir: string): string | undefined {
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(join(dataDir, VAPID_FILENAME), 'utf8')) as {
31
+ publicKey?: string
32
+ }
33
+ return typeof parsed.publicKey === 'string' ? parsed.publicKey : undefined
34
+ } catch {
35
+ return undefined
36
+ }
18
37
  }
19
38
 
20
39
  async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
@@ -29,12 +48,14 @@ async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
29
48
  export async function collectStatus(opts: {
30
49
  cwd: string
31
50
  tuwunelUrl: string
51
+ dataDir?: string
32
52
  }): Promise<StatusReport> {
33
53
  const tuwunelUp = await probe(`${opts.tuwunelUrl}/_matrix/client/versions`)
34
54
  const tuwunel: StatusReport['tuwunel'] = {
35
55
  status: tuwunelUp ? 'up' : 'down',
36
56
  url: opts.tuwunelUrl,
37
57
  }
58
+ const vapidPublicKey = opts.dataDir ? readVapidPublicKey(opts.dataDir) : undefined
38
59
 
39
60
  const found = findConfigFile(opts.cwd)
40
61
  if (!found) {
@@ -42,6 +63,7 @@ export async function collectStatus(opts: {
42
63
  tuwunel,
43
64
  daemon: { status: 'unknown', reason: 'no zooid.yaml' },
44
65
  agents: [],
66
+ ...(vapidPublicKey ? { vapidPublicKey } : {}),
45
67
  }
46
68
  }
47
69
  const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
@@ -67,6 +89,7 @@ export async function collectStatus(opts: {
67
89
  tuwunel,
68
90
  daemon: { status: daemonUp ? 'up' : 'down', url: daemonUrl },
69
91
  agents,
92
+ ...(vapidPublicKey ? { vapidPublicKey } : {}),
70
93
  }
71
94
  }
72
95
 
@@ -89,7 +112,8 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
89
112
  }
90
113
  }
91
114
  const tuwunelUrl = `http://localhost:${port ?? 8448}`
92
- const s = await collectStatus({ cwd, tuwunelUrl })
115
+ const dataDir = resolve(cwd, flags.dataDir ?? './data')
116
+ const s = await collectStatus({ cwd, tuwunelUrl, dataDir })
93
117
  const fmt = (st: 'up' | 'down' | 'unknown'): string =>
94
118
  st === 'up' ? chalk.green('up') : st === 'down' ? chalk.red('down') : chalk.yellow('unknown')
95
119
  process.stdout.write(
@@ -99,6 +123,7 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
99
123
  ...s.agents.map(
100
124
  (a) => ` agent: ${a.name} (${a.userId}, trigger: ${a.trigger})`,
101
125
  ),
126
+ ...(s.vapidPublicKey ? [`vapid public key: ${s.vapidPublicKey}`] : []),
102
127
  '',
103
128
  ].join('\n'),
104
129
  )
@@ -33,6 +33,7 @@ import {
33
33
  } from '@zooid/context-mcp'
34
34
  import { buildAcpRegistry } from '../build-registry.js'
35
35
  import { prepullImages } from '../prepull-images.js'
36
+ import { mountPushGateway } from '../push-gateway/index.js'
36
37
  import { makeSyncCursorStore } from './sync-cursors.js'
37
38
  import { shouldBindHttpListener } from './pull-wiring.js'
38
39
 
@@ -79,6 +80,8 @@ export interface StartDaemonOpts {
79
80
  export interface DaemonHandle {
80
81
  port: number
81
82
  agentNames: string[]
83
+ /** VAPID public key for web push, when the gateway bound (appservice mode with a data dir). */
84
+ vapidPublicKey?: string
82
85
  stop(): Promise<void>
83
86
  whenStopped: Promise<void>
84
87
  }
@@ -166,6 +169,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
166
169
 
167
170
  const matrix = findMatrixTransport(config)
168
171
  let port: number
172
+ let vapidPublicKey: string | undefined
169
173
 
170
174
  if (matrix) {
171
175
  const mode = matrix.transport.mode ?? 'appservice'
@@ -232,6 +236,16 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
232
236
  })
233
237
  if (shouldBindHttpListener(mode)) {
234
238
  const requestedPort = matrix.transport.port ?? 9000
239
+ // The gateway rides the appservice listener, not webStatic: webStatic
240
+ // exists only under `zooid dev`, and on a deployed box Caddy serves the
241
+ // dist directly with the daemon out of the serving path. This is the
242
+ // one HTTP surface bound in both modes.
243
+ if (dataDir) {
244
+ vapidPublicKey = mountPushGateway(transport.app, {
245
+ dataDir,
246
+ subject: `https://${serverName}`,
247
+ }).publicKey
248
+ }
235
249
  // Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
236
250
  // macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
237
251
  // events back to host.docker.internal:<port>.
@@ -347,5 +361,5 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
347
361
  process.on('SIGTERM', () => handler('SIGTERM'))
348
362
  }
349
363
 
350
- return { port, agentNames, stop, whenStopped }
364
+ return { port, agentNames, vapidPublicKey, stop, whenStopped }
351
365
  }
@@ -0,0 +1,150 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const sendNotification = vi.fn()
4
+ class WebPushError extends Error {
5
+ constructor(
6
+ message: string,
7
+ public statusCode: number,
8
+ ) {
9
+ super(message)
10
+ }
11
+ }
12
+ vi.mock('web-push', () => ({
13
+ default: { sendNotification, WebPushError },
14
+ sendNotification,
15
+ WebPushError,
16
+ }))
17
+
18
+ const { pushGateway } = await import('./gateway.js')
19
+
20
+ const KEYS = { publicKey: 'pub', privateKey: 'priv' }
21
+
22
+ function device(over: Record<string, unknown> = {}) {
23
+ return {
24
+ app_id: 'dev.zooid.web',
25
+ pushkey: 'BPk_device_one',
26
+ data: { endpoint: 'https://fcm.example/one', auth: 'auth1' },
27
+ ...over,
28
+ }
29
+ }
30
+
31
+ function notification(devices: unknown[]) {
32
+ return {
33
+ notification: {
34
+ event_id: '$e:example.org',
35
+ room_id: '!r:example.org',
36
+ room_name: 'general',
37
+ sender_display_name: 'Alice',
38
+ type: 'm.room.message',
39
+ content: { msgtype: 'm.text', body: 'hello' },
40
+ counts: { unread: 1 },
41
+ devices,
42
+ },
43
+ }
44
+ }
45
+
46
+ async function notify(body: unknown) {
47
+ const app = pushGateway({ keys: KEYS, subject: 'mailto:ops@example.org' })
48
+ return app.request('/_matrix/push/v1/notify', {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify(body),
52
+ })
53
+ }
54
+
55
+ beforeEach(() => {
56
+ sendNotification.mockReset()
57
+ sendNotification.mockResolvedValue({ statusCode: 201 })
58
+ })
59
+
60
+ describe('pushGateway', () => {
61
+ it('encrypts to the device subscription reassembled from pushkey + data', async () => {
62
+ const res = await notify(notification([device()]))
63
+ expect(res.status).toBe(200)
64
+ expect(await res.json()).toEqual({ rejected: [] })
65
+
66
+ const [subscription, payload, options] = sendNotification.mock.calls[0]!
67
+ expect(subscription).toEqual({
68
+ endpoint: 'https://fcm.example/one',
69
+ keys: { p256dh: 'BPk_device_one', auth: 'auth1' },
70
+ })
71
+ expect(JSON.parse(payload as string).room_name).toBe('general')
72
+ expect((options as { vapidDetails: unknown }).vapidDetails).toEqual({
73
+ subject: 'mailto:ops@example.org',
74
+ publicKey: 'pub',
75
+ privateKey: 'priv',
76
+ })
77
+ })
78
+
79
+ it('fans out to every matching device', async () => {
80
+ await notify(
81
+ notification([
82
+ device(),
83
+ device({ pushkey: 'BPk_two', data: { endpoint: 'https://fcm.example/two', auth: 'a2' } }),
84
+ ]),
85
+ )
86
+ expect(sendNotification).toHaveBeenCalledTimes(2)
87
+ })
88
+
89
+ it('skips a foreign app_id silently — never rejects it', async () => {
90
+ const res = await notify(notification([device({ app_id: 'im.vector.app.ios' })]))
91
+ expect(sendNotification).not.toHaveBeenCalled()
92
+ // Rejecting would make the homeserver permanently delete another client's pusher.
93
+ expect(await res.json()).toEqual({ rejected: [] })
94
+ })
95
+
96
+ it('rejects a pushkey on 410 Gone so the homeserver garbage-collects it', async () => {
97
+ sendNotification.mockRejectedValueOnce(new WebPushError('gone', 410))
98
+ const res = await notify(notification([device()]))
99
+ expect(await res.json()).toEqual({ rejected: ['BPk_device_one'] })
100
+ })
101
+
102
+ it('rejects a pushkey on 404 too', async () => {
103
+ sendNotification.mockRejectedValueOnce(new WebPushError('not found', 404))
104
+ expect(await (await notify(notification([device()]))).json()).toEqual({
105
+ rejected: ['BPk_device_one'],
106
+ })
107
+ })
108
+
109
+ it('does NOT reject on a transient 429 or 5xx', async () => {
110
+ sendNotification.mockRejectedValueOnce(new WebPushError('slow down', 429))
111
+ expect(await (await notify(notification([device()]))).json()).toEqual({ rejected: [] })
112
+
113
+ sendNotification.mockRejectedValueOnce(new WebPushError('bad gateway', 502))
114
+ expect(await (await notify(notification([device()]))).json()).toEqual({ rejected: [] })
115
+ })
116
+
117
+ it('does not let one dead device stop delivery to a live one', async () => {
118
+ sendNotification
119
+ .mockRejectedValueOnce(new WebPushError('gone', 410))
120
+ .mockResolvedValueOnce({ statusCode: 201 })
121
+ const res = await notify(
122
+ notification([
123
+ device(),
124
+ device({ pushkey: 'BPk_two', data: { endpoint: 'https://fcm.example/two', auth: 'a2' } }),
125
+ ]),
126
+ )
127
+ expect(sendNotification).toHaveBeenCalledTimes(2)
128
+ expect(await res.json()).toEqual({ rejected: ['BPk_device_one'] })
129
+ })
130
+
131
+ it('skips a device missing endpoint or auth without rejecting it', async () => {
132
+ const res = await notify(notification([device({ data: { endpoint: 'https://x' } })]))
133
+ expect(sendNotification).not.toHaveBeenCalled()
134
+ expect(await res.json()).toEqual({ rejected: [] })
135
+ })
136
+
137
+ it('400s on a malformed body instead of throwing', async () => {
138
+ expect((await notify({ nope: true })).status).toBe(400)
139
+ })
140
+
141
+ it('logs receipt and outcome — the only way to see a Tuwunel call in the daemon log', async () => {
142
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
143
+ await notify(notification([device()]))
144
+ expect(log).toHaveBeenCalledWith(
145
+ expect.stringContaining('notify room=!r:example.org type=m.room.message devices=1'),
146
+ )
147
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('delivered=1 rejected=0'))
148
+ log.mockRestore()
149
+ })
150
+ })