@shware/workflow 4.0.0 → 4.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.
- package/README.md +141 -13
- package/dist/apns.cjs +3 -2
- package/dist/apns.cjs.map +1 -1
- package/dist/apns.d.cts.map +1 -1
- package/dist/apns.d.mts.map +1 -1
- package/dist/apns.mjs +3 -2
- package/dist/apns.mjs.map +1 -1
- package/dist/aws/function-url.cjs +3 -11
- package/dist/aws/function-url.cjs.map +1 -1
- package/dist/aws/function-url.d.cts.map +1 -1
- package/dist/aws/function-url.d.mts.map +1 -1
- package/dist/aws/function-url.mjs +3 -11
- package/dist/aws/function-url.mjs.map +1 -1
- package/dist/aws/index.cjs +3 -0
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.d.cts +2 -1
- package/dist/aws/index.d.cts.map +1 -1
- package/dist/aws/index.d.mts +2 -1
- package/dist/aws/index.d.mts.map +1 -1
- package/dist/aws/index.mjs +2 -1
- package/dist/aws/index.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +1 -0
- package/dist/cloudflare/index.d.cts +2 -2
- package/dist/cloudflare/index.d.mts +2 -2
- package/dist/cloudflare/index.mjs +2 -2
- package/dist/fcm.cjs +2 -1
- package/dist/fcm.cjs.map +1 -1
- package/dist/fcm.d.cts.map +1 -1
- package/dist/fcm.d.mts.map +1 -1
- package/dist/fcm.mjs +2 -1
- package/dist/fcm.mjs.map +1 -1
- package/dist/host/auth.cjs +15 -15
- package/dist/host/auth.cjs.map +1 -1
- package/dist/host/auth.d.cts +16 -2
- package/dist/host/auth.d.cts.map +1 -1
- package/dist/host/auth.d.mts +16 -2
- package/dist/host/auth.d.mts.map +1 -1
- package/dist/host/auth.mjs +15 -16
- package/dist/host/auth.mjs.map +1 -1
- package/dist/host/channels.cjs +2 -1
- package/dist/host/channels.cjs.map +1 -1
- package/dist/host/channels.d.cts.map +1 -1
- package/dist/host/channels.d.mts.map +1 -1
- package/dist/host/channels.mjs +2 -1
- package/dist/host/channels.mjs.map +1 -1
- package/dist/host/http.cjs +37 -18
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +5 -1
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +5 -1
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +37 -18
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/journey-host.cjs +1 -0
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +12 -0
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +12 -0
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +1 -0
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/jwt.cjs +5 -9
- package/dist/jwt.cjs.map +1 -1
- package/dist/jwt.d.cts +1 -1
- package/dist/jwt.d.cts.map +1 -1
- package/dist/jwt.d.mts +1 -1
- package/dist/jwt.d.mts.map +1 -1
- package/dist/jwt.mjs +5 -9
- package/dist/jwt.mjs.map +1 -1
- package/dist/store/sql.cjs +2 -1
- package/dist/store/sql.cjs.map +1 -1
- package/dist/store/sql.d.cts.map +1 -1
- package/dist/store/sql.d.mts.map +1 -1
- package/dist/store/sql.mjs +2 -1
- package/dist/store/sql.mjs.map +1 -1
- package/dist/transports/sigv4.cjs +2 -3
- package/dist/transports/sigv4.cjs.map +1 -1
- package/dist/transports/sigv4.d.cts.map +1 -1
- package/dist/transports/sigv4.d.mts.map +1 -1
- package/dist/transports/sigv4.mjs +2 -3
- package/dist/transports/sigv4.mjs.map +1 -1
- package/dist/webpush.cjs +11 -20
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts.map +1 -1
- package/dist/webpush.d.mts.map +1 -1
- package/dist/webpush.mjs +11 -20
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -1
package/dist/host/auth.cjs
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_crypto = require("node:crypto");
|
|
2
3
|
//#region src/host/auth.ts
|
|
3
|
-
/**
|
|
4
|
-
* Bearer authentication for the host's HTTP API, shared by the ingest router and the journey
|
|
5
|
-
* host so the two cannot drift apart.
|
|
6
|
-
*/
|
|
7
4
|
const utf8 = new TextEncoder();
|
|
8
5
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* answers at once), which is accepted: it is not the secret.
|
|
13
|
-
*
|
|
14
|
-
* `crypto.subtle.timingSafeEqual` is not available on every runtime the host runs on, hence
|
|
15
|
-
* the XOR loop.
|
|
6
|
+
* `node:crypto`'s constant-time compare — on Node, Lambda, and a Worker with `nodejs_compat`
|
|
7
|
+
* (which the host needs anyway). It demands equal lengths, so callers hash first or check the
|
|
8
|
+
* length; a mismatch on length answers at once, which reveals the length and nothing else.
|
|
16
9
|
*/
|
|
17
10
|
function constantTimeEqual(a, b) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
return a.length === b.length && (0, node_crypto.timingSafeEqual)(a, b);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Whether two secrets are equal, in time that does not depend on where they differ: both are
|
|
15
|
+
* hashed first, so the comparison runs over equal-length digests whatever their lengths — for an
|
|
16
|
+
* authorizer comparing a signature or a token of its own.
|
|
17
|
+
*/
|
|
18
|
+
async function timingSafeEqual(a, b) {
|
|
19
|
+
const [x, y] = await Promise.all([crypto.subtle.digest("SHA-256", utf8.encode(a)), crypto.subtle.digest("SHA-256", utf8.encode(b))]);
|
|
20
|
+
return constantTimeEqual(new Uint8Array(x), new Uint8Array(y));
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
23
|
* Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).
|
|
@@ -45,5 +44,6 @@ function authorized(request, env) {
|
|
|
45
44
|
//#endregion
|
|
46
45
|
exports.authorized = authorized;
|
|
47
46
|
exports.bearerMatches = bearerMatches;
|
|
47
|
+
exports.timingSafeEqual = timingSafeEqual;
|
|
48
48
|
|
|
49
49
|
//# sourceMappingURL=auth.cjs.map
|
package/dist/host/auth.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.cjs","names":[],"sources":["../../src/host/auth.ts"],"sourcesContent":["/**\n *
|
|
1
|
+
{"version":3,"file":"auth.cjs","names":["timingSafeEqualBytes"],"sources":["../../src/host/auth.ts"],"sourcesContent":["import { timingSafeEqual as timingSafeEqualBytes } from 'node:crypto';\n\n/**\n * Who may call the host's protected routes. By default the env's `API_TOKEN` as a bearer; a host\n * replaces it with its own `Authorize` (an HMAC over the body, a JWT, a header its proxy adds).\n * The helpers here are what such an authorizer builds on.\n */\n\n/**\n * The host's authorization: receives a copy of the request (so it may read the body) and the\n * env. True lets the request through, false answers 401, a throw answers 500 and is logged — an\n * authorizer that fails never lets a request in. The tracking routes (`/t/...`) and `/health`\n * are public and never reach it.\n */\nexport type Authorize<Env> = (request: Request, env: Env) => boolean | Promise<boolean>;\n\nconst utf8 = new TextEncoder();\n\n/**\n * `node:crypto`'s constant-time compare — on Node, Lambda, and a Worker with `nodejs_compat`\n * (which the host needs anyway). It demands equal lengths, so callers hash first or check the\n * length; a mismatch on length answers at once, which reveals the length and nothing else.\n */\nfunction constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {\n return a.length === b.length && timingSafeEqualBytes(a, b);\n}\n\n/**\n * Whether two secrets are equal, in time that does not depend on where they differ: both are\n * hashed first, so the comparison runs over equal-length digests whatever their lengths — for an\n * authorizer comparing a signature or a token of its own.\n */\nexport async function timingSafeEqual(a: string, b: string): Promise<boolean> {\n const [x, y] = await Promise.all([\n crypto.subtle.digest('SHA-256', utf8.encode(a)),\n crypto.subtle.digest('SHA-256', utf8.encode(b)),\n ]);\n return constantTimeEqual(new Uint8Array(x), new Uint8Array(y));\n}\n\n/**\n * Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).\n * The scheme is matched case-insensitively, as RFC 9110 says schemes are; the token is compared\n * in constant time.\n */\nexport function bearerMatches(request: Request, token: string): boolean {\n const header = request.headers.get('authorization');\n if (header === null) return false;\n const space = header.indexOf(' ');\n if (space === -1) return false;\n if (header.slice(0, space).toLowerCase() !== 'bearer') return false;\n const presented = header.slice(space + 1).trim();\n return constantTimeEqual(utf8.encode(presented), utf8.encode(token));\n}\n\n/**\n * Whether the request may use the API: always when no token is configured (an open development\n * instance — production refuses to run that way, see `createJourneyHost`), otherwise only with\n * the matching bearer.\n */\nexport function authorized(request: Request, env: { API_TOKEN?: string | undefined }): boolean {\n return env.API_TOKEN === undefined || bearerMatches(request, env.API_TOKEN);\n}\n"],"mappings":";;;AAgBA,MAAM,OAAO,IAAI,YAAY;;;;;;AAO7B,SAAS,kBAAkB,GAAe,GAAwB;CAChE,OAAO,EAAE,WAAW,EAAE,WAAA,GAAUA,YAAAA,gBAAAA,CAAqB,GAAG,CAAC;AAC3D;;;;;;AAOA,eAAsB,gBAAgB,GAAW,GAA6B;CAC5E,MAAM,CAAC,GAAG,KAAK,MAAM,QAAQ,IAAI,CAC/B,OAAO,OAAO,OAAO,WAAW,KAAK,OAAO,CAAC,CAAC,GAC9C,OAAO,OAAO,OAAO,WAAW,KAAK,OAAO,CAAC,CAAC,CAChD,CAAC;CACD,OAAO,kBAAkB,IAAI,WAAW,CAAC,GAAG,IAAI,WAAW,CAAC,CAAC;AAC/D;;;;;;AAOA,SAAgB,cAAc,SAAkB,OAAwB;CACtE,MAAM,SAAS,QAAQ,QAAQ,IAAI,eAAe;CAClD,IAAI,WAAW,MAAM,OAAO;CAC5B,MAAM,QAAQ,OAAO,QAAQ,GAAG;CAChC,IAAI,UAAU,IAAI,OAAO;CACzB,IAAI,OAAO,MAAM,GAAG,KAAK,CAAC,CAAC,YAAY,MAAM,UAAU,OAAO;CAC9D,MAAM,YAAY,OAAO,MAAM,QAAQ,CAAC,CAAC,CAAC,KAAK;CAC/C,OAAO,kBAAkB,KAAK,OAAO,SAAS,GAAG,KAAK,OAAO,KAAK,CAAC;AACrE;;;;;;AAOA,SAAgB,WAAW,SAAkB,KAAkD;CAC7F,OAAO,IAAI,cAAc,KAAA,KAAa,cAAc,SAAS,IAAI,SAAS;AAC5E"}
|
package/dist/host/auth.d.cts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
//#region src/host/auth.d.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Who may call the host's protected routes. By default the env's `API_TOKEN` as a bearer; a host
|
|
4
|
+
* replaces it with its own `Authorize` (an HMAC over the body, a JWT, a header its proxy adds).
|
|
5
|
+
* The helpers here are what such an authorizer builds on.
|
|
5
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* The host's authorization: receives a copy of the request (so it may read the body) and the
|
|
9
|
+
* env. True lets the request through, false answers 401, a throw answers 500 and is logged — an
|
|
10
|
+
* authorizer that fails never lets a request in. The tracking routes (`/t/...`) and `/health`
|
|
11
|
+
* are public and never reach it.
|
|
12
|
+
*/
|
|
13
|
+
export type Authorize<Env> = (request: Request, env: Env) => boolean | Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Whether two secrets are equal, in time that does not depend on where they differ: both are
|
|
16
|
+
* hashed first, so the comparison runs over equal-length digests whatever their lengths — for an
|
|
17
|
+
* authorizer comparing a signature or a token of its own.
|
|
18
|
+
*/
|
|
19
|
+
export declare function timingSafeEqual(a: string, b: string): Promise<boolean>;
|
|
6
20
|
/**
|
|
7
21
|
* Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).
|
|
8
22
|
* The scheme is matched case-insensitively, as RFC 9110 says schemes are; the token is compared
|
package/dist/host/auth.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.cts","names":[],"sources":["../../src/host/auth.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"auth.d.cts","names":[],"sources":["../../src/host/auth.ts"],"mappings":";;;;;;;;;;;;YAcY,UAAU,QAAQ,SAAS,SAAS,KAAK,kBAAkB;;;;;;wBAkBjD,gBAAgB,WAAW,YAAY;;;;;;wBAa7C,cAAc,SAAS,SAAS;;;;;;wBAehC,WAAW,SAAS,SAAS;EAAO"}
|
package/dist/host/auth.d.mts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
//#region src/host/auth.d.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Who may call the host's protected routes. By default the env's `API_TOKEN` as a bearer; a host
|
|
4
|
+
* replaces it with its own `Authorize` (an HMAC over the body, a JWT, a header its proxy adds).
|
|
5
|
+
* The helpers here are what such an authorizer builds on.
|
|
5
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* The host's authorization: receives a copy of the request (so it may read the body) and the
|
|
9
|
+
* env. True lets the request through, false answers 401, a throw answers 500 and is logged — an
|
|
10
|
+
* authorizer that fails never lets a request in. The tracking routes (`/t/...`) and `/health`
|
|
11
|
+
* are public and never reach it.
|
|
12
|
+
*/
|
|
13
|
+
export type Authorize<Env> = (request: Request, env: Env) => boolean | Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Whether two secrets are equal, in time that does not depend on where they differ: both are
|
|
16
|
+
* hashed first, so the comparison runs over equal-length digests whatever their lengths — for an
|
|
17
|
+
* authorizer comparing a signature or a token of its own.
|
|
18
|
+
*/
|
|
19
|
+
export declare function timingSafeEqual(a: string, b: string): Promise<boolean>;
|
|
6
20
|
/**
|
|
7
21
|
* Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).
|
|
8
22
|
* The scheme is matched case-insensitively, as RFC 9110 says schemes are; the token is compared
|
package/dist/host/auth.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.mts","names":[],"sources":["../../src/host/auth.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"auth.d.mts","names":[],"sources":["../../src/host/auth.ts"],"mappings":";;;;;;;;;;;;YAcY,UAAU,QAAQ,SAAS,SAAS,KAAK,kBAAkB;;;;;;wBAkBjD,gBAAgB,WAAW,YAAY;;;;;;wBAa7C,cAAc,SAAS,SAAS;;;;;;wBAehC,WAAW,SAAS,SAAS;EAAO"}
|
package/dist/host/auth.mjs
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
|
+
import { timingSafeEqual as timingSafeEqual$1 } from "node:crypto";
|
|
1
2
|
//#region src/host/auth.ts
|
|
2
|
-
/**
|
|
3
|
-
* Bearer authentication for the host's HTTP API, shared by the ingest router and the journey
|
|
4
|
-
* host so the two cannot drift apart.
|
|
5
|
-
*/
|
|
6
3
|
const utf8 = new TextEncoder();
|
|
7
4
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* answers at once), which is accepted: it is not the secret.
|
|
12
|
-
*
|
|
13
|
-
* `crypto.subtle.timingSafeEqual` is not available on every runtime the host runs on, hence
|
|
14
|
-
* the XOR loop.
|
|
5
|
+
* `node:crypto`'s constant-time compare — on Node, Lambda, and a Worker with `nodejs_compat`
|
|
6
|
+
* (which the host needs anyway). It demands equal lengths, so callers hash first or check the
|
|
7
|
+
* length; a mismatch on length answers at once, which reveals the length and nothing else.
|
|
15
8
|
*/
|
|
16
9
|
function constantTimeEqual(a, b) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
return a.length === b.length && timingSafeEqual$1(a, b);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Whether two secrets are equal, in time that does not depend on where they differ: both are
|
|
14
|
+
* hashed first, so the comparison runs over equal-length digests whatever their lengths — for an
|
|
15
|
+
* authorizer comparing a signature or a token of its own.
|
|
16
|
+
*/
|
|
17
|
+
async function timingSafeEqual(a, b) {
|
|
18
|
+
const [x, y] = await Promise.all([crypto.subtle.digest("SHA-256", utf8.encode(a)), crypto.subtle.digest("SHA-256", utf8.encode(b))]);
|
|
19
|
+
return constantTimeEqual(new Uint8Array(x), new Uint8Array(y));
|
|
21
20
|
}
|
|
22
21
|
/**
|
|
23
22
|
* Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).
|
|
@@ -42,6 +41,6 @@ function authorized(request, env) {
|
|
|
42
41
|
return env.API_TOKEN === void 0 || bearerMatches(request, env.API_TOKEN);
|
|
43
42
|
}
|
|
44
43
|
//#endregion
|
|
45
|
-
export { authorized, bearerMatches };
|
|
44
|
+
export { authorized, bearerMatches, timingSafeEqual };
|
|
46
45
|
|
|
47
46
|
//# sourceMappingURL=auth.mjs.map
|
package/dist/host/auth.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.mjs","names":[],"sources":["../../src/host/auth.ts"],"sourcesContent":["/**\n *
|
|
1
|
+
{"version":3,"file":"auth.mjs","names":["timingSafeEqualBytes"],"sources":["../../src/host/auth.ts"],"sourcesContent":["import { timingSafeEqual as timingSafeEqualBytes } from 'node:crypto';\n\n/**\n * Who may call the host's protected routes. By default the env's `API_TOKEN` as a bearer; a host\n * replaces it with its own `Authorize` (an HMAC over the body, a JWT, a header its proxy adds).\n * The helpers here are what such an authorizer builds on.\n */\n\n/**\n * The host's authorization: receives a copy of the request (so it may read the body) and the\n * env. True lets the request through, false answers 401, a throw answers 500 and is logged — an\n * authorizer that fails never lets a request in. The tracking routes (`/t/...`) and `/health`\n * are public and never reach it.\n */\nexport type Authorize<Env> = (request: Request, env: Env) => boolean | Promise<boolean>;\n\nconst utf8 = new TextEncoder();\n\n/**\n * `node:crypto`'s constant-time compare — on Node, Lambda, and a Worker with `nodejs_compat`\n * (which the host needs anyway). It demands equal lengths, so callers hash first or check the\n * length; a mismatch on length answers at once, which reveals the length and nothing else.\n */\nfunction constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {\n return a.length === b.length && timingSafeEqualBytes(a, b);\n}\n\n/**\n * Whether two secrets are equal, in time that does not depend on where they differ: both are\n * hashed first, so the comparison runs over equal-length digests whatever their lengths — for an\n * authorizer comparing a signature or a token of its own.\n */\nexport async function timingSafeEqual(a: string, b: string): Promise<boolean> {\n const [x, y] = await Promise.all([\n crypto.subtle.digest('SHA-256', utf8.encode(a)),\n crypto.subtle.digest('SHA-256', utf8.encode(b)),\n ]);\n return constantTimeEqual(new Uint8Array(x), new Uint8Array(y));\n}\n\n/**\n * Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).\n * The scheme is matched case-insensitively, as RFC 9110 says schemes are; the token is compared\n * in constant time.\n */\nexport function bearerMatches(request: Request, token: string): boolean {\n const header = request.headers.get('authorization');\n if (header === null) return false;\n const space = header.indexOf(' ');\n if (space === -1) return false;\n if (header.slice(0, space).toLowerCase() !== 'bearer') return false;\n const presented = header.slice(space + 1).trim();\n return constantTimeEqual(utf8.encode(presented), utf8.encode(token));\n}\n\n/**\n * Whether the request may use the API: always when no token is configured (an open development\n * instance — production refuses to run that way, see `createJourneyHost`), otherwise only with\n * the matching bearer.\n */\nexport function authorized(request: Request, env: { API_TOKEN?: string | undefined }): boolean {\n return env.API_TOKEN === undefined || bearerMatches(request, env.API_TOKEN);\n}\n"],"mappings":";;AAgBA,MAAM,OAAO,IAAI,YAAY;;;;;;AAO7B,SAAS,kBAAkB,GAAe,GAAwB;CAChE,OAAO,EAAE,WAAW,EAAE,UAAUA,kBAAqB,GAAG,CAAC;AAC3D;;;;;;AAOA,eAAsB,gBAAgB,GAAW,GAA6B;CAC5E,MAAM,CAAC,GAAG,KAAK,MAAM,QAAQ,IAAI,CAC/B,OAAO,OAAO,OAAO,WAAW,KAAK,OAAO,CAAC,CAAC,GAC9C,OAAO,OAAO,OAAO,WAAW,KAAK,OAAO,CAAC,CAAC,CAChD,CAAC;CACD,OAAO,kBAAkB,IAAI,WAAW,CAAC,GAAG,IAAI,WAAW,CAAC,CAAC;AAC/D;;;;;;AAOA,SAAgB,cAAc,SAAkB,OAAwB;CACtE,MAAM,SAAS,QAAQ,QAAQ,IAAI,eAAe;CAClD,IAAI,WAAW,MAAM,OAAO;CAC5B,MAAM,QAAQ,OAAO,QAAQ,GAAG;CAChC,IAAI,UAAU,IAAI,OAAO;CACzB,IAAI,OAAO,MAAM,GAAG,KAAK,CAAC,CAAC,YAAY,MAAM,UAAU,OAAO;CAC9D,MAAM,YAAY,OAAO,MAAM,QAAQ,CAAC,CAAC,CAAC,KAAK;CAC/C,OAAO,kBAAkB,KAAK,OAAO,SAAS,GAAG,KAAK,OAAO,KAAK,CAAC;AACrE;;;;;;AAOA,SAAgB,WAAW,SAAkB,KAAkD;CAC7F,OAAO,IAAI,cAAc,KAAA,KAAa,cAAc,SAAS,IAAI,SAAS;AAC5E"}
|
package/dist/host/channels.cjs
CHANGED
|
@@ -12,6 +12,7 @@ const require_webhooks = require("../webhooks.cjs");
|
|
|
12
12
|
const require_webpush = require("../webpush.cjs");
|
|
13
13
|
const require_host_ingest = require("./ingest.cjs");
|
|
14
14
|
const require_host_senders = require("./senders.cjs");
|
|
15
|
+
let node_buffer = require("node:buffer");
|
|
15
16
|
//#region src/host/channels.ts
|
|
16
17
|
/**
|
|
17
18
|
* The outlets a `JourneyConfig` describes, assembled against the environment:
|
|
@@ -67,7 +68,7 @@ function identity(value) {
|
|
|
67
68
|
/** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */
|
|
68
69
|
async function secretDigest(secret) {
|
|
69
70
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
70
|
-
return
|
|
71
|
+
return node_buffer.Buffer.from(digest.slice(0, 4)).toString("hex");
|
|
71
72
|
}
|
|
72
73
|
/**
|
|
73
74
|
* A sender built on its first message, then kept for this call. `build` reads the secrets (and
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channels.cjs","names":["resolveRuntime","PermanentError","EmailSender","registryRenderer","registryPushRenderer","removeDevice","apnsEnvironment","ApnsProviderToken","apnsKeyFromBase64","ApnsPushSender","APNS_DEAD_TOKEN_REASONS","serviceAccountFromBase64","FcmAccessToken","FcmPushSender","FCM_DEAD_TOKEN_REASONS","VapidSigner","WebPushSender","WEB_PUSH_DEAD_TOKEN_REASONS","routeByPlatform","indexTemplates","DiscordWebhookSender","lookupTemplate","renderDiscordContent","SlackWebhookSender","renderSlackContent","routeByChannel"],"sources":["../../src/host/channels.ts"],"sourcesContent":["import {\n DEAD_TOKEN_REASONS as APNS_DEAD_TOKEN_REASONS,\n ApnsProviderToken,\n ApnsPushSender,\n apnsKeyFromBase64,\n} from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport { EmailSender, type EmailTransport } from '../email-sender';\nimport type { MessageSender } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport {\n DEAD_TOKEN_REASONS as FCM_DEAD_TOKEN_REASONS,\n FcmAccessToken,\n FcmPushSender,\n serviceAccountFromBase64,\n} from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport {\n VapidSigner,\n DEAD_TOKEN_REASONS as WEB_PUSH_DEAD_TOKEN_REASONS,\n WebPushSender,\n} from '../webpush';\nimport { removeDevice } from './ingest';\nimport type { JourneyEnv } from './platform';\nimport { routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/**\n * A sender that fails every message with the reason the channel cannot deliver — for good: the\n * configuration is the same on every attempt, so the step does not retry into it.\n */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new PermanentError(\n `cannot send '${message.template}' on ${message.channel}: ${reason}`\n );\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n // Missing on this attempt, missing on the next: the deployment has to change, not the step\n throw new PermanentError(\n `${name} is not set: the environment must hold it for the configured channel`\n );\n }\n return value;\n}\n\n/**\n * The provider credentials — `ApnsProviderToken`, `FcmAccessToken`, `VapidSigner` — for as long\n * as the host lives. `channelsFromConfig` runs once per workflow run (every wake of every\n * instance) and builds the senders anew each time; what must outlive the run is the credential\n * state they hold: the APNs provider JWT must be reused for at least 20 minutes (Apple answers a\n * sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`), the FCM access\n * token is good for an hour, the VAPID key import is wasted work twice.\n *\n * The cache is the host's (`createJourneyHost` makes one and hands it to every run), not the\n * run's `env`: a Lambda invocation builds a fresh env object for each run (its instance port is\n * in it), so a cache keyed on env would miss on every wake. Inside, the key names the provider,\n * the ids that identify the key and a digest of the secret — so a rotated secret or a reloaded\n * host mints a new credential instead of reusing the old, and the secret itself is in no key and\n * no log. Nothing that shapes the sender (topic, environment, relay, registry, store) is in it:\n * the sender is built per call around the shared credential.\n */\nexport interface ProviderCache {\n /** The credential under `key`, built on the first call and returned as is after that. */\n get<T>(key: string, build: () => T): T;\n}\n\nexport function createProviderCache(): ProviderCache {\n const credentials = new Map<string, unknown>();\n return {\n get<T>(key: string, build: () => T): T {\n if (!credentials.has(key)) credentials.set(key, build());\n return credentials.get(key) as T;\n },\n };\n}\n\n/** A number for each fetch implementation, so a credential minted through one is not reused with another. */\nconst identities = new WeakMap<object, number>();\nlet nextIdentity = 1;\nfunction identity(value: object | undefined): string {\n if (value === undefined) return 'global';\n let id = identities.get(value);\n if (id === undefined) {\n id = nextIdentity++;\n identities.set(value, id);\n }\n return String(id);\n}\n\n/** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */\nasync function secretDigest(secret: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));\n return Array.from(new Uint8Array(digest).slice(0, 4), (b) =>\n b.toString(16).padStart(2, '0')\n ).join('');\n}\n\n/**\n * A sender built on its first message, then kept for this call. `build` reads the secrets (and\n * so may throw a `PermanentError` naming the missing variable): a platform's secret is only\n * required when something is actually sent there, so a journey that only emails must not fail\n * because the APNs key is missing from a local `.dev.vars`. A build that failed is not kept.\n */\nfunction lazy(build: () => Promise<MessageSender>): MessageSender {\n let sender: Promise<MessageSender> | undefined;\n return {\n async send(message) {\n sender ??= build().catch((error: unknown) => {\n sender = undefined;\n throw error;\n });\n return (await sender).send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n /** Where finished emails go — the host's choice, independent of where the workflow runs. */\n email?: EmailTransport | undefined;\n fetch?: typeof fetch;\n /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */\n apnsFetch?: typeof fetch | undefined;\n /**\n * Where the provider credentials live across runs (`createProviderCache`): the host's, made\n * once with it. Without one, this call's credentials are its own — nothing is reused.\n */\n providers?: ProviderCache | undefined;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n const providers = options.providers ?? createProviderCache();\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n const transport = options.email;\n if (transport === undefined) {\n return unavailable(\"no email transport is configured (the host's `email` option)\");\n }\n // Each template names its own sender: there is no global one to require\n return new EmailSender({ transport, render: registryRenderer(registry, config.email) });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n /**\n * A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n * waits that read devices, as a removal through the API does. Done here, per run, on the\n * settled result, so the credential cached across runs (see `ProviderCache`) never holds a\n * run's store.\n */\n const dropDeadTokens = (\n provider: MessageSender,\n deadTokenReasons: ReadonlySet<string>\n ): MessageSender => ({\n async send(message) {\n const result = await provider.send(message);\n if (\n result !== undefined &&\n deadTokenReasons.has(result.reason) &&\n message.device !== undefined &&\n options.store !== undefined\n ) {\n await removeDevice(env, message.device.token, options.store);\n }\n return result;\n },\n });\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n const apnsFetch = options.apnsFetch ?? options.fetch;\n const environment = apnsEnvironment(apns, mode);\n platforms.ios = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'APNS_PRIVATE_KEY_BASE64');\n const credentials = providers.get(\n JSON.stringify(['apns', apns.keyId, apns.teamId, await secretDigest(secret)]),\n () =>\n new ApnsProviderToken({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(secret),\n })\n );\n return new ApnsPushSender({\n credentials,\n topic: apns.topic,\n environment,\n render,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...(apnsFetch === undefined ? {} : { fetch: apnsFetch }),\n });\n }),\n APNS_DEAD_TOKEN_REASONS\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64');\n const serviceAccount = serviceAccountFromBase64(secret);\n // The token endpoint is called through the fetch: another fetch is another credential\n const credentials = providers.get(\n JSON.stringify([\n 'fcm',\n serviceAccount.client_email,\n serviceAccount.project_id,\n identity(options.fetch),\n await secretDigest(secret),\n ]),\n () =>\n new FcmAccessToken({\n serviceAccount,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n return new FcmPushSender({\n credentials,\n render,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n FCM_DEAD_TOKEN_REASONS\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = dropDeadTokens(\n lazy(async () => {\n const publicKey = requireSecret(env, 'VAPID_PUBLIC_KEY');\n const privateKey = requireSecret(env, 'VAPID_PRIVATE_KEY');\n const credentials = providers.get(\n JSON.stringify(['vapid', webPush.subject, publicKey, await secretDigest(privateKey)]),\n () => new VapidSigner({ subject: webPush.subject, publicKey, privateKey })\n );\n return new WebPushSender({\n credentials,\n render,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n WEB_PUSH_DEAD_TOKEN_REASONS\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new PermanentError(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"channels.cjs","names":["resolveRuntime","PermanentError","Buffer","EmailSender","registryRenderer","registryPushRenderer","removeDevice","apnsEnvironment","ApnsProviderToken","apnsKeyFromBase64","ApnsPushSender","APNS_DEAD_TOKEN_REASONS","serviceAccountFromBase64","FcmAccessToken","FcmPushSender","FCM_DEAD_TOKEN_REASONS","VapidSigner","WebPushSender","WEB_PUSH_DEAD_TOKEN_REASONS","routeByPlatform","indexTemplates","DiscordWebhookSender","lookupTemplate","renderDiscordContent","SlackWebhookSender","renderSlackContent","routeByChannel"],"sources":["../../src/host/channels.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport {\n DEAD_TOKEN_REASONS as APNS_DEAD_TOKEN_REASONS,\n ApnsProviderToken,\n ApnsPushSender,\n apnsKeyFromBase64,\n} from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport { EmailSender, type EmailTransport } from '../email-sender';\nimport type { MessageSender } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport {\n DEAD_TOKEN_REASONS as FCM_DEAD_TOKEN_REASONS,\n FcmAccessToken,\n FcmPushSender,\n serviceAccountFromBase64,\n} from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport {\n VapidSigner,\n DEAD_TOKEN_REASONS as WEB_PUSH_DEAD_TOKEN_REASONS,\n WebPushSender,\n} from '../webpush';\nimport { removeDevice } from './ingest';\nimport type { JourneyEnv } from './platform';\nimport { routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/**\n * A sender that fails every message with the reason the channel cannot deliver — for good: the\n * configuration is the same on every attempt, so the step does not retry into it.\n */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new PermanentError(\n `cannot send '${message.template}' on ${message.channel}: ${reason}`\n );\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n // Missing on this attempt, missing on the next: the deployment has to change, not the step\n throw new PermanentError(\n `${name} is not set: the environment must hold it for the configured channel`\n );\n }\n return value;\n}\n\n/**\n * The provider credentials — `ApnsProviderToken`, `FcmAccessToken`, `VapidSigner` — for as long\n * as the host lives. `channelsFromConfig` runs once per workflow run (every wake of every\n * instance) and builds the senders anew each time; what must outlive the run is the credential\n * state they hold: the APNs provider JWT must be reused for at least 20 minutes (Apple answers a\n * sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`), the FCM access\n * token is good for an hour, the VAPID key import is wasted work twice.\n *\n * The cache is the host's (`createJourneyHost` makes one and hands it to every run), not the\n * run's `env`: a Lambda invocation builds a fresh env object for each run (its instance port is\n * in it), so a cache keyed on env would miss on every wake. Inside, the key names the provider,\n * the ids that identify the key and a digest of the secret — so a rotated secret or a reloaded\n * host mints a new credential instead of reusing the old, and the secret itself is in no key and\n * no log. Nothing that shapes the sender (topic, environment, relay, registry, store) is in it:\n * the sender is built per call around the shared credential.\n */\nexport interface ProviderCache {\n /** The credential under `key`, built on the first call and returned as is after that. */\n get<T>(key: string, build: () => T): T;\n}\n\nexport function createProviderCache(): ProviderCache {\n const credentials = new Map<string, unknown>();\n return {\n get<T>(key: string, build: () => T): T {\n if (!credentials.has(key)) credentials.set(key, build());\n return credentials.get(key) as T;\n },\n };\n}\n\n/** A number for each fetch implementation, so a credential minted through one is not reused with another. */\nconst identities = new WeakMap<object, number>();\nlet nextIdentity = 1;\nfunction identity(value: object | undefined): string {\n if (value === undefined) return 'global';\n let id = identities.get(value);\n if (id === undefined) {\n id = nextIdentity++;\n identities.set(value, id);\n }\n return String(id);\n}\n\n/** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */\nasync function secretDigest(secret: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));\n return Buffer.from(digest.slice(0, 4)).toString('hex');\n}\n\n/**\n * A sender built on its first message, then kept for this call. `build` reads the secrets (and\n * so may throw a `PermanentError` naming the missing variable): a platform's secret is only\n * required when something is actually sent there, so a journey that only emails must not fail\n * because the APNs key is missing from a local `.dev.vars`. A build that failed is not kept.\n */\nfunction lazy(build: () => Promise<MessageSender>): MessageSender {\n let sender: Promise<MessageSender> | undefined;\n return {\n async send(message) {\n sender ??= build().catch((error: unknown) => {\n sender = undefined;\n throw error;\n });\n return (await sender).send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n /** Where finished emails go — the host's choice, independent of where the workflow runs. */\n email?: EmailTransport | undefined;\n fetch?: typeof fetch;\n /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */\n apnsFetch?: typeof fetch | undefined;\n /**\n * Where the provider credentials live across runs (`createProviderCache`): the host's, made\n * once with it. Without one, this call's credentials are its own — nothing is reused.\n */\n providers?: ProviderCache | undefined;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n const providers = options.providers ?? createProviderCache();\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n const transport = options.email;\n if (transport === undefined) {\n return unavailable(\"no email transport is configured (the host's `email` option)\");\n }\n // Each template names its own sender: there is no global one to require\n return new EmailSender({ transport, render: registryRenderer(registry, config.email) });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n /**\n * A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n * waits that read devices, as a removal through the API does. Done here, per run, on the\n * settled result, so the credential cached across runs (see `ProviderCache`) never holds a\n * run's store.\n */\n const dropDeadTokens = (\n provider: MessageSender,\n deadTokenReasons: ReadonlySet<string>\n ): MessageSender => ({\n async send(message) {\n const result = await provider.send(message);\n if (\n result !== undefined &&\n deadTokenReasons.has(result.reason) &&\n message.device !== undefined &&\n options.store !== undefined\n ) {\n await removeDevice(env, message.device.token, options.store);\n }\n return result;\n },\n });\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n const apnsFetch = options.apnsFetch ?? options.fetch;\n const environment = apnsEnvironment(apns, mode);\n platforms.ios = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'APNS_PRIVATE_KEY_BASE64');\n const credentials = providers.get(\n JSON.stringify(['apns', apns.keyId, apns.teamId, await secretDigest(secret)]),\n () =>\n new ApnsProviderToken({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(secret),\n })\n );\n return new ApnsPushSender({\n credentials,\n topic: apns.topic,\n environment,\n render,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...(apnsFetch === undefined ? {} : { fetch: apnsFetch }),\n });\n }),\n APNS_DEAD_TOKEN_REASONS\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64');\n const serviceAccount = serviceAccountFromBase64(secret);\n // The token endpoint is called through the fetch: another fetch is another credential\n const credentials = providers.get(\n JSON.stringify([\n 'fcm',\n serviceAccount.client_email,\n serviceAccount.project_id,\n identity(options.fetch),\n await secretDigest(secret),\n ]),\n () =>\n new FcmAccessToken({\n serviceAccount,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n return new FcmPushSender({\n credentials,\n render,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n FCM_DEAD_TOKEN_REASONS\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = dropDeadTokens(\n lazy(async () => {\n const publicKey = requireSecret(env, 'VAPID_PUBLIC_KEY');\n const privateKey = requireSecret(env, 'VAPID_PRIVATE_KEY');\n const credentials = providers.get(\n JSON.stringify(['vapid', webPush.subject, publicKey, await secretDigest(privateKey)]),\n () => new VapidSigner({ subject: webPush.subject, publicKey, privateKey })\n );\n return new WebPushSender({\n credentials,\n render,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n WEB_PUSH_DEAD_TOKEN_REASONS\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new PermanentError(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channels.d.cts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"channels.d.cts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;;wBAmDgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;;;;;;;;;;;;;;;;iBAoDK;;EAEf,IAAI,GAAG,aAAa,aAAa,IAAI;;wBAGvB,uBAAuB;;YAiD3B,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;;EAER,QAAQ;EACR,eAAe;;EAEf,mBAAmB;;;;;EAKnB,YAAY;IAEb"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channels.d.mts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"channels.d.mts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;;wBAmDgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;;;;;;;;;;;;;;;;iBAoDK;;EAEf,IAAI,GAAG,aAAa,aAAa,IAAI;;wBAGvB,uBAAuB;;YAiD3B,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;;EAER,QAAQ;EACR,eAAe;;EAEf,mBAAmB;;;;;EAKnB,YAAY;IAEb"}
|
package/dist/host/channels.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { DiscordWebhookSender, SlackWebhookSender } from "../webhooks.mjs";
|
|
|
11
11
|
import { DEAD_TOKEN_REASONS as DEAD_TOKEN_REASONS$2, VapidSigner, WebPushSender } from "../webpush.mjs";
|
|
12
12
|
import { removeDevice } from "./ingest.mjs";
|
|
13
13
|
import { routeByChannel, routeByPlatform } from "./senders.mjs";
|
|
14
|
+
import { Buffer } from "node:buffer";
|
|
14
15
|
//#region src/host/channels.ts
|
|
15
16
|
/**
|
|
16
17
|
* The outlets a `JourneyConfig` describes, assembled against the environment:
|
|
@@ -66,7 +67,7 @@ function identity(value) {
|
|
|
66
67
|
/** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */
|
|
67
68
|
async function secretDigest(secret) {
|
|
68
69
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
69
|
-
return
|
|
70
|
+
return Buffer.from(digest.slice(0, 4)).toString("hex");
|
|
70
71
|
}
|
|
71
72
|
/**
|
|
72
73
|
* A sender built on its first message, then kept for this call. `build` reads the secrets (and
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channels.mjs","names":["APNS_DEAD_TOKEN_REASONS","FCM_DEAD_TOKEN_REASONS","WEB_PUSH_DEAD_TOKEN_REASONS"],"sources":["../../src/host/channels.ts"],"sourcesContent":["import {\n DEAD_TOKEN_REASONS as APNS_DEAD_TOKEN_REASONS,\n ApnsProviderToken,\n ApnsPushSender,\n apnsKeyFromBase64,\n} from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport { EmailSender, type EmailTransport } from '../email-sender';\nimport type { MessageSender } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport {\n DEAD_TOKEN_REASONS as FCM_DEAD_TOKEN_REASONS,\n FcmAccessToken,\n FcmPushSender,\n serviceAccountFromBase64,\n} from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport {\n VapidSigner,\n DEAD_TOKEN_REASONS as WEB_PUSH_DEAD_TOKEN_REASONS,\n WebPushSender,\n} from '../webpush';\nimport { removeDevice } from './ingest';\nimport type { JourneyEnv } from './platform';\nimport { routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/**\n * A sender that fails every message with the reason the channel cannot deliver — for good: the\n * configuration is the same on every attempt, so the step does not retry into it.\n */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new PermanentError(\n `cannot send '${message.template}' on ${message.channel}: ${reason}`\n );\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n // Missing on this attempt, missing on the next: the deployment has to change, not the step\n throw new PermanentError(\n `${name} is not set: the environment must hold it for the configured channel`\n );\n }\n return value;\n}\n\n/**\n * The provider credentials — `ApnsProviderToken`, `FcmAccessToken`, `VapidSigner` — for as long\n * as the host lives. `channelsFromConfig` runs once per workflow run (every wake of every\n * instance) and builds the senders anew each time; what must outlive the run is the credential\n * state they hold: the APNs provider JWT must be reused for at least 20 minutes (Apple answers a\n * sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`), the FCM access\n * token is good for an hour, the VAPID key import is wasted work twice.\n *\n * The cache is the host's (`createJourneyHost` makes one and hands it to every run), not the\n * run's `env`: a Lambda invocation builds a fresh env object for each run (its instance port is\n * in it), so a cache keyed on env would miss on every wake. Inside, the key names the provider,\n * the ids that identify the key and a digest of the secret — so a rotated secret or a reloaded\n * host mints a new credential instead of reusing the old, and the secret itself is in no key and\n * no log. Nothing that shapes the sender (topic, environment, relay, registry, store) is in it:\n * the sender is built per call around the shared credential.\n */\nexport interface ProviderCache {\n /** The credential under `key`, built on the first call and returned as is after that. */\n get<T>(key: string, build: () => T): T;\n}\n\nexport function createProviderCache(): ProviderCache {\n const credentials = new Map<string, unknown>();\n return {\n get<T>(key: string, build: () => T): T {\n if (!credentials.has(key)) credentials.set(key, build());\n return credentials.get(key) as T;\n },\n };\n}\n\n/** A number for each fetch implementation, so a credential minted through one is not reused with another. */\nconst identities = new WeakMap<object, number>();\nlet nextIdentity = 1;\nfunction identity(value: object | undefined): string {\n if (value === undefined) return 'global';\n let id = identities.get(value);\n if (id === undefined) {\n id = nextIdentity++;\n identities.set(value, id);\n }\n return String(id);\n}\n\n/** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */\nasync function secretDigest(secret: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));\n return Array.from(new Uint8Array(digest).slice(0, 4), (b) =>\n b.toString(16).padStart(2, '0')\n ).join('');\n}\n\n/**\n * A sender built on its first message, then kept for this call. `build` reads the secrets (and\n * so may throw a `PermanentError` naming the missing variable): a platform's secret is only\n * required when something is actually sent there, so a journey that only emails must not fail\n * because the APNs key is missing from a local `.dev.vars`. A build that failed is not kept.\n */\nfunction lazy(build: () => Promise<MessageSender>): MessageSender {\n let sender: Promise<MessageSender> | undefined;\n return {\n async send(message) {\n sender ??= build().catch((error: unknown) => {\n sender = undefined;\n throw error;\n });\n return (await sender).send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n /** Where finished emails go — the host's choice, independent of where the workflow runs. */\n email?: EmailTransport | undefined;\n fetch?: typeof fetch;\n /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */\n apnsFetch?: typeof fetch | undefined;\n /**\n * Where the provider credentials live across runs (`createProviderCache`): the host's, made\n * once with it. Without one, this call's credentials are its own — nothing is reused.\n */\n providers?: ProviderCache | undefined;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n const providers = options.providers ?? createProviderCache();\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n const transport = options.email;\n if (transport === undefined) {\n return unavailable(\"no email transport is configured (the host's `email` option)\");\n }\n // Each template names its own sender: there is no global one to require\n return new EmailSender({ transport, render: registryRenderer(registry, config.email) });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n /**\n * A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n * waits that read devices, as a removal through the API does. Done here, per run, on the\n * settled result, so the credential cached across runs (see `ProviderCache`) never holds a\n * run's store.\n */\n const dropDeadTokens = (\n provider: MessageSender,\n deadTokenReasons: ReadonlySet<string>\n ): MessageSender => ({\n async send(message) {\n const result = await provider.send(message);\n if (\n result !== undefined &&\n deadTokenReasons.has(result.reason) &&\n message.device !== undefined &&\n options.store !== undefined\n ) {\n await removeDevice(env, message.device.token, options.store);\n }\n return result;\n },\n });\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n const apnsFetch = options.apnsFetch ?? options.fetch;\n const environment = apnsEnvironment(apns, mode);\n platforms.ios = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'APNS_PRIVATE_KEY_BASE64');\n const credentials = providers.get(\n JSON.stringify(['apns', apns.keyId, apns.teamId, await secretDigest(secret)]),\n () =>\n new ApnsProviderToken({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(secret),\n })\n );\n return new ApnsPushSender({\n credentials,\n topic: apns.topic,\n environment,\n render,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...(apnsFetch === undefined ? {} : { fetch: apnsFetch }),\n });\n }),\n APNS_DEAD_TOKEN_REASONS\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64');\n const serviceAccount = serviceAccountFromBase64(secret);\n // The token endpoint is called through the fetch: another fetch is another credential\n const credentials = providers.get(\n JSON.stringify([\n 'fcm',\n serviceAccount.client_email,\n serviceAccount.project_id,\n identity(options.fetch),\n await secretDigest(secret),\n ]),\n () =>\n new FcmAccessToken({\n serviceAccount,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n return new FcmPushSender({\n credentials,\n render,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n FCM_DEAD_TOKEN_REASONS\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = dropDeadTokens(\n lazy(async () => {\n const publicKey = requireSecret(env, 'VAPID_PUBLIC_KEY');\n const privateKey = requireSecret(env, 'VAPID_PRIVATE_KEY');\n const credentials = providers.get(\n JSON.stringify(['vapid', webPush.subject, publicKey, await secretDigest(privateKey)]),\n () => new VapidSigner({ subject: webPush.subject, publicKey, privateKey })\n );\n return new WebPushSender({\n credentials,\n render,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n WEB_PUSH_DEAD_TOKEN_REASONS\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new PermanentError(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"channels.mjs","names":["APNS_DEAD_TOKEN_REASONS","FCM_DEAD_TOKEN_REASONS","WEB_PUSH_DEAD_TOKEN_REASONS"],"sources":["../../src/host/channels.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport {\n DEAD_TOKEN_REASONS as APNS_DEAD_TOKEN_REASONS,\n ApnsProviderToken,\n ApnsPushSender,\n apnsKeyFromBase64,\n} from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport { EmailSender, type EmailTransport } from '../email-sender';\nimport type { MessageSender } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport {\n DEAD_TOKEN_REASONS as FCM_DEAD_TOKEN_REASONS,\n FcmAccessToken,\n FcmPushSender,\n serviceAccountFromBase64,\n} from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport {\n VapidSigner,\n DEAD_TOKEN_REASONS as WEB_PUSH_DEAD_TOKEN_REASONS,\n WebPushSender,\n} from '../webpush';\nimport { removeDevice } from './ingest';\nimport type { JourneyEnv } from './platform';\nimport { routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/**\n * A sender that fails every message with the reason the channel cannot deliver — for good: the\n * configuration is the same on every attempt, so the step does not retry into it.\n */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new PermanentError(\n `cannot send '${message.template}' on ${message.channel}: ${reason}`\n );\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n // Missing on this attempt, missing on the next: the deployment has to change, not the step\n throw new PermanentError(\n `${name} is not set: the environment must hold it for the configured channel`\n );\n }\n return value;\n}\n\n/**\n * The provider credentials — `ApnsProviderToken`, `FcmAccessToken`, `VapidSigner` — for as long\n * as the host lives. `channelsFromConfig` runs once per workflow run (every wake of every\n * instance) and builds the senders anew each time; what must outlive the run is the credential\n * state they hold: the APNs provider JWT must be reused for at least 20 minutes (Apple answers a\n * sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`), the FCM access\n * token is good for an hour, the VAPID key import is wasted work twice.\n *\n * The cache is the host's (`createJourneyHost` makes one and hands it to every run), not the\n * run's `env`: a Lambda invocation builds a fresh env object for each run (its instance port is\n * in it), so a cache keyed on env would miss on every wake. Inside, the key names the provider,\n * the ids that identify the key and a digest of the secret — so a rotated secret or a reloaded\n * host mints a new credential instead of reusing the old, and the secret itself is in no key and\n * no log. Nothing that shapes the sender (topic, environment, relay, registry, store) is in it:\n * the sender is built per call around the shared credential.\n */\nexport interface ProviderCache {\n /** The credential under `key`, built on the first call and returned as is after that. */\n get<T>(key: string, build: () => T): T;\n}\n\nexport function createProviderCache(): ProviderCache {\n const credentials = new Map<string, unknown>();\n return {\n get<T>(key: string, build: () => T): T {\n if (!credentials.has(key)) credentials.set(key, build());\n return credentials.get(key) as T;\n },\n };\n}\n\n/** A number for each fetch implementation, so a credential minted through one is not reused with another. */\nconst identities = new WeakMap<object, number>();\nlet nextIdentity = 1;\nfunction identity(value: object | undefined): string {\n if (value === undefined) return 'global';\n let id = identities.get(value);\n if (id === undefined) {\n id = nextIdentity++;\n identities.set(value, id);\n }\n return String(id);\n}\n\n/** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */\nasync function secretDigest(secret: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));\n return Buffer.from(digest.slice(0, 4)).toString('hex');\n}\n\n/**\n * A sender built on its first message, then kept for this call. `build` reads the secrets (and\n * so may throw a `PermanentError` naming the missing variable): a platform's secret is only\n * required when something is actually sent there, so a journey that only emails must not fail\n * because the APNs key is missing from a local `.dev.vars`. A build that failed is not kept.\n */\nfunction lazy(build: () => Promise<MessageSender>): MessageSender {\n let sender: Promise<MessageSender> | undefined;\n return {\n async send(message) {\n sender ??= build().catch((error: unknown) => {\n sender = undefined;\n throw error;\n });\n return (await sender).send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n /** Where finished emails go — the host's choice, independent of where the workflow runs. */\n email?: EmailTransport | undefined;\n fetch?: typeof fetch;\n /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */\n apnsFetch?: typeof fetch | undefined;\n /**\n * Where the provider credentials live across runs (`createProviderCache`): the host's, made\n * once with it. Without one, this call's credentials are its own — nothing is reused.\n */\n providers?: ProviderCache | undefined;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n const providers = options.providers ?? createProviderCache();\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n const transport = options.email;\n if (transport === undefined) {\n return unavailable(\"no email transport is configured (the host's `email` option)\");\n }\n // Each template names its own sender: there is no global one to require\n return new EmailSender({ transport, render: registryRenderer(registry, config.email) });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n /**\n * A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n * waits that read devices, as a removal through the API does. Done here, per run, on the\n * settled result, so the credential cached across runs (see `ProviderCache`) never holds a\n * run's store.\n */\n const dropDeadTokens = (\n provider: MessageSender,\n deadTokenReasons: ReadonlySet<string>\n ): MessageSender => ({\n async send(message) {\n const result = await provider.send(message);\n if (\n result !== undefined &&\n deadTokenReasons.has(result.reason) &&\n message.device !== undefined &&\n options.store !== undefined\n ) {\n await removeDevice(env, message.device.token, options.store);\n }\n return result;\n },\n });\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n const apnsFetch = options.apnsFetch ?? options.fetch;\n const environment = apnsEnvironment(apns, mode);\n platforms.ios = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'APNS_PRIVATE_KEY_BASE64');\n const credentials = providers.get(\n JSON.stringify(['apns', apns.keyId, apns.teamId, await secretDigest(secret)]),\n () =>\n new ApnsProviderToken({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(secret),\n })\n );\n return new ApnsPushSender({\n credentials,\n topic: apns.topic,\n environment,\n render,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...(apnsFetch === undefined ? {} : { fetch: apnsFetch }),\n });\n }),\n APNS_DEAD_TOKEN_REASONS\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64');\n const serviceAccount = serviceAccountFromBase64(secret);\n // The token endpoint is called through the fetch: another fetch is another credential\n const credentials = providers.get(\n JSON.stringify([\n 'fcm',\n serviceAccount.client_email,\n serviceAccount.project_id,\n identity(options.fetch),\n await secretDigest(secret),\n ]),\n () =>\n new FcmAccessToken({\n serviceAccount,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n return new FcmPushSender({\n credentials,\n render,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n FCM_DEAD_TOKEN_REASONS\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = dropDeadTokens(\n lazy(async () => {\n const publicKey = requireSecret(env, 'VAPID_PUBLIC_KEY');\n const privateKey = requireSecret(env, 'VAPID_PRIVATE_KEY');\n const credentials = providers.get(\n JSON.stringify(['vapid', webPush.subject, publicKey, await secretDigest(privateKey)]),\n () => new VapidSigner({ subject: webPush.subject, publicKey, privateKey })\n );\n return new WebPushSender({\n credentials,\n render,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n WEB_PUSH_DEAD_TOKEN_REASONS\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new PermanentError(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
|
package/dist/host/http.cjs
CHANGED
|
@@ -90,7 +90,7 @@ const routes = [
|
|
|
90
90
|
{
|
|
91
91
|
method: "POST",
|
|
92
92
|
path: "/events",
|
|
93
|
-
auth: "
|
|
93
|
+
auth: "api",
|
|
94
94
|
async handle({ request, env, store, validation }) {
|
|
95
95
|
const { id, userId, event, payload, ts } = await readBody(request);
|
|
96
96
|
if (!isNonEmptyString(userId) || !isNonEmptyString(event)) return json({ error: "userId and event required" }, 400);
|
|
@@ -116,7 +116,7 @@ const routes = [
|
|
|
116
116
|
{
|
|
117
117
|
method: "POST",
|
|
118
118
|
path: "/identify",
|
|
119
|
-
auth: "
|
|
119
|
+
auth: "api",
|
|
120
120
|
async handle({ request, env, store, validation }) {
|
|
121
121
|
const { userId, props } = await readBody(request);
|
|
122
122
|
if (!isNonEmptyString(userId)) return json({ error: "userId required" }, 400);
|
|
@@ -134,7 +134,7 @@ const routes = [
|
|
|
134
134
|
{
|
|
135
135
|
method: "POST",
|
|
136
136
|
path: "/devices",
|
|
137
|
-
auth: "
|
|
137
|
+
auth: "api",
|
|
138
138
|
async handle({ request, env, store }) {
|
|
139
139
|
const { userId, token, platform, ts } = await readBody(request);
|
|
140
140
|
if (!isNonEmptyString(userId)) return json({ error: "userId required" }, 400);
|
|
@@ -153,7 +153,7 @@ const routes = [
|
|
|
153
153
|
{
|
|
154
154
|
method: "POST",
|
|
155
155
|
path: "/devices/unbind",
|
|
156
|
-
auth: "
|
|
156
|
+
auth: "api",
|
|
157
157
|
async handle({ request, env, store }) {
|
|
158
158
|
const { token } = await readBody(request);
|
|
159
159
|
if (!isNonEmptyString(token)) return json({ error: "token required" }, 400);
|
|
@@ -163,7 +163,7 @@ const routes = [
|
|
|
163
163
|
{
|
|
164
164
|
method: "POST",
|
|
165
165
|
path: "/devices/remove",
|
|
166
|
-
auth: "
|
|
166
|
+
auth: "api",
|
|
167
167
|
async handle({ request, env, store }) {
|
|
168
168
|
const { token } = await readBody(request);
|
|
169
169
|
if (!isNonEmptyString(token)) return json({ error: "token required" }, 400);
|
|
@@ -173,7 +173,7 @@ const routes = [
|
|
|
173
173
|
{
|
|
174
174
|
method: "POST",
|
|
175
175
|
path: "/messages/opened",
|
|
176
|
-
auth: "
|
|
176
|
+
auth: "api",
|
|
177
177
|
async handle({ request, store }) {
|
|
178
178
|
const { userId, messageId } = await readBody(request);
|
|
179
179
|
if (!isNonEmptyString(userId)) return json({ error: "userId required" }, 400);
|
|
@@ -188,7 +188,7 @@ const routes = [
|
|
|
188
188
|
{
|
|
189
189
|
method: "POST",
|
|
190
190
|
path: "/users/forget",
|
|
191
|
-
auth: "
|
|
191
|
+
auth: "api",
|
|
192
192
|
async handle({ request, env, store }) {
|
|
193
193
|
const { userId } = await readBody(request);
|
|
194
194
|
if (!isNonEmptyString(userId)) return json({ error: "userId required" }, 400);
|
|
@@ -198,7 +198,7 @@ const routes = [
|
|
|
198
198
|
{
|
|
199
199
|
method: "POST",
|
|
200
200
|
path: "/deploy",
|
|
201
|
-
auth: "
|
|
201
|
+
auth: "api",
|
|
202
202
|
async handle({ request, env, store }) {
|
|
203
203
|
return json(await require_host_ingest.deployBundle(env, await readJson(request), store));
|
|
204
204
|
}
|
|
@@ -206,7 +206,7 @@ const routes = [
|
|
|
206
206
|
{
|
|
207
207
|
method: "GET",
|
|
208
208
|
path: "/bundle",
|
|
209
|
-
auth: "
|
|
209
|
+
auth: "api",
|
|
210
210
|
async handle({ host }) {
|
|
211
211
|
return host.bundle === void 0 ? notFound() : json(host.bundle());
|
|
212
212
|
}
|
|
@@ -214,7 +214,7 @@ const routes = [
|
|
|
214
214
|
{
|
|
215
215
|
method: "POST",
|
|
216
216
|
path: "/bundle/deploy",
|
|
217
|
-
auth: "
|
|
217
|
+
auth: "api",
|
|
218
218
|
async handle({ env, store, host }) {
|
|
219
219
|
if (host.bundle === void 0) return notFound();
|
|
220
220
|
return json(await require_host_ingest.deployBundle(env, host.bundle(), store));
|
|
@@ -223,7 +223,7 @@ const routes = [
|
|
|
223
223
|
{
|
|
224
224
|
method: "POST",
|
|
225
225
|
path: "/segments/sweep",
|
|
226
|
-
auth: "
|
|
226
|
+
auth: "api",
|
|
227
227
|
async handle({ host }) {
|
|
228
228
|
return host.sweep === void 0 ? notFound() : json(await host.sweep());
|
|
229
229
|
}
|
|
@@ -231,7 +231,7 @@ const routes = [
|
|
|
231
231
|
{
|
|
232
232
|
method: "GET",
|
|
233
233
|
path: /^\/preview\//,
|
|
234
|
-
auth: "
|
|
234
|
+
auth: "api",
|
|
235
235
|
async handle({ url, host }) {
|
|
236
236
|
const { preview } = host;
|
|
237
237
|
if (preview === void 0) return notFound();
|
|
@@ -258,11 +258,21 @@ function matches(path, pathname) {
|
|
|
258
258
|
return path(pathname);
|
|
259
259
|
}
|
|
260
260
|
/**
|
|
261
|
-
* Production without a bearer token
|
|
262
|
-
* ingest, identify and deploy. Only what recipients open
|
|
261
|
+
* Production without a bearer token (and no authorizer of the host's own) is not an open
|
|
262
|
+
* instance, it is a mistake: anyone could ingest, identify and deploy. Only what recipients open
|
|
263
|
+
* (`/t/*`) and the health check stay up.
|
|
263
264
|
*/
|
|
264
|
-
function refusesOpen(request,
|
|
265
|
-
return env.API_TOKEN === void 0 && require_host_channels.journeyMode(env) === "production" && !pathname.startsWith("/t/") && !(request.method === "GET" && pathname === "/health");
|
|
265
|
+
function refusesOpen(request, ctx, pathname) {
|
|
266
|
+
return ctx.authorize === void 0 && ctx.env.API_TOKEN === void 0 && require_host_channels.journeyMode(ctx.env) === "production" && !pathname.startsWith("/t/") && !(request.method === "GET" && pathname === "/health");
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Whether the caller may use an API route: the host's authorizer on a copy of the request (so a
|
|
270
|
+
* body it verifies is still there for the route), or the env's `API_TOKEN` as a bearer. An
|
|
271
|
+
* authorizer that throws is a failure, never an admission.
|
|
272
|
+
*/
|
|
273
|
+
async function allowed(request, ctx) {
|
|
274
|
+
if (ctx.authorize === void 0) return require_host_auth.authorized(request, ctx.env);
|
|
275
|
+
return ctx.authorize(request.clone(), ctx.env);
|
|
266
276
|
}
|
|
267
277
|
/**
|
|
268
278
|
* What a failed request answers. Validation problems are the caller's to fix (a 400, saying
|
|
@@ -287,10 +297,19 @@ function failure(error) {
|
|
|
287
297
|
/** One request through the table: the production gate, the bearer, the route, the error mapping. */
|
|
288
298
|
async function serve(request, ctx) {
|
|
289
299
|
const url = new URL(request.url);
|
|
290
|
-
if (ctx.host !== void 0 && refusesOpen(request, ctx
|
|
300
|
+
if (ctx.host !== void 0 && refusesOpen(request, ctx, url.pathname)) return json({ error: "API_TOKEN is not set: production refuses to serve without it (set ENVIRONMENT=development to run open)" }, 503);
|
|
291
301
|
const candidates = routes.filter((route) => matches(route.path, url.pathname));
|
|
292
302
|
if (candidates.length === 0) return notFound();
|
|
293
|
-
if (candidates.some((route) => route.auth === "
|
|
303
|
+
if (candidates.some((route) => route.auth === "api")) {
|
|
304
|
+
let ok;
|
|
305
|
+
try {
|
|
306
|
+
ok = await allowed(request, ctx);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error("[workflow http] authorize failed", error);
|
|
309
|
+
return json({ error: "internal error" }, 500);
|
|
310
|
+
}
|
|
311
|
+
if (!ok) return json({ error: "unauthorized" }, 401);
|
|
312
|
+
}
|
|
294
313
|
const route = candidates.find((r) => r.method === "*" || r.method === request.method);
|
|
295
314
|
if (route === void 0) return json({ error: "method not allowed" }, 405);
|
|
296
315
|
try {
|