easy-ping 0.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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +374 -0
  3. package/dist/adapter-f0OR2DhY.d.cts +200 -0
  4. package/dist/adapter-f0OR2DhY.d.ts +200 -0
  5. package/dist/adapters/drizzle.cjs +440 -0
  6. package/dist/adapters/drizzle.cjs.map +1 -0
  7. package/dist/adapters/drizzle.d.cts +991 -0
  8. package/dist/adapters/drizzle.d.ts +991 -0
  9. package/dist/adapters/drizzle.js +409 -0
  10. package/dist/adapters/drizzle.js.map +1 -0
  11. package/dist/adapters/mongodb.cjs +458 -0
  12. package/dist/adapters/mongodb.cjs.map +1 -0
  13. package/dist/adapters/mongodb.d.cts +32 -0
  14. package/dist/adapters/mongodb.d.ts +32 -0
  15. package/dist/adapters/mongodb.js +376 -0
  16. package/dist/adapters/mongodb.js.map +1 -0
  17. package/dist/browser.cjs +96 -0
  18. package/dist/browser.cjs.map +1 -0
  19. package/dist/browser.d.cts +45 -0
  20. package/dist/browser.d.ts +45 -0
  21. package/dist/browser.js +89 -0
  22. package/dist/browser.js.map +1 -0
  23. package/dist/chunk-BV3U542P.js +11 -0
  24. package/dist/chunk-BV3U542P.js.map +1 -0
  25. package/dist/chunk-NYDVNMKG.js +62 -0
  26. package/dist/chunk-NYDVNMKG.js.map +1 -0
  27. package/dist/chunk-OZSOIX23.js +120 -0
  28. package/dist/chunk-OZSOIX23.js.map +1 -0
  29. package/dist/chunk-PTGHMCCG.js +23 -0
  30. package/dist/chunk-PTGHMCCG.js.map +1 -0
  31. package/dist/chunk-W3BMEPPQ.js +50 -0
  32. package/dist/chunk-W3BMEPPQ.js.map +1 -0
  33. package/dist/client.cjs +160 -0
  34. package/dist/client.cjs.map +1 -0
  35. package/dist/client.d.cts +42 -0
  36. package/dist/client.d.ts +42 -0
  37. package/dist/client.js +158 -0
  38. package/dist/client.js.map +1 -0
  39. package/dist/index.cjs +920 -0
  40. package/dist/index.cjs.map +1 -0
  41. package/dist/index.d.cts +203 -0
  42. package/dist/index.d.ts +203 -0
  43. package/dist/index.js +723 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/node.cjs +49 -0
  46. package/dist/node.cjs.map +1 -0
  47. package/dist/node.d.cts +8 -0
  48. package/dist/node.d.ts +8 -0
  49. package/dist/node.js +46 -0
  50. package/dist/node.js.map +1 -0
  51. package/dist/plugin-Bqj9xAOS.d.cts +217 -0
  52. package/dist/plugin-bTJKmnlq.d.ts +217 -0
  53. package/dist/plugins/digests.cjs +206 -0
  54. package/dist/plugins/digests.cjs.map +1 -0
  55. package/dist/plugins/digests.d.cts +68 -0
  56. package/dist/plugins/digests.d.ts +68 -0
  57. package/dist/plugins/digests.js +201 -0
  58. package/dist/plugins/digests.js.map +1 -0
  59. package/dist/plugins/preferences-client.cjs +23 -0
  60. package/dist/plugins/preferences-client.cjs.map +1 -0
  61. package/dist/plugins/preferences-client.d.cts +29 -0
  62. package/dist/plugins/preferences-client.d.ts +29 -0
  63. package/dist/plugins/preferences-client.js +18 -0
  64. package/dist/plugins/preferences-client.js.map +1 -0
  65. package/dist/plugins/preferences-react.cjs +125 -0
  66. package/dist/plugins/preferences-react.cjs.map +1 -0
  67. package/dist/plugins/preferences-react.d.cts +40 -0
  68. package/dist/plugins/preferences-react.d.ts +40 -0
  69. package/dist/plugins/preferences-react.js +101 -0
  70. package/dist/plugins/preferences-react.js.map +1 -0
  71. package/dist/plugins/preferences.cjs +147 -0
  72. package/dist/plugins/preferences.cjs.map +1 -0
  73. package/dist/plugins/preferences.d.cts +22 -0
  74. package/dist/plugins/preferences.d.ts +22 -0
  75. package/dist/plugins/preferences.js +119 -0
  76. package/dist/plugins/preferences.js.map +1 -0
  77. package/dist/plugins/push.cjs +146 -0
  78. package/dist/plugins/push.cjs.map +1 -0
  79. package/dist/plugins/push.d.cts +60 -0
  80. package/dist/plugins/push.d.ts +60 -0
  81. package/dist/plugins/push.js +144 -0
  82. package/dist/plugins/push.js.map +1 -0
  83. package/dist/provider-CA45WVqt.d.cts +23 -0
  84. package/dist/provider-CA45WVqt.d.ts +23 -0
  85. package/dist/providers/resend.cjs +69 -0
  86. package/dist/providers/resend.cjs.map +1 -0
  87. package/dist/providers/resend.d.cts +20 -0
  88. package/dist/providers/resend.d.ts +20 -0
  89. package/dist/providers/resend.js +66 -0
  90. package/dist/providers/resend.js.map +1 -0
  91. package/dist/providers/web-push.cjs +211 -0
  92. package/dist/providers/web-push.cjs.map +1 -0
  93. package/dist/providers/web-push.d.cts +33 -0
  94. package/dist/providers/web-push.d.ts +33 -0
  95. package/dist/providers/web-push.js +193 -0
  96. package/dist/providers/web-push.js.map +1 -0
  97. package/dist/react.cjs +218 -0
  98. package/dist/react.cjs.map +1 -0
  99. package/dist/react.d.cts +57 -0
  100. package/dist/react.d.ts +57 -0
  101. package/dist/react.js +193 -0
  102. package/dist/react.js.map +1 -0
  103. package/dist/schema.cjs +263 -0
  104. package/dist/schema.cjs.map +1 -0
  105. package/dist/schema.d.cts +180 -0
  106. package/dist/schema.d.ts +180 -0
  107. package/dist/schema.js +200 -0
  108. package/dist/schema.js.map +1 -0
  109. package/dist/testing.cjs +342 -0
  110. package/dist/testing.cjs.map +1 -0
  111. package/dist/testing.d.cts +38 -0
  112. package/dist/testing.d.ts +38 -0
  113. package/dist/testing.js +340 -0
  114. package/dist/testing.js.map +1 -0
  115. package/package.json +168 -0
