@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.1

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,224 @@
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.js";
6
+ import { PushService } from "../service.js";
7
+ import { PushTypes } from "../types.js";
8
+ import { errors } from "@ericsanchezok/synergy-server/server/error";
9
+ const ForbiddenEndpointError = z
10
+ .object({
11
+ message: z.string(),
12
+ })
13
+ .meta({ ref: "PushEndpointRejected" });
14
+ const CategoriesSchema = z
15
+ .object({
16
+ completion: z.boolean(),
17
+ error: z.boolean(),
18
+ input: z.boolean(),
19
+ })
20
+ .meta({ ref: "PushCategories" });
21
+ const SubscriptionSchema = z
22
+ .object({
23
+ id: z.string(),
24
+ endpoint: z.string(),
25
+ deviceLabel: z.string().optional(),
26
+ created: z.number(),
27
+ categories: CategoriesSchema,
28
+ })
29
+ .meta({ ref: "PushSubscriptionInfo" });
30
+ const SubscribeBody = z.object({
31
+ endpoint: z.string().url(),
32
+ keys: z.object({ p256dh: z.string(), auth: z.string() }),
33
+ deviceLabel: z.string().optional(),
34
+ categories: CategoriesSchema.optional(),
35
+ });
36
+ export const PushRoute = () => new Hono()
37
+ .get("/vapid-key", describeRoute({
38
+ summary: "Get the server's public Web Push VAPID key",
39
+ description: "Returns the public VAPID key browsers need for PushManager.subscribe. The key pair is generated on first use.",
40
+ operationId: "push.getVapidKey",
41
+ responses: {
42
+ 200: {
43
+ description: "Public VAPID key",
44
+ content: {
45
+ "application/json": {
46
+ schema: resolver(z.object({ publicKey: z.string() }).meta({ ref: "PushVapidKey" })),
47
+ },
48
+ },
49
+ },
50
+ },
51
+ }), async (c) => {
52
+ const keys = await PushStore.vapidKeys();
53
+ return c.json({ publicKey: keys.publicKey });
54
+ })
55
+ .get("/subscriptions", describeRoute({
56
+ summary: "List Web Push subscriptions",
57
+ description: "List every registered push subscription. Subscription keys are never returned.",
58
+ operationId: "push.list",
59
+ responses: {
60
+ 200: {
61
+ description: "Registered subscriptions without transport keys",
62
+ content: {
63
+ "application/json": {
64
+ schema: resolver(SubscriptionSchema.array()),
65
+ },
66
+ },
67
+ },
68
+ },
69
+ }), async (c) => {
70
+ const all = await PushStore.list();
71
+ return c.json(all.map((s) => ({
72
+ id: s.id,
73
+ endpoint: s.endpoint,
74
+ ...(s.deviceLabel !== undefined ? { deviceLabel: s.deviceLabel } : {}),
75
+ created: s.created,
76
+ categories: s.categories,
77
+ })));
78
+ })
79
+ .post("/subscribe", describeRoute({
80
+ summary: "Register a Web Push subscription",
81
+ description: "Register a browser push subscription (endpoint + encryption keys). Re-subscribing with the same endpoint updates it in place.",
82
+ operationId: "push.subscribe",
83
+ responses: {
84
+ 200: {
85
+ description: "Subscription stored",
86
+ content: {
87
+ "application/json": {
88
+ schema: resolver(SubscriptionSchema),
89
+ },
90
+ },
91
+ },
92
+ ...errors(400),
93
+ },
94
+ }), validator("json", SubscribeBody), async (c) => {
95
+ const body = c.req.valid("json");
96
+ if (!PushTypes.isAllowedPushEndpoint(body.endpoint)) {
97
+ return c.json(ForbiddenEndpointError.parse({ message: "Push endpoint is not an allowed push service" }), 400);
98
+ }
99
+ const stored = await PushStore.upsert(body);
100
+ return c.json({
101
+ id: stored.id,
102
+ endpoint: stored.endpoint,
103
+ ...(stored.deviceLabel !== undefined ? { deviceLabel: stored.deviceLabel } : {}),
104
+ created: stored.created,
105
+ categories: stored.categories,
106
+ });
107
+ })
108
+ .post("/unsubscribe", describeRoute({
109
+ summary: "Remove a Web Push subscription",
110
+ description: "Remove the subscription matching the given push endpoint.",
111
+ operationId: "push.unsubscribe",
112
+ responses: {
113
+ 200: {
114
+ description: "Subscription removed (idempotent)",
115
+ content: {
116
+ "application/json": {
117
+ schema: resolver(z.boolean()),
118
+ },
119
+ },
120
+ },
121
+ ...errors(400),
122
+ },
123
+ }), validator("json", z.object({ endpoint: z.string().url() })), async (c) => {
124
+ const body = c.req.valid("json");
125
+ if (!PushTypes.isAllowedPushEndpoint(body.endpoint)) {
126
+ return c.json(ForbiddenEndpointError.parse({ message: "Push endpoint is not an allowed push service" }), 400);
127
+ }
128
+ await PushStore.removeByEndpoint(body.endpoint);
129
+ return c.json(true);
130
+ })
131
+ .post("/test", describeRoute({
132
+ summary: "Send a test Web Push notification",
133
+ description: "Send one test notification to verify an existing subscription end to end. With an endpoint, only that subscription receives it.",
134
+ operationId: "push.test",
135
+ responses: {
136
+ 200: {
137
+ description: "Test notification dispatched",
138
+ content: {
139
+ "application/json": {
140
+ schema: resolver(z.boolean()),
141
+ },
142
+ },
143
+ },
144
+ 404: {
145
+ description: "Unknown subscription endpoint",
146
+ content: {
147
+ "application/json": {
148
+ schema: resolver(z.object({ message: z.string() })),
149
+ },
150
+ },
151
+ },
152
+ 502: {
153
+ description: "Delivery to the requested subscription failed",
154
+ content: {
155
+ "application/json": {
156
+ schema: resolver(z.object({ message: z.string() })),
157
+ },
158
+ },
159
+ },
160
+ ...errors(400),
161
+ },
162
+ }), validator("json", z.object({ endpoint: z.string().url().optional() })), async (c) => {
163
+ const body = c.req.valid("json");
164
+ if (body.endpoint && !PushTypes.isAllowedPushEndpoint(body.endpoint)) {
165
+ return c.json(ForbiddenEndpointError.parse({ message: "Push endpoint is not an allowed push service" }), 400);
166
+ }
167
+ const payload = {
168
+ title: "Synergy push test",
169
+ body: "If you can read this, device push is working.",
170
+ href: "/",
171
+ tag: "push-test",
172
+ category: "test",
173
+ };
174
+ if (body.endpoint) {
175
+ const existing = await PushStore.findByEndpoint(body.endpoint);
176
+ if (!existing)
177
+ return c.json({ message: "Unknown subscription endpoint" }, 404);
178
+ // Send only to the requested device and surface its delivery failure,
179
+ // so a rejected endpoint cannot masquerade as a successful test.
180
+ try {
181
+ await PushService.sendTo(existing, payload);
182
+ }
183
+ catch {
184
+ return c.json({ message: "Test delivery failed" }, 502);
185
+ }
186
+ return c.json(true);
187
+ }
188
+ await PushService.send(payload);
189
+ return c.json(true);
190
+ })
191
+ .patch("/subscriptions/:id/categories", describeRoute({
192
+ summary: "Update push category preferences for one subscription",
193
+ description: "Enable or disable completion/error/input pushes per subscribed device.",
194
+ operationId: "push.updateCategories",
195
+ responses: {
196
+ 200: {
197
+ description: "Categories updated",
198
+ content: {
199
+ "application/json": {
200
+ schema: resolver(z.boolean()),
201
+ },
202
+ },
203
+ },
204
+ 404: {
205
+ description: "Unknown subscription id",
206
+ content: {
207
+ "application/json": {
208
+ schema: resolver(z.object({ message: z.string() })),
209
+ },
210
+ },
211
+ },
212
+ ...errors(400),
213
+ },
214
+ }), validator("param", z.object({
215
+ id: z.string(),
216
+ })), validator("json", CategoriesSchema), async (c) => {
217
+ const { id } = c.req.valid("param");
218
+ const existing = await PushStore.list();
219
+ if (!existing.some((s) => s.id === id)) {
220
+ return c.json({ message: "Unknown subscription id" }, 404);
221
+ }
222
+ await PushStore.updateCategories(id, c.req.valid("json"));
223
+ return c.json(true);
224
+ });
@@ -0,0 +1,93 @@
1
+ import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context";
2
+ import webpush from "web-push";
3
+ import { PushStore } from "./store.js";
4
+ import { Log } from "@ericsanchezok/synergy-harness/util/log";
5
+ export var PushService;
6
+ (function (PushService) {
7
+ const log = Log.create({ service: "push.service" });
8
+ const TTL_BY_CATEGORY = {
9
+ input: 3600,
10
+ completion: 300,
11
+ error: 300,
12
+ test: 300,
13
+ };
14
+ const URGENCY_BY_CATEGORY = {
15
+ input: "high",
16
+ completion: "normal",
17
+ error: "normal",
18
+ test: "normal",
19
+ };
20
+ const runtimeState = RuntimeContext.state(() => ({
21
+ sendFn: webpush.sendNotification.bind(webpush),
22
+ }));
23
+ /** Test seam: replace the transport without touching the real push services. */
24
+ function setSender(fn) {
25
+ const instanceState = runtimeState();
26
+ instanceState.sendFn = fn;
27
+ }
28
+ PushService.setSender = setSender;
29
+ function resetSender() {
30
+ const instanceState = runtimeState();
31
+ instanceState.sendFn = webpush.sendNotification.bind(webpush);
32
+ }
33
+ PushService.resetSender = resetSender;
34
+ /**
35
+ * Fan out one payload to every opted-in subscription. Endpoint failures are
36
+ * per-device: 404/410 prunes the stale subscription, every other error is
37
+ * logged and never propagates — a broken device must not block other
38
+ * devices or the event publisher.
39
+ */
40
+ async function send(payload) {
41
+ const subscriptions = await PushStore.list();
42
+ const targets = subscriptions.filter((s) => {
43
+ if (payload.category === "test")
44
+ return true;
45
+ return s.categories[payload.category] === true;
46
+ });
47
+ if (targets.length === 0)
48
+ return;
49
+ const vapid = await PushStore.vapidKeys();
50
+ const serialized = JSON.stringify(payload);
51
+ await Promise.allSettled(targets.map((sub) => deliver(sub, serialized, payload.category, vapid)));
52
+ }
53
+ PushService.send = send;
54
+ /**
55
+ * Deliver one payload to a single subscription (the settings "test" flow).
56
+ * Rejects on transport failure so the caller can surface why the test did
57
+ * not arrive; 404/410 still prunes the dead subscription before rejecting.
58
+ */
59
+ async function sendTo(subscription, payload) {
60
+ const vapid = await PushStore.vapidKeys();
61
+ await deliver(subscription, JSON.stringify(payload), payload.category, vapid, { propagate: true });
62
+ }
63
+ PushService.sendTo = sendTo;
64
+ async function deliver(sub, serialized, category, vapid, options) {
65
+ const instanceState = runtimeState();
66
+ try {
67
+ await instanceState.sendFn({ endpoint: sub.endpoint, keys: sub.keys }, serialized, {
68
+ TTL: TTL_BY_CATEGORY[category],
69
+ urgency: URGENCY_BY_CATEGORY[category],
70
+ vapidDetails: {
71
+ // RFC 8292 requires the subject to be a contactable mailto or
72
+ // https URI. Apple rejects JWTs whose host cannot be resolved
73
+ // (403 BadJwtToken), so this must stay a public, project-owned URL.
74
+ subject: "https://github.com/SII-Holos/synergy",
75
+ publicKey: vapid.publicKey,
76
+ privateKey: vapid.privateKey,
77
+ },
78
+ });
79
+ }
80
+ catch (error) {
81
+ const statusCode = error?.statusCode;
82
+ if (statusCode === 404 || statusCode === 410) {
83
+ await PushStore.removeById(sub.id).catch(() => undefined);
84
+ log.info("pruned expired push subscription", { subscriptionID: sub.id });
85
+ }
86
+ else {
87
+ log.warn("push delivery failed", { subscriptionID: sub.id, statusCode, error });
88
+ }
89
+ if (options?.propagate)
90
+ throw error;
91
+ }
92
+ }
93
+ })(PushService || (PushService = {}));
@@ -0,0 +1,121 @@
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.js";
9
+ import { Log } from "@ericsanchezok/synergy-harness/util/log";
10
+ export var PushStore;
11
+ (function (PushStore) {
12
+ const log = Log.create({ service: "push.store" });
13
+ // Subscription endpoints + p256dh/auth and the VAPID private key are
14
+ // credential material: together they allow authenticated, encrypted pushes
15
+ // to registered devices. Persist them owner-only, like the provider auth
16
+ // stores (api-key.ts writes its store through a 0600 handle).
17
+ function credentialFile(key) {
18
+ return path.join(Global.Path.data, ...key) + ".json";
19
+ }
20
+ async function list() {
21
+ const ids = await Storage.scan(StoragePath.pushSubscriptionsRoot());
22
+ const records = await Storage.readMany(ids.map((id) => StoragePath.pushSubscription(id)));
23
+ return records.filter((x) => Boolean(x));
24
+ }
25
+ PushStore.list = list;
26
+ /**
27
+ * Idempotent upsert keyed by endpoint: re-subscribing from the same device
28
+ * refreshes its keys/categories instead of duplicating fan-out targets.
29
+ */
30
+ async function upsert(input) {
31
+ return Storage.transaction(async () => {
32
+ const existing = await findByEndpoint(input.endpoint);
33
+ const record = {
34
+ id: existing?.id ?? `push_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`,
35
+ endpoint: input.endpoint,
36
+ keys: input.keys,
37
+ ...(input.deviceLabel !== undefined ? { deviceLabel: input.deviceLabel } : {}),
38
+ created: existing?.created ?? Date.now(),
39
+ categories: input.categories ?? existing?.categories ?? PushTypes.DEFAULT_CATEGORIES,
40
+ };
41
+ await Storage.write(StoragePath.pushSubscription(record.id), record);
42
+ return record;
43
+ });
44
+ }
45
+ PushStore.upsert = upsert;
46
+ async function removeByEndpoint(endpoint) {
47
+ await Storage.transaction(async () => {
48
+ const existing = await findByEndpoint(endpoint);
49
+ if (existing)
50
+ await Storage.remove(StoragePath.pushSubscription(existing.id));
51
+ });
52
+ }
53
+ PushStore.removeByEndpoint = removeByEndpoint;
54
+ async function removeById(id) {
55
+ return Storage.transaction(async () => {
56
+ const [existing] = await Storage.readMany([StoragePath.pushSubscription(id)]);
57
+ if (!existing)
58
+ return false;
59
+ await Storage.remove(StoragePath.pushSubscription(id));
60
+ return true;
61
+ });
62
+ }
63
+ PushStore.removeById = removeById;
64
+ async function findByEndpoint(endpoint) {
65
+ const all = await list();
66
+ return all.find((s) => s.endpoint === endpoint);
67
+ }
68
+ PushStore.findByEndpoint = findByEndpoint;
69
+ async function updateCategories(id, categories) {
70
+ await Storage.transaction(async () => {
71
+ const [existing] = await Storage.readMany([StoragePath.pushSubscription(id)]);
72
+ if (existing)
73
+ await Storage.write(StoragePath.pushSubscription(id), { ...existing, categories });
74
+ });
75
+ }
76
+ PushStore.updateCategories = updateCategories;
77
+ // Memoized first-use generation keyed by the data home: two concurrent
78
+ // callers must observe the same key pair (otherwise one subscribes with a
79
+ // public key the persisted private key no longer backs), and a different
80
+ // home (isolated test fixture) must not inherit another home's keys.
81
+ const runtimeState = RuntimeContext.state(() => ({
82
+ vapidInitHome: undefined,
83
+ vapidInit: undefined,
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
+ function vapidKeys() {
91
+ const instanceState = runtimeState();
92
+ const home = Global.Path.data;
93
+ if (instanceState.vapidInit && instanceState.vapidInitHome === home)
94
+ 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")
102
+ return;
103
+ throw error;
104
+ });
105
+ if (existing !== undefined)
106
+ return z.object({ publicKey: z.string().min(1), privateKey: z.string().min(1) }).parse(existing);
107
+ const generated = webpush.generateVAPIDKeys();
108
+ await Storage.writeJsonAtomic(filename, JSON.stringify(generated), { private: true, durable: true });
109
+ log.info("generated VAPID key pair");
110
+ return generated;
111
+ })().catch((error) => {
112
+ const instanceState = runtimeState();
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
+ PushStore.vapidKeys = vapidKeys;
121
+ })(PushStore || (PushStore = {}));
@@ -0,0 +1,58 @@
1
+ import z from "zod";
2
+ export var PushTypes;
3
+ (function (PushTypes) {
4
+ PushTypes.Categories = z
5
+ .object({
6
+ completion: z.boolean(),
7
+ error: z.boolean(),
8
+ input: z.boolean(),
9
+ })
10
+ .meta({ ref: "PushCategories" });
11
+ PushTypes.DEFAULT_CATEGORIES = { completion: true, error: true, input: true };
12
+ PushTypes.Subscription = z
13
+ .object({
14
+ id: z.string(),
15
+ endpoint: z.string().url(),
16
+ keys: z.object({ p256dh: z.string(), auth: z.string() }),
17
+ deviceLabel: z.string().optional(),
18
+ created: z.number(),
19
+ categories: PushTypes.Categories,
20
+ })
21
+ .meta({ ref: "PushSubscription" });
22
+ PushTypes.SubscribeInput = z.object({
23
+ endpoint: z.string().url(),
24
+ keys: z.object({ p256dh: z.string(), auth: z.string() }),
25
+ deviceLabel: z.string().optional(),
26
+ categories: PushTypes.Categories.optional(),
27
+ });
28
+ PushTypes.UnsubscribeInput = z.object({
29
+ endpoint: z.string().url(),
30
+ });
31
+ PushTypes.Payload = z.object({
32
+ title: z.string(),
33
+ body: z.string(),
34
+ href: z.string(),
35
+ tag: z.string(),
36
+ category: z.enum(["completion", "error", "input", "test"]),
37
+ badge: z.number().int().nonnegative().optional(),
38
+ });
39
+ /**
40
+ * Web Push endpoints are attacker-controllable URLs; the server POSTs the
41
+ * encrypted payload to them, so only known browser push service hosts are
42
+ * accepted (SSRF boundary for /push/subscribe).
43
+ */
44
+ function isAllowedPushEndpoint(endpoint) {
45
+ let parsed;
46
+ try {
47
+ parsed = new URL(endpoint);
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ if (parsed.protocol !== "https:")
53
+ return false;
54
+ const host = parsed.hostname;
55
+ return (host === "fcm.googleapis.com" || host === "updates.push.services.mozilla.com" || host.endsWith(".push.apple.com"));
56
+ }
57
+ PushTypes.isAllowedPushEndpoint = isAllowedPushEndpoint;
58
+ })(PushTypes || (PushTypes = {}));