@hatk/hatk 0.0.1-alpha.78 → 0.0.1-alpha.79

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/dist/config.d.ts CHANGED
@@ -52,8 +52,21 @@ export interface ApnsPushConfig {
52
52
  bundleId: string;
53
53
  production?: boolean;
54
54
  }
55
+ /**
56
+ * Firebase Cloud Messaging, for Android devices.
57
+ *
58
+ * `keyFile` is a Google service-account JSON carrying the Firebase Messaging
59
+ * role, resolved relative to the config file like the APNs key. The project id
60
+ * comes from that file unless it's overridden here.
61
+ */
62
+ export interface FcmPushConfig {
63
+ keyFile: string;
64
+ projectId?: string;
65
+ }
66
+ /** Either transport may stand alone; a platform without one simply isn't sent to. */
55
67
  export interface PushConfig {
56
- apns: ApnsPushConfig;
68
+ apns?: ApnsPushConfig;
69
+ fcm?: FcmPushConfig;
57
70
  }
58
71
  export interface CdnConfig {
59
72
  url: string;
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
package/dist/main.js CHANGED
@@ -34,7 +34,7 @@ import { runBackfill } from "./backfill.js";
34
34
  import { initOAuth } from "./oauth/server.js";
35
35
  import { parseSessionCookie, getSessionCookieName } from "./oauth/session.js";
36
36
  import { loadOnLoginHook } from "./hooks.js";
37
- import { initPush, isPushEnabled } from "./push.js";
37
+ import { enabledPushTransports, initPush, isPushEnabled } from "./push.js";
38
38
  import { initSetup } from "./setup.js";
39
39
  import { initServer } from "./server-init.js";
40
40
  const configPath = process.argv[2] || 'hatk.config.ts';
@@ -140,10 +140,10 @@ if (config.oauth) {
140
140
  if (config.push) {
141
141
  initPush(config.push, configDir);
142
142
  if (isPushEnabled()) {
143
- log(`[main] Push initialized (APNs bundle: ${config.push.apns.bundleId})`);
143
+ log(`[main] Push initialized (${enabledPushTransports().join(', ')})`);
144
144
  }
145
145
  else {
146
- log(`[main] Push configured but key file missing — push disabled`);
146
+ log(`[main] Push configured but no usable credential — push disabled`);
147
147
  }
148
148
  }
149
149
  // 5. Start server immediately (don't wait for backfill)
package/dist/push.d.ts CHANGED
@@ -5,8 +5,20 @@ export interface ApnsConfig {
5
5
  bundleId: string;
6
6
  production?: boolean;
7
7
  }
8
+ /**
9
+ * Firebase Cloud Messaging, for Android devices.
10
+ *
11
+ * `keyFile` is a Google service-account JSON with the Firebase Messaging role,
12
+ * resolved like the APNs key: relative to the config file. The project id is
13
+ * read from that file unless overridden here.
14
+ */
15
+ export interface FcmConfig {
16
+ keyFile: string;
17
+ projectId?: string;
18
+ }
8
19
  export interface PushConfig {
9
- apns: ApnsConfig;
20
+ apns?: ApnsConfig;
21
+ fcm?: FcmConfig;
10
22
  }
11
23
  export interface PushPayload {
12
24
  did: string;
@@ -19,10 +31,18 @@ export interface PushPayload {
19
31
  export interface PushInterface {
20
32
  send: (payload: PushPayload) => Promise<void>;
21
33
  }
22
- /** Initialize push with config. Must be called before send(). */
34
+ /**
35
+ * Initialize push with config. Must be called before send().
36
+ *
37
+ * Each transport is loaded on its own: a missing or malformed credential
38
+ * disables that platform and leaves the other one sending, rather than taking
39
+ * push down entirely.
40
+ */
23
41
  export declare function initPush(config: PushConfig, configDir: string): void;
24
- /** Check if push is configured and available. */
42
+ /** Check if push is configured and at least one transport is usable. */
25
43
  export declare function isPushEnabled(): boolean;
44
+ /** Which platforms this process can actually deliver to, for startup logging. */
45
+ export declare function enabledPushTransports(): string[];
26
46
  /** Build the push interface injected into hook contexts. */
27
47
  export declare function buildPushInterface(): PushInterface;
28
48
  /** Register a push token for a DID. Upserts on conflict. */
@@ -1 +1 @@
1
- {"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,UAAU,CAAA;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9C;AAOD,iEAAiE;AACjE,wBAAgB,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CASpE;AAED,iDAAiD;AACjD,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED,4DAA4D;AAC5D,wBAAgB,kBAAkB,IAAI,aAAa,CAElD;AA0JD,4DAA4D;AAC5D,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO/F;AAED,2BAA2B;AAC3B,wBAAsB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE9D;AAED,6CAA6C;AAC7C,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/E"}
1
+ {"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,GAAG,CAAC,EAAE,SAAS,CAAA;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9C;AAsBD;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CA4CpE;AAED,wEAAwE;AACxE,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED,iFAAiF;AACjF,wBAAgB,qBAAqB,IAAI,MAAM,EAAE,CAKhD;AAED,4DAA4D;AAC5D,wBAAgB,kBAAkB,IAAI,aAAa,CAElD;AAmSD,4DAA4D;AAC5D,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO/F;AAED,2BAA2B;AAC3B,wBAAsB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE9D;AAED,6CAA6C;AAC7C,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/E"}
package/dist/push.js CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
- * Push notification delivery via APNs HTTP/2.
2
+ * Push notification delivery via APNs HTTP/2 and FCM HTTP v1.
3
3
  *
4
4
  * Provides `push.send()` for use in on-commit hook context. Looks up device
5
- * tokens, builds APNs payloads, and sends via HTTP/2. Self-cleans invalid
6
- * tokens on Apple 410 responses. Fire-and-forget — failures are logged via
5
+ * tokens, builds a payload per platform, and sends over the transport that
6
+ * platform's tokens belong to. Self-cleans tokens the vendor reports as dead —
7
+ * Apple's 410, Google's UNREGISTERED. Fire-and-forget — failures are logged via
7
8
  * `emit()` but never throw.
9
+ *
10
+ * Either transport can be configured alone. A deployment with only an APNs key
11
+ * still sends to iOS, and Android tokens simply sit unused until an FCM service
12
+ * account is supplied.
8
13
  */
9
14
  import { connect } from 'node:http2';
10
15
  import { readFileSync } from 'node:fs';
@@ -16,21 +21,71 @@ let pushConfig = null;
16
21
  let apnsKey = null;
17
22
  let cachedJwt = null;
18
23
  let http2Session = null;
19
- /** Initialize push with config. Must be called before send(). */
24
+ let fcmCredentials = null;
25
+ let cachedFcmToken = null;
26
+ /** Where a service account trades its signed JWT for a bearer token. */
27
+ const GOOGLE_TOKEN_URI = 'https://oauth2.googleapis.com/token';
28
+ /** The only scope FCM sending needs. */
29
+ const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
30
+ /**
31
+ * Initialize push with config. Must be called before send().
32
+ *
33
+ * Each transport is loaded on its own: a missing or malformed credential
34
+ * disables that platform and leaves the other one sending, rather than taking
35
+ * push down entirely.
36
+ */
20
37
  export function initPush(config, configDir) {
21
38
  pushConfig = config;
22
- const keyPath = resolve(configDir, config.apns.keyFile);
23
- try {
24
- apnsKey = readFileSync(keyPath, 'utf8');
39
+ apnsKey = null;
40
+ fcmCredentials = null;
41
+ cachedJwt = null;
42
+ cachedFcmToken = null;
43
+ if (config.apns) {
44
+ const keyPath = resolve(configDir, config.apns.keyFile);
45
+ try {
46
+ apnsKey = readFileSync(keyPath, 'utf8');
47
+ }
48
+ catch {
49
+ emit('push', 'init_error', { transport: 'apns', error: `APNs key file not found: ${keyPath}` });
50
+ }
25
51
  }
26
- catch {
27
- emit('push', 'init_error', { error: `APNs key file not found: ${keyPath}` });
28
- pushConfig = null;
52
+ if (config.fcm) {
53
+ const keyPath = resolve(configDir, config.fcm.keyFile);
54
+ try {
55
+ const account = JSON.parse(readFileSync(keyPath, 'utf8'));
56
+ const projectId = config.fcm.projectId ?? account.project_id;
57
+ if (!account.client_email || !account.private_key || !projectId) {
58
+ throw new Error('needs client_email, private_key and a project id');
59
+ }
60
+ fcmCredentials = {
61
+ clientEmail: account.client_email,
62
+ privateKey: account.private_key,
63
+ projectId,
64
+ tokenUri: account.token_uri ?? GOOGLE_TOKEN_URI,
65
+ };
66
+ }
67
+ catch (err) {
68
+ emit('push', 'init_error', {
69
+ transport: 'fcm',
70
+ error: `FCM service account unusable (${keyPath}): ${err.message}`,
71
+ });
72
+ }
29
73
  }
74
+ if (!apnsKey && !fcmCredentials)
75
+ pushConfig = null;
30
76
  }
31
- /** Check if push is configured and available. */
77
+ /** Check if push is configured and at least one transport is usable. */
32
78
  export function isPushEnabled() {
33
- return pushConfig !== null && apnsKey !== null;
79
+ return pushConfig !== null && (apnsKey !== null || fcmCredentials !== null);
80
+ }
81
+ /** Which platforms this process can actually deliver to, for startup logging. */
82
+ export function enabledPushTransports() {
83
+ const transports = [];
84
+ if (apnsKey)
85
+ transports.push('apns');
86
+ if (fcmCredentials)
87
+ transports.push('fcm');
88
+ return transports;
34
89
  }
35
90
  /** Build the push interface injected into hook contexts. */
36
91
  export function buildPushInterface() {
@@ -40,15 +95,16 @@ export function buildPushInterface() {
40
95
  function getApnsJwt() {
41
96
  if (cachedJwt && Date.now() < cachedJwt.expires)
42
97
  return cachedJwt.token;
43
- if (!pushConfig || !apnsKey)
44
- throw new Error('Push not initialized');
98
+ const apns = pushConfig?.apns;
99
+ if (!apns || !apnsKey)
100
+ throw new Error('APNs not initialized');
45
101
  const header = Buffer.from(JSON.stringify({
46
102
  alg: 'ES256',
47
- kid: pushConfig.apns.keyId,
103
+ kid: apns.keyId,
48
104
  })).toString('base64url');
49
105
  const now = Math.floor(Date.now() / 1000);
50
106
  const claims = Buffer.from(JSON.stringify({
51
- iss: pushConfig.apns.teamId,
107
+ iss: apns.teamId,
52
108
  iat: now,
53
109
  })).toString('base64url');
54
110
  const signer = createSign('SHA256');
@@ -63,7 +119,7 @@ function getHttp2Session() {
63
119
  if (http2Session && !http2Session.closed && !http2Session.destroyed) {
64
120
  return http2Session;
65
121
  }
66
- const host = pushConfig?.apns.production !== false ? 'https://api.push.apple.com' : 'https://api.sandbox.push.apple.com';
122
+ const host = pushConfig?.apns?.production !== false ? 'https://api.push.apple.com' : 'https://api.sandbox.push.apple.com';
67
123
  emit('push', 'connecting', { host });
68
124
  http2Session = connect(host, {
69
125
  peerMaxConcurrentStreams: 100,
@@ -82,41 +138,60 @@ function getHttp2Session() {
82
138
  }
83
139
  /** Send a push notification to all devices registered for a DID. */
84
140
  async function send(payload) {
85
- if (!pushConfig || !apnsKey)
141
+ if (!isPushEnabled())
86
142
  return;
87
143
  const tokens = (await querySQL(`SELECT token, platform FROM _push_tokens WHERE did = $1`, [payload.did]));
88
144
  if (tokens.length === 0)
89
145
  return;
90
- const jwt = getApnsJwt();
91
- const aps = {
92
- alert: { title: payload.title, body: payload.body },
93
- sound: 'default',
94
- // Run the client's notification service extension before the alert is
95
- // shown, so it can enrich the payload (Grain attaches the actor's avatar
96
- // from `data.avatar`). Ignored by clients that don't ship one.
97
- 'mutable-content': 1,
98
- };
99
- if (payload.badge !== undefined) {
100
- aps.badge = payload.badge;
146
+ // A device is reachable over exactly one transport, named by the platform it
147
+ // registered under. An unrecognized platform is left alone rather than
148
+ // guessed at — the row may belong to a client this build doesn't serve yet.
149
+ const apnsTokens = tokens.filter((t) => t.platform === 'apns');
150
+ const fcmTokens = tokens.filter((t) => t.platform === 'fcm');
151
+ if (apnsKey && apnsTokens.length > 0) {
152
+ const jwt = getApnsJwt();
153
+ const aps = {
154
+ alert: { title: payload.title, body: payload.body },
155
+ sound: 'default',
156
+ };
157
+ if (payload.badge !== undefined) {
158
+ aps.badge = payload.badge;
159
+ }
160
+ const apnsPayload = JSON.stringify({
161
+ aps,
162
+ ...(payload.data || {}),
163
+ });
164
+ for (const { token } of apnsTokens) {
165
+ sendToApns(token, apnsPayload, jwt, payload).catch(() => { });
166
+ }
101
167
  }
102
- const apnsPayload = JSON.stringify({
103
- aps,
104
- ...(payload.data || {}),
105
- });
106
- for (const { token, platform } of tokens) {
107
- if (platform !== 'apns')
108
- continue;
109
- sendToApns(token, apnsPayload, jwt, payload).catch(() => { });
168
+ if (fcmCredentials && fcmTokens.length > 0) {
169
+ // One access token serves every device in this fan-out; it's minted once
170
+ // and cached for the hour Google grants it.
171
+ let accessToken;
172
+ try {
173
+ accessToken = await getFcmAccessToken();
174
+ }
175
+ catch (err) {
176
+ emit('push', 'send_error', { did: payload.did, transport: 'fcm', error: err.message });
177
+ return;
178
+ }
179
+ for (const { token } of fcmTokens) {
180
+ sendToFcm(token, accessToken, payload).catch(() => { });
181
+ }
110
182
  }
111
183
  }
112
184
  /** Send a single APNs push and handle the response. */
113
185
  async function sendToApns(token, payload, jwt, original) {
186
+ const apns = pushConfig?.apns;
187
+ if (!apns)
188
+ return;
114
189
  const session = getHttp2Session();
115
190
  const headers = {
116
191
  ':method': 'POST',
117
192
  ':path': `/3/device/${token}`,
118
193
  authorization: `bearer ${jwt}`,
119
- 'apns-topic': pushConfig.apns.bundleId,
194
+ 'apns-topic': apns.bundleId,
120
195
  'apns-push-type': 'alert',
121
196
  };
122
197
  if (original.collapseId) {
@@ -174,6 +249,108 @@ async function sendToApns(token, payload, jwt, original) {
174
249
  req.end();
175
250
  });
176
251
  }
252
+ /**
253
+ * Mint an OAuth2 access token for the FCM HTTP v1 API.
254
+ *
255
+ * The service account signs a JWT asserting the messaging scope, and Google
256
+ * trades it for a bearer token good for an hour. Cached with a minute of
257
+ * headroom so one can't expire between minting and the send that uses it.
258
+ */
259
+ async function getFcmAccessToken() {
260
+ if (cachedFcmToken && Date.now() < cachedFcmToken.expires)
261
+ return cachedFcmToken.token;
262
+ if (!fcmCredentials)
263
+ throw new Error('FCM not initialized');
264
+ const now = Math.floor(Date.now() / 1000);
265
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
266
+ const claims = Buffer.from(JSON.stringify({
267
+ iss: fcmCredentials.clientEmail,
268
+ scope: FCM_SCOPE,
269
+ aud: fcmCredentials.tokenUri,
270
+ iat: now,
271
+ exp: now + 3600,
272
+ })).toString('base64url');
273
+ const signer = createSign('RSA-SHA256');
274
+ signer.update(`${header}.${claims}`);
275
+ const assertion = `${header}.${claims}.${signer.sign(fcmCredentials.privateKey, 'base64url')}`;
276
+ const res = await fetch(fcmCredentials.tokenUri, {
277
+ method: 'POST',
278
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
279
+ body: new URLSearchParams({
280
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
281
+ assertion,
282
+ }),
283
+ signal: AbortSignal.timeout(15_000),
284
+ });
285
+ if (!res.ok) {
286
+ const body = await res.text().catch(() => '');
287
+ throw new Error(`FCM token exchange failed: ${res.status} ${body.slice(0, 200)}`);
288
+ }
289
+ const granted = (await res.json());
290
+ if (!granted.access_token)
291
+ throw new Error('FCM token exchange returned no access_token');
292
+ cachedFcmToken = {
293
+ token: granted.access_token,
294
+ expires: Date.now() + ((granted.expires_in ?? 3600) - 60) * 1000,
295
+ };
296
+ return cachedFcmToken.token;
297
+ }
298
+ /** Send a single FCM push and handle the response. */
299
+ async function sendToFcm(token, accessToken, original) {
300
+ // Data-only, deliberately: a `notification` block has the Android SDK draw
301
+ // the alert itself while the app is backgrounded, which skips the client's
302
+ // handler and with it the tap destination it derives from `type` and `uri`.
303
+ // FCM only carries strings in `data`, so everything is sent as one.
304
+ const data = { title: original.title, body: original.body };
305
+ for (const [key, value] of Object.entries(original.data ?? {})) {
306
+ data[key] = String(value);
307
+ }
308
+ const message = {
309
+ token,
310
+ data,
311
+ android: {
312
+ // Data-only messages are "normal" priority by default, which lets Doze
313
+ // hold one until the device next wakes. These are alerts somebody is
314
+ // waiting on, so they go high, the way APNs alert pushes do.
315
+ priority: 'HIGH',
316
+ ...(original.collapseId ? { collapse_key: original.collapseId } : {}),
317
+ },
318
+ // `badge` has no FCM equivalent — an Android launcher badges from the
319
+ // notification the client posts, not from the payload.
320
+ };
321
+ const res = await fetch(`https://fcm.googleapis.com/v1/projects/${fcmCredentials.projectId}/messages:send`, {
322
+ method: 'POST',
323
+ headers: {
324
+ authorization: `Bearer ${accessToken}`,
325
+ 'content-type': 'application/json',
326
+ },
327
+ body: JSON.stringify({ message }),
328
+ signal: AbortSignal.timeout(15_000),
329
+ });
330
+ if (res.ok) {
331
+ emit('push', 'sent', { did: original.did, transport: 'fcm', token: token.slice(0, 8) + '...' });
332
+ return;
333
+ }
334
+ const body = await res.text().catch(() => '');
335
+ // Google's word for a token that no longer belongs to an install — the same
336
+ // fact Apple reports as a 410.
337
+ if (res.status === 404 || body.includes('UNREGISTERED')) {
338
+ await removeToken(token).catch(() => { });
339
+ emit('push', 'token_removed', { did: original.did, transport: 'fcm', reason: 'unregistered' });
340
+ return;
341
+ }
342
+ // The bearer token was refused: drop it so the next send mints a fresh one
343
+ // rather than replaying a credential Google has already rejected.
344
+ if (res.status === 401 || res.status === 403) {
345
+ cachedFcmToken = null;
346
+ }
347
+ emit('push', 'send_error', {
348
+ did: original.did,
349
+ transport: 'fcm',
350
+ status: res.status,
351
+ body: body.slice(0, 200),
352
+ });
353
+ }
177
354
  /** Register a push token for a DID. Upserts on conflict. */
178
355
  export async function registerToken(did, token, platform) {
179
356
  await runSQL(`INSERT INTO _push_tokens (did, token, platform, created_at)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatk/hatk",
3
- "version": "0.0.1-alpha.78",
3
+ "version": "0.0.1-alpha.79",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "hatk": "dist/cli.js"