@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.
- package/CLAUDE.md +29 -5
- package/README.md +94 -2
- package/package.json +18 -5
- package/src/apns.ts +145 -0
- package/src/attempt.ts +39 -7
- package/src/channel-push.ts +44 -5
- package/src/errors.ts +48 -1
- package/src/fanout-digest.ts +15 -11
- package/src/fanout.ts +2 -2
- package/src/fcm.ts +211 -0
- package/src/index.ts +36 -1
- package/src/ledger-pg.ts +55 -16
- package/src/ledger.ts +59 -19
- package/src/native-jwt.ts +63 -0
- package/src/native-push.ts +173 -0
- package/src/native-transport.ts +99 -0
- package/src/notifier.ts +2 -1
- package/src/plan.ts +2 -0
- package/src/push-device-pg.ts +126 -0
- package/src/push-device.ts +125 -0
- package/src/push-errors.ts +83 -0
- package/src/push-message.ts +109 -0
- package/src/push-register.ts +167 -0
- package/src/token-cache.ts +57 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Single responsibility: the native push transport's three refusals. Registered with the rest of
|
|
2
|
+
// the package's codes in `errors.ts`; here so that file stays the fan-out's. No cause ever carries
|
|
3
|
+
// a device token, a credential or a message body — only the provider, its status and its reason.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
export type PushProvider = 'apns' | 'fcm';
|
|
8
|
+
|
|
9
|
+
/** The provider's reason word (`Unregistered`, `UNREGISTERED`), or `unknown` — never free text. */
|
|
10
|
+
export const providerReason = (value: unknown): string =>
|
|
11
|
+
typeof value === 'string' && /^[A-Za-z_]{1,64}$/.test(value) ? value : 'unknown';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The provider says this token names no install any more: APNs 410, FCM `UNREGISTERED`. Thrown by
|
|
15
|
+
* one device's send and caught by `nativePush()`, which deletes the row and logs this — so it
|
|
16
|
+
* never fails a delivery, and the next notification does not pay for the dead device again.
|
|
17
|
+
*/
|
|
18
|
+
export class NotifyPushDeviceGoneError extends UltimateError {
|
|
19
|
+
constructor(input: { provider: PushProvider; status: number; reason: string }) {
|
|
20
|
+
super({
|
|
21
|
+
code: 'X_NOTIFY_PUSH_DEVICE_GONE',
|
|
22
|
+
cause: `${input.provider} answered ${String(input.status)} (${input.reason}) for a device token: the app was uninstalled, or the device rotated its token`,
|
|
23
|
+
fix: 'x jobs list --json # nothing to repair: the dead device row is already deleted, and the app stores its new token with its next pushRegister() call',
|
|
24
|
+
meta: { provider: input.provider, status: input.status, reason: input.reason },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Spelled per provider rather than interpolated: a `fix:` names its command whole. */
|
|
30
|
+
const APNS_CREDENTIAL_FIX =
|
|
31
|
+
"x secrets set ULTIMATE_APNS_PRIVATE_KEY # with the team's current .p8 key, and check teamId, keyId and topic on nativePush({ apns })";
|
|
32
|
+
const FCM_CREDENTIAL_FIX =
|
|
33
|
+
"x secrets set ULTIMATE_FCM_PRIVATE_KEY # with the service account's current private_key, and check projectId and clientEmail on nativePush({ fcm })";
|
|
34
|
+
|
|
35
|
+
const REQUEST_FIX =
|
|
36
|
+
'x jobs list --json # read meta.reason on the failed step, then fix the topic, project or message nativePush() was given';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A refusal the same request gets again: a credential, a topic or project, a payload limit.
|
|
40
|
+
* `meta.credential` (a 401/403, or Google refusing the assertion) ends that provider's fan-out in
|
|
41
|
+
* `nativePush()`: every other device would be refused the same way, each after a re-sign.
|
|
42
|
+
* `meta.badToken` (APNs `BadDeviceToken`, FCM `INVALID_ARGUMENT` on `message.token`) counts
|
|
43
|
+
* against that one device row.
|
|
44
|
+
*/
|
|
45
|
+
export class NotifyPushProviderRefusedError extends UltimateError {
|
|
46
|
+
constructor(input: {
|
|
47
|
+
provider: PushProvider;
|
|
48
|
+
status: number;
|
|
49
|
+
reason: string;
|
|
50
|
+
credential?: boolean | undefined;
|
|
51
|
+
badToken?: boolean | undefined;
|
|
52
|
+
}) {
|
|
53
|
+
const credential = input.credential ?? (input.status === 401 || input.status === 403);
|
|
54
|
+
super({
|
|
55
|
+
code: 'X_NOTIFY_PUSH_PROVIDER_REFUSED',
|
|
56
|
+
cause: `${input.provider} answered ${String(input.status)} (${input.reason}), and the same request will be refused again`,
|
|
57
|
+
fix: credential
|
|
58
|
+
? input.provider === 'apns'
|
|
59
|
+
? APNS_CREDENTIAL_FIX
|
|
60
|
+
: FCM_CREDENTIAL_FIX
|
|
61
|
+
: REQUEST_FIX,
|
|
62
|
+
meta: {
|
|
63
|
+
provider: input.provider,
|
|
64
|
+
status: input.status,
|
|
65
|
+
reason: input.reason,
|
|
66
|
+
credential,
|
|
67
|
+
badToken: input.badToken === true,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A data-only push carrying more than an id: a reserved key, too many keys, a long value. */
|
|
74
|
+
export class NotifyPushMessageInvalidError extends UltimateError {
|
|
75
|
+
constructor(input: { problem: string }) {
|
|
76
|
+
super({
|
|
77
|
+
code: 'X_NOTIFY_PUSH_MESSAGE_INVALID',
|
|
78
|
+
cause: `the data of a data-only push ${input.problem}; it travels through the provider, so it carries ids and nothing a person would read`,
|
|
79
|
+
fix: 'pushChannel({ pusher, data: ({ event }) => ({ id: event.params.id }) }) # in the notifier: one id per push, the app fetches the rest over its API',
|
|
80
|
+
meta: { problem: input.problem },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Single responsibility: what a native push SAYS, before any provider's wire shape — a visible one
|
|
2
|
+
// rendered from catalog keys in the device's locale, or a data-only one screened down to ids. The
|
|
3
|
+
// APNs and FCM bodies are built from this and from nothing else, so nothing else can leave.
|
|
4
|
+
|
|
5
|
+
import { finiteCount } from '@ultimat3/core';
|
|
6
|
+
import type { NotifyPushMessage } from './channel-push';
|
|
7
|
+
import { NotifyPushMessageInvalidError } from './push-errors';
|
|
8
|
+
|
|
9
|
+
/** A data-only push's payload: string ids, never content. */
|
|
10
|
+
export type PushData = Readonly<Record<string, string>>;
|
|
11
|
+
|
|
12
|
+
export const DATA_PUSH_MAX_KEYS = 8;
|
|
13
|
+
export const DATA_PUSH_MAX_VALUE = 256;
|
|
14
|
+
/** The default hold for an offline device, as the web channel's: one day. */
|
|
15
|
+
export const DEFAULT_PUSH_TTL_SECONDS = 86_400;
|
|
16
|
+
|
|
17
|
+
const DATA_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Keys a provider owns. `aps` would overwrite APNs' own dictionary, and FCM refuses the rest with a
|
|
21
|
+
* 400 — refused here, before a send, with the key named.
|
|
22
|
+
*/
|
|
23
|
+
const RESERVED = new Set(['aps', 'from', 'notification', 'message_type', 'collapse_key', 'ttl']);
|
|
24
|
+
const RESERVED_PREFIX = /^(?:google|gcm)/i;
|
|
25
|
+
|
|
26
|
+
/** `data` as the wire may carry it, or `X_NOTIFY_PUSH_MESSAGE_INVALID` naming what is wrong. */
|
|
27
|
+
export function screenPushData(data: unknown): PushData {
|
|
28
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
29
|
+
throw new NotifyPushMessageInvalidError({ problem: 'is not an object of strings' });
|
|
30
|
+
}
|
|
31
|
+
const entries = Object.entries(data);
|
|
32
|
+
if (entries.length === 0) {
|
|
33
|
+
throw new NotifyPushMessageInvalidError({ problem: 'is empty, so the app wakes to nothing' });
|
|
34
|
+
}
|
|
35
|
+
if (entries.length > DATA_PUSH_MAX_KEYS) {
|
|
36
|
+
throw new NotifyPushMessageInvalidError({
|
|
37
|
+
problem: `holds ${String(entries.length)} keys and at most ${String(DATA_PUSH_MAX_KEYS)} are allowed`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const screened = new Map<string, string>();
|
|
41
|
+
for (const [key, value] of entries) {
|
|
42
|
+
if (!DATA_KEY.test(key)) {
|
|
43
|
+
throw new NotifyPushMessageInvalidError({ problem: 'names a key that is not an identifier' });
|
|
44
|
+
}
|
|
45
|
+
if (RESERVED.has(key) || RESERVED_PREFIX.test(key)) {
|
|
46
|
+
throw new NotifyPushMessageInvalidError({
|
|
47
|
+
problem: `names the provider-reserved key "${key}"`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (typeof value !== 'string' || value.length > DATA_PUSH_MAX_VALUE) {
|
|
51
|
+
throw new NotifyPushMessageInvalidError({
|
|
52
|
+
problem: `holds "${key}" as something other than a string of at most ${String(DATA_PUSH_MAX_VALUE)} characters`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
screened.set(key, value);
|
|
56
|
+
}
|
|
57
|
+
return Object.freeze(Object.fromEntries(screened));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Renders one catalog key; `@ultimat3/i18n`'s `translatorFor(locale)` unless a test hands one. */
|
|
61
|
+
export type PushTranslate = (
|
|
62
|
+
key: string,
|
|
63
|
+
params?: Readonly<Record<string, string | number>>,
|
|
64
|
+
) => string;
|
|
65
|
+
|
|
66
|
+
/** A visible push as both providers read it: text in one locale, plus how it travels. */
|
|
67
|
+
export interface RenderedNativePush {
|
|
68
|
+
readonly title: string;
|
|
69
|
+
readonly body: string;
|
|
70
|
+
readonly url: string;
|
|
71
|
+
/** Replaces an earlier notification carrying it — `topic`, else `tag`. */
|
|
72
|
+
readonly collapse: string | undefined;
|
|
73
|
+
readonly ttlSeconds: number;
|
|
74
|
+
readonly urgent: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The web-only fields (`icon`, `badge`, `actions`, `requireInteraction`, `renotify`) have no
|
|
79
|
+
* native meaning here and are not sent: an app's native shell decides how its alerts look.
|
|
80
|
+
*/
|
|
81
|
+
export function renderNativePush(
|
|
82
|
+
message: NotifyPushMessage,
|
|
83
|
+
translate: PushTranslate,
|
|
84
|
+
): RenderedNativePush {
|
|
85
|
+
const collapse = message.topic ?? message.tag;
|
|
86
|
+
return {
|
|
87
|
+
title: translate(message.titleKey, message.params),
|
|
88
|
+
body: translate(message.bodyKey, message.params),
|
|
89
|
+
url: message.url,
|
|
90
|
+
collapse: collapse === undefined || collapse === '' ? undefined : collapse,
|
|
91
|
+
ttlSeconds: finiteCount(
|
|
92
|
+
'pushChannel()',
|
|
93
|
+
'ttlSeconds',
|
|
94
|
+
message.ttlSeconds ?? DEFAULT_PUSH_TTL_SECONDS,
|
|
95
|
+
),
|
|
96
|
+
urgent:
|
|
97
|
+
message.urgency === undefined || message.urgency === 'normal' || message.urgency === 'high',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** What one device's send carries: a rendered alert, or ids that wake the app and show nothing. */
|
|
102
|
+
export type NativePush =
|
|
103
|
+
| { readonly kind: 'alert'; readonly push: RenderedNativePush }
|
|
104
|
+
| { readonly kind: 'data'; readonly data: PushData };
|
|
105
|
+
|
|
106
|
+
/** One provider, one device. `apns.ts` and `fcm.ts` are the two. */
|
|
107
|
+
export interface NativeSender {
|
|
108
|
+
send(token: string, push: NativePush, signal: AbortSignal): Promise<void>;
|
|
109
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Single responsibility: native device registration, as two FACTORIES OVER `action` — never a ninth
|
|
2
|
+
// primitive and never a hand-mounted route. What they return is an action an app exports from its
|
|
3
|
+
// own actions module, so each one has its route, OpenAPI operation, typed client, MCP tool and
|
|
4
|
+
// policy like any other write:
|
|
5
|
+
//
|
|
6
|
+
// export const registerDevice = pushRegister({ permission: 'push:subscribe', platforms, devices });
|
|
7
|
+
//
|
|
8
|
+
// The device is stored against `ctx.actor.id`, the person the request ran as — never an id the body
|
|
9
|
+
// names, so a caller can register only themselves and unregister only their own device.
|
|
10
|
+
|
|
11
|
+
import type { Action } from '@ultimat3/action';
|
|
12
|
+
import { action } from '@ultimat3/action';
|
|
13
|
+
import { timingSafeEqual } from '@ultimat3/core';
|
|
14
|
+
import type { KnownPermission, PolicyArgs, PolicyDecision } from '@ultimat3/policy';
|
|
15
|
+
import { can, denied } from '@ultimat3/policy';
|
|
16
|
+
import { t } from '@ultimat3/schema';
|
|
17
|
+
import type { PushDeviceRecord, PushDeviceStore, PushPlatform } from './push-device';
|
|
18
|
+
import { FCM_TOKEN, isDeviceToken } from './push-device';
|
|
19
|
+
|
|
20
|
+
/** Either platform's token alphabet; `platformToken` then holds each to its own. */
|
|
21
|
+
const anyToken = () => t.string.min(32).max(4096).pattern(FCM_TOKEN);
|
|
22
|
+
|
|
23
|
+
type Platforms = readonly [PushPlatform, ...PushPlatform[]];
|
|
24
|
+
|
|
25
|
+
const tokensMatch = (value: {
|
|
26
|
+
platform: PushPlatform;
|
|
27
|
+
token: string;
|
|
28
|
+
previousToken?: string | undefined;
|
|
29
|
+
}): boolean =>
|
|
30
|
+
isDeviceToken(value.platform, value.token) &&
|
|
31
|
+
(value.previousToken === undefined || isDeviceToken(value.platform, value.previousToken));
|
|
32
|
+
|
|
33
|
+
const registerInput = (platforms: Platforms) =>
|
|
34
|
+
t.refine(
|
|
35
|
+
t.object({
|
|
36
|
+
platform: t.enum(platforms),
|
|
37
|
+
token: anyToken(),
|
|
38
|
+
/** The token this install held before the OS rotated it — deleted as this one is stored. */
|
|
39
|
+
previousToken: anyToken().optional(),
|
|
40
|
+
/** BCP-47 — every visible push to this device renders in it. */
|
|
41
|
+
locale: t.locale,
|
|
42
|
+
/** IANA — the zone a push formatting a date would need. */
|
|
43
|
+
timeZone: t.timezone,
|
|
44
|
+
}),
|
|
45
|
+
{
|
|
46
|
+
name: 'platform-token',
|
|
47
|
+
message:
|
|
48
|
+
'an ios token is 64 to 200 hex characters, and an android token is an FCM registration token',
|
|
49
|
+
check: tokensMatch,
|
|
50
|
+
path: ['token'],
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const deviceOutput = () => t.object({ platform: t.string, token: t.string });
|
|
55
|
+
const unregisterInput = (platforms: Platforms) =>
|
|
56
|
+
t.object({ platform: t.enum(platforms), token: anyToken() });
|
|
57
|
+
const unregisterOutput = () => t.object({ removed: t.boolean });
|
|
58
|
+
|
|
59
|
+
type RegisterInput = ReturnType<typeof registerInput>;
|
|
60
|
+
type DeviceOutput = ReturnType<typeof deviceOutput>;
|
|
61
|
+
type UnregisterInput = ReturnType<typeof unregisterInput>;
|
|
62
|
+
type UnregisterOutput = ReturnType<typeof unregisterOutput>;
|
|
63
|
+
|
|
64
|
+
export interface PushRegisterOptions {
|
|
65
|
+
/** What the registering actor must hold — read by `can()`, like every action's permission. */
|
|
66
|
+
readonly permission: KnownPermission;
|
|
67
|
+
/** The platforms the app ships; another is a 422. */
|
|
68
|
+
readonly platforms: Platforms;
|
|
69
|
+
/** The store `nativePush({ devices })` reads — the same one, handed to both. */
|
|
70
|
+
readonly devices: PushDeviceStore;
|
|
71
|
+
/**
|
|
72
|
+
* The app's further rule, over the actor. Absent, any actor holding the permission registers
|
|
73
|
+
* themselves. An AGENT never does, whatever this says: a notification is for a person's device.
|
|
74
|
+
*/
|
|
75
|
+
readonly check?: ((args: PolicyArgs<unknown, null>) => boolean | PolicyDecision) | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const policyOf = (options: PushRegisterOptions) =>
|
|
79
|
+
can<unknown, null>(options.permission, (args) => {
|
|
80
|
+
if (args.actor?.kind === 'agent') return denied('push.agent-subscriber');
|
|
81
|
+
return options.check === undefined ? true : options.check(args);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Devices one person keeps. Past it, the OLDEST registration is evicted rather than the new one
|
|
86
|
+
* refused: the newest is the install most likely alive, a person whose rotated tokens were never
|
|
87
|
+
* pruned keeps getting pushes on the phone they just set up, and a caller registering a pile of
|
|
88
|
+
* made-up tokens costs each notification at most this many provider requests.
|
|
89
|
+
*/
|
|
90
|
+
export const PUSH_DEVICES_PER_ACTOR = 20;
|
|
91
|
+
|
|
92
|
+
/** Oldest first; `(platform, token)` breaks a tie, so both stores evict the same row. */
|
|
93
|
+
const byAge = (a: PushDeviceRecord, b: PushDeviceRecord): number =>
|
|
94
|
+
a.createdAt - b.createdAt || (a.platform + a.token < b.platform + b.token ? -1 : 1);
|
|
95
|
+
|
|
96
|
+
/** Evict past the cap, never the row just written. Racing registers trim on the next one. */
|
|
97
|
+
async function capDevices(
|
|
98
|
+
devices: PushDeviceStore,
|
|
99
|
+
actorId: string,
|
|
100
|
+
kept: { readonly platform: PushPlatform; readonly token: string },
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
const held = await devices.listFor(actorId);
|
|
103
|
+
const extra = held.length - PUSH_DEVICES_PER_ACTOR;
|
|
104
|
+
if (extra <= 0) return;
|
|
105
|
+
const evictable = held
|
|
106
|
+
.filter((device) => device.platform !== kept.platform || device.token !== kept.token)
|
|
107
|
+
.sort(byAge);
|
|
108
|
+
for (const device of evictable.slice(0, extra)) {
|
|
109
|
+
await devices.remove(device.platform, device.token, actorId);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** APNs reads hex case-insensitively; one spelling keeps one row per install. */
|
|
114
|
+
const canonical = (platform: PushPlatform, token: string): string =>
|
|
115
|
+
platform === 'ios' ? token.toLowerCase() : token;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Store this install's token for the person signed in, and — with `previousToken` — forget the one
|
|
119
|
+
* it replaces. Idempotent per token. The new row is written FIRST: a crash between the two leaves
|
|
120
|
+
* both, and the old one is deleted by the provider's first "gone", never a device left with none.
|
|
121
|
+
* Past `PUSH_DEVICES_PER_ACTOR` the oldest of the caller's own devices is evicted.
|
|
122
|
+
*/
|
|
123
|
+
export function pushRegister(options: PushRegisterOptions): Action<RegisterInput, DeviceOutput> {
|
|
124
|
+
return action({
|
|
125
|
+
input: registerInput(options.platforms),
|
|
126
|
+
output: deviceOutput(),
|
|
127
|
+
policy: policyOf(options),
|
|
128
|
+
idempotent: true,
|
|
129
|
+
async handle({ input, ctx }) {
|
|
130
|
+
const token = canonical(input.platform, input.token);
|
|
131
|
+
await options.devices.save({
|
|
132
|
+
platform: input.platform,
|
|
133
|
+
token,
|
|
134
|
+
actorId: ctx.actor.id,
|
|
135
|
+
locale: input.locale,
|
|
136
|
+
timeZone: input.timeZone,
|
|
137
|
+
createdAt: ctx.now().getTime(),
|
|
138
|
+
});
|
|
139
|
+
if (input.previousToken !== undefined) {
|
|
140
|
+
const previous = canonical(input.platform, input.previousToken);
|
|
141
|
+
// Equal is the app re-sending its current token as the previous one: deleting it would
|
|
142
|
+
// drop the row just written. Neither is a secret; the helper is the one comparison.
|
|
143
|
+
if (!timingSafeEqual(previous, token)) {
|
|
144
|
+
await options.devices.remove(input.platform, previous, ctx.actor.id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
await capDevices(options.devices, ctx.actor.id, { platform: input.platform, token });
|
|
148
|
+
return { platform: input.platform, token };
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Forget this install — the caller's own registration only; another person's is absent. */
|
|
154
|
+
export function pushUnregister(
|
|
155
|
+
options: PushRegisterOptions,
|
|
156
|
+
): Action<UnregisterInput, UnregisterOutput> {
|
|
157
|
+
return action({
|
|
158
|
+
input: unregisterInput(options.platforms),
|
|
159
|
+
output: unregisterOutput(),
|
|
160
|
+
policy: policyOf(options),
|
|
161
|
+
idempotent: true,
|
|
162
|
+
async handle({ input, ctx }) {
|
|
163
|
+
const token = canonical(input.platform, input.token);
|
|
164
|
+
return { removed: await options.devices.remove(input.platform, token, ctx.actor.id) };
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Single responsibility: one bearer credential, minted once and reused until shortly before it
|
|
2
|
+
// expires. A fan-out to a thousand devices signs ONE APNs token and exchanges ONE Google assertion,
|
|
3
|
+
// and concurrent sends share the mint in flight rather than each starting their own.
|
|
4
|
+
|
|
5
|
+
export interface MintedToken {
|
|
6
|
+
readonly value: string;
|
|
7
|
+
/** Epoch ms after which the provider refuses it. */
|
|
8
|
+
readonly expiresAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TokenCache {
|
|
12
|
+
/** The cached token, or a fresh one once it is within `refreshBeforeMs` of expiring. */
|
|
13
|
+
get(): Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Forget `stale` — the token the provider just refused — so the next send mints again. Only
|
|
16
|
+
* when it is still the cached one: a straggler's late refusal for a token already replaced
|
|
17
|
+
* would otherwise drop the fresh one and re-sign, and Apple throttles a re-sign inside 20 min.
|
|
18
|
+
* A mint in flight is left alone: it started after `stale` was handed out.
|
|
19
|
+
*/
|
|
20
|
+
invalidate(stale: string): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function tokenCache(
|
|
24
|
+
mint: () => Promise<MintedToken>,
|
|
25
|
+
now: () => number,
|
|
26
|
+
refreshBeforeMs: number,
|
|
27
|
+
): TokenCache {
|
|
28
|
+
let current: MintedToken | undefined;
|
|
29
|
+
let pending: Promise<MintedToken> | undefined;
|
|
30
|
+
return {
|
|
31
|
+
async get() {
|
|
32
|
+
if (current !== undefined && now() < current.expiresAt - refreshBeforeMs) {
|
|
33
|
+
return current.value;
|
|
34
|
+
}
|
|
35
|
+
if (pending === undefined) {
|
|
36
|
+
const minting = mint();
|
|
37
|
+
pending = minting;
|
|
38
|
+
// A failed mint is never cached: the next send tries again rather than replaying it.
|
|
39
|
+
minting.then(
|
|
40
|
+
(token) => {
|
|
41
|
+
if (pending === minting) current = token;
|
|
42
|
+
},
|
|
43
|
+
() => undefined,
|
|
44
|
+
);
|
|
45
|
+
minting
|
|
46
|
+
.finally(() => {
|
|
47
|
+
if (pending === minting) pending = undefined;
|
|
48
|
+
})
|
|
49
|
+
.catch(() => undefined);
|
|
50
|
+
}
|
|
51
|
+
return (await pending).value;
|
|
52
|
+
},
|
|
53
|
+
invalidate(stale) {
|
|
54
|
+
if (current?.value === stale) current = undefined;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|