@@ -0,0 +1,101 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/plugins/preferences/react.ts
5
+ import { useCallback, useEffect, useMemo, useState } from "react";
6
+ var matches = (row, key) => row.type === key.type && row.channel === key.channel;
7
+ function usePreferences(options = {}) {
8
+ const { baseUrl = "/api/notifications", fetch: providedFetch } = options;
9
+ const doFetch = useMemo(
10
+ () => providedFetch ?? ((...args) => globalThis.fetch(...args)),
11
+ [providedFetch]
12
+ );
13
+ const [preferences, setPreferences] = useState([]);
14
+ const [defaultEnabled, setDefaultEnabled] = useState(true);
15
+ const [isLoading, setIsLoading] = useState(true);
16
+ const [error, setError] = useState(null);
17
+ const refresh = useCallback(async () => {
18
+ try {
19
+ const response = await doFetch(`${baseUrl}/preferences`);
20
+ if (!response.ok) throw new Error(`GET /preferences failed: ${response.status}`);
21
+ const body = await response.json();
22
+ setPreferences(body.preferences);
23
+ setDefaultEnabled(body.defaultEnabled);
24
+ setError(null);
25
+ } catch (caught) {
26
+ setError(caught instanceof Error ? caught : new Error(String(caught)));
27
+ } finally {
28
+ setIsLoading(false);
29
+ }
30
+ }, [baseUrl, doFetch]);
31
+ useEffect(() => {
32
+ void refresh();
33
+ }, [refresh]);
34
+ const persist = useCallback(
35
+ async (input, optimistic) => {
36
+ const previous = preferences;
37
+ setPreferences((rows) => {
38
+ const existing = rows.find((row) => matches(row, input));
39
+ if (existing) {
40
+ return rows.map((row) => matches(row, input) ? { ...row, ...optimistic } : row);
41
+ }
42
+ return [
43
+ ...rows,
44
+ {
45
+ userId: "",
46
+ type: input.type,
47
+ channel: input.channel,
48
+ enabled: true,
49
+ frequency: "instant",
50
+ ...optimistic
51
+ }
52
+ ];
53
+ });
54
+ try {
55
+ const response = await doFetch(`${baseUrl}/preferences`, {
56
+ method: "POST",
57
+ headers: { "content-type": "application/json" },
58
+ body: JSON.stringify(input)
59
+ });
60
+ if (!response.ok) throw new Error(`POST /preferences failed: ${response.status}`);
61
+ } catch (caught) {
62
+ setPreferences(previous);
63
+ setError(caught instanceof Error ? caught : new Error(String(caught)));
64
+ throw caught;
65
+ }
66
+ },
67
+ [baseUrl, doFetch, preferences]
68
+ );
69
+ return {
70
+ preferences,
71
+ defaultEnabled,
72
+ isLoading,
73
+ error,
74
+ isEnabled: useCallback(
75
+ (key) => {
76
+ const row = preferences.find((candidate) => matches(candidate, key));
77
+ return row ? row.enabled && row.frequency !== "off" : defaultEnabled;
78
+ },
79
+ [preferences, defaultEnabled]
80
+ ),
81
+ setEnabled: useCallback(
82
+ (key, enabled) => persist(
83
+ { ...key, enabled, frequency: enabled ? "instant" : "off" },
84
+ { enabled, frequency: enabled ? "instant" : "off" }
85
+ ),
86
+ [persist]
87
+ ),
88
+ setFrequency: useCallback(
89
+ (key, frequency) => persist(
90
+ { ...key, enabled: frequency !== "off", frequency },
91
+ { enabled: frequency !== "off", frequency }
92
+ ),
93
+ [persist]
94
+ ),
95
+ refresh
96
+ };
97
+ }
98
+ export {
99
+ usePreferences
100
+ };
101
+ //# sourceMappingURL=preferences-react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/plugins/preferences/react.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n\nexport type UsePreferencesOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n};\n\nexport type PreferenceKey = { type: string; channel: Channel };\n\nexport type UsePreferencesResult = {\n preferences: readonly PreferenceView[];\n defaultEnabled: boolean;\n isLoading: boolean;\n error: Error | null;\n /** Resolved value for a cell, falling back to the configured default. */\n isEnabled: (key: PreferenceKey) => boolean;\n setEnabled: (key: PreferenceKey, enabled: boolean) => Promise<void>;\n setFrequency: (key: PreferenceKey, frequency: Frequency) => Promise<void>;\n refresh: () => Promise<void>;\n};\n\nconst matches = (row: PreferenceView, key: PreferenceKey) =>\n row.type === key.type && row.channel === key.channel;\n\n/** Headless: state and persistence only, no markup. */\nexport function usePreferences(options: UsePreferencesOptions = {}): UsePreferencesResult {\n const { baseUrl = \"/api/notifications\", fetch: providedFetch } = options;\n\n const doFetch = useMemo(\n () =>\n providedFetch ??\n ((...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args)),\n [providedFetch],\n );\n\n const [preferences, setPreferences] = useState<readonly PreferenceView[]>([]);\n const [defaultEnabled, setDefaultEnabled] = useState(true);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n\n const refresh = useCallback(async () => {\n try {\n const response = await doFetch(`${baseUrl}/preferences`);\n if (!response.ok) throw new Error(`GET /preferences failed: ${response.status}`);\n\n const body = (await response.json()) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n };\n\n setPreferences(body.preferences);\n setDefaultEnabled(body.defaultEnabled);\n setError(null);\n } catch (caught) {\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n } finally {\n setIsLoading(false);\n }\n }, [baseUrl, doFetch]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n const persist = useCallback(\n async (input: UpdatePreferenceInput, optimistic: Partial<PreferenceView>) => {\n const previous = preferences;\n\n // A preference toggle that lags behind the network feels broken, so\n // apply immediately and revert if the write fails.\n setPreferences((rows) => {\n const existing = rows.find((row) => matches(row, input));\n if (existing) {\n return rows.map((row) => (matches(row, input) ? { ...row, ...optimistic } : row));\n }\n return [\n ...rows,\n {\n userId: \"\",\n type: input.type,\n channel: input.channel,\n enabled: true,\n frequency: \"instant\" as Frequency,\n ...optimistic,\n },\n ];\n });\n\n try {\n const response = await doFetch(`${baseUrl}/preferences`, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(input),\n });\n if (!response.ok) throw new Error(`POST /preferences failed: ${response.status}`);\n } catch (caught) {\n setPreferences(previous);\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n throw caught;\n }\n },\n [baseUrl, doFetch, preferences],\n );\n\n return {\n preferences,\n defaultEnabled,\n isLoading,\n error,\n\n isEnabled: useCallback(\n (key: PreferenceKey) => {\n const row = preferences.find((candidate) => matches(candidate, key));\n return row ? row.enabled && row.frequency !== \"off\" : defaultEnabled;\n },\n [preferences, defaultEnabled],\n ),\n\n setEnabled: useCallback(\n (key: PreferenceKey, enabled: boolean) =>\n persist(\n { ...key, enabled, frequency: enabled ? \"instant\" : \"off\" },\n { enabled, frequency: enabled ? \"instant\" : \"off\" },\n ),\n [persist],\n ),\n\n setFrequency: useCallback(\n (key: PreferenceKey, frequency: Frequency) =>\n persist(\n { ...key, enabled: frequency !== \"off\", frequency },\n { enabled: frequency !== \"off\", frequency },\n ),\n [persist],\n ),\n\n refresh,\n };\n}\n\nexport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n"],"mappings":"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","names":[]}
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ // src/core/base64url.ts
4
+ function encodeBase64Url(input) {
5
+ const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
6
+ let binary = "";
7
+ for (const byte of bytes) binary += String.fromCharCode(byte);
8
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9
+ }
10
+
11
+ // src/core/tokens.ts
12
+ var encoder = new TextEncoder();
13
+ async function hmacKey(secret) {
14
+ return crypto.subtle.importKey(
15
+ "raw",
16
+ encoder.encode(secret),
17
+ { name: "HMAC", hash: "SHA-256" },
18
+ false,
19
+ ["sign", "verify"]
20
+ );
21
+ }
22
+ async function signToken(secret, claims) {
23
+ const body = encodeBase64Url(JSON.stringify(claims));
24
+ const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder.encode(body));
25
+ return `${body}.${encodeBase64Url(new Uint8Array(signature))}`;
26
+ }
27
+ var DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
28
+ var expiresIn = (seconds, now = /* @__PURE__ */ new Date()) => Math.floor(now.getTime() / 1e3) + seconds;
29
+
30
+ // src/plugins/preferences/index.ts
31
+ var UNSUBSCRIBE_PURPOSE = "unsubscribe";
32
+ var TABLE = "notification_preference";
33
+ var key = (userId, type, channel) => `${userId} ${type} ${channel}`;
34
+ function buildUnsubscribeToken(secret, userId, type, channel, ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS) {
35
+ return signToken(secret, {
36
+ uid: userId,
37
+ purpose: UNSUBSCRIBE_PURPOSE,
38
+ exp: expiresIn(ttlSeconds),
39
+ data: { type, channel }
40
+ });
41
+ }
42
+ function preferences(options = {}) {
43
+ const defaultEnabled = options.defaultEnabled ?? true;
44
+ const alwaysSend = new Set(options.alwaysSend ?? []);
45
+ let ctx;
46
+ const isAllowed = (userId, type, channel, rows) => {
47
+ const row = rows.get(key(userId, type, channel));
48
+ return row ? row.enabled && row.frequency !== "off" : defaultEnabled;
49
+ };
50
+ const load = (userIds) => ctx.store.find(TABLE, { userId: { in: [...userIds] } });
51
+ const save = (row) => ctx.store.upsert(TABLE, [row], { onConflict: ["userId", "type", "channel"] });
52
+ return {
53
+ id: "preferences",
54
+ init: (context) => {
55
+ ctx = context;
56
+ },
57
+ schema: {
58
+ notificationPreference: {
59
+ tableName: TABLE,
60
+ fields: {
61
+ userId: { type: "string", required: true },
62
+ type: { type: "string", required: true },
63
+ channel: { type: "string", required: true },
64
+ enabled: { type: "boolean", required: true, default: true },
65
+ frequency: { type: "string", required: true, default: "instant" }
66
+ },
67
+ primaryKey: ["userId", "type", "channel"]
68
+ }
69
+ },
70
+ hooks: {
71
+ /** One query per send; doing this per recipient would be an N+1. */
72
+ prepare: async (context) => {
73
+ if (alwaysSend.has(context.type)) return /* @__PURE__ */ new Map();
74
+ const rows = await load(context.recipients.map((recipient) => recipient.userId));
75
+ return new Map(rows.map((row) => [key(row.userId, row.type, row.channel), row]));
76
+ },
77
+ resolveChannels: (context) => {
78
+ if (alwaysSend.has(context.type)) return context.decisions;
79
+ if (!(context.prepared instanceof Map)) {
80
+ return context.decisions.map((decision) => ({ ...decision, action: "skip" }));
81
+ }
82
+ const rows = context.prepared;
83
+ return context.decisions.map(
84
+ (decision) => isAllowed(context.recipient.userId, context.type, decision.channel, rows) ? decision : { ...decision, action: "skip" }
85
+ );
86
+ }
87
+ },
88
+ routes: [
89
+ {
90
+ path: "/preferences",
91
+ method: "GET",
92
+ scope: { type: "user" },
93
+ handler: async ({ userId }) => {
94
+ const rows = await load([userId ?? ""]);
95
+ return Response.json({ preferences: rows, defaultEnabled });
96
+ }
97
+ },
98
+ {
99
+ path: "/preferences",
100
+ method: "POST",
101
+ scope: { type: "user" },
102
+ handler: async ({ request, userId }) => {
103
+ const body = await request.json().catch(() => null);
104
+ if (!body?.type || !body.channel) {
105
+ return Response.json({ error: "type and channel are required" }, { status: 400 });
106
+ }
107
+ await save({
108
+ userId: userId ?? "",
109
+ type: body.type,
110
+ channel: body.channel,
111
+ enabled: body.enabled ?? true,
112
+ frequency: body.frequency ?? "instant"
113
+ });
114
+ return Response.json({ ok: true });
115
+ }
116
+ },
117
+ {
118
+ // Reached from a mail client with no session, so it is authenticated
119
+ // by the signature on the link itself.
120
+ path: "/unsubscribe",
121
+ method: "POST",
122
+ scope: { type: "signed", purpose: UNSUBSCRIBE_PURPOSE },
123
+ handler: async ({ claims }) => {
124
+ const type = claims?.data?.type;
125
+ const channel = claims?.data?.channel;
126
+ if (!claims || !type || !channel) {
127
+ return Response.json({ error: "invalid token" }, { status: 400 });
128
+ }
129
+ await save({
130
+ userId: claims.uid,
131
+ type,
132
+ channel,
133
+ enabled: false,
134
+ frequency: "off"
135
+ });
136
+ return Response.json({ ok: true, unsubscribed: { type, channel } });
137
+ }
138
+ }
139
+ ]
140
+ };
141
+ }
142
+
143
+ exports.UNSUBSCRIBE_PURPOSE = UNSUBSCRIBE_PURPOSE;
144
+ exports.buildUnsubscribeToken = buildUnsubscribeToken;
145
+ exports.preferences = preferences;
146
+ //# sourceMappingURL=preferences.cjs.map
147
+ //# sourceMappingURL=preferences.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/base64url.ts","../../src/core/tokens.ts","../../src/plugins/preferences/index.ts"],"names":[],"mappings":"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","file":"preferences.cjs","sourcesContent":["// Web APIs, not Buffer: this runs on Workers and Vercel Edge.\n\nexport function encodeBase64Url(input: string | Uint8Array): string {\n const bytes = typeof input === \"string\" ? new TextEncoder().encode(input) : input;\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n// Uint8Array<ArrayBuffer>: bare Uint8Array widens and is not a BufferSource.\nexport function decodeBase64UrlBytes(value: string): Uint8Array<ArrayBuffer> | null {\n try {\n const binary = atob(value.replace(/-/g, \"+\").replace(/_/g, \"/\"));\n return Uint8Array.from(binary, (char) => char.charCodeAt(0));\n } catch {\n return null;\n }\n}\n\nexport function decodeBase64Url(value: string): string | null {\n const bytes = decodeBase64UrlBytes(value);\n return bytes ? new TextDecoder().decode(bytes) : null;\n}\n","import { decodeBase64Url, decodeBase64UrlBytes, encodeBase64Url } from \"./base64url\";\n\nexport type TokenClaims = {\n /** Subject — the user the token acts for. */\n uid: string;\n /** What the token is allowed to do. Verification requires an exact match. */\n purpose: string;\n /** Expiry, epoch seconds. */\n exp: number;\n /** Free-form, purpose-specific. */\n data?: Record<string, string>;\n};\n\nconst encoder = new TextEncoder();\n\nasync function hmacKey(secret: string): Promise<CryptoKey> {\n return crypto.subtle.importKey(\n \"raw\",\n encoder.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\", \"verify\"],\n );\n}\n\nexport async function signToken(secret: string, claims: TokenClaims): Promise<string> {\n const body = encodeBase64Url(JSON.stringify(claims));\n const signature = await crypto.subtle.sign(\"HMAC\", await hmacKey(secret), encoder.encode(body));\n return `${body}.${encodeBase64Url(new Uint8Array(signature))}`;\n}\n\n/** null for every failure mode. Distinguishing them tells an attacker which part to fix. */\nexport async function verifyToken(\n secret: string,\n token: string,\n purpose: string,\n now: Date = new Date(),\n): Promise<TokenClaims | null> {\n const separator = token.lastIndexOf(\".\");\n if (separator <= 0) return null;\n\n const body = token.slice(0, separator);\n const signature = decodeBase64UrlBytes(token.slice(separator + 1));\n if (!signature) return null;\n\n // crypto.subtle.verify is constant-time.\n const valid = await crypto.subtle.verify(\n \"HMAC\",\n await hmacKey(secret),\n signature,\n encoder.encode(body),\n );\n if (!valid) return null;\n\n const decoded = decodeBase64Url(body);\n if (!decoded) return null;\n\n let claims: TokenClaims;\n try {\n claims = JSON.parse(decoded) as TokenClaims;\n } catch {\n return null;\n }\n\n if (typeof claims?.uid !== \"string\" || typeof claims.exp !== \"number\") return null;\n // Scoped so an unsubscribe token can never be replayed against another route.\n if (claims.purpose !== purpose) return null;\n if (claims.exp * 1000 <= now.getTime()) return null;\n\n return claims;\n}\n\nexport const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;\n\nexport const expiresIn = (seconds: number, now: Date = new Date()): number =>\n Math.floor(now.getTime() / 1000) + seconds;\n","import type {\n ChannelDecision,\n EasyPingPlugin,\n PluginInitContext,\n PrepareContext,\n ResolveChannelsContext,\n} from \"../../core/plugin\";\nimport { DEFAULT_TOKEN_TTL_SECONDS, expiresIn, signToken } from \"../../core/tokens\";\nimport type { Channel, Frequency } from \"../../core/types\";\n\nexport const UNSUBSCRIBE_PURPOSE = \"unsubscribe\";\n\nconst TABLE = \"notification_preference\";\n\nexport type PreferenceRow = {\n userId: string;\n type: string;\n channel: Channel;\n enabled: boolean;\n frequency: Frequency;\n};\n\nexport type PreferencesOptions = {\n /** What an absent row means. Flip to false for opt-in-only notifications. */\n defaultEnabled?: boolean;\n /** Types that ignore preferences entirely: receipts, security alerts. */\n alwaysSend?: readonly string[];\n unsubscribeTtlSeconds?: number;\n};\n\nconst key = (userId: string, type: string, channel: string) => `${userId} ${type} ${channel}`;\n\ntype PreparedPreferences = Map<string, PreferenceRow>;\n\nexport function buildUnsubscribeToken(\n secret: string,\n userId: string,\n type: string,\n channel: Channel,\n ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS,\n): Promise<string> {\n return signToken(secret, {\n uid: userId,\n purpose: UNSUBSCRIBE_PURPOSE,\n exp: expiresIn(ttlSeconds),\n data: { type, channel },\n });\n}\n\nexport function preferences(options: PreferencesOptions = {}): EasyPingPlugin<\"preferences\"> {\n const defaultEnabled = options.defaultEnabled ?? true;\n const alwaysSend = new Set(options.alwaysSend ?? []);\n\n // Assigned by init(), which easyPing() runs before any hook or route.\n let ctx: PluginInitContext;\n\n const isAllowed = (userId: string, type: string, channel: Channel, rows: PreparedPreferences) => {\n const row = rows.get(key(userId, type, channel));\n return row ? row.enabled && row.frequency !== \"off\" : defaultEnabled;\n };\n\n const load = (userIds: readonly string[]) =>\n ctx.store.find<PreferenceRow>(TABLE, { userId: { in: [...userIds] } });\n\n const save = (row: PreferenceRow) =>\n ctx.store.upsert(TABLE, [row], { onConflict: [\"userId\", \"type\", \"channel\"] });\n\n return {\n id: \"preferences\",\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n notificationPreference: {\n tableName: TABLE,\n fields: {\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n channel: { type: \"string\", required: true },\n enabled: { type: \"boolean\", required: true, default: true },\n frequency: { type: \"string\", required: true, default: \"instant\" },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\n },\n\n hooks: {\n /** One query per send; doing this per recipient would be an N+1. */\n prepare: async (context: PrepareContext): Promise<PreparedPreferences> => {\n if (alwaysSend.has(context.type)) return new Map();\n\n const rows = await load(context.recipients.map((recipient) => recipient.userId));\n return new Map(rows.map((row) => [key(row.userId, row.type, row.channel), row]));\n },\n\n resolveChannels: (context: ResolveChannelsContext) => {\n if (alwaysSend.has(context.type)) return context.decisions;\n\n // prepare() failing leaves this undefined. Fail closed rather than\n // assuming everyone opted in: that is exactly how people who\n // unsubscribed get emailed anyway.\n if (!(context.prepared instanceof Map)) {\n return context.decisions.map((decision) => ({ ...decision, action: \"skip\" as const }));\n }\n\n const rows = context.prepared as PreparedPreferences;\n\n return context.decisions.map<ChannelDecision>((decision) =>\n isAllowed(context.recipient.userId, context.type, decision.channel, rows)\n ? decision\n : { ...decision, action: \"skip\" },\n );\n },\n },\n\n routes: [\n {\n path: \"/preferences\",\n method: \"GET\",\n scope: { type: \"user\" },\n handler: async ({ userId }) => {\n const rows = await load([userId ?? \"\"]);\n return Response.json({ preferences: rows, defaultEnabled });\n },\n },\n\n {\n path: \"/preferences\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const body = (await request.json().catch(() => null)) as Partial<PreferenceRow> | null;\n\n if (!body?.type || !body.channel) {\n return Response.json({ error: \"type and channel are required\" }, { status: 400 });\n }\n\n // userId comes from the resolved session, never from the body.\n await save({\n userId: userId ?? \"\",\n type: body.type,\n channel: body.channel,\n enabled: body.enabled ?? true,\n frequency: body.frequency ?? \"instant\",\n });\n\n return Response.json({ ok: true });\n },\n },\n\n {\n // Reached from a mail client with no session, so it is authenticated\n // by the signature on the link itself.\n path: \"/unsubscribe\",\n method: \"POST\",\n scope: { type: \"signed\", purpose: UNSUBSCRIBE_PURPOSE },\n handler: async ({ claims }) => {\n const type = claims?.data?.type;\n const channel = claims?.data?.channel as Channel | undefined;\n if (!claims || !type || !channel) {\n return Response.json({ error: \"invalid token\" }, { status: 400 });\n }\n\n await save({\n userId: claims.uid,\n type,\n channel,\n enabled: false,\n frequency: \"off\",\n });\n\n // Gmail and Yahoo one-click requires the POST itself to unsubscribe,\n // with no confirmation page.\n return Response.json({ ok: true, unsubscribed: { type, channel } });\n },\n },\n ],\n };\n}\n"]}
@@ -0,0 +1,22 @@
1
+ import { h as EasyPingPlugin } from '../plugin-Bqj9xAOS.cjs';
2
+ import { C as Channel, F as Frequency } from '../adapter-f0OR2DhY.cjs';
3
+
4
+ declare const UNSUBSCRIBE_PURPOSE = "unsubscribe";
5
+ type PreferenceRow = {
6
+ userId: string;
7
+ type: string;
8
+ channel: Channel;
9
+ enabled: boolean;
10
+ frequency: Frequency;
11
+ };
12
+ type PreferencesOptions = {
13
+ /** What an absent row means. Flip to false for opt-in-only notifications. */
14
+ defaultEnabled?: boolean;
15
+ /** Types that ignore preferences entirely: receipts, security alerts. */
16
+ alwaysSend?: readonly string[];
17
+ unsubscribeTtlSeconds?: number;
18
+ };
19
+ declare function buildUnsubscribeToken(secret: string, userId: string, type: string, channel: Channel, ttlSeconds?: number): Promise<string>;
20
+ declare function preferences(options?: PreferencesOptions): EasyPingPlugin<"preferences">;
21
+
22
+ export { type PreferenceRow, type PreferencesOptions, UNSUBSCRIBE_PURPOSE, buildUnsubscribeToken, preferences };
@@ -0,0 +1,22 @@
1
+ import { h as EasyPingPlugin } from '../plugin-bTJKmnlq.js';
2
+ import { C as Channel, F as Frequency } from '../adapter-f0OR2DhY.js';
3
+
4
+ declare const UNSUBSCRIBE_PURPOSE = "unsubscribe";
5
+ type PreferenceRow = {
6
+ userId: string;
7
+ type: string;
8
+ channel: Channel;
9
+ enabled: boolean;
10
+ frequency: Frequency;
11
+ };
12
+ type PreferencesOptions = {
13
+ /** What an absent row means. Flip to false for opt-in-only notifications. */
14
+ defaultEnabled?: boolean;
15
+ /** Types that ignore preferences entirely: receipts, security alerts. */
16
+ alwaysSend?: readonly string[];
17
+ unsubscribeTtlSeconds?: number;
18
+ };
19
+ declare function buildUnsubscribeToken(secret: string, userId: string, type: string, channel: Channel, ttlSeconds?: number): Promise<string>;
20
+ declare function preferences(options?: PreferencesOptions): EasyPingPlugin<"preferences">;
21
+
22
+ export { type PreferenceRow, type PreferencesOptions, UNSUBSCRIBE_PURPOSE, buildUnsubscribeToken, preferences };
@@ -0,0 +1,119 @@
1
+ import { DEFAULT_TOKEN_TTL_SECONDS, signToken, expiresIn } from '../chunk-W3BMEPPQ.js';
2
+ import '../chunk-PTGHMCCG.js';
3
+
4
+ // src/plugins/preferences/index.ts
5
+ var UNSUBSCRIBE_PURPOSE = "unsubscribe";
6
+ var TABLE = "notification_preference";
7
+ var key = (userId, type, channel) => `${userId} ${type} ${channel}`;
8
+ function buildUnsubscribeToken(secret, userId, type, channel, ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS) {
9
+ return signToken(secret, {
10
+ uid: userId,
11
+ purpose: UNSUBSCRIBE_PURPOSE,
12
+ exp: expiresIn(ttlSeconds),
13
+ data: { type, channel }
14
+ });
15
+ }
16
+ function preferences(options = {}) {
17
+ const defaultEnabled = options.defaultEnabled ?? true;
18
+ const alwaysSend = new Set(options.alwaysSend ?? []);
19
+ let ctx;
20
+ const isAllowed = (userId, type, channel, rows) => {
21
+ const row = rows.get(key(userId, type, channel));
22
+ return row ? row.enabled && row.frequency !== "off" : defaultEnabled;
23
+ };
24
+ const load = (userIds) => ctx.store.find(TABLE, { userId: { in: [...userIds] } });
25
+ const save = (row) => ctx.store.upsert(TABLE, [row], { onConflict: ["userId", "type", "channel"] });
26
+ return {
27
+ id: "preferences",
28
+ init: (context) => {
29
+ ctx = context;
30
+ },
31
+ schema: {
32
+ notificationPreference: {
33
+ tableName: TABLE,
34
+ fields: {
35
+ userId: { type: "string", required: true },
36
+ type: { type: "string", required: true },
37
+ channel: { type: "string", required: true },
38
+ enabled: { type: "boolean", required: true, default: true },
39
+ frequency: { type: "string", required: true, default: "instant" }
40
+ },
41
+ primaryKey: ["userId", "type", "channel"]
42
+ }
43
+ },
44
+ hooks: {
45
+ /** One query per send; doing this per recipient would be an N+1. */
46
+ prepare: async (context) => {
47
+ if (alwaysSend.has(context.type)) return /* @__PURE__ */ new Map();
48
+ const rows = await load(context.recipients.map((recipient) => recipient.userId));
49
+ return new Map(rows.map((row) => [key(row.userId, row.type, row.channel), row]));
50
+ },
51
+ resolveChannels: (context) => {
52
+ if (alwaysSend.has(context.type)) return context.decisions;
53
+ if (!(context.prepared instanceof Map)) {
54
+ return context.decisions.map((decision) => ({ ...decision, action: "skip" }));
55
+ }
56
+ const rows = context.prepared;
57
+ return context.decisions.map(
58
+ (decision) => isAllowed(context.recipient.userId, context.type, decision.channel, rows) ? decision : { ...decision, action: "skip" }
59
+ );
60
+ }
61
+ },
62
+ routes: [
63
+ {
64
+ path: "/preferences",
65
+ method: "GET",
66
+ scope: { type: "user" },
67
+ handler: async ({ userId }) => {
68
+ const rows = await load([userId ?? ""]);
69
+ return Response.json({ preferences: rows, defaultEnabled });
70
+ }
71
+ },
72
+ {
73
+ path: "/preferences",
74
+ method: "POST",
75
+ scope: { type: "user" },
76
+ handler: async ({ request, userId }) => {
77
+ const body = await request.json().catch(() => null);
78
+ if (!body?.type || !body.channel) {
79
+ return Response.json({ error: "type and channel are required" }, { status: 400 });
80
+ }
81
+ await save({
82
+ userId: userId ?? "",
83
+ type: body.type,
84
+ channel: body.channel,
85
+ enabled: body.enabled ?? true,
86
+ frequency: body.frequency ?? "instant"
87
+ });
88
+ return Response.json({ ok: true });
89
+ }
90
+ },
91
+ {
92
+ // Reached from a mail client with no session, so it is authenticated
93
+ // by the signature on the link itself.
94
+ path: "/unsubscribe",
95
+ method: "POST",
96
+ scope: { type: "signed", purpose: UNSUBSCRIBE_PURPOSE },
97
+ handler: async ({ claims }) => {
98
+ const type = claims?.data?.type;
99
+ const channel = claims?.data?.channel;
100
+ if (!claims || !type || !channel) {
101
+ return Response.json({ error: "invalid token" }, { status: 400 });
102
+ }
103
+ await save({
104
+ userId: claims.uid,
105
+ type,
106
+ channel,
107
+ enabled: false,
108
+ frequency: "off"
109
+ });
110
+ return Response.json({ ok: true, unsubscribed: { type, channel } });
111
+ }
112
+ }
113
+ ]
114
+ };
115
+ }
116
+
117
+ export { UNSUBSCRIBE_PURPOSE, buildUnsubscribeToken, preferences };
118
+ //# sourceMappingURL=preferences.js.map
119
+ //# sourceMappingURL=preferences.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/plugins/preferences/index.ts"],"names":[],"mappings":"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","file":"preferences.js","sourcesContent":["import type {\n ChannelDecision,\n EasyPingPlugin,\n PluginInitContext,\n PrepareContext,\n ResolveChannelsContext,\n} from \"../../core/plugin\";\nimport { DEFAULT_TOKEN_TTL_SECONDS, expiresIn, signToken } from \"../../core/tokens\";\nimport type { Channel, Frequency } from \"../../core/types\";\n\nexport const UNSUBSCRIBE_PURPOSE = \"unsubscribe\";\n\nconst TABLE = \"notification_preference\";\n\nexport type PreferenceRow = {\n userId: string;\n type: string;\n channel: Channel;\n enabled: boolean;\n frequency: Frequency;\n};\n\nexport type PreferencesOptions = {\n /** What an absent row means. Flip to false for opt-in-only notifications. */\n defaultEnabled?: boolean;\n /** Types that ignore preferences entirely: receipts, security alerts. */\n alwaysSend?: readonly string[];\n unsubscribeTtlSeconds?: number;\n};\n\nconst key = (userId: string, type: string, channel: string) => `${userId} ${type} ${channel}`;\n\ntype PreparedPreferences = Map<string, PreferenceRow>;\n\nexport function buildUnsubscribeToken(\n secret: string,\n userId: string,\n type: string,\n channel: Channel,\n ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS,\n): Promise<string> {\n return signToken(secret, {\n uid: userId,\n purpose: UNSUBSCRIBE_PURPOSE,\n exp: expiresIn(ttlSeconds),\n data: { type, channel },\n });\n}\n\nexport function preferences(options: PreferencesOptions = {}): EasyPingPlugin<\"preferences\"> {\n const defaultEnabled = options.defaultEnabled ?? true;\n const alwaysSend = new Set(options.alwaysSend ?? []);\n\n // Assigned by init(), which easyPing() runs before any hook or route.\n let ctx: PluginInitContext;\n\n const isAllowed = (userId: string, type: string, channel: Channel, rows: PreparedPreferences) => {\n const row = rows.get(key(userId, type, channel));\n return row ? row.enabled && row.frequency !== \"off\" : defaultEnabled;\n };\n\n const load = (userIds: readonly string[]) =>\n ctx.store.find<PreferenceRow>(TABLE, { userId: { in: [...userIds] } });\n\n const save = (row: PreferenceRow) =>\n ctx.store.upsert(TABLE, [row], { onConflict: [\"userId\", \"type\", \"channel\"] });\n\n return {\n id: \"preferences\",\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n notificationPreference: {\n tableName: TABLE,\n fields: {\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n channel: { type: \"string\", required: true },\n enabled: { type: \"boolean\", required: true, default: true },\n frequency: { type: \"string\", required: true, default: \"instant\" },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\n },\n\n hooks: {\n /** One query per send; doing this per recipient would be an N+1. */\n prepare: async (context: PrepareContext): Promise<PreparedPreferences> => {\n if (alwaysSend.has(context.type)) return new Map();\n\n const rows = await load(context.recipients.map((recipient) => recipient.userId));\n return new Map(rows.map((row) => [key(row.userId, row.type, row.channel), row]));\n },\n\n resolveChannels: (context: ResolveChannelsContext) => {\n if (alwaysSend.has(context.type)) return context.decisions;\n\n // prepare() failing leaves this undefined. Fail closed rather than\n // assuming everyone opted in: that is exactly how people who\n // unsubscribed get emailed anyway.\n if (!(context.prepared instanceof Map)) {\n return context.decisions.map((decision) => ({ ...decision, action: \"skip\" as const }));\n }\n\n const rows = context.prepared as PreparedPreferences;\n\n return context.decisions.map<ChannelDecision>((decision) =>\n isAllowed(context.recipient.userId, context.type, decision.channel, rows)\n ? decision\n : { ...decision, action: \"skip\" },\n );\n },\n },\n\n routes: [\n {\n path: \"/preferences\",\n method: \"GET\",\n scope: { type: \"user\" },\n handler: async ({ userId }) => {\n const rows = await load([userId ?? \"\"]);\n return Response.json({ preferences: rows, defaultEnabled });\n },\n },\n\n {\n path: \"/preferences\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const body = (await request.json().catch(() => null)) as Partial<PreferenceRow> | null;\n\n if (!body?.type || !body.channel) {\n return Response.json({ error: \"type and channel are required\" }, { status: 400 });\n }\n\n // userId comes from the resolved session, never from the body.\n await save({\n userId: userId ?? \"\",\n type: body.type,\n channel: body.channel,\n enabled: body.enabled ?? true,\n frequency: body.frequency ?? \"instant\",\n });\n\n return Response.json({ ok: true });\n },\n },\n\n {\n // Reached from a mail client with no session, so it is authenticated\n // by the signature on the link itself.\n path: \"/unsubscribe\",\n method: \"POST\",\n scope: { type: \"signed\", purpose: UNSUBSCRIBE_PURPOSE },\n handler: async ({ claims }) => {\n const type = claims?.data?.type;\n const channel = claims?.data?.channel as Channel | undefined;\n if (!claims || !type || !channel) {\n return Response.json({ error: \"invalid token\" }, { status: 400 });\n }\n\n await save({\n userId: claims.uid,\n type,\n channel,\n enabled: false,\n frequency: \"off\",\n });\n\n // Gmail and Yahoo one-click requires the POST itself to unsubscribe,\n // with no confirmation page.\n return Response.json({ ok: true, unsubscribed: { type, channel } });\n },\n },\n ],\n };\n}\n"]}
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ // src/plugins/push/index.ts
4
+ var DEVICE = "notification_push_device";
5
+ function push(options) {
6
+ let ctx;
7
+ const staleAfterDays = options.staleAfterDays ?? 180;
8
+ return {
9
+ id: "push",
10
+ channels: ["push"],
11
+ init: (context) => {
12
+ ctx = context;
13
+ },
14
+ schema: {
15
+ pushDevice: {
16
+ tableName: DEVICE,
17
+ fields: {
18
+ id: { type: "string", required: true },
19
+ userId: { type: "string", required: true },
20
+ endpoint: { type: "string", required: true },
21
+ p256dh: { type: "string", required: true },
22
+ auth: { type: "string", required: true },
23
+ userAgent: { type: "string" },
24
+ createdAt: { type: "date", required: true, defaultNow: true },
25
+ lastSeenAt: { type: "date", required: true, defaultNow: true }
26
+ },
27
+ primaryKey: ["id"],
28
+ indexes: [
29
+ { on: ["userId"], name: "push_device_user_idx" },
30
+ // One row per endpoint: re-registering the same browser must update,
31
+ // not duplicate, or every send goes out N times to one device.
32
+ { on: ["endpoint"], unique: true, name: "push_device_endpoint_idx" }
33
+ ]
34
+ }
35
+ },
36
+ hooks: {
37
+ deliver: async (context) => {
38
+ const devices = await ctx.store.find(DEVICE, {
39
+ userId: context.notification.userId
40
+ });
41
+ if (devices.length === 0) {
42
+ return { result: "failed", error: "no registered devices", retryable: false };
43
+ }
44
+ const content = options.render({
45
+ type: context.notification.type,
46
+ payload: context.notification.payload
47
+ });
48
+ const expired = [];
49
+ let delivered = 0;
50
+ let retryable = false;
51
+ let lastError = "";
52
+ for (const device of devices) {
53
+ try {
54
+ const result = await options.provider.send({
55
+ subscription: {
56
+ endpoint: device.endpoint,
57
+ keys: { p256dh: device.p256dh, auth: device.auth }
58
+ },
59
+ ...content,
60
+ data: { notificationId: context.notification.id, type: context.notification.type },
61
+ signal: context.signal
62
+ });
63
+ if (result.expired) expired.push(device.id);
64
+ else {
65
+ delivered += 1;
66
+ if (result.retryable) retryable = true;
67
+ }
68
+ } catch (error) {
69
+ lastError = error instanceof Error ? error.message : String(error);
70
+ retryable = true;
71
+ }
72
+ }
73
+ if (expired.length > 0) await ctx.store.remove(DEVICE, { id: { in: expired } });
74
+ if (delivered > 0) return { result: "sent" };
75
+ if (expired.length === devices.length) {
76
+ return { result: "failed", error: "all devices expired", retryable: false };
77
+ }
78
+ return {
79
+ result: "failed",
80
+ error: lastError || "no device accepted the push",
81
+ retryable
82
+ };
83
+ }
84
+ },
85
+ routes: [
86
+ {
87
+ path: "/push/devices",
88
+ method: "POST",
89
+ scope: { type: "user" },
90
+ handler: async ({ request, userId }) => {
91
+ const body = await request.json().catch(() => null);
92
+ if (!body?.endpoint || !body.keys?.p256dh || !body.keys?.auth) {
93
+ return Response.json({ error: "endpoint and keys are required" }, { status: 400 });
94
+ }
95
+ await ctx.store.upsert(
96
+ DEVICE,
97
+ [
98
+ {
99
+ id: crypto.randomUUID(),
100
+ userId: userId ?? "",
101
+ endpoint: body.endpoint,
102
+ p256dh: body.keys.p256dh,
103
+ auth: body.keys.auth,
104
+ userAgent: request.headers.get("user-agent"),
105
+ createdAt: /* @__PURE__ */ new Date(),
106
+ lastSeenAt: /* @__PURE__ */ new Date()
107
+ }
108
+ ],
109
+ { onConflict: ["endpoint"] }
110
+ );
111
+ return Response.json({ ok: true });
112
+ }
113
+ },
114
+ {
115
+ path: "/push/devices/remove",
116
+ method: "POST",
117
+ scope: { type: "user" },
118
+ handler: async ({ request, userId }) => {
119
+ const body = await request.json().catch(() => null);
120
+ if (!body?.endpoint) {
121
+ return Response.json({ error: "endpoint is required" }, { status: 400 });
122
+ }
123
+ const removed = await ctx.store.remove(DEVICE, {
124
+ userId: userId ?? "",
125
+ endpoint: body.endpoint
126
+ });
127
+ return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });
128
+ }
129
+ },
130
+ {
131
+ path: "/push/prune",
132
+ method: "POST",
133
+ scope: { type: "machine" },
134
+ handler: async () => {
135
+ const cutoff = new Date(Date.now() - staleAfterDays * 864e5);
136
+ const removed = await ctx.store.remove(DEVICE, { lastSeenAt: { lt: cutoff } });
137
+ return Response.json({ removed });
138
+ }
139
+ }
140
+ ]
141
+ };
142
+ }
143
+
144
+ exports.push = push;
145
+ //# sourceMappingURL=push.cjs.map
146
+ //# sourceMappingURL=push.cjs.map