zooid 0.11.2 → 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/README.md +8 -3
- package/dist/bin.js +503 -124
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-KGYQ5YNP.js → chunk-YZ4IO5MR.js} +78 -11
- package/dist/chunk-YZ4IO5MR.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +11 -9
- package/src/bin.test.ts +63 -0
- package/src/bin.ts +55 -6
- package/src/bootstrap/configs.test.ts +7 -0
- package/src/bootstrap/configs.ts +14 -0
- package/src/build-registry.ts +1 -1
- package/src/build-registry.zod044.test.ts +23 -0
- package/src/commands/dev.ts +37 -7
- package/src/commands/init/generators.test.ts +43 -0
- package/src/commands/init/generators.ts +24 -5
- package/src/commands/init/pi-scaffold.test.ts +151 -0
- package/src/commands/init/prompts.ts +53 -2
- package/src/commands/init/registry.test.ts +60 -0
- package/src/commands/init/registry.ts +58 -0
- package/src/commands/init/sniff.test.ts +61 -2
- package/src/commands/init/sniff.ts +43 -12
- package/src/commands/init.ts +91 -5
- package/src/commands/status.test.ts +15 -0
- package/src/commands/status.ts +27 -2
- package/src/daemon/start-daemon.ts +15 -1
- 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-KGYQ5YNP.js.map +0 -1
|
@@ -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
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { CLI_VERSION, readCliVersion } from './version.js'
|
|
8
|
+
|
|
9
|
+
const manifestVersion = (
|
|
10
|
+
JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
|
11
|
+
version: string
|
|
12
|
+
}
|
|
13
|
+
).version
|
|
14
|
+
|
|
15
|
+
describe('readCliVersion', () => {
|
|
16
|
+
it('reports the version from the package manifest', () => {
|
|
17
|
+
expect(readCliVersion()).toBe(manifestVersion)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// The regression this exists for: `cli.version()` was a hardcoded '0.0.1'
|
|
21
|
+
// from the first release through 0.12.0, so every published build claimed
|
|
22
|
+
// to be a version that was never released.
|
|
23
|
+
it('is not a hardcoded placeholder', () => {
|
|
24
|
+
expect(CLI_VERSION).toBe(manifestVersion)
|
|
25
|
+
expect(CLI_VERSION).not.toBe('0.0.1')
|
|
26
|
+
expect(CLI_VERSION).toMatch(/^\d+\.\d+\.\d+/)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// dist/bin.js and src/bin.ts are the same depth from package.json, so the
|
|
30
|
+
// single '../package.json' has to work from either. Simulate the built
|
|
31
|
+
// bundle's location rather than trusting that they stay in step.
|
|
32
|
+
it('resolves from a sibling directory the way dist/ does', () => {
|
|
33
|
+
const root = mkdtempSync(join(tmpdir(), 'zooid-version-'))
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ version: '9.9.9' }))
|
|
36
|
+
const fromDist = pathToFileURL(join(root, 'dist', 'bin.js')).href
|
|
37
|
+
expect(readCliVersion(fromDist)).toBe('9.9.9')
|
|
38
|
+
const fromSrc = pathToFileURL(join(root, 'src', 'bin.ts')).href
|
|
39
|
+
expect(readCliVersion(fromSrc)).toBe('9.9.9')
|
|
40
|
+
} finally {
|
|
41
|
+
rmSync(root, { recursive: true, force: true })
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('falls back to "unknown" rather than throwing when the manifest is missing', () => {
|
|
46
|
+
const root = mkdtempSync(join(tmpdir(), 'zooid-version-'))
|
|
47
|
+
try {
|
|
48
|
+
const url = pathToFileURL(join(root, 'dist', 'bin.js')).href
|
|
49
|
+
expect(readCliVersion(url)).toBe('unknown')
|
|
50
|
+
} finally {
|
|
51
|
+
rmSync(root, { recursive: true, force: true })
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('falls back to "unknown" on a manifest with no usable version', () => {
|
|
56
|
+
const root = mkdtempSync(join(tmpdir(), 'zooid-version-'))
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'zooid' }))
|
|
59
|
+
const url = pathToFileURL(join(root, 'dist', 'bin.js')).href
|
|
60
|
+
expect(readCliVersion(url)).toBe('unknown')
|
|
61
|
+
writeFileSync(join(root, 'package.json'), '{not json')
|
|
62
|
+
expect(readCliVersion(url)).toBe('unknown')
|
|
63
|
+
} finally {
|
|
64
|
+
rmSync(root, { recursive: true, force: true })
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
})
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The CLI's own version, read from the package manifest at startup.
|
|
5
|
+
*
|
|
6
|
+
* Never hardcode this. A literal goes stale on the very next release, and
|
|
7
|
+
* `zooid --version` then lies about which build is installed — which is
|
|
8
|
+
* exactly what it did from the first release through 0.12.0, where it
|
|
9
|
+
* reported `0.0.1` forever.
|
|
10
|
+
*
|
|
11
|
+
* `dist/bin.js` (the published bundle) and `src/bin.ts` (a source run under
|
|
12
|
+
* tsx) sit at the same depth relative to package.json, so a single
|
|
13
|
+
* `../package.json` works for both without a build-time define.
|
|
14
|
+
*
|
|
15
|
+
* `url` is overridable for tests.
|
|
16
|
+
*/
|
|
17
|
+
export function readCliVersion(url: string = import.meta.url): string {
|
|
18
|
+
try {
|
|
19
|
+
const manifest = new URL('../package.json', url)
|
|
20
|
+
const raw = JSON.parse(readFileSync(manifest, 'utf8')) as { version?: unknown }
|
|
21
|
+
if (typeof raw.version === 'string' && raw.version) return raw.version
|
|
22
|
+
return 'unknown'
|
|
23
|
+
} catch {
|
|
24
|
+
// A missing or malformed manifest must never stop the CLI from running —
|
|
25
|
+
// `--version` is the least important thing it does.
|
|
26
|
+
return 'unknown'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const CLI_VERSION = readCliVersion()
|
package/src/web/static.test.ts
CHANGED
|
@@ -48,4 +48,26 @@ describe('webStatic', () => {
|
|
|
48
48
|
const r = await app.request('/assets/nope.js')
|
|
49
49
|
expect(r.status).toBe(404)
|
|
50
50
|
})
|
|
51
|
+
|
|
52
|
+
it('serves push_gateway_url and vapid_public_key when configured', async () => {
|
|
53
|
+
const app = webStatic({
|
|
54
|
+
webRoot: dir,
|
|
55
|
+
homeserverUrl: 'http://localhost:8448',
|
|
56
|
+
pushGatewayUrl: 'http://host.docker.internal:9000/_matrix/push/v1/notify',
|
|
57
|
+
vapidPublicKey: 'BPk',
|
|
58
|
+
})
|
|
59
|
+
const res = await app.request('/config.json')
|
|
60
|
+
expect(await res.json()).toEqual({
|
|
61
|
+
homeserver_url: 'http://localhost:8448',
|
|
62
|
+
push_gateway_url: 'http://host.docker.internal:9000/_matrix/push/v1/notify',
|
|
63
|
+
vapid_public_key: 'BPk',
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('omits both when unconfigured, so the client falls back instead of half-subscribing', async () => {
|
|
68
|
+
const app = webStatic({ webRoot: dir, homeserverUrl: 'http://localhost:8448' })
|
|
69
|
+
expect(await (await app.request('/config.json')).json()).toEqual({
|
|
70
|
+
homeserver_url: 'http://localhost:8448',
|
|
71
|
+
})
|
|
72
|
+
})
|
|
51
73
|
})
|
package/src/web/static.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { Hono } from 'hono'
|
|
|
5
5
|
export interface WebStaticOpts {
|
|
6
6
|
webRoot: string
|
|
7
7
|
homeserverUrl: string
|
|
8
|
+
pushGatewayUrl?: string
|
|
9
|
+
vapidPublicKey?: string
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
const MIME: Record<string, string> = {
|
|
@@ -28,7 +30,13 @@ function isAssetPath(p: string): boolean {
|
|
|
28
30
|
export function webStatic(opts: WebStaticOpts): Hono {
|
|
29
31
|
const app = new Hono()
|
|
30
32
|
|
|
31
|
-
app.get('/config.json', (c) =>
|
|
33
|
+
app.get('/config.json', (c) =>
|
|
34
|
+
c.json({
|
|
35
|
+
homeserver_url: opts.homeserverUrl,
|
|
36
|
+
...(opts.pushGatewayUrl ? { push_gateway_url: opts.pushGatewayUrl } : {}),
|
|
37
|
+
...(opts.vapidPublicKey ? { vapid_public_key: opts.vapidPublicKey } : {}),
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
32
40
|
|
|
33
41
|
app.get('*', (c) => {
|
|
34
42
|
const url = new URL(c.req.url)
|