@roko-app/instant-admin 1.0.67-roko.2 → 1.0.67-roko.3

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/src/alarms.ts ADDED
@@ -0,0 +1,342 @@
1
+ // Roko DB: alarms. An alarm asks the server to POST a signed JSON body to a
2
+ // url at a given time. One alarm per key; setting a key again replaces it.
3
+
4
+ // #region Types
5
+
6
+ /** Where an alarm is in its life. */
7
+ export type AlarmStatus =
8
+ /** Waits for its time. */
9
+ | 'pending'
10
+ /** Being delivered right now. */
11
+ | 'processing'
12
+ /** A delivery failed; it will be retried. */
13
+ | 'error'
14
+ /** Delivered. */
15
+ | 'fired'
16
+ /** Gave up after the last retry, or the endpoint answered 410 Gone. */
17
+ | 'failed';
18
+
19
+ /** The last delivery attempt of an alarm. */
20
+ export type AlarmAttempt = {
21
+ /** When the attempt was made (ISO 8601). */
22
+ at: string | null;
23
+ /** True if the endpoint answered with a 2xx status. */
24
+ success: boolean;
25
+ /** HTTP status of the answer, if there was one. */
26
+ statusCode: number | null;
27
+ /** Kind of failure when there was no answer, e.g. `timeout` or `dns`. */
28
+ errorType: string | null;
29
+ errorMessage: string | null;
30
+ };
31
+
32
+ /** An alarm as the server stores it. */
33
+ export type AlarmInfo = {
34
+ /** Changes every time the key is set again; also the `Idempotency-Key` of its deliveries. */
35
+ id: string;
36
+ /** Your name for the alarm, unique per app. */
37
+ key: string;
38
+ /** When it fires (ISO 8601). */
39
+ fireAt: string;
40
+ /** The https url that is called. */
41
+ url: string;
42
+ /** Sent back to you, unchanged, when the alarm fires. */
43
+ payload: Record<string, unknown> | null;
44
+ status: AlarmStatus;
45
+ /** How many deliveries were attempted. */
46
+ attempts: number;
47
+ lastAttempt: AlarmAttempt | null;
48
+ /** When the next retry happens, while `status` is `error`. */
49
+ nextAttemptAfter: string | null;
50
+ firedAt: string | null;
51
+ createdAt: string;
52
+ updatedAt: string;
53
+ };
54
+
55
+ /** The body POSTed to the alarm's url when it fires. */
56
+ export type AlarmEvent = {
57
+ type: 'alarm';
58
+ id: string;
59
+ appId: string;
60
+ key: string;
61
+ /** The time the alarm was set for (ISO 8601). */
62
+ fireAt: string;
63
+ /** 1 for the first delivery, 2 for the first retry, and so on. */
64
+ attempt: number;
65
+ payload: Record<string, unknown> | null;
66
+ };
67
+
68
+ export type SetAlarmParams = {
69
+ /** Your name for the alarm. Setting the same key again replaces the alarm. */
70
+ key: string;
71
+ /** When to fire: a Date, milliseconds since the epoch, or an ISO 8601 string. A time in the past fires at once. */
72
+ fireAt: Date | number | string;
73
+ /** A public https url. It receives a POST signed with `Instant-Signature`. */
74
+ url: string;
75
+ /** A JSON object (64 KB at most) sent back to you when the alarm fires. */
76
+ payload?: Record<string, unknown> | null;
77
+ };
78
+
79
+ export type ListAlarmsParams = {
80
+ /** Keep one status only. */
81
+ status?: AlarmStatus;
82
+ /** Page size, 100 by default, 1000 at most. */
83
+ limit?: number;
84
+ /** Continue after this `fireAt` (the last alarm of the previous page). */
85
+ after?: Date | number | string;
86
+ };
87
+
88
+ export type AlarmValidateOpts = {
89
+ receivedAt?: Date | null;
90
+ /** Oldest signature accepted, in seconds. Default 300. */
91
+ tolerance?: number | null;
92
+ };
93
+
94
+ type AlarmsDeps = {
95
+ apiURI: string;
96
+ appId: string;
97
+ /** Builds the authorized request headers; throws without an admin token. */
98
+ headers: () => Record<string, string>;
99
+ jsonFetch: (input: string, init?: RequestInit) => Promise<unknown>;
100
+ /** Checks an `Instant-Signature` header and returns the verified body text. */
101
+ verifySignature: (
102
+ signatureHeader: string,
103
+ body: string | (() => Promise<string>),
104
+ opts?: AlarmValidateOpts | null,
105
+ ) => Promise<string>;
106
+ };
107
+
108
+ // #endregion
109
+
110
+ // #region Parsing
111
+
112
+ const ALARM_STATUSES: readonly AlarmStatus[] = [
113
+ 'pending',
114
+ 'processing',
115
+ 'error',
116
+ 'fired',
117
+ 'failed',
118
+ ];
119
+
120
+ function isRecord(x: unknown): x is Record<string, unknown> {
121
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
122
+ }
123
+
124
+ function isAlarmStatus(x: unknown): x is AlarmStatus {
125
+ return ALARM_STATUSES.some((status) => status === x);
126
+ }
127
+
128
+ function stringOrNull(x: unknown): string | null {
129
+ return typeof x === 'string' ? x : null;
130
+ }
131
+
132
+ function numberOrNull(x: unknown): number | null {
133
+ return typeof x === 'number' ? x : null;
134
+ }
135
+
136
+ function requiredString(source: Record<string, unknown>, field: string) {
137
+ const value = source[field];
138
+ if (typeof value !== 'string') {
139
+ throw new Error(`Invalid alarm: expected a string in \`${field}\`.`);
140
+ }
141
+ return value;
142
+ }
143
+
144
+ function parseAttempt(x: unknown): AlarmAttempt | null {
145
+ if (!isRecord(x)) {
146
+ return null;
147
+ }
148
+ return {
149
+ at: stringOrNull(x.at),
150
+ success: x.success === true,
151
+ statusCode: numberOrNull(x.statusCode),
152
+ errorType: stringOrNull(x.errorType),
153
+ errorMessage: stringOrNull(x.errorMessage),
154
+ };
155
+ }
156
+
157
+ function parseAlarm(x: unknown): AlarmInfo {
158
+ if (!isRecord(x)) {
159
+ throw new Error('Invalid alarm: expected an object.');
160
+ }
161
+ if (!isAlarmStatus(x.status)) {
162
+ throw new Error('Invalid alarm: unknown status.');
163
+ }
164
+ return {
165
+ id: requiredString(x, 'id'),
166
+ key: requiredString(x, 'key'),
167
+ fireAt: requiredString(x, 'fireAt'),
168
+ url: requiredString(x, 'url'),
169
+ payload: isRecord(x.payload) ? x.payload : null,
170
+ status: x.status,
171
+ attempts: numberOrNull(x.attempts) ?? 0,
172
+ lastAttempt: parseAttempt(x.lastAttempt),
173
+ nextAttemptAfter: stringOrNull(x.nextAttemptAfter),
174
+ firedAt: stringOrNull(x.firedAt),
175
+ createdAt: requiredString(x, 'createdAt'),
176
+ updatedAt: requiredString(x, 'updatedAt'),
177
+ };
178
+ }
179
+
180
+ /** Reads a verified alarm body. Throws if it is not an alarm. */
181
+ export function parseAlarmEvent(bodyText: string): AlarmEvent {
182
+ const body: unknown = JSON.parse(bodyText);
183
+ if (!isRecord(body) || body.type !== 'alarm') {
184
+ throw new Error(
185
+ 'Invalid alarm body, expected an object with type "alarm".',
186
+ );
187
+ }
188
+ return {
189
+ type: 'alarm',
190
+ id: requiredString(body, 'id'),
191
+ appId: requiredString(body, 'appId'),
192
+ key: requiredString(body, 'key'),
193
+ fireAt: requiredString(body, 'fireAt'),
194
+ attempt: numberOrNull(body.attempt) ?? 1,
195
+ payload: isRecord(body.payload) ? body.payload : null,
196
+ };
197
+ }
198
+
199
+ /** Turns a Date / epoch milliseconds / ISO string into what the server takes. */
200
+ export function toAlarmTime(time: Date | number | string): number | string {
201
+ if (time instanceof Date) {
202
+ const ms = time.getTime();
203
+ if (Number.isNaN(ms)) {
204
+ throw new Error('Invalid alarm time: the Date is not valid.');
205
+ }
206
+ return ms;
207
+ }
208
+ return time;
209
+ }
210
+
211
+ // #endregion
212
+
213
+ // #region Alarms
214
+
215
+ /**
216
+ * Alarms: ask the server to call your url at a given time.
217
+ *
218
+ * @example
219
+ * // When work starts, ask to be called if it is not done in one hour.
220
+ * await db.alarms.set({
221
+ * key: `effort:${taskId}`,
222
+ * fireAt: Date.now() + 60 * 60 * 1000,
223
+ * url: 'https://example.com/api/alarms',
224
+ * payload: { taskId },
225
+ * });
226
+ *
227
+ * // In the route behind that url.
228
+ * export async function POST(req: Request) {
229
+ * const alarm = await db.alarms.validateRequest(req);
230
+ * await pauseTask(alarm.payload);
231
+ * return new Response('ok');
232
+ * }
233
+ */
234
+ export class Alarms {
235
+ #deps: AlarmsDeps;
236
+
237
+ constructor(deps: AlarmsDeps) {
238
+ this.#deps = deps;
239
+ }
240
+
241
+ #url(query?: Record<string, string>): string {
242
+ const qs = new URLSearchParams({ app_id: this.#deps.appId, ...query });
243
+ return `${this.#deps.apiURI}/admin/alarms?${qs.toString()}`;
244
+ }
245
+
246
+ /**
247
+ * Creates the alarm for `key`, or replaces the one that exists. A replaced
248
+ * alarm starts over with a new id, even if it had already fired.
249
+ */
250
+ set = async (params: SetAlarmParams): Promise<AlarmInfo> => {
251
+ const res = await this.#deps.jsonFetch(this.#url(), {
252
+ method: 'PUT',
253
+ headers: this.#deps.headers(),
254
+ body: JSON.stringify({
255
+ key: params.key,
256
+ fireAt: toAlarmTime(params.fireAt),
257
+ url: params.url,
258
+ payload: params.payload ?? null,
259
+ }),
260
+ });
261
+ return parseAlarm(isRecord(res) ? res.alarm : null);
262
+ };
263
+
264
+ /** Returns the alarm for `key`, or null if there is none. */
265
+ get = async (key: string): Promise<AlarmInfo | null> => {
266
+ const res = await this.#deps.jsonFetch(this.#url({ key }), {
267
+ method: 'GET',
268
+ headers: this.#deps.headers(),
269
+ });
270
+ const alarm = isRecord(res) ? res.alarm : null;
271
+ return alarm == null ? null : parseAlarm(alarm);
272
+ };
273
+
274
+ /** Lists the app's alarms, soonest first. */
275
+ list = async (params: ListAlarmsParams = {}): Promise<AlarmInfo[]> => {
276
+ const query: Record<string, string> = {};
277
+ if (params.status) {
278
+ query.status = params.status;
279
+ }
280
+ if (params.limit !== undefined) {
281
+ query.limit = String(params.limit);
282
+ }
283
+ if (params.after !== undefined) {
284
+ const after = toAlarmTime(params.after);
285
+ query.after =
286
+ typeof after === 'number' ? new Date(after).toISOString() : after;
287
+ }
288
+ const res = await this.#deps.jsonFetch(this.#url(query), {
289
+ method: 'GET',
290
+ headers: this.#deps.headers(),
291
+ });
292
+ const alarms = isRecord(res) ? res.alarms : null;
293
+ return Array.isArray(alarms) ? alarms.map(parseAlarm) : [];
294
+ };
295
+
296
+ /** Deletes the alarm for `key`. Returns false if there was none. */
297
+ delete = async (key: string): Promise<boolean> => {
298
+ const res = await this.#deps.jsonFetch(this.#url({ key }), {
299
+ method: 'DELETE',
300
+ headers: this.#deps.headers(),
301
+ });
302
+ return isRecord(res) && res.deleted === true;
303
+ };
304
+
305
+ /**
306
+ * Verifies the `Instant-Signature` header of an alarm delivery against its
307
+ * raw body and returns the alarm. Throws if the signature is wrong or too
308
+ * old, if the body is not an alarm, or if the alarm belongs to another app.
309
+ */
310
+ validate = async (
311
+ signatureHeader: string,
312
+ body: string | (() => Promise<string>),
313
+ opts?: AlarmValidateOpts | null,
314
+ ): Promise<AlarmEvent> => {
315
+ const bodyText = await this.#deps.verifySignature(
316
+ signatureHeader,
317
+ body,
318
+ opts,
319
+ );
320
+ const event = parseAlarmEvent(bodyText);
321
+ // The signing key is the server's, shared by every app on it: without
322
+ // this check another app could aim its own alarm at this endpoint.
323
+ if (event.appId !== this.#deps.appId) {
324
+ throw new Error('Alarm belongs to another app');
325
+ }
326
+ return event;
327
+ };
328
+
329
+ /** Same as {@link validate}, reading the header and body from a `Request`. */
330
+ validateRequest = async (
331
+ req: Request,
332
+ opts?: AlarmValidateOpts | null,
333
+ ): Promise<AlarmEvent> => {
334
+ const signatureHeader = req.headers.get('instant-signature');
335
+ if (!signatureHeader) {
336
+ throw new Error('Request is missing Instant-Signature header');
337
+ }
338
+ return this.validate(signatureHeader, () => req.text(), opts);
339
+ };
340
+ }
341
+
342
+ // #endregion
package/src/index.ts CHANGED
@@ -99,6 +99,16 @@ import {
99
99
  import { parseCookie } from 'cookie';
100
100
  import { EventSource } from '@instantdb/eventsource';
101
101
  import { MessageEventPolyfill } from './polyfill.ts';
102
+ import {
103
+ Alarms,
104
+ type AlarmStatus,
105
+ type AlarmAttempt,
106
+ type AlarmInfo,
107
+ type AlarmEvent,
108
+ type SetAlarmParams,
109
+ type ListAlarmsParams,
110
+ type AlarmValidateOpts,
111
+ } from './alarms.ts';
102
112
  import {
103
113
  Webhooks,
104
114
  WebhooksManager,
@@ -1142,6 +1152,8 @@ class InstantAdminDatabase<
1142
1152
  rooms: Rooms<Schema>;
1143
1153
  impersonationOpts?: ImpersonationOpts;
1144
1154
  webhooks: Webhooks<Schema>;
1155
+ /** Roko DB: ask the server to call your url at a given time. */
1156
+ alarms: Alarms;
1145
1157
 
1146
1158
  #sseConnection: SSEConnection | null = null;
1147
1159
  #sseBackoff = 0;
@@ -1157,6 +1169,14 @@ class InstantAdminDatabase<
1157
1169
  this.streams = new Streams(this.#ensureInstantStream.bind(this));
1158
1170
  this.rooms = new Rooms<Schema>(this.config);
1159
1171
  this.webhooks = new Webhooks<Schema>(this.config, jsonFetch);
1172
+ this.alarms = new Alarms({
1173
+ apiURI: this.config.apiURI,
1174
+ appId: this.config.appId,
1175
+ headers: () => authorizedHeaders(this.config),
1176
+ jsonFetch,
1177
+ verifySignature: (signatureHeader, body, opts) =>
1178
+ this.webhooks.verifySignature(signatureHeader, body, opts),
1179
+ });
1160
1180
  this.#log = createLogger(!!this.config.verbose, this.config.logger);
1161
1181
  }
1162
1182
 
@@ -1627,6 +1647,14 @@ export {
1627
1647
  type InstantRouteHandlerPayloadByType,
1628
1648
  type InstantRouteHandlerType,
1629
1649
  type InstantRouteHandlerBody,
1650
+ Alarms,
1651
+ type AlarmStatus,
1652
+ type AlarmAttempt,
1653
+ type AlarmInfo,
1654
+ type AlarmEvent,
1655
+ type SetAlarmParams,
1656
+ type ListAlarmsParams,
1657
+ type AlarmValidateOpts,
1630
1658
  Webhooks,
1631
1659
  WebhooksManager,
1632
1660
  type WebhookAction,