zooid 0.12.0 → 0.14.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/README.md +8 -1
- package/dist/bin.js +923 -184
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-3Q4BPAZD.js → chunk-R5S26T7B.js} +20576 -514
- package/dist/chunk-R5S26T7B.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/package.json +13 -9
- package/src/bin.test.ts +63 -0
- package/src/bin.ts +52 -3
- package/src/bootstrap/configs.test.ts +7 -0
- package/src/bootstrap/configs.ts +14 -0
- package/src/build-registry.context.test.ts +3 -3
- package/src/build-registry.ts +67 -24
- package/src/commands/dev.ts +37 -7
- package/src/commands/status.test.ts +15 -0
- package/src/commands/status.ts +27 -2
- package/src/daemon/delivery-cache.test.ts +34 -0
- package/src/daemon/delivery-cache.ts +36 -0
- package/src/daemon/load-custom-verifiers.ts +43 -0
- package/src/daemon/start-daemon.ts +177 -39
- package/src/daemon/task-journal.test.ts +19 -0
- package/src/daemon/task-journal.ts +32 -0
- package/src/daemon/trigger-rooms.test.ts +60 -0
- package/src/daemon/trigger-rooms.ts +0 -0
- package/src/daemon/trigger-runner.test.ts +71 -0
- package/src/daemon/trigger-runner.ts +41 -0
- package/src/daemon/trigger-scheduler.ts +55 -0
- package/src/daemon/webhook-routes.test.ts +74 -0
- package/src/daemon/webhook-routes.ts +202 -0
- package/src/daemon/webhook-verify.test.ts +155 -0
- package/src/daemon/webhook-verify.ts +155 -0
- package/src/pi-extension-install.test.ts +65 -0
- package/src/pi-extension-install.ts +56 -0
- package/src/push-gateway/gateway.test.ts +150 -0
- package/src/push-gateway/gateway.ts +78 -0
- package/src/push-gateway/index.ts +17 -0
- package/src/push-gateway/payload.test.ts +88 -0
- package/src/push-gateway/payload.ts +39 -0
- package/src/push-gateway/types.ts +37 -0
- package/src/push-gateway/vapid.test.ts +45 -0
- package/src/push-gateway/vapid.ts +34 -0
- package/src/services/tuwunel.ts +21 -2
- package/src/version.test.ts +67 -0
- package/src/version.ts +30 -0
- package/src/web/static.test.ts +22 -0
- package/src/web/static.ts +9 -1
- package/dist/chunk-3Q4BPAZD.js.map +0 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { installPiExtension, resolvePiAgentDir } from './pi-extension-install.js'
|
|
6
|
+
|
|
7
|
+
const dirs: string[] = []
|
|
8
|
+
const scratch = () => { const dir = mkdtempSync(join(tmpdir(), 'zooid-pi-')); dirs.push(dir); return dir }
|
|
9
|
+
afterEach(() => dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })))
|
|
10
|
+
|
|
11
|
+
describe('resolvePiAgentDir', () => {
|
|
12
|
+
it('resolves a relative PI_CODING_AGENT_DIR against each agent workdir', () => {
|
|
13
|
+
expect(
|
|
14
|
+
resolvePiAgentDir({
|
|
15
|
+
agentWorkdir: '/proj/agents/alice',
|
|
16
|
+
daemonHome: '/home/op',
|
|
17
|
+
env: { PI_CODING_AGENT_DIR: '.pi-agent' },
|
|
18
|
+
}),
|
|
19
|
+
).toEqual({ dir: '/proj/agents/alice/.pi-agent', scope: 'project' })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('honours an absolute override and falls back to the operator home', () => {
|
|
23
|
+
expect(
|
|
24
|
+
resolvePiAgentDir({
|
|
25
|
+
agentWorkdir: '/proj/agents/alice',
|
|
26
|
+
daemonHome: '/home/op',
|
|
27
|
+
env: { PI_CODING_AGENT_DIR: '/shared/pi' },
|
|
28
|
+
}),
|
|
29
|
+
).toEqual({ dir: '/shared/pi', scope: 'project' })
|
|
30
|
+
expect(
|
|
31
|
+
resolvePiAgentDir({ agentWorkdir: '/proj/agents/alice', daemonHome: '/home/op', env: {} }),
|
|
32
|
+
).toEqual({ dir: '/home/op/.pi/agent', scope: 'home' })
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('installPiExtension', () => {
|
|
37
|
+
it('only installs into an existing Pi home and preserves other extensions', () => {
|
|
38
|
+
const home = scratch(); const source = join(scratch(), 'bundle.js')
|
|
39
|
+
writeFileSync(source, '// bundle')
|
|
40
|
+
const agentDir = join(home, '.pi', 'agent')
|
|
41
|
+
expect(installPiExtension({ agentDir, bundlePath: source })).toMatchObject({ status: 'skipped' })
|
|
42
|
+
expect(existsSync(join(home, '.pi'))).toBe(false)
|
|
43
|
+
const extensions = join(agentDir, 'extensions')
|
|
44
|
+
mkdirSync(extensions, { recursive: true }); writeFileSync(join(extensions, 'user.js'), '// user')
|
|
45
|
+
expect(installPiExtension({ agentDir, bundlePath: source })).toMatchObject({ status: 'installed' })
|
|
46
|
+
expect(readFileSync(join(extensions, 'zooid-tasks.js'), 'utf8')).toBe('// bundle')
|
|
47
|
+
expect(readFileSync(join(extensions, 'user.js'), 'utf8')).toBe('// user')
|
|
48
|
+
expect(installPiExtension({ agentDir, bundlePath: source })).toMatchObject({ status: 'unchanged' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('creates a project agent dir that does not exist yet', () => {
|
|
52
|
+
const workdir = scratch(); const source = join(scratch(), 'bundle.js')
|
|
53
|
+
writeFileSync(source, '// bundle')
|
|
54
|
+
const { dir } = resolvePiAgentDir({
|
|
55
|
+
agentWorkdir: workdir,
|
|
56
|
+
daemonHome: '/home/op',
|
|
57
|
+
env: { PI_CODING_AGENT_DIR: '.pi-agent' },
|
|
58
|
+
})
|
|
59
|
+
expect(installPiExtension({ agentDir: dir, bundlePath: source, createMissing: true })).toMatchObject({
|
|
60
|
+
status: 'installed',
|
|
61
|
+
target: join(workdir, '.pi-agent', 'extensions', 'zooid-tasks.js'),
|
|
62
|
+
})
|
|
63
|
+
expect(readFileSync(join(workdir, '.pi-agent', 'extensions', 'zooid-tasks.js'), 'utf8')).toBe('// bundle')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface InstallPiExtensionResult {
|
|
5
|
+
status: 'installed' | 'unchanged' | 'skipped'
|
|
6
|
+
reason?: string
|
|
7
|
+
target?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PiAgentDir {
|
|
11
|
+
dir: string
|
|
12
|
+
/** `project` dirs are ours to create; `home` is the operator's ~/.pi. */
|
|
13
|
+
scope: 'project' | 'home'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve pi's agent dir the way pi does: PI_CODING_AGENT_DIR when set,
|
|
18
|
+
* otherwise ~/.pi/agent. `zooid init` writes that variable as a *relative*
|
|
19
|
+
* path on purpose (ZOD075) so one value is correct under both runtimes — it
|
|
20
|
+
* resolves against the agent's cwd, and `agents/<name>` locally and
|
|
21
|
+
* `/workspace` in a container are the same directory. We always resolve
|
|
22
|
+
* against the host workdir, which is the host side of that same mount.
|
|
23
|
+
*/
|
|
24
|
+
export function resolvePiAgentDir(opts: {
|
|
25
|
+
agentWorkdir: string
|
|
26
|
+
daemonHome: string
|
|
27
|
+
env?: { PI_CODING_AGENT_DIR?: string | undefined }
|
|
28
|
+
}): PiAgentDir {
|
|
29
|
+
const override = opts.env?.PI_CODING_AGENT_DIR
|
|
30
|
+
if (override) {
|
|
31
|
+
return {
|
|
32
|
+
dir: isAbsolute(override) ? override : resolve(opts.agentWorkdir, override),
|
|
33
|
+
scope: 'project',
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { dir: join(opts.daemonHome, '.pi', 'agent'), scope: 'home' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Install our bundle without replacing an operator's existing Pi extensions. */
|
|
40
|
+
export function installPiExtension(opts: {
|
|
41
|
+
agentDir: string
|
|
42
|
+
bundlePath: string
|
|
43
|
+
/** Project dirs belong to the workspace, so we may create them. ~/.pi is the
|
|
44
|
+
* operator's: install into it only if they already use pi. */
|
|
45
|
+
createMissing?: boolean
|
|
46
|
+
}): InstallPiExtensionResult {
|
|
47
|
+
if (!opts.createMissing && !existsSync(opts.agentDir)) {
|
|
48
|
+
return { status: 'skipped', reason: 'no Pi home' }
|
|
49
|
+
}
|
|
50
|
+
const target = join(opts.agentDir, 'extensions', 'zooid-tasks.js')
|
|
51
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
52
|
+
const source = readFileSync(opts.bundlePath)
|
|
53
|
+
if (existsSync(target) && readFileSync(target).equals(source)) return { status: 'unchanged', target }
|
|
54
|
+
writeFileSync(target, source)
|
|
55
|
+
return { status: 'installed', target }
|
|
56
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import webpush from 'web-push'
|
|
3
|
+
import { buildPushPayload, ZOOID_APP_ID } from './payload.js'
|
|
4
|
+
import type { PushNotification } from './types.js'
|
|
5
|
+
import type { VapidKeys } from './vapid.js'
|
|
6
|
+
|
|
7
|
+
export interface PushGatewayOpts {
|
|
8
|
+
keys: VapidKeys
|
|
9
|
+
/** VAPID `sub` claim — a mailto: or https: the push service can complain to. */
|
|
10
|
+
subject: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseNotifyBody(raw: unknown): PushNotification | null {
|
|
14
|
+
if (!raw || typeof raw !== 'object') return null
|
|
15
|
+
const notification = (raw as { notification?: unknown }).notification
|
|
16
|
+
if (!notification || typeof notification !== 'object') return null
|
|
17
|
+
const n = notification as Record<string, unknown>
|
|
18
|
+
if (typeof n.event_id !== 'string' || typeof n.room_id !== 'string' || typeof n.type !== 'string')
|
|
19
|
+
return null
|
|
20
|
+
if (!Array.isArray(n.devices)) return null
|
|
21
|
+
return n as unknown as PushNotification
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pushGateway(opts: PushGatewayOpts): Hono {
|
|
25
|
+
const app = new Hono()
|
|
26
|
+
|
|
27
|
+
app.post('/_matrix/push/v1/notify', async (c) => {
|
|
28
|
+
const parsed = parseNotifyBody(await c.req.json().catch(() => null))
|
|
29
|
+
if (!parsed) {
|
|
30
|
+
console.warn('[push] notify: malformed body')
|
|
31
|
+
return c.json({ error: 'malformed notification' }, 400)
|
|
32
|
+
}
|
|
33
|
+
// Logged unconditionally: this is the only way to tell "Tuwunel never
|
|
34
|
+
// called us" (nothing below appears at all — check ip_range_denylist)
|
|
35
|
+
// apart from "it called us and here's what happened."
|
|
36
|
+
console.log(
|
|
37
|
+
`[push] notify room=${parsed.room_id} type=${parsed.type} devices=${parsed.devices.length}`,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
let delivered = 0
|
|
41
|
+
const rejected: string[] = []
|
|
42
|
+
await Promise.all(
|
|
43
|
+
parsed.devices.map(async (device) => {
|
|
44
|
+
// An app_id we don't own belongs to some other client sharing this
|
|
45
|
+
// gateway URL. Skipping is mandatory: naming it in `rejected` makes the
|
|
46
|
+
// homeserver PERMANENTLY DELETE that client's pusher (spec §4).
|
|
47
|
+
if (device.app_id !== ZOOID_APP_ID) return
|
|
48
|
+
const endpoint = device.data?.endpoint
|
|
49
|
+
const auth = device.data?.auth
|
|
50
|
+
if (typeof endpoint !== 'string' || typeof auth !== 'string') return
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await webpush.sendNotification(
|
|
54
|
+
{ endpoint, keys: { p256dh: device.pushkey, auth } },
|
|
55
|
+
JSON.stringify(buildPushPayload(parsed, device)),
|
|
56
|
+
{
|
|
57
|
+
vapidDetails: { subject: opts.subject, ...opts.keys },
|
|
58
|
+
TTL: 60 * 60 * 12,
|
|
59
|
+
urgency: device.tweaks?.sound !== undefined ? 'high' : 'normal',
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
delivered++
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const status = (err as { statusCode?: number }).statusCode
|
|
65
|
+
// 404/410 are the ONLY statuses that mean "this device is gone".
|
|
66
|
+
// 429 and 5xx are transient; rejecting on those deletes live pushers.
|
|
67
|
+
if (status === 404 || status === 410) rejected.push(device.pushkey)
|
|
68
|
+
else console.warn(`[push] delivery to ${device.pushkey} failed (${status ?? '?'}):`, err)
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
console.log(`[push] notify done: delivered=${delivered} rejected=${rejected.length}`)
|
|
74
|
+
return c.json({ rejected })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return app
|
|
78
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Hono } from 'hono'
|
|
2
|
+
import { pushGateway } from './gateway.js'
|
|
3
|
+
import { loadOrCreateVapidKeys } from './vapid.js'
|
|
4
|
+
|
|
5
|
+
export interface MountPushGatewayOpts {
|
|
6
|
+
/** Directory the VAPID keypair is persisted under (`<dataDir>/vapid.json`). */
|
|
7
|
+
dataDir: string
|
|
8
|
+
/** VAPID `sub` claim — a mailto: or https: the push service can complain to. */
|
|
9
|
+
subject: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Mount the push gateway's routes onto an existing Hono app. */
|
|
13
|
+
export function mountPushGateway(app: Hono, opts: MountPushGatewayOpts): { publicKey: string } {
|
|
14
|
+
const keys = loadOrCreateVapidKeys(opts.dataDir)
|
|
15
|
+
app.route('/', pushGateway({ keys, subject: opts.subject }))
|
|
16
|
+
return { publicKey: keys.publicKey }
|
|
17
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildPushPayload, MAX_BODY } from './payload.js'
|
|
3
|
+
import type { PushNotification } from './types.js'
|
|
4
|
+
|
|
5
|
+
const base: PushNotification = {
|
|
6
|
+
event_id: '$evt:example.org',
|
|
7
|
+
room_id: '!room:example.org',
|
|
8
|
+
room_name: 'general',
|
|
9
|
+
sender_display_name: 'Alice',
|
|
10
|
+
type: 'm.room.message',
|
|
11
|
+
content: { msgtype: 'm.text', body: 'hello there' },
|
|
12
|
+
counts: { unread: 3 },
|
|
13
|
+
devices: [],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('buildPushPayload', () => {
|
|
17
|
+
it('carries the fields the service worker renders', () => {
|
|
18
|
+
expect(buildPushPayload(base)).toEqual({
|
|
19
|
+
event_id: '$evt:example.org',
|
|
20
|
+
room_id: '!room:example.org',
|
|
21
|
+
room_name: 'general',
|
|
22
|
+
sender_display_name: 'Alice',
|
|
23
|
+
type: 'm.room.message',
|
|
24
|
+
body: 'hello there',
|
|
25
|
+
unread: 3,
|
|
26
|
+
sound: false,
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("forwards turn.end's last_message as the preview the worker renders", () => {
|
|
31
|
+
const out = buildPushPayload({
|
|
32
|
+
...base,
|
|
33
|
+
type: 'dev.zooid.turn.end',
|
|
34
|
+
content: { body: 'claude finished', last_message: 'the deploy is green' },
|
|
35
|
+
})
|
|
36
|
+
expect(out.preview).toBe('the deploy is green')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('truncates a long preview too — push payloads are size-capped', () => {
|
|
40
|
+
const out = buildPushPayload({
|
|
41
|
+
...base,
|
|
42
|
+
type: 'dev.zooid.turn.end',
|
|
43
|
+
content: { body: 'claude finished', last_message: 'y'.repeat(MAX_BODY + 50) },
|
|
44
|
+
})
|
|
45
|
+
expect(out.preview!.length).toBe(MAX_BODY)
|
|
46
|
+
expect(out.preview!.endsWith('…')).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('omits preview when the event carries no last_message', () => {
|
|
50
|
+
expect(buildPushPayload(base).preview).toBeUndefined()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('truncates a long body rather than shipping the whole message', () => {
|
|
54
|
+
const long = 'x'.repeat(MAX_BODY + 50)
|
|
55
|
+
const out = buildPushPayload({ ...base, content: { msgtype: 'm.text', body: long } })
|
|
56
|
+
expect(out.body!.length).toBe(MAX_BODY)
|
|
57
|
+
expect(out.body!.endsWith('…')).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('omits body for an agent event that carries no prose', () => {
|
|
61
|
+
const out = buildPushPayload({
|
|
62
|
+
...base,
|
|
63
|
+
type: 'dev.zooid.turn.end',
|
|
64
|
+
content: { produced_output: true },
|
|
65
|
+
})
|
|
66
|
+
expect(out.type).toBe('dev.zooid.turn.end')
|
|
67
|
+
expect(out.body).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('sets sound from the push rule tweak, not from the event type', () => {
|
|
71
|
+
const device = { app_id: 'dev.zooid.web', pushkey: 'pk', tweaks: { sound: 'default' } }
|
|
72
|
+
const out = buildPushPayload({ ...base, type: 'dev.zooid.turn.end', content: {} }, device)
|
|
73
|
+
expect(out.sound).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('tolerates a notification with no counts and no display name', () => {
|
|
77
|
+
const out = buildPushPayload({
|
|
78
|
+
event_id: '$e',
|
|
79
|
+
room_id: '!r:example.org',
|
|
80
|
+
type: 'm.room.message',
|
|
81
|
+
content: { msgtype: 'm.text', body: 'hi' },
|
|
82
|
+
devices: [],
|
|
83
|
+
})
|
|
84
|
+
expect(out.unread).toBe(0)
|
|
85
|
+
expect(out.room_name).toBe('!r:example.org')
|
|
86
|
+
expect(out.sender_display_name).toBeUndefined()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PushDevice, PushNotification, PushPayload } from './types.js'
|
|
2
|
+
|
|
3
|
+
export const MAX_BODY = 140
|
|
4
|
+
export const ZOOID_APP_ID = 'dev.zooid.web'
|
|
5
|
+
|
|
6
|
+
function truncate(s: string, max: number): string {
|
|
7
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Trim a homeserver notification down to what the service worker renders.
|
|
12
|
+
*
|
|
13
|
+
* Full content, not `event_id_only`: rooms are unencrypted, so the homeserver
|
|
14
|
+
* already holds plaintext and the gateway is ours. `event_id_only` would force
|
|
15
|
+
* the worker to hold an access token and fetch each event before it could show
|
|
16
|
+
* anything (spec §7). RFC 8291 means the browser vendor sees ciphertext either
|
|
17
|
+
* way.
|
|
18
|
+
*/
|
|
19
|
+
export function buildPushPayload(n: PushNotification, device?: PushDevice): PushPayload {
|
|
20
|
+
const body =
|
|
21
|
+
typeof n.content?.body === 'string' ? truncate(n.content.body as string, MAX_BODY) : undefined
|
|
22
|
+
const preview =
|
|
23
|
+
typeof n.content?.last_message === 'string'
|
|
24
|
+
? truncate(n.content.last_message as string, MAX_BODY)
|
|
25
|
+
: undefined
|
|
26
|
+
return {
|
|
27
|
+
event_id: n.event_id,
|
|
28
|
+
room_id: n.room_id,
|
|
29
|
+
room_name: n.room_name ?? n.room_id,
|
|
30
|
+
...(n.sender_display_name !== undefined ? { sender_display_name: n.sender_display_name } : {}),
|
|
31
|
+
type: n.type,
|
|
32
|
+
...(body !== undefined ? { body } : {}),
|
|
33
|
+
...(preview !== undefined ? { preview } : {}),
|
|
34
|
+
unread: n.counts?.unread ?? 0,
|
|
35
|
+
// Whether this makes a noise is a push-rule property the server evaluated,
|
|
36
|
+
// not a second decision made here (spec §12).
|
|
37
|
+
sound: device?.tweaks?.sound !== undefined,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Mirrors the Matrix Push Gateway API notify request body — the subset the gateway reads. */
|
|
2
|
+
|
|
3
|
+
export interface PushDevice {
|
|
4
|
+
app_id: string
|
|
5
|
+
pushkey: string
|
|
6
|
+
data?: {
|
|
7
|
+
endpoint?: string
|
|
8
|
+
auth?: string
|
|
9
|
+
}
|
|
10
|
+
tweaks?: {
|
|
11
|
+
sound?: string
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PushNotification {
|
|
16
|
+
event_id: string
|
|
17
|
+
room_id: string
|
|
18
|
+
room_name?: string
|
|
19
|
+
sender_display_name?: string
|
|
20
|
+
type: string
|
|
21
|
+
content?: Record<string, unknown>
|
|
22
|
+
counts?: { unread?: number }
|
|
23
|
+
devices: PushDevice[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PushPayload {
|
|
27
|
+
event_id: string
|
|
28
|
+
room_id: string
|
|
29
|
+
room_name: string
|
|
30
|
+
sender_display_name?: string
|
|
31
|
+
type: string
|
|
32
|
+
body?: string
|
|
33
|
+
/** The agent's final message on a turn.end — the prose itself never pushes. */
|
|
34
|
+
preview?: string
|
|
35
|
+
unread: number
|
|
36
|
+
sound: boolean
|
|
37
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { loadOrCreateVapidKeys, VAPID_FILENAME } from './vapid.js'
|
|
6
|
+
|
|
7
|
+
let dir: string
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dir = mkdtempSync(join(tmpdir(), 'zooid-vapid-'))
|
|
11
|
+
})
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('loadOrCreateVapidKeys', () => {
|
|
17
|
+
it('generates a keypair on first call and persists it', () => {
|
|
18
|
+
const keys = loadOrCreateVapidKeys(dir)
|
|
19
|
+
expect(keys.publicKey).toMatch(/^[A-Za-z0-9_-]+$/)
|
|
20
|
+
expect(keys.privateKey).toMatch(/^[A-Za-z0-9_-]+$/)
|
|
21
|
+
// base64url-encoded uncompressed P-256 point: 65 bytes → 87 chars
|
|
22
|
+
expect(keys.publicKey.length).toBe(87)
|
|
23
|
+
|
|
24
|
+
const onDisk = JSON.parse(readFileSync(join(dir, VAPID_FILENAME), 'utf8'))
|
|
25
|
+
expect(onDisk).toEqual({ publicKey: keys.publicKey, privateKey: keys.privateKey })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('is stable across calls — regenerating would invalidate every subscription', () => {
|
|
29
|
+
const first = loadOrCreateVapidKeys(dir)
|
|
30
|
+
const second = loadOrCreateVapidKeys(dir)
|
|
31
|
+
expect(second).toEqual(first)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('creates the directory when it does not exist yet', () => {
|
|
35
|
+
const nested = join(dir, 'a', 'b')
|
|
36
|
+
const keys = loadOrCreateVapidKeys(nested)
|
|
37
|
+
expect(readFileSync(join(nested, VAPID_FILENAME), 'utf8')).toContain(keys.publicKey)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('regenerates rather than throwing when the file is corrupt', () => {
|
|
41
|
+
writeFileSync(join(dir, VAPID_FILENAME), 'not json')
|
|
42
|
+
const keys = loadOrCreateVapidKeys(dir)
|
|
43
|
+
expect(keys.publicKey.length).toBe(87)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import webpush from 'web-push'
|
|
4
|
+
|
|
5
|
+
export const VAPID_FILENAME = 'vapid.json'
|
|
6
|
+
|
|
7
|
+
export interface VapidKeys {
|
|
8
|
+
publicKey: string
|
|
9
|
+
privateKey: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load the daemon's VAPID keypair, generating it on first call.
|
|
14
|
+
*
|
|
15
|
+
* Regenerating invalidates every existing browser subscription — every device
|
|
16
|
+
* has to re-enable notifications by hand. It is a one-way door, so this never
|
|
17
|
+
* regenerates over a readable file; only over a missing or corrupt one, and it
|
|
18
|
+
* says so on the way past.
|
|
19
|
+
*/
|
|
20
|
+
export function loadOrCreateVapidKeys(dataDir: string): VapidKeys {
|
|
21
|
+
const path = join(dataDir, VAPID_FILENAME)
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<VapidKeys>
|
|
24
|
+
if (typeof parsed.publicKey === 'string' && typeof parsed.privateKey === 'string')
|
|
25
|
+
return { publicKey: parsed.publicKey, privateKey: parsed.privateKey }
|
|
26
|
+
console.warn(`[push] ${path} is malformed; generating a new VAPID keypair.`)
|
|
27
|
+
} catch {
|
|
28
|
+
// Missing file on first start is the normal path — say nothing.
|
|
29
|
+
}
|
|
30
|
+
const keys = webpush.generateVAPIDKeys()
|
|
31
|
+
mkdirSync(dataDir, { recursive: true })
|
|
32
|
+
writeFileSync(path, JSON.stringify(keys, null, 2), { mode: 0o600 })
|
|
33
|
+
return keys
|
|
34
|
+
}
|
package/src/services/tuwunel.ts
CHANGED
|
@@ -52,9 +52,28 @@ export class TuwunelService {
|
|
|
52
52
|
async stop(): Promise<void> {
|
|
53
53
|
// Foregrounded container: kill the engine process; --rm cleans up after.
|
|
54
54
|
if (this.child && this.child.exitCode === null) {
|
|
55
|
-
this.child
|
|
55
|
+
const child = this.child
|
|
56
|
+
child.kill('SIGTERM')
|
|
57
|
+
// Bounded: `docker stop` below is the real cleanup and is a no-op if the
|
|
58
|
+
// container is already gone, so a wedged engine process must not be able
|
|
59
|
+
// to hang shutdown forever. Measured ~450ms for a healthy Tuwunel.
|
|
56
60
|
await new Promise<void>((resolve) => {
|
|
57
|
-
|
|
61
|
+
let done = false
|
|
62
|
+
const finish = (): void => {
|
|
63
|
+
if (done) return
|
|
64
|
+
done = true
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
resolve()
|
|
67
|
+
}
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
try {
|
|
70
|
+
child.kill('SIGKILL')
|
|
71
|
+
} catch {
|
|
72
|
+
// already gone
|
|
73
|
+
}
|
|
74
|
+
finish()
|
|
75
|
+
}, 5000)
|
|
76
|
+
child.once('exit', finish)
|
|
58
77
|
})
|
|
59
78
|
}
|
|
60
79
|
this.child = null
|