easy-ping 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/dist/{adapter-f0OR2DhY.d.cts → adapter-BfLe2J0t.d.cts} +10 -0
- package/dist/{adapter-f0OR2DhY.d.ts → adapter-BfLe2J0t.d.ts} +10 -0
- package/dist/adapters/drizzle.cjs +9 -4
- package/dist/adapters/drizzle.cjs.map +1 -1
- package/dist/adapters/drizzle.d.cts +35 -1
- package/dist/adapters/drizzle.d.ts +35 -1
- package/dist/adapters/drizzle.js +10 -5
- package/dist/adapters/drizzle.js.map +1 -1
- package/dist/adapters/mongodb.cjs +9 -4
- package/dist/adapters/mongodb.cjs.map +1 -1
- package/dist/adapters/mongodb.d.cts +2 -2
- package/dist/adapters/mongodb.d.ts +2 -2
- package/dist/adapters/mongodb.js +8 -4
- package/dist/adapters/mongodb.js.map +1 -1
- package/dist/adapters/postgres.cjs +136 -3
- package/dist/adapters/postgres.cjs.map +1 -1
- package/dist/adapters/postgres.d.cts +45 -2
- package/dist/adapters/postgres.d.ts +45 -2
- package/dist/adapters/postgres.js +38 -5
- package/dist/adapters/postgres.js.map +1 -1
- package/dist/{chunk-OZSOIX23.js → chunk-3Y2MBUAW.js} +53 -12
- package/dist/chunk-3Y2MBUAW.js.map +1 -0
- package/dist/chunk-CYKAM7LE.js +226 -0
- package/dist/chunk-CYKAM7LE.js.map +1 -0
- package/dist/chunk-DG3ZL5NP.js +315 -0
- package/dist/chunk-DG3ZL5NP.js.map +1 -0
- package/dist/{chunk-NYDVNMKG.js → chunk-PSJHXZPR.js} +4 -3
- package/dist/chunk-PSJHXZPR.js.map +1 -0
- package/dist/{chunk-BV3U542P.js → chunk-W6I3B2CI.js} +2 -2
- package/dist/chunk-W6I3B2CI.js.map +1 -0
- package/dist/chunk-YEMRRMJQ.js +199 -0
- package/dist/chunk-YEMRRMJQ.js.map +1 -0
- package/dist/client.cjs +3 -5
- package/dist/client.cjs.map +1 -1
- package/dist/client.js +3 -5
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +377 -73
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +88 -22
- package/dist/index.d.ts +88 -22
- package/dist/index.js +119 -351
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +27 -6
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +8 -2
- package/dist/node.d.ts +8 -2
- package/dist/node.js +26 -6
- package/dist/node.js.map +1 -1
- package/dist/{plugin-Bqj9xAOS.d.cts → plugin-C9Hoxus2.d.ts} +38 -10
- package/dist/{plugin-bTJKmnlq.d.ts → plugin-CiMsGlQe.d.cts} +38 -10
- package/dist/plugins/digests.d.cts +2 -2
- package/dist/plugins/digests.d.ts +2 -2
- package/dist/plugins/preferences-client.cjs.map +1 -1
- package/dist/plugins/preferences-client.d.cts +2 -2
- package/dist/plugins/preferences-client.d.ts +2 -2
- package/dist/plugins/preferences-client.js +1 -1
- package/dist/plugins/preferences.cjs +100 -15
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.d.cts +4 -2
- package/dist/plugins/preferences.d.ts +4 -2
- package/dist/plugins/preferences.js +35 -7
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.cjs +222 -61
- package/dist/plugins/push.cjs.map +1 -1
- package/dist/plugins/push.d.cts +81 -3
- package/dist/plugins/push.d.ts +81 -3
- package/dist/plugins/push.js +164 -62
- package/dist/plugins/push.js.map +1 -1
- package/dist/providers/web-push.cjs +37 -11
- package/dist/providers/web-push.cjs.map +1 -1
- package/dist/providers/web-push.d.cts +9 -3
- package/dist/providers/web-push.d.ts +9 -3
- package/dist/providers/web-push.js +37 -12
- package/dist/providers/web-push.js.map +1 -1
- package/dist/react.cjs +3 -5
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +3 -5
- package/dist/react.js.map +1 -1
- package/dist/schema.cjs +2 -1
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +7 -2
- package/dist/schema.d.ts +7 -2
- package/dist/schema.js +2 -198
- package/dist/schema.js.map +1 -1
- package/dist/testing.cjs +29 -0
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +29 -0
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-BV3U542P.js.map +0 -1
- package/dist/chunk-NYDVNMKG.js.map +0 -1
- package/dist/chunk-OZSOIX23.js.map +0 -1
- package/dist/chunk-W3BMEPPQ.js +0 -50
- package/dist/chunk-W3BMEPPQ.js.map +0 -1
package/dist/plugins/push.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { h as EasyPingPlugin } from '../plugin-
|
|
2
|
-
import '../adapter-
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-CiMsGlQe.cjs';
|
|
2
|
+
import '../adapter-BfLe2J0t.cjs';
|
|
3
3
|
|
|
4
4
|
type PushSubscription = {
|
|
5
5
|
endpoint: string;
|
|
@@ -19,6 +19,8 @@ type PushSendResult = {
|
|
|
19
19
|
/** True when the endpoint is permanently gone (404/410) and must be pruned. */
|
|
20
20
|
expired?: boolean;
|
|
21
21
|
retryable?: boolean;
|
|
22
|
+
/** True when the subscription can never work — bad keys, unusable endpoint. Pruned like expired. */
|
|
23
|
+
invalid?: boolean;
|
|
22
24
|
};
|
|
23
25
|
type PushProvider = {
|
|
24
26
|
name: string;
|
|
@@ -46,7 +48,83 @@ type PushOptions = {
|
|
|
46
48
|
};
|
|
47
49
|
/** Devices untouched for this long are pruned on next delivery. */
|
|
48
50
|
staleAfterDays?: number;
|
|
51
|
+
/** Registering past this evicts the user's least recently seen device. Default 20. */
|
|
52
|
+
maxDevicesPerUser?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Hostnames an endpoint may point at, e.g. `["fcm.googleapis.com",
|
|
55
|
+
* "*.notify.windows.com"]`. Unset accepts any public https host, which is
|
|
56
|
+
* what self-hosted UnifiedPush distributors need.
|
|
57
|
+
*/
|
|
58
|
+
allowedEndpointHosts?: readonly string[];
|
|
59
|
+
/** Accepts http and private hosts. For a local fake push service only; never in production. */
|
|
60
|
+
allowInsecureEndpoints?: boolean;
|
|
49
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* The tables this plugin owns, as a standalone value.
|
|
64
|
+
*
|
|
65
|
+
* Exported separately from `push()` so DDL can be rendered without building a
|
|
66
|
+
* plugin instance — otherwise creating the tables means inventing a provider
|
|
67
|
+
* and a render function you never intend to call.
|
|
68
|
+
*/
|
|
69
|
+
declare const pushSchema: {
|
|
70
|
+
readonly pushDevice: {
|
|
71
|
+
readonly tableName: "notification_push_device";
|
|
72
|
+
readonly fields: {
|
|
73
|
+
readonly id: {
|
|
74
|
+
readonly type: "string";
|
|
75
|
+
readonly required: true;
|
|
76
|
+
};
|
|
77
|
+
readonly userId: {
|
|
78
|
+
readonly type: "string";
|
|
79
|
+
readonly required: true;
|
|
80
|
+
};
|
|
81
|
+
readonly endpoint: {
|
|
82
|
+
readonly type: "string";
|
|
83
|
+
readonly required: true;
|
|
84
|
+
};
|
|
85
|
+
readonly p256dh: {
|
|
86
|
+
readonly type: "string";
|
|
87
|
+
readonly required: true;
|
|
88
|
+
};
|
|
89
|
+
readonly auth: {
|
|
90
|
+
readonly type: "string";
|
|
91
|
+
readonly required: true;
|
|
92
|
+
};
|
|
93
|
+
readonly userAgent: {
|
|
94
|
+
readonly type: "string";
|
|
95
|
+
};
|
|
96
|
+
readonly createdAt: {
|
|
97
|
+
readonly type: "date";
|
|
98
|
+
readonly required: true;
|
|
99
|
+
readonly defaultNow: true;
|
|
100
|
+
};
|
|
101
|
+
readonly lastSeenAt: {
|
|
102
|
+
readonly type: "date";
|
|
103
|
+
readonly required: true;
|
|
104
|
+
readonly defaultNow: true;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
readonly primaryKey: readonly ["id"];
|
|
108
|
+
readonly indexes: readonly [{
|
|
109
|
+
readonly on: readonly ["userId"];
|
|
110
|
+
readonly name: "push_device_user_idx";
|
|
111
|
+
}, {
|
|
112
|
+
readonly on: readonly ["endpoint"];
|
|
113
|
+
readonly unique: true;
|
|
114
|
+
readonly name: "push_device_endpoint_idx";
|
|
115
|
+
}];
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* The server will POST to whatever is stored here, so this is the SSRF
|
|
120
|
+
* boundary: public https only, optionally pinned to known push services.
|
|
121
|
+
*/
|
|
122
|
+
declare function validateEndpoint(endpoint: string, allowedHosts?: readonly string[] | undefined, allowInsecure?: boolean): string | null;
|
|
123
|
+
/** RFC 8291: an uncompressed P-256 point and a 16-byte auth secret. */
|
|
124
|
+
declare function validateSubscriptionKeys(keys: {
|
|
125
|
+
p256dh: string;
|
|
126
|
+
auth: string;
|
|
127
|
+
}): string | null;
|
|
50
128
|
/**
|
|
51
129
|
* Web push over the device registry.
|
|
52
130
|
*
|
|
@@ -57,4 +135,4 @@ type PushOptions = {
|
|
|
57
135
|
*/
|
|
58
136
|
declare function push(options: PushOptions): EasyPingPlugin<"push">;
|
|
59
137
|
|
|
60
|
-
export { type PushDevice, type PushMessage, type PushOptions, type PushProvider, type PushSendResult, type PushSubscription, push };
|
|
138
|
+
export { type PushDevice, type PushMessage, type PushOptions, type PushProvider, type PushSendResult, type PushSubscription, push, pushSchema, validateEndpoint, validateSubscriptionKeys };
|
package/dist/plugins/push.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { h as EasyPingPlugin } from '../plugin-
|
|
2
|
-
import '../adapter-
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-C9Hoxus2.js';
|
|
2
|
+
import '../adapter-BfLe2J0t.js';
|
|
3
3
|
|
|
4
4
|
type PushSubscription = {
|
|
5
5
|
endpoint: string;
|
|
@@ -19,6 +19,8 @@ type PushSendResult = {
|
|
|
19
19
|
/** True when the endpoint is permanently gone (404/410) and must be pruned. */
|
|
20
20
|
expired?: boolean;
|
|
21
21
|
retryable?: boolean;
|
|
22
|
+
/** True when the subscription can never work — bad keys, unusable endpoint. Pruned like expired. */
|
|
23
|
+
invalid?: boolean;
|
|
22
24
|
};
|
|
23
25
|
type PushProvider = {
|
|
24
26
|
name: string;
|
|
@@ -46,7 +48,83 @@ type PushOptions = {
|
|
|
46
48
|
};
|
|
47
49
|
/** Devices untouched for this long are pruned on next delivery. */
|
|
48
50
|
staleAfterDays?: number;
|
|
51
|
+
/** Registering past this evicts the user's least recently seen device. Default 20. */
|
|
52
|
+
maxDevicesPerUser?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Hostnames an endpoint may point at, e.g. `["fcm.googleapis.com",
|
|
55
|
+
* "*.notify.windows.com"]`. Unset accepts any public https host, which is
|
|
56
|
+
* what self-hosted UnifiedPush distributors need.
|
|
57
|
+
*/
|
|
58
|
+
allowedEndpointHosts?: readonly string[];
|
|
59
|
+
/** Accepts http and private hosts. For a local fake push service only; never in production. */
|
|
60
|
+
allowInsecureEndpoints?: boolean;
|
|
49
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* The tables this plugin owns, as a standalone value.
|
|
64
|
+
*
|
|
65
|
+
* Exported separately from `push()` so DDL can be rendered without building a
|
|
66
|
+
* plugin instance — otherwise creating the tables means inventing a provider
|
|
67
|
+
* and a render function you never intend to call.
|
|
68
|
+
*/
|
|
69
|
+
declare const pushSchema: {
|
|
70
|
+
readonly pushDevice: {
|
|
71
|
+
readonly tableName: "notification_push_device";
|
|
72
|
+
readonly fields: {
|
|
73
|
+
readonly id: {
|
|
74
|
+
readonly type: "string";
|
|
75
|
+
readonly required: true;
|
|
76
|
+
};
|
|
77
|
+
readonly userId: {
|
|
78
|
+
readonly type: "string";
|
|
79
|
+
readonly required: true;
|
|
80
|
+
};
|
|
81
|
+
readonly endpoint: {
|
|
82
|
+
readonly type: "string";
|
|
83
|
+
readonly required: true;
|
|
84
|
+
};
|
|
85
|
+
readonly p256dh: {
|
|
86
|
+
readonly type: "string";
|
|
87
|
+
readonly required: true;
|
|
88
|
+
};
|
|
89
|
+
readonly auth: {
|
|
90
|
+
readonly type: "string";
|
|
91
|
+
readonly required: true;
|
|
92
|
+
};
|
|
93
|
+
readonly userAgent: {
|
|
94
|
+
readonly type: "string";
|
|
95
|
+
};
|
|
96
|
+
readonly createdAt: {
|
|
97
|
+
readonly type: "date";
|
|
98
|
+
readonly required: true;
|
|
99
|
+
readonly defaultNow: true;
|
|
100
|
+
};
|
|
101
|
+
readonly lastSeenAt: {
|
|
102
|
+
readonly type: "date";
|
|
103
|
+
readonly required: true;
|
|
104
|
+
readonly defaultNow: true;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
readonly primaryKey: readonly ["id"];
|
|
108
|
+
readonly indexes: readonly [{
|
|
109
|
+
readonly on: readonly ["userId"];
|
|
110
|
+
readonly name: "push_device_user_idx";
|
|
111
|
+
}, {
|
|
112
|
+
readonly on: readonly ["endpoint"];
|
|
113
|
+
readonly unique: true;
|
|
114
|
+
readonly name: "push_device_endpoint_idx";
|
|
115
|
+
}];
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* The server will POST to whatever is stored here, so this is the SSRF
|
|
120
|
+
* boundary: public https only, optionally pinned to known push services.
|
|
121
|
+
*/
|
|
122
|
+
declare function validateEndpoint(endpoint: string, allowedHosts?: readonly string[] | undefined, allowInsecure?: boolean): string | null;
|
|
123
|
+
/** RFC 8291: an uncompressed P-256 point and a 16-byte auth secret. */
|
|
124
|
+
declare function validateSubscriptionKeys(keys: {
|
|
125
|
+
p256dh: string;
|
|
126
|
+
auth: string;
|
|
127
|
+
}): string | null;
|
|
50
128
|
/**
|
|
51
129
|
* Web push over the device registry.
|
|
52
130
|
*
|
|
@@ -57,4 +135,4 @@ type PushOptions = {
|
|
|
57
135
|
*/
|
|
58
136
|
declare function push(options: PushOptions): EasyPingPlugin<"push">;
|
|
59
137
|
|
|
60
|
-
export { type PushDevice, type PushMessage, type PushOptions, type PushProvider, type PushSendResult, type PushSubscription, push };
|
|
138
|
+
export { type PushDevice, type PushMessage, type PushOptions, type PushProvider, type PushSendResult, type PushSubscription, push, pushSchema, validateEndpoint, validateSubscriptionKeys };
|
package/dist/plugins/push.js
CHANGED
|
@@ -1,55 +1,105 @@
|
|
|
1
|
+
import { redactErrorMessage } from '../chunk-CYKAM7LE.js';
|
|
2
|
+
import { readJsonBody } from '../chunk-DG3ZL5NP.js';
|
|
3
|
+
import { decodeBase64UrlBytes } from '../chunk-PTGHMCCG.js';
|
|
4
|
+
|
|
1
5
|
// src/plugins/push/index.ts
|
|
2
6
|
var DEVICE = "notification_push_device";
|
|
7
|
+
var pushSchema = {
|
|
8
|
+
pushDevice: {
|
|
9
|
+
tableName: DEVICE,
|
|
10
|
+
fields: {
|
|
11
|
+
id: { type: "string", required: true },
|
|
12
|
+
userId: { type: "string", required: true },
|
|
13
|
+
endpoint: { type: "string", required: true },
|
|
14
|
+
p256dh: { type: "string", required: true },
|
|
15
|
+
auth: { type: "string", required: true },
|
|
16
|
+
userAgent: { type: "string" },
|
|
17
|
+
createdAt: { type: "date", required: true, defaultNow: true },
|
|
18
|
+
lastSeenAt: { type: "date", required: true, defaultNow: true }
|
|
19
|
+
},
|
|
20
|
+
primaryKey: ["id"],
|
|
21
|
+
indexes: [
|
|
22
|
+
{ on: ["userId"], name: "push_device_user_idx" },
|
|
23
|
+
// One row per endpoint: re-registering the same browser must update,
|
|
24
|
+
// not duplicate, or every send goes out N times to one device.
|
|
25
|
+
{ on: ["endpoint"], unique: true, name: "push_device_endpoint_idx" }
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var PRIVATE_IPV4 = /^(0\.|10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/;
|
|
30
|
+
function isInternalHost(hostname) {
|
|
31
|
+
const host = hostname.toLowerCase();
|
|
32
|
+
if (host === "localhost" || /\.(localhost|local|internal|lan)$/.test(host)) return true;
|
|
33
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return PRIVATE_IPV4.test(host);
|
|
34
|
+
if (host.startsWith("[")) {
|
|
35
|
+
const v6 = host.slice(1, -1);
|
|
36
|
+
return v6 === "::1" || v6 === "::" || /^(fc|fd|fe[89ab])/.test(v6) || v6.startsWith("::ffff:");
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
var hostAllowed = (host, allowed) => allowed.some(
|
|
41
|
+
(entry) => entry.startsWith("*.") ? host.endsWith(entry.slice(1)) : host === entry.toLowerCase()
|
|
42
|
+
);
|
|
43
|
+
function validateEndpoint(endpoint, allowedHosts, allowInsecure = false) {
|
|
44
|
+
let url;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(endpoint);
|
|
47
|
+
} catch {
|
|
48
|
+
return "endpoint is not a valid URL";
|
|
49
|
+
}
|
|
50
|
+
if (url.username || url.password) return "endpoint must not carry credentials";
|
|
51
|
+
if (!allowInsecure) {
|
|
52
|
+
if (url.protocol !== "https:") return "endpoint must use https";
|
|
53
|
+
if (isInternalHost(url.hostname)) return "endpoint must be a public host";
|
|
54
|
+
} else if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
55
|
+
return "endpoint must be an http(s) URL";
|
|
56
|
+
}
|
|
57
|
+
if (allowedHosts && !hostAllowed(url.hostname.toLowerCase(), allowedHosts)) {
|
|
58
|
+
return "endpoint host is not an allowed push service";
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
function validateSubscriptionKeys(keys) {
|
|
63
|
+
const p256dh = decodeBase64UrlBytes(keys.p256dh);
|
|
64
|
+
if (p256dh?.length !== 65 || p256dh[0] !== 4) {
|
|
65
|
+
return "keys.p256dh must be a 65-byte uncompressed EC point";
|
|
66
|
+
}
|
|
67
|
+
const auth = decodeBase64UrlBytes(keys.auth);
|
|
68
|
+
if (auth?.length !== 16) return "keys.auth must be 16 bytes";
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
var isDuplicate = (error) => {
|
|
72
|
+
const code = error?.code;
|
|
73
|
+
if (code === "23505" || code === 11e3) return true;
|
|
74
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
75
|
+
return /duplicate|unique/i.test(text);
|
|
76
|
+
};
|
|
3
77
|
function push(options) {
|
|
4
78
|
let ctx;
|
|
5
79
|
const staleAfterDays = options.staleAfterDays ?? 180;
|
|
80
|
+
const maxDevices = Math.max(1, options.maxDevicesPerUser ?? 20);
|
|
6
81
|
return {
|
|
7
82
|
id: "push",
|
|
8
83
|
channels: ["push"],
|
|
9
84
|
init: (context) => {
|
|
10
85
|
ctx = context;
|
|
11
86
|
},
|
|
12
|
-
schema:
|
|
13
|
-
pushDevice: {
|
|
14
|
-
tableName: DEVICE,
|
|
15
|
-
fields: {
|
|
16
|
-
id: { type: "string", required: true },
|
|
17
|
-
userId: { type: "string", required: true },
|
|
18
|
-
endpoint: { type: "string", required: true },
|
|
19
|
-
p256dh: { type: "string", required: true },
|
|
20
|
-
auth: { type: "string", required: true },
|
|
21
|
-
userAgent: { type: "string" },
|
|
22
|
-
createdAt: { type: "date", required: true, defaultNow: true },
|
|
23
|
-
lastSeenAt: { type: "date", required: true, defaultNow: true }
|
|
24
|
-
},
|
|
25
|
-
primaryKey: ["id"],
|
|
26
|
-
indexes: [
|
|
27
|
-
{ on: ["userId"], name: "push_device_user_idx" },
|
|
28
|
-
// One row per endpoint: re-registering the same browser must update,
|
|
29
|
-
// not duplicate, or every send goes out N times to one device.
|
|
30
|
-
{ on: ["endpoint"], unique: true, name: "push_device_endpoint_idx" }
|
|
31
|
-
]
|
|
32
|
-
}
|
|
33
|
-
},
|
|
87
|
+
schema: pushSchema,
|
|
34
88
|
hooks: {
|
|
35
89
|
deliver: async (context) => {
|
|
36
90
|
const devices = await ctx.store.find(DEVICE, {
|
|
37
91
|
userId: context.notification.userId
|
|
38
92
|
});
|
|
39
93
|
if (devices.length === 0) {
|
|
40
|
-
return { result: "
|
|
94
|
+
return { result: "skipped", reason: "no registered devices" };
|
|
41
95
|
}
|
|
42
96
|
const content = options.render({
|
|
43
97
|
type: context.notification.type,
|
|
44
98
|
payload: context.notification.payload
|
|
45
99
|
});
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
let lastError = "";
|
|
50
|
-
for (const device of devices) {
|
|
51
|
-
try {
|
|
52
|
-
const result = await options.provider.send({
|
|
100
|
+
const results = await Promise.allSettled(
|
|
101
|
+
devices.map(
|
|
102
|
+
(device) => options.provider.send({
|
|
53
103
|
subscription: {
|
|
54
104
|
endpoint: device.endpoint,
|
|
55
105
|
keys: { p256dh: device.p256dh, auth: device.auth }
|
|
@@ -57,20 +107,30 @@ function push(options) {
|
|
|
57
107
|
...content,
|
|
58
108
|
data: { notificationId: context.notification.id, type: context.notification.type },
|
|
59
109
|
signal: context.signal
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
110
|
+
})
|
|
111
|
+
)
|
|
112
|
+
);
|
|
113
|
+
const prune = [];
|
|
114
|
+
let delivered = 0;
|
|
115
|
+
let retryable = false;
|
|
116
|
+
let lastError = "";
|
|
117
|
+
results.forEach((result, index) => {
|
|
118
|
+
const device = devices[index];
|
|
119
|
+
if (!device) return;
|
|
120
|
+
if (result.status === "rejected") {
|
|
121
|
+
lastError = redactErrorMessage(
|
|
122
|
+
result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
123
|
+
);
|
|
68
124
|
retryable = true;
|
|
125
|
+
return;
|
|
69
126
|
}
|
|
70
|
-
|
|
71
|
-
|
|
127
|
+
if (result.value.expired || result.value.invalid) prune.push(device.id);
|
|
128
|
+
else if (result.value.retryable) retryable = true;
|
|
129
|
+
else delivered += 1;
|
|
130
|
+
});
|
|
131
|
+
if (prune.length > 0) await ctx.store.remove(DEVICE, { id: { in: prune } });
|
|
72
132
|
if (delivered > 0) return { result: "sent" };
|
|
73
|
-
if (
|
|
133
|
+
if (prune.length === devices.length) {
|
|
74
134
|
return { result: "failed", error: "all devices expired", retryable: false };
|
|
75
135
|
}
|
|
76
136
|
return {
|
|
@@ -86,26 +146,69 @@ function push(options) {
|
|
|
86
146
|
method: "POST",
|
|
87
147
|
scope: { type: "user" },
|
|
88
148
|
handler: async ({ request, userId }) => {
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
149
|
+
const parsed = await readJsonBody(request);
|
|
150
|
+
if ("error" in parsed) return parsed.error;
|
|
151
|
+
const { endpoint, keys } = parsed.body;
|
|
152
|
+
if (typeof endpoint !== "string" || typeof keys?.p256dh !== "string" || typeof keys?.auth !== "string") {
|
|
91
153
|
return Response.json({ error: "endpoint and keys are required" }, { status: 400 });
|
|
92
154
|
}
|
|
93
|
-
|
|
155
|
+
const problem = validateEndpoint(
|
|
156
|
+
endpoint,
|
|
157
|
+
options.allowedEndpointHosts,
|
|
158
|
+
options.allowInsecureEndpoints ?? false
|
|
159
|
+
) ?? validateSubscriptionKeys(keys);
|
|
160
|
+
if (problem) return Response.json({ error: problem }, { status: 400 });
|
|
161
|
+
const owner = userId ?? "";
|
|
162
|
+
const now = /* @__PURE__ */ new Date();
|
|
163
|
+
const userAgent = request.headers.get("user-agent")?.slice(0, 512) ?? null;
|
|
164
|
+
const [existing] = await ctx.store.find(DEVICE, { endpoint }, { limit: 1 });
|
|
165
|
+
if (existing && existing.userId !== owner) {
|
|
166
|
+
return Response.json(
|
|
167
|
+
{ error: "endpoint is registered to another account" },
|
|
168
|
+
{ status: 409 }
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (existing) {
|
|
172
|
+
await ctx.store.update(
|
|
173
|
+
DEVICE,
|
|
174
|
+
{ id: existing.id },
|
|
175
|
+
{ p256dh: keys.p256dh, auth: keys.auth, userAgent, lastSeenAt: now }
|
|
176
|
+
);
|
|
177
|
+
return Response.json({ ok: true });
|
|
178
|
+
}
|
|
179
|
+
const devices = await ctx.store.find(
|
|
94
180
|
DEVICE,
|
|
95
|
-
|
|
181
|
+
{ userId: owner },
|
|
182
|
+
{ orderBy: { field: "lastSeenAt", direction: "asc" } }
|
|
183
|
+
);
|
|
184
|
+
const excess = devices.length - maxDevices + 1;
|
|
185
|
+
if (excess > 0) {
|
|
186
|
+
await ctx.store.remove(DEVICE, {
|
|
187
|
+
id: { in: devices.slice(0, excess).map((device) => device.id) }
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await ctx.store.insert(DEVICE, [
|
|
96
192
|
{
|
|
97
193
|
id: crypto.randomUUID(),
|
|
98
|
-
userId:
|
|
99
|
-
endpoint
|
|
100
|
-
p256dh:
|
|
101
|
-
auth:
|
|
102
|
-
userAgent
|
|
103
|
-
createdAt:
|
|
104
|
-
lastSeenAt:
|
|
194
|
+
userId: owner,
|
|
195
|
+
endpoint,
|
|
196
|
+
p256dh: keys.p256dh,
|
|
197
|
+
auth: keys.auth,
|
|
198
|
+
userAgent,
|
|
199
|
+
createdAt: now,
|
|
200
|
+
lastSeenAt: now
|
|
105
201
|
}
|
|
106
|
-
]
|
|
107
|
-
|
|
108
|
-
|
|
202
|
+
]);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (isDuplicate(error)) {
|
|
205
|
+
return Response.json(
|
|
206
|
+
{ error: "endpoint is registered to another account" },
|
|
207
|
+
{ status: 409 }
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
109
212
|
return Response.json({ ok: true });
|
|
110
213
|
}
|
|
111
214
|
},
|
|
@@ -114,14 +217,13 @@ function push(options) {
|
|
|
114
217
|
method: "POST",
|
|
115
218
|
scope: { type: "user" },
|
|
116
219
|
handler: async ({ request, userId }) => {
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
220
|
+
const parsed = await readJsonBody(request);
|
|
221
|
+
if ("error" in parsed) return parsed.error;
|
|
222
|
+
const { endpoint } = parsed.body;
|
|
223
|
+
if (typeof endpoint !== "string" || endpoint === "") {
|
|
119
224
|
return Response.json({ error: "endpoint is required" }, { status: 400 });
|
|
120
225
|
}
|
|
121
|
-
const removed = await ctx.store.remove(DEVICE, {
|
|
122
|
-
userId: userId ?? "",
|
|
123
|
-
endpoint: body.endpoint
|
|
124
|
-
});
|
|
226
|
+
const removed = await ctx.store.remove(DEVICE, { userId: userId ?? "", endpoint });
|
|
125
227
|
return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });
|
|
126
228
|
}
|
|
127
229
|
},
|
|
@@ -139,6 +241,6 @@ function push(options) {
|
|
|
139
241
|
};
|
|
140
242
|
}
|
|
141
243
|
|
|
142
|
-
export { push };
|
|
244
|
+
export { push, pushSchema, validateEndpoint, validateSubscriptionKeys };
|
|
143
245
|
//# sourceMappingURL=push.js.map
|
|
144
246
|
//# sourceMappingURL=push.js.map
|
package/dist/plugins/push.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/plugins/push/index.ts"],"names":[],"mappings":"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","file":"push.js","sourcesContent":["import type { DeliveryOutcome } from \"../../core/adapter\";\nimport type { DeliverContext, EasyPingPlugin, PluginInitContext } from \"../../core/plugin\";\n\nconst DEVICE = \"notification_push_device\";\n\nexport type PushSubscription = {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n};\n\nexport type PushMessage = {\n subscription: PushSubscription;\n title: string;\n body: string;\n data?: Record<string, unknown> | undefined;\n signal?: AbortSignal | undefined;\n};\n\nexport type PushSendResult = {\n /** True when the endpoint is permanently gone (404/410) and must be pruned. */\n expired?: boolean;\n retryable?: boolean;\n};\n\nexport type PushProvider = {\n name: string;\n send(message: PushMessage): Promise<PushSendResult>;\n};\n\nexport type PushDevice = {\n id: string;\n userId: string;\n endpoint: string;\n p256dh: string;\n auth: string;\n userAgent: string | null;\n createdAt: Date;\n lastSeenAt: Date;\n};\n\nexport type PushOptions = {\n provider: PushProvider;\n /** Builds the notification shown on the device. */\n render: (input: { type: string; payload: unknown }) => { title: string; body: string };\n /** Devices untouched for this long are pruned on next delivery. */\n staleAfterDays?: number;\n};\n\n/**\n * Web push over the device registry.\n *\n * Tokens are per device, not per user, so a delivery fans out and succeeds if\n * any endpoint accepts. Endpoints that report themselves gone are deleted\n * immediately: an unpruned registry accumulates dead subscriptions forever and\n * every send slows down behind them.\n */\nexport function push(options: PushOptions): EasyPingPlugin<\"push\"> {\n let ctx: PluginInitContext;\n const staleAfterDays = options.staleAfterDays ?? 180;\n\n return {\n id: \"push\",\n channels: [\"push\"],\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n pushDevice: {\n tableName: DEVICE,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n endpoint: { type: \"string\", required: true },\n p256dh: { type: \"string\", required: true },\n auth: { type: \"string\", required: true },\n userAgent: { type: \"string\" },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n lastSeenAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [\n { on: [\"userId\"], name: \"push_device_user_idx\" },\n // One row per endpoint: re-registering the same browser must update,\n // not duplicate, or every send goes out N times to one device.\n { on: [\"endpoint\"], unique: true, name: \"push_device_endpoint_idx\" },\n ],\n },\n },\n\n hooks: {\n deliver: async (context: DeliverContext): Promise<DeliveryOutcome> => {\n const devices = await ctx.store.find<PushDevice>(DEVICE, {\n userId: context.notification.userId,\n });\n\n if (devices.length === 0) {\n return { result: \"failed\", error: \"no registered devices\", retryable: false };\n }\n\n const content = options.render({\n type: context.notification.type,\n payload: context.notification.payload,\n });\n\n const expired: string[] = [];\n let delivered = 0;\n let retryable = false;\n let lastError = \"\";\n\n for (const device of devices) {\n try {\n const result = await options.provider.send({\n subscription: {\n endpoint: device.endpoint,\n keys: { p256dh: device.p256dh, auth: device.auth },\n },\n ...content,\n data: { notificationId: context.notification.id, type: context.notification.type },\n signal: context.signal,\n });\n\n if (result.expired) expired.push(device.id);\n else {\n delivered += 1;\n if (result.retryable) retryable = true;\n }\n } catch (error) {\n lastError = error instanceof Error ? error.message : String(error);\n retryable = true;\n }\n }\n\n if (expired.length > 0) await ctx.store.remove(DEVICE, { id: { in: expired } });\n\n if (delivered > 0) return { result: \"sent\" };\n\n // Every endpoint was gone: retrying cannot help, and the rows are\n // already deleted.\n if (expired.length === devices.length) {\n return { result: \"failed\", error: \"all devices expired\", retryable: false };\n }\n\n return {\n result: \"failed\",\n error: lastError || \"no device accepted the push\",\n retryable,\n };\n },\n },\n\n routes: [\n {\n path: \"/push/devices\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const body = (await request.json().catch(() => null)) as PushSubscription | null;\n if (!body?.endpoint || !body.keys?.p256dh || !body.keys?.auth) {\n return Response.json({ error: \"endpoint and keys are required\" }, { status: 400 });\n }\n\n // Upsert on endpoint: the same browser re-registering after a\n // service-worker update must not create a second row.\n await ctx.store.upsert(\n DEVICE,\n [\n {\n id: crypto.randomUUID(),\n userId: userId ?? \"\",\n endpoint: body.endpoint,\n p256dh: body.keys.p256dh,\n auth: body.keys.auth,\n userAgent: request.headers.get(\"user-agent\"),\n createdAt: new Date(),\n lastSeenAt: new Date(),\n },\n ],\n { onConflict: [\"endpoint\"] },\n );\n\n return Response.json({ ok: true });\n },\n },\n\n {\n path: \"/push/devices/remove\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const body = (await request.json().catch(() => null)) as { endpoint?: string } | null;\n if (!body?.endpoint) {\n return Response.json({ error: \"endpoint is required\" }, { status: 400 });\n }\n\n // Scoped by userId as well as endpoint: an endpoint string is not a\n // secret, and must not let one user unregister another's device.\n const removed = await ctx.store.remove(DEVICE, {\n userId: userId ?? \"\",\n endpoint: body.endpoint,\n });\n\n return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });\n },\n },\n\n {\n path: \"/push/prune\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => {\n const cutoff = new Date(Date.now() - staleAfterDays * 86_400_000);\n const removed = await ctx.store.remove(DEVICE, { lastSeenAt: { lt: cutoff } });\n return Response.json({ removed });\n },\n },\n ],\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/push/index.ts"],"names":[],"mappings":"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","file":"push.js","sourcesContent":["import type { DeliveryOutcome } from \"../../core/adapter\";\nimport { decodeBase64UrlBytes } from \"../../core/base64url\";\nimport { readJsonBody } from \"../../core/handler\";\nimport type {\n DeliverContext,\n EasyPingPlugin,\n PluginInitContext,\n SchemaDeclaration,\n} from \"../../core/plugin\";\nimport { redactErrorMessage } from \"../../core/runner\";\n\nconst DEVICE = \"notification_push_device\";\n\nexport type PushSubscription = {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n};\n\nexport type PushMessage = {\n subscription: PushSubscription;\n title: string;\n body: string;\n data?: Record<string, unknown> | undefined;\n signal?: AbortSignal | undefined;\n};\n\nexport type PushSendResult = {\n /** True when the endpoint is permanently gone (404/410) and must be pruned. */\n expired?: boolean;\n retryable?: boolean;\n /** True when the subscription can never work — bad keys, unusable endpoint. Pruned like expired. */\n invalid?: boolean;\n};\n\nexport type PushProvider = {\n name: string;\n send(message: PushMessage): Promise<PushSendResult>;\n};\n\nexport type PushDevice = {\n id: string;\n userId: string;\n endpoint: string;\n p256dh: string;\n auth: string;\n userAgent: string | null;\n createdAt: Date;\n lastSeenAt: Date;\n};\n\nexport type PushOptions = {\n provider: PushProvider;\n /** Builds the notification shown on the device. */\n render: (input: { type: string; payload: unknown }) => { title: string; body: string };\n /** Devices untouched for this long are pruned on next delivery. */\n staleAfterDays?: number;\n /** Registering past this evicts the user's least recently seen device. Default 20. */\n maxDevicesPerUser?: number;\n /**\n * Hostnames an endpoint may point at, e.g. `[\"fcm.googleapis.com\",\n * \"*.notify.windows.com\"]`. Unset accepts any public https host, which is\n * what self-hosted UnifiedPush distributors need.\n */\n allowedEndpointHosts?: readonly string[];\n /** Accepts http and private hosts. For a local fake push service only; never in production. */\n allowInsecureEndpoints?: boolean;\n};\n\n/**\n * The tables this plugin owns, as a standalone value.\n *\n * Exported separately from `push()` so DDL can be rendered without building a\n * plugin instance — otherwise creating the tables means inventing a provider\n * and a render function you never intend to call.\n */\nexport const pushSchema = {\n pushDevice: {\n tableName: DEVICE,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n endpoint: { type: \"string\", required: true },\n p256dh: { type: \"string\", required: true },\n auth: { type: \"string\", required: true },\n userAgent: { type: \"string\" },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n lastSeenAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [\n { on: [\"userId\"], name: \"push_device_user_idx\" },\n // One row per endpoint: re-registering the same browser must update,\n // not duplicate, or every send goes out N times to one device.\n { on: [\"endpoint\"], unique: true, name: \"push_device_endpoint_idx\" },\n ],\n },\n} as const satisfies SchemaDeclaration;\n\nconst PRIVATE_IPV4 =\n /^(0\\.|10\\.|127\\.|169\\.254\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.|100\\.(6[4-9]|[7-9]\\d|1[01]\\d|12[0-7])\\.)/;\n\nfunction isInternalHost(hostname: string): boolean {\n const host = hostname.toLowerCase();\n if (host === \"localhost\" || /\\.(localhost|local|internal|lan)$/.test(host)) return true;\n if (/^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(host)) return PRIVATE_IPV4.test(host);\n if (host.startsWith(\"[\")) {\n const v6 = host.slice(1, -1);\n return v6 === \"::1\" || v6 === \"::\" || /^(fc|fd|fe[89ab])/.test(v6) || v6.startsWith(\"::ffff:\");\n }\n return false;\n}\n\nconst hostAllowed = (host: string, allowed: readonly string[]) =>\n allowed.some((entry) =>\n entry.startsWith(\"*.\") ? host.endsWith(entry.slice(1)) : host === entry.toLowerCase(),\n );\n\n/**\n * The server will POST to whatever is stored here, so this is the SSRF\n * boundary: public https only, optionally pinned to known push services.\n */\nexport function validateEndpoint(\n endpoint: string,\n allowedHosts?: readonly string[] | undefined,\n allowInsecure = false,\n): string | null {\n let url: URL;\n try {\n url = new URL(endpoint);\n } catch {\n return \"endpoint is not a valid URL\";\n }\n if (url.username || url.password) return \"endpoint must not carry credentials\";\n if (!allowInsecure) {\n if (url.protocol !== \"https:\") return \"endpoint must use https\";\n if (isInternalHost(url.hostname)) return \"endpoint must be a public host\";\n } else if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n return \"endpoint must be an http(s) URL\";\n }\n if (allowedHosts && !hostAllowed(url.hostname.toLowerCase(), allowedHosts)) {\n return \"endpoint host is not an allowed push service\";\n }\n return null;\n}\n\n/** RFC 8291: an uncompressed P-256 point and a 16-byte auth secret. */\nexport function validateSubscriptionKeys(keys: { p256dh: string; auth: string }): string | null {\n const p256dh = decodeBase64UrlBytes(keys.p256dh);\n if (p256dh?.length !== 65 || p256dh[0] !== 0x04) {\n return \"keys.p256dh must be a 65-byte uncompressed EC point\";\n }\n const auth = decodeBase64UrlBytes(keys.auth);\n if (auth?.length !== 16) return \"keys.auth must be 16 bytes\";\n return null;\n}\n\nconst isDuplicate = (error: unknown): boolean => {\n const code = (error as { code?: unknown })?.code;\n if (code === \"23505\" || code === 11000) return true;\n const text = error instanceof Error ? error.message : String(error);\n return /duplicate|unique/i.test(text);\n};\n\n/**\n * Web push over the device registry.\n *\n * Tokens are per device, not per user, so a delivery fans out and succeeds if\n * any endpoint accepts. Endpoints that report themselves gone are deleted\n * immediately: an unpruned registry accumulates dead subscriptions forever and\n * every send slows down behind them.\n */\nexport function push(options: PushOptions): EasyPingPlugin<\"push\"> {\n let ctx: PluginInitContext;\n const staleAfterDays = options.staleAfterDays ?? 180;\n const maxDevices = Math.max(1, options.maxDevicesPerUser ?? 20);\n\n return {\n id: \"push\",\n channels: [\"push\"],\n\n init: (context) => {\n ctx = context;\n },\n\n schema: pushSchema,\n\n hooks: {\n deliver: async (context: DeliverContext): Promise<DeliveryOutcome> => {\n const devices = await ctx.store.find<PushDevice>(DEVICE, {\n userId: context.notification.userId,\n });\n\n if (devices.length === 0) {\n // Not a failure: this person has simply not enabled push anywhere.\n return { result: \"skipped\", reason: \"no registered devices\" };\n }\n\n const content = options.render({\n type: context.notification.type,\n payload: context.notification.payload,\n });\n\n // In parallel: one dead endpoint must not hold the others, or the\n // sweep, up to the runner's timeout.\n const results = await Promise.allSettled(\n devices.map((device) =>\n options.provider.send({\n subscription: {\n endpoint: device.endpoint,\n keys: { p256dh: device.p256dh, auth: device.auth },\n },\n ...content,\n data: { notificationId: context.notification.id, type: context.notification.type },\n signal: context.signal,\n }),\n ),\n );\n\n const prune: string[] = [];\n let delivered = 0;\n let retryable = false;\n let lastError = \"\";\n\n results.forEach((result, index) => {\n const device = devices[index];\n if (!device) return;\n\n if (result.status === \"rejected\") {\n lastError = redactErrorMessage(\n result.reason instanceof Error ? result.reason.message : String(result.reason),\n );\n retryable = true;\n return;\n }\n\n if (result.value.expired || result.value.invalid) prune.push(device.id);\n else if (result.value.retryable) retryable = true;\n else delivered += 1;\n });\n\n if (prune.length > 0) await ctx.store.remove(DEVICE, { id: { in: prune } });\n\n if (delivered > 0) return { result: \"sent\" };\n\n // Every endpoint was gone: retrying cannot help, and the rows are\n // already deleted.\n if (prune.length === devices.length) {\n return { result: \"failed\", error: \"all devices expired\", retryable: false };\n }\n\n return {\n result: \"failed\",\n error: lastError || \"no device accepted the push\",\n retryable,\n };\n },\n },\n\n routes: [\n {\n path: \"/push/devices\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n\n const { endpoint, keys } = parsed.body as Partial<PushSubscription>;\n if (\n typeof endpoint !== \"string\" ||\n typeof keys?.p256dh !== \"string\" ||\n typeof keys?.auth !== \"string\"\n ) {\n return Response.json({ error: \"endpoint and keys are required\" }, { status: 400 });\n }\n\n const problem =\n validateEndpoint(\n endpoint,\n options.allowedEndpointHosts,\n options.allowInsecureEndpoints ?? false,\n ) ?? validateSubscriptionKeys(keys);\n if (problem) return Response.json({ error: problem }, { status: 400 });\n\n const owner = userId ?? \"\";\n const now = new Date();\n const userAgent = request.headers.get(\"user-agent\")?.slice(0, 512) ?? null;\n\n // An endpoint belongs to exactly one account for its whole life.\n // Upserting on endpoint alone let any caller re-home another user's\n // device by knowing its URL.\n const [existing] = await ctx.store.find<PushDevice>(DEVICE, { endpoint }, { limit: 1 });\n\n if (existing && existing.userId !== owner) {\n return Response.json(\n { error: \"endpoint is registered to another account\" },\n { status: 409 },\n );\n }\n\n if (existing) {\n await ctx.store.update(\n DEVICE,\n { id: existing.id },\n { p256dh: keys.p256dh, auth: keys.auth, userAgent, lastSeenAt: now },\n );\n return Response.json({ ok: true });\n }\n\n const devices = await ctx.store.find<PushDevice>(\n DEVICE,\n { userId: owner },\n { orderBy: { field: \"lastSeenAt\", direction: \"asc\" } },\n );\n const excess = devices.length - maxDevices + 1;\n if (excess > 0) {\n await ctx.store.remove(DEVICE, {\n id: { in: devices.slice(0, excess).map((device) => device.id) },\n });\n }\n\n try {\n await ctx.store.insert(DEVICE, [\n {\n id: crypto.randomUUID(),\n userId: owner,\n endpoint,\n p256dh: keys.p256dh,\n auth: keys.auth,\n userAgent,\n createdAt: now,\n lastSeenAt: now,\n },\n ]);\n } catch (error) {\n // Lost the race to the unique index: someone registered it first.\n if (isDuplicate(error)) {\n return Response.json(\n { error: \"endpoint is registered to another account\" },\n { status: 409 },\n );\n }\n throw error;\n }\n\n return Response.json({ ok: true });\n },\n },\n\n {\n path: \"/push/devices/remove\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n\n const { endpoint } = parsed.body;\n if (typeof endpoint !== \"string\" || endpoint === \"\") {\n return Response.json({ error: \"endpoint is required\" }, { status: 400 });\n }\n\n // Scoped by userId as well as endpoint: an endpoint string is not a\n // secret, and must not let one user unregister another's device.\n const removed = await ctx.store.remove(DEVICE, { userId: userId ?? \"\", endpoint });\n\n return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });\n },\n },\n\n {\n path: \"/push/prune\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => {\n const cutoff = new Date(Date.now() - staleAfterDays * 86_400_000);\n const removed = await ctx.store.remove(DEVICE, { lastSeenAt: { lt: cutoff } });\n return Response.json({ removed });\n },\n },\n ],\n };\n}\n"]}
|
|
@@ -28,11 +28,17 @@ function concat(...parts) {
|
|
|
28
28
|
}
|
|
29
29
|
return out;
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
var InvalidSubscriptionError = class extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "InvalidSubscriptionError";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
function requireBytes(value, label, length, Failure = Error) {
|
|
32
38
|
const bytes = decodeBase64UrlBytes(value);
|
|
33
|
-
if (!bytes) throw new
|
|
39
|
+
if (!bytes) throw new Failure(`${label} is not valid base64url`);
|
|
34
40
|
if (length !== void 0 && bytes.length !== length) {
|
|
35
|
-
throw new
|
|
41
|
+
throw new Failure(`${label} must be ${length} bytes, got ${bytes.length}`);
|
|
36
42
|
}
|
|
37
43
|
return bytes;
|
|
38
44
|
}
|
|
@@ -51,8 +57,11 @@ async function encryptPayload(plaintext, userAgentPublicKey, authSecret, overrid
|
|
|
51
57
|
if (plaintext.length > MAX_PAYLOAD_BYTES) {
|
|
52
58
|
throw new Error(`push payload is ${plaintext.length} bytes; the limit is ${MAX_PAYLOAD_BYTES}`);
|
|
53
59
|
}
|
|
54
|
-
const uaPublic = requireBytes(userAgentPublicKey, "p256dh", 65);
|
|
55
|
-
const auth = requireBytes(authSecret, "auth", 16);
|
|
60
|
+
const uaPublic = requireBytes(userAgentPublicKey, "p256dh", 65, InvalidSubscriptionError);
|
|
61
|
+
const auth = requireBytes(authSecret, "auth", 16, InvalidSubscriptionError);
|
|
62
|
+
if (uaPublic[0] !== 4) {
|
|
63
|
+
throw new InvalidSubscriptionError("p256dh must be an uncompressed EC point");
|
|
64
|
+
}
|
|
56
65
|
const salt = overrides?.salt ? new Uint8Array(overrides.salt) : crypto.getRandomValues(new Uint8Array(16));
|
|
57
66
|
const serverKeys = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
58
67
|
const serverPublic = new Uint8Array(
|
|
@@ -165,12 +174,28 @@ function webPush(options) {
|
|
|
165
174
|
`push payload is ${payload.length} bytes, over the ${MAX_PAYLOAD_BYTES} byte limit`
|
|
166
175
|
);
|
|
167
176
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
message.subscription.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
177
|
+
let endpoint;
|
|
178
|
+
try {
|
|
179
|
+
endpoint = new URL(message.subscription.endpoint);
|
|
180
|
+
} catch {
|
|
181
|
+
return { invalid: true };
|
|
182
|
+
}
|
|
183
|
+
const secure = endpoint.protocol === "https:";
|
|
184
|
+
if (!secure && !(options.allowInsecureEndpoints && endpoint.protocol === "http:")) {
|
|
185
|
+
return { invalid: true };
|
|
186
|
+
}
|
|
187
|
+
let encrypted;
|
|
188
|
+
try {
|
|
189
|
+
encrypted = await encryptPayload(
|
|
190
|
+
payload,
|
|
191
|
+
message.subscription.keys.p256dh,
|
|
192
|
+
message.subscription.keys.auth
|
|
193
|
+
);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error instanceof InvalidSubscriptionError) return { invalid: true };
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
const authorization = await vapidAuthorization(endpoint.href, {
|
|
174
199
|
subject: options.subject,
|
|
175
200
|
keys: options.vapid
|
|
176
201
|
});
|
|
@@ -204,6 +229,7 @@ function webPush(options) {
|
|
|
204
229
|
};
|
|
205
230
|
}
|
|
206
231
|
|
|
232
|
+
exports.InvalidSubscriptionError = InvalidSubscriptionError;
|
|
207
233
|
exports.MAX_PAYLOAD_BYTES = MAX_PAYLOAD_BYTES;
|
|
208
234
|
exports.generateVapidKeys = generateVapidKeys;
|
|
209
235
|
exports.webPush = webPush;
|