@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.
Files changed (153) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +16 -2
  3. package/dist/agents.js +11 -0
  4. package/dist/cli-adapter.js +8 -0
  5. package/dist/component.js +65 -0
  6. package/dist/config-schema.js +265 -0
  7. package/dist/execution/activity.js +78 -0
  8. package/dist/execution/content.js +184 -0
  9. package/dist/execution/json-sections.js +231 -0
  10. package/dist/execution/presentation.js +79 -0
  11. package/dist/execution/preview.js +13 -0
  12. package/dist/execution/process.js +125 -0
  13. package/dist/execution/projection.js +327 -0
  14. package/dist/execution/query.js +18 -0
  15. package/dist/execution/routes.js +189 -0
  16. package/dist/execution/schema.js +322 -0
  17. package/dist/execution/service.js +1993 -0
  18. package/dist/features.js +22 -0
  19. package/dist/http.js +65 -0
  20. package/dist/performance/analysis.js +330 -0
  21. package/dist/performance/catalog.js +391 -0
  22. package/dist/performance/dashboard.js +483 -0
  23. package/dist/performance/error.js +15 -0
  24. package/dist/performance/index.js +6 -0
  25. package/dist/performance/inflight.js +43 -0
  26. package/dist/performance/projection.js +138 -0
  27. package/dist/performance/prompt/base.txt +38 -0
  28. package/dist/performance/prompt/builder.js +21 -0
  29. package/dist/performance/routes/performance-route.js +439 -0
  30. package/dist/performance/schema.js +628 -0
  31. package/dist/performance/timeline.js +98 -0
  32. package/dist/performance/trace-detail.js +91 -0
  33. package/dist/presentation/service.js +270 -0
  34. package/dist/presentation/tools/workbench-view.js +65 -0
  35. package/dist/presentation/tools.js +10 -0
  36. package/dist/project/directories.js +363 -0
  37. package/dist/project/git-health.js +498 -0
  38. package/dist/project/ide.js +66 -0
  39. package/dist/project/migration.js +22 -0
  40. package/dist/project/routes/directories.js +89 -0
  41. package/dist/project/routes/git.js +30 -0
  42. package/dist/project/routes/task-defaults.js +42 -0
  43. package/dist/project/session-health.js +9 -0
  44. package/dist/project/startup.js +28 -0
  45. package/dist/project/task-defaults.js +68 -0
  46. package/dist/project/tools/session-control.js +664 -0
  47. package/dist/project/tools/session-control.txt +60 -0
  48. package/dist/project/tools/worktree-archive.js +76 -0
  49. package/dist/project/tools/worktree-enter.js +175 -0
  50. package/dist/project/tools/worktree-leave.js +119 -0
  51. package/dist/project/tools/worktree-list.js +52 -0
  52. package/dist/project/tools.js +26 -0
  53. package/dist/project/vcs-branch-watcher.js +76 -0
  54. package/dist/project/vcs.js +114 -0
  55. package/dist/project/worktree-janitor.js +140 -0
  56. package/dist/project/worktrees.js +98 -0
  57. package/dist/push/bridge.js +171 -0
  58. package/dist/push/index.js +4 -0
  59. package/dist/push/routes/push.js +224 -0
  60. package/dist/push/service.js +93 -0
  61. package/dist/push/store.js +121 -0
  62. package/dist/push/types.js +58 -0
  63. package/dist/review/git.js +226 -0
  64. package/dist/review/routes.js +45 -0
  65. package/dist/review/schema.js +32 -0
  66. package/dist/review/state.js +79 -0
  67. package/dist/stats/aggregator.js +252 -0
  68. package/dist/stats/canonical.js +89 -0
  69. package/dist/stats/cli/stats.js +302 -0
  70. package/dist/stats/engine.js +176 -0
  71. package/dist/stats/index.js +5 -0
  72. package/dist/stats/rollup.js +460 -0
  73. package/dist/stats/routes/stats.js +94 -0
  74. package/dist/stats/routes/usage.js +83 -0
  75. package/dist/stats/storage.js +86 -0
  76. package/dist/stats/types.js +267 -0
  77. package/dist/worker.js +6 -0
  78. package/package.json +232 -4
  79. package/src/agents.ts +15 -0
  80. package/src/cli-adapter.ts +11 -0
  81. package/src/component.ts +73 -0
  82. package/src/config-schema.ts +289 -0
  83. package/src/execution/activity.ts +83 -0
  84. package/src/execution/content.ts +189 -0
  85. package/src/execution/json-sections.ts +230 -0
  86. package/src/execution/presentation.ts +91 -0
  87. package/src/execution/preview.ts +12 -0
  88. package/src/execution/process.ts +118 -0
  89. package/src/execution/projection.ts +330 -0
  90. package/src/execution/query.ts +19 -0
  91. package/src/execution/routes.ts +324 -0
  92. package/src/execution/schema.ts +336 -0
  93. package/src/execution/service.ts +2149 -0
  94. package/src/features.ts +22 -0
  95. package/src/http.ts +75 -0
  96. package/src/performance/analysis.ts +356 -0
  97. package/src/performance/catalog.ts +902 -0
  98. package/src/performance/dashboard.ts +521 -0
  99. package/src/performance/error.ts +32 -0
  100. package/src/performance/index.ts +6 -0
  101. package/src/performance/inflight.ts +46 -0
  102. package/src/performance/projection.ts +143 -0
  103. package/src/performance/prompt/base.txt +38 -0
  104. package/src/performance/prompt/builder.ts +24 -0
  105. package/src/performance/routes/performance-route.ts +541 -0
  106. package/src/performance/schema.ts +695 -0
  107. package/src/performance/timeline.ts +100 -0
  108. package/src/performance/trace-detail.ts +97 -0
  109. package/src/presentation/service.ts +303 -0
  110. package/src/presentation/tools/workbench-view.ts +75 -0
  111. package/src/presentation/tools.ts +11 -0
  112. package/src/project/directories.ts +391 -0
  113. package/src/project/git-health.ts +577 -0
  114. package/src/project/ide.ts +80 -0
  115. package/src/project/migration.ts +24 -0
  116. package/src/project/routes/directories.ts +124 -0
  117. package/src/project/routes/git.ts +42 -0
  118. package/src/project/routes/task-defaults.ts +50 -0
  119. package/src/project/session-health.ts +10 -0
  120. package/src/project/startup.ts +28 -0
  121. package/src/project/task-defaults.ts +70 -0
  122. package/src/project/tools/session-control.ts +725 -0
  123. package/src/project/tools/session-control.txt +60 -0
  124. package/src/project/tools/worktree-archive.ts +92 -0
  125. package/src/project/tools/worktree-enter.ts +227 -0
  126. package/src/project/tools/worktree-leave.ts +140 -0
  127. package/src/project/tools/worktree-list.ts +68 -0
  128. package/src/project/tools.ts +29 -0
  129. package/src/project/vcs-branch-watcher.ts +81 -0
  130. package/src/project/vcs.ts +124 -0
  131. package/src/project/worktree-janitor.ts +153 -0
  132. package/src/project/worktrees.ts +110 -0
  133. package/src/push/bridge.ts +182 -0
  134. package/src/push/index.ts +4 -0
  135. package/src/push/routes/push.ts +267 -0
  136. package/src/push/service.ts +108 -0
  137. package/src/push/store.ts +120 -0
  138. package/src/push/types.ts +67 -0
  139. package/src/review/git.ts +221 -0
  140. package/src/review/routes.ts +65 -0
  141. package/src/review/schema.ts +34 -0
  142. package/src/review/state.ts +77 -0
  143. package/src/stats/aggregator.ts +277 -0
  144. package/src/stats/canonical.ts +93 -0
  145. package/src/stats/cli/stats.ts +348 -0
  146. package/src/stats/engine.ts +197 -0
  147. package/src/stats/index.ts +5 -0
  148. package/src/stats/rollup.ts +522 -0
  149. package/src/stats/routes/stats.ts +107 -0
  150. package/src/stats/routes/usage.ts +103 -0
  151. package/src/stats/storage.ts +78 -0
  152. package/src/stats/types.ts +351 -0
  153. 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
+ }