@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.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/AGENTS.md +30 -0
- package/README.md +16 -2
- package/dist/agents.js +11 -0
- package/dist/cli-adapter.js +8 -0
- package/dist/component.js +65 -0
- package/dist/config-schema.js +265 -0
- package/dist/execution/activity.js +78 -0
- package/dist/execution/content.js +184 -0
- package/dist/execution/json-sections.js +231 -0
- package/dist/execution/presentation.js +79 -0
- package/dist/execution/preview.js +13 -0
- package/dist/execution/process.js +125 -0
- package/dist/execution/projection.js +327 -0
- package/dist/execution/query.js +18 -0
- package/dist/execution/routes.js +189 -0
- package/dist/execution/schema.js +322 -0
- package/dist/execution/service.js +1993 -0
- package/dist/features.js +22 -0
- package/dist/http.js +65 -0
- package/dist/performance/analysis.js +330 -0
- package/dist/performance/catalog.js +391 -0
- package/dist/performance/dashboard.js +483 -0
- package/dist/performance/error.js +15 -0
- package/dist/performance/index.js +6 -0
- package/dist/performance/inflight.js +43 -0
- package/dist/performance/projection.js +138 -0
- package/dist/performance/prompt/base.txt +38 -0
- package/dist/performance/prompt/builder.js +21 -0
- package/dist/performance/routes/performance-route.js +439 -0
- package/dist/performance/schema.js +628 -0
- package/dist/performance/timeline.js +98 -0
- package/dist/performance/trace-detail.js +91 -0
- package/dist/presentation/service.js +270 -0
- package/dist/presentation/tools/workbench-view.js +65 -0
- package/dist/presentation/tools.js +10 -0
- package/dist/project/directories.js +363 -0
- package/dist/project/git-health.js +498 -0
- package/dist/project/ide.js +66 -0
- package/dist/project/migration.js +22 -0
- package/dist/project/routes/directories.js +89 -0
- package/dist/project/routes/git.js +30 -0
- package/dist/project/routes/task-defaults.js +42 -0
- package/dist/project/session-health.js +9 -0
- package/dist/project/startup.js +28 -0
- package/dist/project/task-defaults.js +68 -0
- package/dist/project/tools/session-control.js +664 -0
- package/dist/project/tools/session-control.txt +60 -0
- package/dist/project/tools/worktree-archive.js +76 -0
- package/dist/project/tools/worktree-enter.js +175 -0
- package/dist/project/tools/worktree-leave.js +119 -0
- package/dist/project/tools/worktree-list.js +52 -0
- package/dist/project/tools.js +26 -0
- package/dist/project/vcs-branch-watcher.js +76 -0
- package/dist/project/vcs.js +114 -0
- package/dist/project/worktree-janitor.js +140 -0
- package/dist/project/worktrees.js +98 -0
- package/dist/push/bridge.js +171 -0
- package/dist/push/index.js +4 -0
- package/dist/push/routes/push.js +224 -0
- package/dist/push/service.js +93 -0
- package/dist/push/store.js +121 -0
- package/dist/push/types.js +58 -0
- package/dist/review/git.js +226 -0
- package/dist/review/routes.js +45 -0
- package/dist/review/schema.js +32 -0
- package/dist/review/state.js +79 -0
- package/dist/stats/aggregator.js +252 -0
- package/dist/stats/canonical.js +89 -0
- package/dist/stats/cli/stats.js +302 -0
- package/dist/stats/engine.js +176 -0
- package/dist/stats/index.js +5 -0
- package/dist/stats/rollup.js +460 -0
- package/dist/stats/routes/stats.js +94 -0
- package/dist/stats/routes/usage.js +83 -0
- package/dist/stats/storage.js +86 -0
- package/dist/stats/types.js +267 -0
- package/dist/worker.js +6 -0
- package/package.json +232 -4
- package/src/agents.ts +15 -0
- package/src/cli-adapter.ts +11 -0
- package/src/component.ts +73 -0
- package/src/config-schema.ts +289 -0
- package/src/execution/activity.ts +83 -0
- package/src/execution/content.ts +189 -0
- package/src/execution/json-sections.ts +230 -0
- package/src/execution/presentation.ts +91 -0
- package/src/execution/preview.ts +12 -0
- package/src/execution/process.ts +118 -0
- package/src/execution/projection.ts +330 -0
- package/src/execution/query.ts +19 -0
- package/src/execution/routes.ts +324 -0
- package/src/execution/schema.ts +336 -0
- package/src/execution/service.ts +2149 -0
- package/src/features.ts +22 -0
- package/src/http.ts +75 -0
- package/src/performance/analysis.ts +356 -0
- package/src/performance/catalog.ts +902 -0
- package/src/performance/dashboard.ts +521 -0
- package/src/performance/error.ts +32 -0
- package/src/performance/index.ts +6 -0
- package/src/performance/inflight.ts +46 -0
- package/src/performance/projection.ts +143 -0
- package/src/performance/prompt/base.txt +38 -0
- package/src/performance/prompt/builder.ts +24 -0
- package/src/performance/routes/performance-route.ts +541 -0
- package/src/performance/schema.ts +695 -0
- package/src/performance/timeline.ts +100 -0
- package/src/performance/trace-detail.ts +97 -0
- package/src/presentation/service.ts +303 -0
- package/src/presentation/tools/workbench-view.ts +75 -0
- package/src/presentation/tools.ts +11 -0
- package/src/project/directories.ts +391 -0
- package/src/project/git-health.ts +577 -0
- package/src/project/ide.ts +80 -0
- package/src/project/migration.ts +24 -0
- package/src/project/routes/directories.ts +124 -0
- package/src/project/routes/git.ts +42 -0
- package/src/project/routes/task-defaults.ts +50 -0
- package/src/project/session-health.ts +10 -0
- package/src/project/startup.ts +28 -0
- package/src/project/task-defaults.ts +70 -0
- package/src/project/tools/session-control.ts +725 -0
- package/src/project/tools/session-control.txt +60 -0
- package/src/project/tools/worktree-archive.ts +92 -0
- package/src/project/tools/worktree-enter.ts +227 -0
- package/src/project/tools/worktree-leave.ts +140 -0
- package/src/project/tools/worktree-list.ts +68 -0
- package/src/project/tools.ts +29 -0
- package/src/project/vcs-branch-watcher.ts +81 -0
- package/src/project/vcs.ts +124 -0
- package/src/project/worktree-janitor.ts +153 -0
- package/src/project/worktrees.ts +110 -0
- package/src/push/bridge.ts +182 -0
- package/src/push/index.ts +4 -0
- package/src/push/routes/push.ts +267 -0
- package/src/push/service.ts +108 -0
- package/src/push/store.ts +120 -0
- package/src/push/types.ts +67 -0
- package/src/review/git.ts +221 -0
- package/src/review/routes.ts +65 -0
- package/src/review/schema.ts +34 -0
- package/src/review/state.ts +77 -0
- package/src/stats/aggregator.ts +277 -0
- package/src/stats/canonical.ts +93 -0
- package/src/stats/cli/stats.ts +348 -0
- package/src/stats/engine.ts +197 -0
- package/src/stats/index.ts +5 -0
- package/src/stats/rollup.ts +522 -0
- package/src/stats/routes/stats.ts +107 -0
- package/src/stats/routes/usage.ts +103 -0
- package/src/stats/storage.ts +78 -0
- package/src/stats/types.ts +351 -0
- package/src/worker.ts +7 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { Hono } from "hono"
|
|
2
|
+
import { describeRoute, validator } from "hono-openapi"
|
|
3
|
+
import { resolver } from "hono-openapi"
|
|
4
|
+
import z from "zod"
|
|
5
|
+
import { PushStore } from "../store"
|
|
6
|
+
import { PushService } from "../service"
|
|
7
|
+
import { PushTypes } from "../types"
|
|
8
|
+
import { errors } from "@ericsanchezok/synergy-server/server/error"
|
|
9
|
+
|
|
10
|
+
const ForbiddenEndpointError = z
|
|
11
|
+
.object({
|
|
12
|
+
message: z.string(),
|
|
13
|
+
})
|
|
14
|
+
.meta({ ref: "PushEndpointRejected" })
|
|
15
|
+
|
|
16
|
+
const CategoriesSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
completion: z.boolean(),
|
|
19
|
+
error: z.boolean(),
|
|
20
|
+
input: z.boolean(),
|
|
21
|
+
})
|
|
22
|
+
.meta({ ref: "PushCategories" })
|
|
23
|
+
|
|
24
|
+
const SubscriptionSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
endpoint: z.string(),
|
|
28
|
+
deviceLabel: z.string().optional(),
|
|
29
|
+
created: z.number(),
|
|
30
|
+
categories: CategoriesSchema,
|
|
31
|
+
})
|
|
32
|
+
.meta({ ref: "PushSubscriptionInfo" })
|
|
33
|
+
|
|
34
|
+
const SubscribeBody = z.object({
|
|
35
|
+
endpoint: z.string().url(),
|
|
36
|
+
keys: z.object({ p256dh: z.string(), auth: z.string() }),
|
|
37
|
+
deviceLabel: z.string().optional(),
|
|
38
|
+
categories: CategoriesSchema.optional(),
|
|
39
|
+
})
|
|
40
|
+
export const PushRoute = () =>
|
|
41
|
+
new Hono()
|
|
42
|
+
.get(
|
|
43
|
+
"/vapid-key",
|
|
44
|
+
describeRoute({
|
|
45
|
+
summary: "Get the server's public Web Push VAPID key",
|
|
46
|
+
description:
|
|
47
|
+
"Returns the public VAPID key browsers need for PushManager.subscribe. The key pair is generated on first use.",
|
|
48
|
+
operationId: "push.getVapidKey",
|
|
49
|
+
responses: {
|
|
50
|
+
200: {
|
|
51
|
+
description: "Public VAPID key",
|
|
52
|
+
content: {
|
|
53
|
+
"application/json": {
|
|
54
|
+
schema: resolver(z.object({ publicKey: z.string() }).meta({ ref: "PushVapidKey" })),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
async (c) => {
|
|
61
|
+
const keys = await PushStore.vapidKeys()
|
|
62
|
+
return c.json({ publicKey: keys.publicKey })
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
.get(
|
|
66
|
+
"/subscriptions",
|
|
67
|
+
describeRoute({
|
|
68
|
+
summary: "List Web Push subscriptions",
|
|
69
|
+
description: "List every registered push subscription. Subscription keys are never returned.",
|
|
70
|
+
operationId: "push.list",
|
|
71
|
+
responses: {
|
|
72
|
+
200: {
|
|
73
|
+
description: "Registered subscriptions without transport keys",
|
|
74
|
+
content: {
|
|
75
|
+
"application/json": {
|
|
76
|
+
schema: resolver(SubscriptionSchema.array()),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
async (c) => {
|
|
83
|
+
const all = await PushStore.list()
|
|
84
|
+
return c.json(
|
|
85
|
+
all.map((s) => ({
|
|
86
|
+
id: s.id,
|
|
87
|
+
endpoint: s.endpoint,
|
|
88
|
+
...(s.deviceLabel !== undefined ? { deviceLabel: s.deviceLabel } : {}),
|
|
89
|
+
created: s.created,
|
|
90
|
+
categories: s.categories,
|
|
91
|
+
})),
|
|
92
|
+
)
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
.post(
|
|
96
|
+
"/subscribe",
|
|
97
|
+
describeRoute({
|
|
98
|
+
summary: "Register a Web Push subscription",
|
|
99
|
+
description:
|
|
100
|
+
"Register a browser push subscription (endpoint + encryption keys). Re-subscribing with the same endpoint updates it in place.",
|
|
101
|
+
operationId: "push.subscribe",
|
|
102
|
+
responses: {
|
|
103
|
+
200: {
|
|
104
|
+
description: "Subscription stored",
|
|
105
|
+
content: {
|
|
106
|
+
"application/json": {
|
|
107
|
+
schema: resolver(SubscriptionSchema),
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
...errors(400),
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
validator("json", SubscribeBody),
|
|
115
|
+
async (c) => {
|
|
116
|
+
const body = c.req.valid("json")
|
|
117
|
+
if (!PushTypes.isAllowedPushEndpoint(body.endpoint)) {
|
|
118
|
+
return c.json(ForbiddenEndpointError.parse({ message: "Push endpoint is not an allowed push service" }), 400)
|
|
119
|
+
}
|
|
120
|
+
const stored = await PushStore.upsert(body)
|
|
121
|
+
return c.json({
|
|
122
|
+
id: stored.id,
|
|
123
|
+
endpoint: stored.endpoint,
|
|
124
|
+
...(stored.deviceLabel !== undefined ? { deviceLabel: stored.deviceLabel } : {}),
|
|
125
|
+
created: stored.created,
|
|
126
|
+
categories: stored.categories,
|
|
127
|
+
})
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
.post(
|
|
131
|
+
"/unsubscribe",
|
|
132
|
+
describeRoute({
|
|
133
|
+
summary: "Remove a Web Push subscription",
|
|
134
|
+
description: "Remove the subscription matching the given push endpoint.",
|
|
135
|
+
operationId: "push.unsubscribe",
|
|
136
|
+
responses: {
|
|
137
|
+
200: {
|
|
138
|
+
description: "Subscription removed (idempotent)",
|
|
139
|
+
content: {
|
|
140
|
+
"application/json": {
|
|
141
|
+
schema: resolver(z.boolean()),
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
...errors(400),
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
validator("json", z.object({ endpoint: z.string().url() })),
|
|
149
|
+
async (c) => {
|
|
150
|
+
const body = c.req.valid("json")
|
|
151
|
+
if (!PushTypes.isAllowedPushEndpoint(body.endpoint)) {
|
|
152
|
+
return c.json(ForbiddenEndpointError.parse({ message: "Push endpoint is not an allowed push service" }), 400)
|
|
153
|
+
}
|
|
154
|
+
await PushStore.removeByEndpoint(body.endpoint)
|
|
155
|
+
return c.json(true)
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
.post(
|
|
159
|
+
"/test",
|
|
160
|
+
describeRoute({
|
|
161
|
+
summary: "Send a test Web Push notification",
|
|
162
|
+
description:
|
|
163
|
+
"Send one test notification to verify an existing subscription end to end. With an endpoint, only that subscription receives it.",
|
|
164
|
+
operationId: "push.test",
|
|
165
|
+
responses: {
|
|
166
|
+
200: {
|
|
167
|
+
description: "Test notification dispatched",
|
|
168
|
+
content: {
|
|
169
|
+
"application/json": {
|
|
170
|
+
schema: resolver(z.boolean()),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
404: {
|
|
175
|
+
description: "Unknown subscription endpoint",
|
|
176
|
+
content: {
|
|
177
|
+
"application/json": {
|
|
178
|
+
schema: resolver(z.object({ message: z.string() })),
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
502: {
|
|
183
|
+
description: "Delivery to the requested subscription failed",
|
|
184
|
+
content: {
|
|
185
|
+
"application/json": {
|
|
186
|
+
schema: resolver(z.object({ message: z.string() })),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
...errors(400),
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
validator("json", z.object({ endpoint: z.string().url().optional() })),
|
|
194
|
+
async (c) => {
|
|
195
|
+
const body = c.req.valid("json")
|
|
196
|
+
if (body.endpoint && !PushTypes.isAllowedPushEndpoint(body.endpoint)) {
|
|
197
|
+
return c.json(ForbiddenEndpointError.parse({ message: "Push endpoint is not an allowed push service" }), 400)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const payload: PushTypes.Payload = {
|
|
201
|
+
title: "Synergy push test",
|
|
202
|
+
body: "If you can read this, device push is working.",
|
|
203
|
+
href: "/",
|
|
204
|
+
tag: "push-test",
|
|
205
|
+
category: "test",
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (body.endpoint) {
|
|
209
|
+
const existing = await PushStore.findByEndpoint(body.endpoint)
|
|
210
|
+
if (!existing) return c.json({ message: "Unknown subscription endpoint" }, 404)
|
|
211
|
+
// Send only to the requested device and surface its delivery failure,
|
|
212
|
+
// so a rejected endpoint cannot masquerade as a successful test.
|
|
213
|
+
try {
|
|
214
|
+
await PushService.sendTo(existing, payload)
|
|
215
|
+
} catch {
|
|
216
|
+
return c.json({ message: "Test delivery failed" }, 502)
|
|
217
|
+
}
|
|
218
|
+
return c.json(true)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await PushService.send(payload)
|
|
222
|
+
return c.json(true)
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
.patch(
|
|
226
|
+
"/subscriptions/:id/categories",
|
|
227
|
+
describeRoute({
|
|
228
|
+
summary: "Update push category preferences for one subscription",
|
|
229
|
+
description: "Enable or disable completion/error/input pushes per subscribed device.",
|
|
230
|
+
operationId: "push.updateCategories",
|
|
231
|
+
responses: {
|
|
232
|
+
200: {
|
|
233
|
+
description: "Categories updated",
|
|
234
|
+
content: {
|
|
235
|
+
"application/json": {
|
|
236
|
+
schema: resolver(z.boolean()),
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
404: {
|
|
241
|
+
description: "Unknown subscription id",
|
|
242
|
+
content: {
|
|
243
|
+
"application/json": {
|
|
244
|
+
schema: resolver(z.object({ message: z.string() })),
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
...errors(400),
|
|
249
|
+
},
|
|
250
|
+
}),
|
|
251
|
+
validator(
|
|
252
|
+
"param",
|
|
253
|
+
z.object({
|
|
254
|
+
id: z.string(),
|
|
255
|
+
}),
|
|
256
|
+
),
|
|
257
|
+
validator("json", CategoriesSchema),
|
|
258
|
+
async (c) => {
|
|
259
|
+
const { id } = c.req.valid("param")
|
|
260
|
+
const existing = await PushStore.list()
|
|
261
|
+
if (!existing.some((s) => s.id === id)) {
|
|
262
|
+
return c.json({ message: "Unknown subscription id" }, 404)
|
|
263
|
+
}
|
|
264
|
+
await PushStore.updateCategories(id, c.req.valid("json"))
|
|
265
|
+
return c.json(true)
|
|
266
|
+
},
|
|
267
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
|
|
2
|
+
import webpush from "web-push"
|
|
3
|
+
import { PushStore } from "./store"
|
|
4
|
+
import { PushTypes } from "./types"
|
|
5
|
+
import { Log } from "@ericsanchezok/synergy-harness/util/log"
|
|
6
|
+
|
|
7
|
+
export namespace PushService {
|
|
8
|
+
const log = Log.create({ service: "push.service" })
|
|
9
|
+
|
|
10
|
+
type PayloadCategory = PushTypes.Payload["category"]
|
|
11
|
+
|
|
12
|
+
const TTL_BY_CATEGORY: Record<PayloadCategory, number> = {
|
|
13
|
+
input: 3600,
|
|
14
|
+
completion: 300,
|
|
15
|
+
error: 300,
|
|
16
|
+
test: 300,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const URGENCY_BY_CATEGORY: Record<PayloadCategory, "high" | "normal"> = {
|
|
20
|
+
input: "high",
|
|
21
|
+
completion: "normal",
|
|
22
|
+
error: "normal",
|
|
23
|
+
test: "normal",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SendFn = typeof webpush.sendNotification
|
|
27
|
+
|
|
28
|
+
const runtimeState = RuntimeContext.state(() => ({
|
|
29
|
+
sendFn: webpush.sendNotification.bind(webpush) as SendFn,
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
/** Test seam: replace the transport without touching the real push services. */
|
|
33
|
+
export function setSender(fn: SendFn) {
|
|
34
|
+
const instanceState = runtimeState()
|
|
35
|
+
|
|
36
|
+
instanceState.sendFn = fn
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resetSender() {
|
|
40
|
+
const instanceState = runtimeState()
|
|
41
|
+
|
|
42
|
+
instanceState.sendFn = webpush.sendNotification.bind(webpush)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fan out one payload to every opted-in subscription. Endpoint failures are
|
|
47
|
+
* per-device: 404/410 prunes the stale subscription, every other error is
|
|
48
|
+
* logged and never propagates — a broken device must not block other
|
|
49
|
+
* devices or the event publisher.
|
|
50
|
+
*/
|
|
51
|
+
export async function send(payload: PushTypes.Payload): Promise<void> {
|
|
52
|
+
const subscriptions = await PushStore.list()
|
|
53
|
+
const targets = subscriptions.filter((s) => {
|
|
54
|
+
if (payload.category === "test") return true
|
|
55
|
+
return s.categories[payload.category as "completion" | "error" | "input"] === true
|
|
56
|
+
})
|
|
57
|
+
if (targets.length === 0) return
|
|
58
|
+
|
|
59
|
+
const vapid = await PushStore.vapidKeys()
|
|
60
|
+
const serialized = JSON.stringify(payload)
|
|
61
|
+
|
|
62
|
+
await Promise.allSettled(targets.map((sub) => deliver(sub, serialized, payload.category, vapid)))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Deliver one payload to a single subscription (the settings "test" flow).
|
|
67
|
+
* Rejects on transport failure so the caller can surface why the test did
|
|
68
|
+
* not arrive; 404/410 still prunes the dead subscription before rejecting.
|
|
69
|
+
*/
|
|
70
|
+
export async function sendTo(subscription: PushTypes.Subscription, payload: PushTypes.Payload): Promise<void> {
|
|
71
|
+
const vapid = await PushStore.vapidKeys()
|
|
72
|
+
await deliver(subscription, JSON.stringify(payload), payload.category, vapid, { propagate: true })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function deliver(
|
|
76
|
+
sub: PushTypes.Subscription,
|
|
77
|
+
serialized: string,
|
|
78
|
+
category: PushTypes.Payload["category"],
|
|
79
|
+
vapid: { publicKey: string; privateKey: string },
|
|
80
|
+
options?: { propagate?: boolean },
|
|
81
|
+
) {
|
|
82
|
+
const instanceState = runtimeState()
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await instanceState.sendFn({ endpoint: sub.endpoint, keys: sub.keys }, serialized, {
|
|
86
|
+
TTL: TTL_BY_CATEGORY[category],
|
|
87
|
+
urgency: URGENCY_BY_CATEGORY[category],
|
|
88
|
+
vapidDetails: {
|
|
89
|
+
// RFC 8292 requires the subject to be a contactable mailto or
|
|
90
|
+
// https URI. Apple rejects JWTs whose host cannot be resolved
|
|
91
|
+
// (403 BadJwtToken), so this must stay a public, project-owned URL.
|
|
92
|
+
subject: "https://github.com/SII-Holos/synergy",
|
|
93
|
+
publicKey: vapid.publicKey,
|
|
94
|
+
privateKey: vapid.privateKey,
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
} catch (error: unknown) {
|
|
98
|
+
const statusCode = (error as { statusCode?: number })?.statusCode
|
|
99
|
+
if (statusCode === 404 || statusCode === 410) {
|
|
100
|
+
await PushStore.removeById(sub.id).catch(() => undefined)
|
|
101
|
+
log.info("pruned expired push subscription", { subscriptionID: sub.id })
|
|
102
|
+
} else {
|
|
103
|
+
log.warn("push delivery failed", { subscriptionID: sub.id, statusCode, error })
|
|
104
|
+
}
|
|
105
|
+
if (options?.propagate) throw error
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import webpush from "web-push"
|
|
5
|
+
import { Storage } from "@ericsanchezok/synergy-harness/storage/storage"
|
|
6
|
+
import { StoragePath } from "@ericsanchezok/synergy-harness/storage/path"
|
|
7
|
+
import { Global } from "@ericsanchezok/synergy-harness/global"
|
|
8
|
+
import { PushTypes } from "./types"
|
|
9
|
+
import { Log } from "@ericsanchezok/synergy-harness/util/log"
|
|
10
|
+
|
|
11
|
+
export namespace PushStore {
|
|
12
|
+
const log = Log.create({ service: "push.store" })
|
|
13
|
+
|
|
14
|
+
// Subscription endpoints + p256dh/auth and the VAPID private key are
|
|
15
|
+
// credential material: together they allow authenticated, encrypted pushes
|
|
16
|
+
// to registered devices. Persist them owner-only, like the provider auth
|
|
17
|
+
// stores (api-key.ts writes its store through a 0600 handle).
|
|
18
|
+
function credentialFile(key: string[]): string {
|
|
19
|
+
return path.join(Global.Path.data, ...key) + ".json"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function list(): Promise<PushTypes.Subscription[]> {
|
|
23
|
+
const ids = await Storage.scan(StoragePath.pushSubscriptionsRoot())
|
|
24
|
+
const records = await Storage.readMany<PushTypes.Subscription>(ids.map((id) => StoragePath.pushSubscription(id)))
|
|
25
|
+
return records.filter((x): x is PushTypes.Subscription => Boolean(x))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Idempotent upsert keyed by endpoint: re-subscribing from the same device
|
|
30
|
+
* refreshes its keys/categories instead of duplicating fan-out targets.
|
|
31
|
+
*/
|
|
32
|
+
export async function upsert(input: PushTypes.SubscribeInput): Promise<PushTypes.Subscription> {
|
|
33
|
+
return Storage.transaction(async () => {
|
|
34
|
+
const existing = await findByEndpoint(input.endpoint)
|
|
35
|
+
const record: PushTypes.Subscription = {
|
|
36
|
+
id: existing?.id ?? `push_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`,
|
|
37
|
+
endpoint: input.endpoint,
|
|
38
|
+
keys: input.keys,
|
|
39
|
+
...(input.deviceLabel !== undefined ? { deviceLabel: input.deviceLabel } : {}),
|
|
40
|
+
created: existing?.created ?? Date.now(),
|
|
41
|
+
categories: input.categories ?? existing?.categories ?? PushTypes.DEFAULT_CATEGORIES,
|
|
42
|
+
}
|
|
43
|
+
await Storage.write(StoragePath.pushSubscription(record.id), record)
|
|
44
|
+
return record
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function removeByEndpoint(endpoint: string): Promise<void> {
|
|
49
|
+
await Storage.transaction(async () => {
|
|
50
|
+
const existing = await findByEndpoint(endpoint)
|
|
51
|
+
if (existing) await Storage.remove(StoragePath.pushSubscription(existing.id))
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function removeById(id: string): Promise<boolean> {
|
|
56
|
+
return Storage.transaction(async () => {
|
|
57
|
+
const [existing] = await Storage.readMany<PushTypes.Subscription>([StoragePath.pushSubscription(id)])
|
|
58
|
+
if (!existing) return false
|
|
59
|
+
await Storage.remove(StoragePath.pushSubscription(id))
|
|
60
|
+
return true
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function findByEndpoint(endpoint: string): Promise<PushTypes.Subscription | undefined> {
|
|
65
|
+
const all = await list()
|
|
66
|
+
return all.find((s) => s.endpoint === endpoint)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function updateCategories(id: string, categories: PushTypes.Categories): Promise<void> {
|
|
70
|
+
await Storage.transaction(async () => {
|
|
71
|
+
const [existing] = await Storage.readMany<PushTypes.Subscription>([StoragePath.pushSubscription(id)])
|
|
72
|
+
if (existing) await Storage.write(StoragePath.pushSubscription(id), { ...existing, categories })
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Memoized first-use generation keyed by the data home: two concurrent
|
|
77
|
+
// callers must observe the same key pair (otherwise one subscribes with a
|
|
78
|
+
// public key the persisted private key no longer backs), and a different
|
|
79
|
+
// home (isolated test fixture) must not inherit another home's keys.
|
|
80
|
+
const runtimeState = RuntimeContext.state(() => ({
|
|
81
|
+
vapidInitHome: undefined as string | undefined,
|
|
82
|
+
vapidInit: undefined as Promise<{ publicKey: string; privateKey: string }> | undefined,
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* VAPID server keys. Generated once on first use and persisted owner-only;
|
|
87
|
+
* the private key is a credential — never logged, exported, or returned by
|
|
88
|
+
* any route.
|
|
89
|
+
*/
|
|
90
|
+
export function vapidKeys(): Promise<{ publicKey: string; privateKey: string }> {
|
|
91
|
+
const instanceState = runtimeState()
|
|
92
|
+
|
|
93
|
+
const home = Global.Path.data
|
|
94
|
+
if (instanceState.vapidInit && instanceState.vapidInitHome === home) return instanceState.vapidInit
|
|
95
|
+
instanceState.vapidInitHome = home
|
|
96
|
+
instanceState.vapidInit = (async () => {
|
|
97
|
+
const filename = credentialFile(StoragePath.pushVapid())
|
|
98
|
+
const existing = await Bun.file(filename)
|
|
99
|
+
.json()
|
|
100
|
+
.catch((error) => {
|
|
101
|
+
if (error?.code === "ENOENT") return
|
|
102
|
+
throw error
|
|
103
|
+
})
|
|
104
|
+
if (existing !== undefined)
|
|
105
|
+
return z.object({ publicKey: z.string().min(1), privateKey: z.string().min(1) }).parse(existing)
|
|
106
|
+
const generated = webpush.generateVAPIDKeys()
|
|
107
|
+
await Storage.writeJsonAtomic(filename, JSON.stringify(generated), { private: true, durable: true })
|
|
108
|
+
log.info("generated VAPID key pair")
|
|
109
|
+
return generated
|
|
110
|
+
})().catch((error) => {
|
|
111
|
+
const instanceState = runtimeState()
|
|
112
|
+
|
|
113
|
+
// Allow a later caller to retry generation after a transient failure.
|
|
114
|
+
instanceState.vapidInit = undefined
|
|
115
|
+
instanceState.vapidInitHome = undefined
|
|
116
|
+
throw error
|
|
117
|
+
})
|
|
118
|
+
return instanceState.vapidInit
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import z from "zod"
|
|
2
|
+
|
|
3
|
+
export namespace PushTypes {
|
|
4
|
+
export const Categories = z
|
|
5
|
+
.object({
|
|
6
|
+
completion: z.boolean(),
|
|
7
|
+
error: z.boolean(),
|
|
8
|
+
input: z.boolean(),
|
|
9
|
+
})
|
|
10
|
+
.meta({ ref: "PushCategories" })
|
|
11
|
+
export type Categories = z.infer<typeof Categories>
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_CATEGORIES: Categories = { completion: true, error: true, input: true }
|
|
14
|
+
|
|
15
|
+
export const Subscription = z
|
|
16
|
+
.object({
|
|
17
|
+
id: z.string(),
|
|
18
|
+
endpoint: z.string().url(),
|
|
19
|
+
keys: z.object({ p256dh: z.string(), auth: z.string() }),
|
|
20
|
+
deviceLabel: z.string().optional(),
|
|
21
|
+
created: z.number(),
|
|
22
|
+
categories: Categories,
|
|
23
|
+
})
|
|
24
|
+
.meta({ ref: "PushSubscription" })
|
|
25
|
+
export type Subscription = z.infer<typeof Subscription>
|
|
26
|
+
|
|
27
|
+
export const SubscribeInput = z.object({
|
|
28
|
+
endpoint: z.string().url(),
|
|
29
|
+
keys: z.object({ p256dh: z.string(), auth: z.string() }),
|
|
30
|
+
deviceLabel: z.string().optional(),
|
|
31
|
+
categories: Categories.optional(),
|
|
32
|
+
})
|
|
33
|
+
export type SubscribeInput = z.infer<typeof SubscribeInput>
|
|
34
|
+
|
|
35
|
+
export const UnsubscribeInput = z.object({
|
|
36
|
+
endpoint: z.string().url(),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const Payload = z.object({
|
|
40
|
+
title: z.string(),
|
|
41
|
+
body: z.string(),
|
|
42
|
+
href: z.string(),
|
|
43
|
+
tag: z.string(),
|
|
44
|
+
category: z.enum(["completion", "error", "input", "test"]),
|
|
45
|
+
badge: z.number().int().nonnegative().optional(),
|
|
46
|
+
})
|
|
47
|
+
export type Payload = z.infer<typeof Payload>
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Web Push endpoints are attacker-controllable URLs; the server POSTs the
|
|
51
|
+
* encrypted payload to them, so only known browser push service hosts are
|
|
52
|
+
* accepted (SSRF boundary for /push/subscribe).
|
|
53
|
+
*/
|
|
54
|
+
export function isAllowedPushEndpoint(endpoint: string): boolean {
|
|
55
|
+
let parsed: URL
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(endpoint)
|
|
58
|
+
} catch {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
if (parsed.protocol !== "https:") return false
|
|
62
|
+
const host = parsed.hostname
|
|
63
|
+
return (
|
|
64
|
+
host === "fcm.googleapis.com" || host === "updates.push.services.mozilla.com" || host.endsWith(".push.apple.com")
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|