@ultimat3/notify 29.3.2 → 30.0.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.
@@ -0,0 +1,63 @@
1
+ // Single responsibility: the two signed JWTs native push needs, over WebCrypto and nothing else —
2
+ // APNs' ES256 provider token (a `.p8` key) and Google's RS256 service-account assertion. The key
3
+ // arrives as a `Secret` and is revealed in exactly one place, the import below.
4
+
5
+ import type { Secret } from '@ultimat3/core';
6
+ import { revealSecret } from '@ultimat3/core';
7
+
8
+ export type JwtAlgorithm = 'ES256' | 'RS256';
9
+
10
+ /** A ternary over the closed pair, never a map read: `bun run proto-index` (`X_PROTO_CHAIN_INDEX`). */
11
+ const importParams = (algorithm: JwtAlgorithm): EcKeyImportParams | RsaHashedImportParams =>
12
+ algorithm === 'ES256'
13
+ ? { name: 'ECDSA', namedCurve: 'P-256' }
14
+ : { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
15
+
16
+ /** WebCrypto's ECDSA output is raw `r || s`, which is exactly JWS ES256's encoding — no DER. */
17
+ const signParams = (algorithm: JwtAlgorithm): AlgorithmIdentifier | EcdsaParams =>
18
+ algorithm === 'ES256' ? { name: 'ECDSA', hash: 'SHA-256' } : { name: 'RSASSA-PKCS1-v1_5' };
19
+
20
+ const base64Url = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64url');
21
+
22
+ const PEM_BODY = /-----BEGIN PRIVATE KEY-----([\s\S]+?)-----END PRIVATE KEY-----/;
23
+
24
+ /**
25
+ * The DER inside a PKCS#8 PEM, or `undefined`. A key pasted into an env var often arrives with its
26
+ * newlines spelled `\n` (a JSON service-account file's `private_key`), so both spellings read.
27
+ */
28
+ export function pkcs8Der(pem: string): Uint8Array<ArrayBuffer> | undefined {
29
+ const match = PEM_BODY.exec(pem.replaceAll('\\n', '\n'));
30
+ if (match?.[1] === undefined) return undefined;
31
+ const body = match[1].replace(/\s+/g, '');
32
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return undefined;
33
+ return Uint8Array.from(Buffer.from(body, 'base64'));
34
+ }
35
+
36
+ /** Import a PKCS#8 key for signing, or `undefined` when it is no key of that algorithm. */
37
+ export async function importSigningKey(
38
+ algorithm: JwtAlgorithm,
39
+ key: Secret,
40
+ ): Promise<CryptoKey | undefined> {
41
+ const der = pkcs8Der(revealSecret(key));
42
+ if (der === undefined) return undefined;
43
+ try {
44
+ return await crypto.subtle.importKey('pkcs8', der, importParams(algorithm), false, ['sign']);
45
+ } catch {
46
+ // The engine's message can quote key material; the caller names the variable instead.
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ /** `header.claims.signature`, each segment base64url JSON. */
52
+ export async function signJwt(
53
+ algorithm: JwtAlgorithm,
54
+ key: CryptoKey,
55
+ header: Readonly<Record<string, string>>,
56
+ claims: Readonly<Record<string, string | number>>,
57
+ ): Promise<string> {
58
+ const text = new TextEncoder();
59
+ const segment = (value: object): string => base64Url(text.encode(JSON.stringify(value)));
60
+ const unsigned = `${segment({ ...header, alg: algorithm })}.${segment(claims)}`;
61
+ const signature = await crypto.subtle.sign(signParams(algorithm), key, text.encode(unsigned));
62
+ return `${unsigned}.${base64Url(new Uint8Array(signature))}`;
63
+ }
@@ -0,0 +1,173 @@
1
+ // Single responsibility: `nativePush()`, the APNs + FCM transport as one `Pusher` — every device a
2
+ // person registered, each in its own locale, dead tokens deleted, refusals coded. It satisfies
3
+ // `pushChannel({ pusher })` for a visible push and `pushChannel({ pusher, data })` for a data-only
4
+ // one, which `webPush()` cannot (a browser push always shows) — so that mistake is a type error.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import { finiteCount, isUltimateError, logger, systemClock } from '@ultimat3/core';
8
+ import type { OutboundFetch } from '@ultimat3/core/outbound';
9
+ import { translatorFor } from '@ultimat3/i18n';
10
+ import type { ApnsOptions } from './apns';
11
+ import { apnsSender } from './apns';
12
+ import type { DataPusher, NotifyPushMessage, Pusher } from './channel-push';
13
+ import type { FcmOptions } from './fcm';
14
+ import { fcmSender } from './fcm';
15
+ import { platformFetch } from './native-transport';
16
+ import type { PushDeviceStore, PushPlatform } from './push-device';
17
+ import { isDeviceToken } from './push-device';
18
+ import type { NativePush, NativeSender, PushData, PushTranslate } from './push-message';
19
+ import { renderNativePush, screenPushData } from './push-message';
20
+
21
+ export const DEFAULT_NATIVE_PUSH_DEADLINE_MS = 10_000;
22
+
23
+ /**
24
+ * Bad-token answers in a row (APNs `BadDeviceToken`, FCM `INVALID_ARGUMENT` on `message.token`)
25
+ * that delete a device. Never 1: `BadDeviceToken` is also a sandbox build's token sent to
26
+ * production, and a delivery in between starts the count again.
27
+ */
28
+ export const PUSH_BAD_TOKEN_LIMIT = 3;
29
+
30
+ export interface NativePushOptions {
31
+ /** The registrations `pushRegister()` writes — the same store, handed to both. */
32
+ readonly devices: PushDeviceStore;
33
+ /** iOS devices. Absent, an `ios` registration is skipped with a warning. */
34
+ readonly apns?: ApnsOptions | undefined;
35
+ /** Android devices. Absent, an `android` registration is skipped with a warning. */
36
+ readonly fcm?: FcmOptions | undefined;
37
+ /** Each provider call, retries included. Default `DEFAULT_NATIVE_PUSH_DEADLINE_MS`. */
38
+ readonly deadlineMs?: number | undefined;
39
+ /** Test seams: the fake provider, the environment the keys are read from, the clock. */
40
+ readonly fetch?: OutboundFetch | undefined;
41
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
42
+ readonly clock?: Clock | undefined;
43
+ readonly translate?: ((locale: string) => PushTranslate) | undefined;
44
+ }
45
+
46
+ /** Both seams, each answering a promise: a send settles once every device was tried. */
47
+ export interface NativePusher extends Pusher, DataPusher {
48
+ send(push: Parameters<Pusher['send']>[0]): Promise<void>;
49
+ sendData(push: Parameters<DataPusher['sendData']>[0]): Promise<void>;
50
+ }
51
+
52
+ /** The message for one device, in that device's locale. */
53
+ type PushFor = (locale: string) => NativePush;
54
+
55
+ type Senders = ReadonlyMap<PushPlatform, NativeSender>;
56
+
57
+ const metaOf = (error: { readonly meta?: Readonly<Record<string, unknown>> | undefined }) =>
58
+ error.meta ?? {};
59
+
60
+ /**
61
+ * Every device of `to`, in `(platform, token)` order. A dead token is deleted; a token the
62
+ * provider cannot parse is deleted on its `PUSH_BAD_TOKEN_LIMIT`th answer in a row; a refusal for
63
+ * good is logged with its code and the next device tried; a blip (a 5xx, a 429, a timeout) is
64
+ * rethrown AFTER every other device was tried, for the job's retry. A CREDENTIAL refusal ends that
65
+ * provider's fan-out on the spot and is thrown — every other device would be refused alike, each
66
+ * after a re-sign Apple throttles. When no device took it and one refused, the first refusal is
67
+ * thrown: a delivery nothing accepted must fail, not settle as sent.
68
+ */
69
+ async function eachDevice(
70
+ devices: PushDeviceStore,
71
+ senders: Senders,
72
+ push: { readonly to: string; readonly signal: AbortSignal; readonly message: PushFor },
73
+ ): Promise<void> {
74
+ let delivered = 0;
75
+ let retry: unknown;
76
+ let refusal: unknown;
77
+ let credential: unknown;
78
+ const stopped = new Set<PushPlatform>();
79
+ for (const device of await devices.listFor(push.to)) {
80
+ if (stopped.has(device.platform)) continue;
81
+ const sender = senders.get(device.platform);
82
+ if (sender === undefined) {
83
+ logger.warn('notify.push.unconfigured', {
84
+ platform: device.platform,
85
+ fix: `nativePush({ ${device.platform === 'ios' ? 'apns' : 'fcm'}: { … } }) # or drop '${device.platform}' from pushRegister({ platforms })`,
86
+ });
87
+ continue;
88
+ }
89
+ // A stored token no provider could take is a row no send will ever reach.
90
+ if (!isDeviceToken(device.platform, device.token)) {
91
+ await devices.remove(device.platform, device.token);
92
+ continue;
93
+ }
94
+ try {
95
+ await sender.send(device.token, push.message(device.locale), push.signal);
96
+ delivered += 1;
97
+ if ((device.badTokens ?? 0) > 0) await devices.clearBadToken(device.platform, device.token);
98
+ } catch (error) {
99
+ if (!isUltimateError(error) || !error.code.startsWith('X_NOTIFY_PUSH_')) {
100
+ retry ??= error;
101
+ continue;
102
+ }
103
+ if (error.code === 'X_NOTIFY_PUSH_DEVICE_GONE') {
104
+ await devices.remove(device.platform, device.token);
105
+ logger.info('notify.push.gone', { code: error.code, cause: error.cause });
106
+ continue;
107
+ }
108
+ logger.error('notify.push.refused', { code: error.code, cause: error.cause, fix: error.fix });
109
+ const meta = metaOf(error);
110
+ if (meta['credential'] === true) {
111
+ stopped.add(device.platform);
112
+ credential ??= error;
113
+ continue;
114
+ }
115
+ refusal ??= error;
116
+ if (
117
+ meta['badToken'] === true &&
118
+ (await devices.noteBadToken(device.platform, device.token)) >= PUSH_BAD_TOKEN_LIMIT
119
+ ) {
120
+ await devices.remove(device.platform, device.token);
121
+ logger.info('notify.push.bad_token_removed', { code: error.code, cause: error.cause });
122
+ }
123
+ }
124
+ }
125
+ if (credential !== undefined) throw credential;
126
+ if (retry !== undefined) throw retry;
127
+ if (delivered === 0 && refusal !== undefined) throw refusal;
128
+ }
129
+
130
+ /**
131
+ * `pushChannel({ pusher: nativePush({ devices, apns: { teamId, keyId, topic }, fcm: { projectId,
132
+ * clientEmail } }), message })`. The private keys are never options: they are read from
133
+ * `ULTIMATE_APNS_PRIVATE_KEY` / `ULTIMATE_FCM_PRIVATE_KEY` (`x secrets set …`) when a token is
134
+ * minted, boxed in a `Secret` at once, and signed with through WebCrypto.
135
+ */
136
+ export function nativePush(options: NativePushOptions): NativePusher {
137
+ const deps = {
138
+ fetch: options.fetch ?? platformFetch,
139
+ env: options.env ?? Bun.env,
140
+ clock: options.clock ?? systemClock,
141
+ deadlineMs: finiteCount(
142
+ 'nativePush()',
143
+ 'deadlineMs',
144
+ options.deadlineMs ?? DEFAULT_NATIVE_PUSH_DEADLINE_MS,
145
+ 1,
146
+ ),
147
+ };
148
+ const senders = new Map<PushPlatform, NativeSender>();
149
+ if (options.apns !== undefined) senders.set('ios', apnsSender(options.apns, deps));
150
+ if (options.fcm !== undefined) senders.set('android', fcmSender(options.fcm, deps));
151
+ const translate = options.translate ?? ((locale: string) => translatorFor(locale));
152
+ return {
153
+ send({ to, message, signal }: { to: string; message: NotifyPushMessage; signal: AbortSignal }) {
154
+ return eachDevice(options.devices, senders, {
155
+ to,
156
+ signal,
157
+ message: (locale) => ({
158
+ kind: 'alert',
159
+ push: renderNativePush(message, translate(locale)),
160
+ }),
161
+ });
162
+ },
163
+ sendData({ to, data, signal }: { to: string; data: PushData; signal: AbortSignal }) {
164
+ // Screened once, before any device: a refusal here is the notifier's, not a provider's.
165
+ const screened = screenPushData(data);
166
+ return eachDevice(options.devices, senders, {
167
+ to,
168
+ signal,
169
+ message: () => ({ kind: 'data', data: screened }),
170
+ });
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,99 @@
1
+ // Single responsibility: what the APNs and FCM senders share — the injected seams, the private key
2
+ // read out of the environment (where `x secrets` puts it) straight into a `Secret` box, and a coded
3
+ // `outbound()` refusal read back into a status and the provider's reason.
4
+
5
+ import type { Clock } from '@ultimat3/core';
6
+ import { ConfigInvalidError, EnvMissingError, isUltimateError, secret } from '@ultimat3/core';
7
+ import type { OutboundFetch, OutboundInit } from '@ultimat3/core/outbound';
8
+ import { importSigningKey } from './native-jwt';
9
+ import type { PushProvider } from './push-errors';
10
+
11
+ export interface NativeTransportDeps {
12
+ /** "Just the call" — the platform's `fetch` unless a test hands its fake provider. */
13
+ readonly fetch: OutboundFetch;
14
+ /** Where the private keys are read, per mint. `Bun.env` unless a test hands its own. */
15
+ readonly env: Readonly<Record<string, string | undefined>>;
16
+ readonly clock: Clock;
17
+ /** The whole call to the provider, retries included. */
18
+ readonly deadlineMs: number;
19
+ }
20
+
21
+ /** The platform's `fetch`, read per call so a test's sealed network sees it. */
22
+ export const platformFetch: OutboundFetch = (url, init) => fetch(url, init);
23
+
24
+ /**
25
+ * APNs speaks HTTP/2 only; an HTTP/1.1 request is a malformed response. Bun's `fetch` negotiates it
26
+ * when asked, so the transport asks on every APNs hop.
27
+ */
28
+ export const http2 =
29
+ (base: OutboundFetch): OutboundFetch =>
30
+ (url, init) =>
31
+ base(url, Object.assign({}, init, { protocol: 'http2' }) satisfies OutboundInit);
32
+
33
+ /** Where each provider's private key is read from — `x secrets set <name>` puts it there. */
34
+ export const APNS_KEY_ENV = 'ULTIMATE_APNS_PRIVATE_KEY';
35
+ export const FCM_KEY_ENV = 'ULTIMATE_FCM_PRIVATE_KEY';
36
+
37
+ /** Spelled per provider rather than interpolated: a `fix:` names its command whole. */
38
+ const SET_APNS_KEY =
39
+ 'x secrets set ULTIMATE_APNS_PRIVATE_KEY # paste the .p8 file whole, BEGIN and END lines included; a real environment variable of that name wins';
40
+ const SET_FCM_KEY =
41
+ 'x secrets set ULTIMATE_FCM_PRIVATE_KEY # paste the service-account private_key whole, BEGIN and END lines included; a real environment variable of that name wins';
42
+
43
+ /** The provider's signing key, imported — or the refusal naming its variable, never its value. */
44
+ export async function signingKeyFrom(
45
+ env: NativeTransportDeps['env'],
46
+ provider: PushProvider,
47
+ ): Promise<CryptoKey> {
48
+ const apns = provider === 'apns';
49
+ const variable = apns ? APNS_KEY_ENV : FCM_KEY_ENV;
50
+ const fix = apns ? SET_APNS_KEY : SET_FCM_KEY;
51
+ const raw = env[variable];
52
+ if (raw === undefined || raw.trim() === '') {
53
+ throw new EnvMissingError({
54
+ cause: `nativePush() needs ${variable} to sign its ${provider} token, and the environment has none`,
55
+ fix,
56
+ meta: { variable },
57
+ });
58
+ }
59
+ const key = await importSigningKey(apns ? 'ES256' : 'RS256', secret(raw, variable));
60
+ if (key === undefined) {
61
+ throw new ConfigInvalidError({
62
+ cause: `${variable} is not a PKCS#8 ${apns ? 'P-256 (.p8)' : 'RSA'} private key`,
63
+ fix,
64
+ meta: { variable },
65
+ });
66
+ }
67
+ return key;
68
+ }
69
+
70
+ /** A provider's refusal as `outbound()` coded it: the status, and the JSON body it kept. */
71
+ export interface ProviderRefusal {
72
+ readonly status: number;
73
+ readonly body: Readonly<Record<string, unknown>> | undefined;
74
+ /** The redacted text itself — read when a long body was cut and no longer parses. */
75
+ readonly detail: string;
76
+ }
77
+
78
+ /**
79
+ * `X_OUTBOUND_REFUSED`'s status and `meta.detail` (a redacted JSON object, 200 characters at most —
80
+ * so a cut one parses to nothing and the status alone decides). Anything else is `undefined`: a
81
+ * 5xx, a 429 or a timeout is the provider's blip and is rethrown for the job's retry.
82
+ */
83
+ export function refusalOf(error: unknown): ProviderRefusal | undefined {
84
+ if (!isUltimateError(error) || error.code !== 'X_OUTBOUND_REFUSED') return undefined;
85
+ const status = error.meta?.['status'];
86
+ if (typeof status !== 'number') return undefined;
87
+ const raw = error.meta?.['detail'];
88
+ const detail = typeof raw === 'string' ? raw : '';
89
+ let body: Record<string, unknown> | undefined;
90
+ try {
91
+ const parsed: unknown = detail === '' ? null : JSON.parse(detail);
92
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
93
+ body = parsed as Record<string, unknown>;
94
+ }
95
+ } catch {
96
+ body = undefined;
97
+ }
98
+ return { status, body, detail };
99
+ }
package/src/notifier.ts CHANGED
@@ -13,7 +13,7 @@ import { finiteCount } from '@ultimat3/core';
13
13
  import type { JobHandle, JobTenant, RetryPolicy } from '@ultimat3/jobs';
14
14
  import { DEFAULT_RETRY, job } from '@ultimat3/jobs';
15
15
  import type { Schema } from '@ultimat3/schema';
16
- import { t } from '@ultimat3/schema';
16
+ import { parse, t } from '@ultimat3/schema';
17
17
  import { isBulkChannel } from './channel';
18
18
  import {
19
19
  NotifyChannelDuplicateError,
@@ -142,6 +142,7 @@ export function notifier<Params>(
142
142
  ),
143
143
  deliveries,
144
144
  keyFor: (params) => definition.key(params),
145
+ parseParams: (raw) => parse(definition.input, raw) as Params,
145
146
  // Bound to the definition rather than torn off it, so an author who writes `recipients` as a
146
147
  // method rather than an arrow still gets the right `this`. An enqueue that names no audience
147
148
  // and a notifier that resolves none is an empty fan-out, not an error: a broadcast with no
package/src/plan.ts CHANGED
@@ -107,6 +107,8 @@ export interface NotifyPlan<Params> {
107
107
  * channel with no wait fires immediately even when a later one waits an hour. */
108
108
  readonly deliveries: readonly ResolvedDelivery<Params>[];
109
109
  keyFor(params: Params): string;
110
+ /** The notifier's `input`, for params read back out of a store or a step in their stored form. */
111
+ parseParams(raw: unknown): Params;
110
112
  recipientsFor(args: RecipientArgs<Params>): Promise<readonly Recipient[]> | readonly Recipient[];
111
113
  }
112
114
 
@@ -0,0 +1,126 @@
1
+ // Single responsibility: native push devices in Postgres — `x_push_devices`, a framework table
2
+ // applied by the boot (`@ultimat3/cli`'s `FRAMEWORK_SCHEMA`) the way `x_notify_inbox` is, never an
3
+ // `entity()` an app's `x db gen` would own. Statements are spelled out so the one in a log is the
4
+ // one to run.
5
+
6
+ import type { PgExecutor } from '@ultimat3/core';
7
+ import type { PushDeviceRecord, PushDeviceStore, PushPlatform } from './push-device';
8
+
9
+ /** The table. One name, read by this DDL and by the statements below. */
10
+ export const PUSH_DEVICES_TABLE = 'x_push_devices';
11
+
12
+ /**
13
+ * Applied by the boot, never by an app migration; `if not exists` throughout, so every boot can.
14
+ * `(platform, token)` is the key (`push-device.ts` says why). `actor_id` is indexed because the one
15
+ * hot read is "every device of this person", once per notification sent.
16
+ */
17
+ export const SQL_PUSH_DEVICES_TABLE = `
18
+ create table if not exists x_push_devices (
19
+ platform text not null check (platform in ('ios', 'android')),
20
+ token text not null,
21
+ actor_id text not null,
22
+ locale text not null,
23
+ time_zone text not null,
24
+ created_at timestamptz not null default now(),
25
+ bad_tokens integer not null default 0,
26
+ primary key (platform, token)
27
+ );
28
+
29
+ alter table x_push_devices add column if not exists bad_tokens integer not null default 0;
30
+
31
+ create index if not exists x_push_devices_actor_idx on x_push_devices (actor_id);
32
+ `;
33
+
34
+ /** A re-register moves the token to this actor and its locale; `created_at` and `bad_tokens` stay. */
35
+ export const SQL_PUSH_DEVICE_SAVE = `
36
+ insert into x_push_devices (platform, token, actor_id, locale, time_zone, created_at)
37
+ values ($1, $2, $3, $4, $5, $6)
38
+ on conflict (platform, token) do update set
39
+ actor_id = excluded.actor_id, locale = excluded.locale, time_zone = excluded.time_zone
40
+ `;
41
+
42
+ /** `collate "C"`: the memory store orders by code point, and one order is the contract. */
43
+ export const SQL_PUSH_DEVICE_LIST = `
44
+ select platform, token, actor_id, locale, time_zone, created_at, bad_tokens
45
+ from x_push_devices where actor_id = $1
46
+ order by platform collate "C", token collate "C"
47
+ `;
48
+
49
+ /** `$3` null deletes whoever holds it — the sender's dead token; an unregister names its owner. */
50
+ export const SQL_PUSH_DEVICE_REMOVE = `
51
+ delete from x_push_devices
52
+ where platform = $1 and token = $2 and ($3::text is null or actor_id = $3)
53
+ returning token
54
+ `;
55
+
56
+ /** One more bad-token answer; no row answers no count, which the sender reads as 0. */
57
+ export const SQL_PUSH_DEVICE_BAD_TOKEN = `
58
+ update x_push_devices set bad_tokens = bad_tokens + 1
59
+ where platform = $1 and token = $2
60
+ returning bad_tokens
61
+ `;
62
+
63
+ /** Only a row that has a count is written: a delivery to a healthy device costs no write. */
64
+ export const SQL_PUSH_DEVICE_BAD_TOKEN_CLEAR = `
65
+ update x_push_devices set bad_tokens = 0
66
+ where platform = $1 and token = $2 and bad_tokens > 0
67
+ `;
68
+
69
+ interface DeviceDbRow {
70
+ readonly platform: PushPlatform;
71
+ readonly token: string;
72
+ readonly actor_id: string;
73
+ readonly locale: string;
74
+ readonly time_zone: string;
75
+ readonly created_at: Date | string;
76
+ readonly bad_tokens: number | string;
77
+ }
78
+
79
+ const fromRow = (row: DeviceDbRow): PushDeviceRecord => ({
80
+ platform: row.platform,
81
+ token: row.token,
82
+ actorId: row.actor_id,
83
+ locale: row.locale,
84
+ timeZone: row.time_zone,
85
+ createdAt: (row.created_at instanceof Date ? row.created_at : new Date(row.created_at)).getTime(),
86
+ // Absent at 0, as the memory store leaves it: one record shape from both.
87
+ ...(Number(row.bad_tokens) > 0 ? { badTokens: Number(row.bad_tokens) } : {}),
88
+ });
89
+
90
+ export interface PostgresPushDeviceStoreOptions {
91
+ /** `@ultimat3/db`'s `dbExecutor()` — `Bun.sql` does not satisfy it. */
92
+ readonly executor: PgExecutor;
93
+ }
94
+
95
+ export function postgresPushDeviceStore(options: PostgresPushDeviceStoreOptions): PushDeviceStore {
96
+ const { executor } = options;
97
+ return {
98
+ async save(record) {
99
+ await executor.query(SQL_PUSH_DEVICE_SAVE, [
100
+ record.platform,
101
+ record.token,
102
+ record.actorId,
103
+ record.locale,
104
+ record.timeZone,
105
+ new Date(record.createdAt),
106
+ ]);
107
+ },
108
+ async listFor(actorId) {
109
+ return (await executor.query<DeviceDbRow>(SQL_PUSH_DEVICE_LIST, [actorId])).map(fromRow);
110
+ },
111
+ async remove(platform, token, actorId) {
112
+ const rows = await executor.query(SQL_PUSH_DEVICE_REMOVE, [platform, token, actorId ?? null]);
113
+ return rows.length > 0;
114
+ },
115
+ async noteBadToken(platform, token) {
116
+ const rows = await executor.query<{ bad_tokens: number | string }>(
117
+ SQL_PUSH_DEVICE_BAD_TOKEN,
118
+ [platform, token],
119
+ );
120
+ return rows[0] === undefined ? 0 : Number(rows[0].bad_tokens);
121
+ },
122
+ async clearBadToken(platform, token) {
123
+ await executor.query(SQL_PUSH_DEVICE_BAD_TOKEN_CLEAR, [platform, token]);
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,125 @@
1
+ // Single responsibility: what one native device registration is — its platform, its token's
2
+ // shape, the store seam it lives behind, and the in-memory store a test or a single process uses.
3
+ // The Postgres store is `push-device-pg.ts`.
4
+ //
5
+ // A device is keyed by `(platform, token)`, never by actor: a token names one app install, and when
6
+ // a second person signs in on that phone the registration is theirs now — keyed by the actor too,
7
+ // the first person would go on receiving the second person's notifications.
8
+
9
+ /** The two native transports `nativePush()` speaks: APNs for `ios`, FCM for `android`. */
10
+ export const PUSH_PLATFORMS = ['ios', 'android'] as const;
11
+
12
+ export type PushPlatform = (typeof PUSH_PLATFORMS)[number];
13
+
14
+ /**
15
+ * An APNs device token is hex — 32 bytes today, and Apple says it may grow, so up to 100. It is
16
+ * spliced into the APNs URL PATH, so anything else is refused rather than escaped: a token is
17
+ * never a place a `/` or a `?` reaches the provider from.
18
+ */
19
+ export const APNS_TOKEN = /^(?:[0-9a-fA-F]{2}){32,100}$/;
20
+
21
+ /** An FCM registration token: URL-safe base64 with `:` and `-`, 4 KiB at most. */
22
+ export const FCM_TOKEN = /^[A-Za-z0-9_:.-]{32,4096}$/;
23
+
24
+ /** Whether `token` is one `platform`'s provider could ever accept — read at register AND send. */
25
+ export const isDeviceToken = (platform: PushPlatform, token: string): boolean =>
26
+ (platform === 'ios' ? APNS_TOKEN : FCM_TOKEN).test(token);
27
+
28
+ /** One app install's registration. Tokens are stored lowercase for `ios`, as APNs reads them. */
29
+ export interface PushDeviceRecord {
30
+ readonly platform: PushPlatform;
31
+ readonly token: string;
32
+ readonly actorId: string;
33
+ /** BCP-47 — a visible push to this device renders in it. */
34
+ readonly locale: string;
35
+ /** IANA — the zone a push formatting a date would need. */
36
+ readonly timeZone: string;
37
+ /** Epoch ms of the first registration; a re-register keeps it. */
38
+ readonly createdAt: number;
39
+ /**
40
+ * Bad-token answers in a row (`noteBadToken`), present only when above 0 — a store writes it,
41
+ * a caller of `save` never does, and a re-register keeps it: the token is no better for it.
42
+ */
43
+ readonly badTokens?: number | undefined;
44
+ }
45
+
46
+ export interface PushDeviceStore {
47
+ /** Insert, or hand an existing `(platform, token)` to this record's actor and locale. */
48
+ save(record: PushDeviceRecord): Promise<void>;
49
+ /** Every device of one actor, ordered `(platform, token)` by code point in both stores. */
50
+ listFor(actorId: string): Promise<readonly PushDeviceRecord[]>;
51
+ /**
52
+ * Delete one device. With `actorId`, only when it is that actor's — an unregister names its
53
+ * owner, so a token somebody else holds is simply absent. Without, unconditionally — the
54
+ * sender's answer to a provider saying the token is dead, whoever held it.
55
+ */
56
+ remove(platform: PushPlatform, token: string, actorId?: string): Promise<boolean>;
57
+ /** One more bad-token answer for this device; the count after it, `0` when there is no row. */
58
+ noteBadToken(platform: PushPlatform, token: string): Promise<number>;
59
+ /** It delivered: the bad-token answers before it were not in a row after all. */
60
+ clearBadToken(platform: PushPlatform, token: string): Promise<void>;
61
+ }
62
+
63
+ const keyOf = (platform: PushPlatform, token: string): string => `${platform}\u0000${token}`;
64
+
65
+ const byDevice = (a: PushDeviceRecord, b: PushDeviceRecord): number => {
66
+ const left = keyOf(a.platform, a.token);
67
+ const right = keyOf(b.platform, b.token);
68
+ return left < right ? -1 : left > right ? 1 : 0;
69
+ };
70
+
71
+ /** One process's devices. Lost on restart, which is the right fate for a test's. */
72
+ export function memoryPushDeviceStore(): PushDeviceStore {
73
+ const rows = new Map<string, PushDeviceRecord>();
74
+ return {
75
+ save(record) {
76
+ const key = keyOf(record.platform, record.token);
77
+ const existing = rows.get(key);
78
+ // The first `createdAt` and the bad-token count survive a re-register, as `on conflict do
79
+ // update` leaves both.
80
+ const { badTokens: _ignored, ...fields } = record;
81
+ rows.set(
82
+ key,
83
+ existing === undefined
84
+ ? fields
85
+ : {
86
+ ...fields,
87
+ createdAt: existing.createdAt,
88
+ ...(existing.badTokens === undefined ? {} : { badTokens: existing.badTokens }),
89
+ },
90
+ );
91
+ return Promise.resolve();
92
+ },
93
+ noteBadToken(platform, token) {
94
+ const key = keyOf(platform, token);
95
+ const row = rows.get(key);
96
+ if (row === undefined) return Promise.resolve(0);
97
+ const badTokens = (row.badTokens ?? 0) + 1;
98
+ rows.set(key, { ...row, badTokens });
99
+ return Promise.resolve(badTokens);
100
+ },
101
+ clearBadToken(platform, token) {
102
+ const key = keyOf(platform, token);
103
+ const row = rows.get(key);
104
+ if (row !== undefined) {
105
+ const { badTokens: _cleared, ...rest } = row;
106
+ rows.set(key, rest);
107
+ }
108
+ return Promise.resolve();
109
+ },
110
+ listFor(actorId) {
111
+ return Promise.resolve(
112
+ [...rows.values()].filter((row) => row.actorId === actorId).sort(byDevice),
113
+ );
114
+ },
115
+ remove(platform, token, actorId) {
116
+ const key = keyOf(platform, token);
117
+ const row = rows.get(key);
118
+ if (row === undefined || (actorId !== undefined && row.actorId !== actorId)) {
119
+ return Promise.resolve(false);
120
+ }
121
+ rows.delete(key);
122
+ return Promise.resolve(true);
123
+ },
124
+ };
125
+ }