@steve31415/baselib 3.43.0 → 3.45.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/dist/auth.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Context } from 'hono';
2
- import type { Logger } from './log-core.js';
2
+ import { type Logger } from './log-core.js';
3
3
  import { ServiceCaller, S2sOptions } from './s2s.js';
4
4
  export declare const DEFAULT_AUTH_URL = "https://auth.apps.snewman.net";
5
5
  export declare const SESSION_COOKIE = "pw_session";
package/dist/auth.js CHANGED
@@ -9,7 +9,11 @@
9
9
  // policy, app-level checks) still runs.
10
10
  // 2. Authorization: Bearer → service-to-service OIDC (s2s.ts).
11
11
  // 3. pw_session cookie → whoami call to the auth service, cached
12
- // in-instance (10 min hits / 30 s misses — the old-world numbers).
12
+ // in-instance (10 min hits / 30 s misses — the old-world numbers),
13
+ // one call per cookie at a time (concurrent misses share it).
14
+ // Every call (never a cache hit) logs one INFO 'session check' event
15
+ // saying why the cache could not answer and what the call cost the
16
+ // waiting request (~/migration/research/whoami-latency-2026-10-30.md).
13
17
  //
14
18
  // Then the user-access policy: by default an app admits only users whose
15
19
  // auth role is 'owner'; `userAccess: 'invited'` admits any active fleet
@@ -25,6 +29,7 @@
25
29
  import { createHash } from 'node:crypto';
26
30
  import { createMiddleware } from 'hono/factory';
27
31
  import { authMode } from './config.js';
32
+ import { truncate } from './log-core.js';
28
33
  import { RateLimiter, resolveS2sCaller } from './s2s.js';
29
34
  export const DEFAULT_AUTH_URL = 'https://auth.apps.snewman.net';
30
35
  export const SESSION_COOKIE = 'pw_session';
@@ -46,6 +51,13 @@ export function requireIdentity(opts) {
46
51
  const policy = opts.userAccess ?? 'owner';
47
52
  const limiter = new RateLimiter(opts.rateLimitPerMin ?? 600);
48
53
  const cache = new Map();
54
+ // The whoami call in flight per cache key (single-flight).
55
+ const inFlight = new Map();
56
+ // Session-check log state (per middleware, like the cache): cache hits
57
+ // since the last whoami call, and when the last call ended.
58
+ let hitsSinceLastCall = 0;
59
+ let callCount = 0;
60
+ let lastCallEndedAt;
49
61
  const isApi = opts.isApiRequest ??
50
62
  ((c) => c.req.path.startsWith('/api/') || (c.req.header('accept') ?? '').includes('application/json'));
51
63
  const unauthenticated = (c) => {
@@ -108,18 +120,77 @@ export function requireIdentity(opts) {
108
120
  const key = createHash('sha256').update(cookieHeader).digest('hex');
109
121
  const cached = cache.get(key);
110
122
  if (cached && cached.expires > Date.now()) {
123
+ hitsSinceLastCall++;
111
124
  if (!cached.user)
112
125
  return unauthenticated(c);
113
126
  return admit(cached.user);
114
127
  }
128
+ // Single-flight: a request that misses while a call for the same cookie
129
+ // is already in flight shares that call's answer instead of making its
130
+ // own (30% of calls were such duplicates). Every request still gets an
131
+ // answer fetched during its own lifetime, so what is admitted does not
132
+ // change; the answer is applied per request below.
133
+ const flight = inFlight.get(key);
134
+ if (flight) {
135
+ flight.joiners.count++;
136
+ return settle(await flight.outcome);
137
+ }
138
+ const joiners = { count: 0 };
139
+ const outcome = callWhoami(c, key, cached, cookieHeader, joiners).finally(() => inFlight.delete(key));
140
+ inFlight.set(key, { outcome, joiners });
141
+ return settle(await outcome);
142
+ function settle(outcome) {
143
+ if (outcome.kind === 'user')
144
+ return admit(outcome.user);
145
+ if (outcome.kind === 'unauthenticated')
146
+ return unauthenticated(c);
147
+ return c.json({ error: 'auth service unavailable' }, 503);
148
+ }
149
+ });
150
+ // One whoami call, whose answer every request that joined it shares. The
151
+ // waiting request pays its full latency. One INFO event per call (a few
152
+ // hundred a day fleet-wide; never per hit or per joined request), with a
153
+ // fixed vocabulary of keys and nothing from the cookie but how many
154
+ // cookies the header carries (a changing header defeats the cache).
155
+ async function callWhoami(c, key, cached, cookieHeader, joiners) {
156
+ const started = Date.now();
157
+ const cacheState = !cached ? 'absent' : cached.user ? 'expired-user' : 'expired-unauthenticated';
158
+ const callFields = {
159
+ cacheState,
160
+ ...(cached ? { entryAgeMs: started - cached.filled } : {}),
161
+ hitsSinceLastCall,
162
+ ...(lastCallEndedAt !== undefined ? { sinceLastCallMs: started - lastCallEndedAt } : {}),
163
+ ...(callCount === 0 ? { firstCall: true } : {}),
164
+ uptimeMs: Math.round(process.uptime() * 1000),
165
+ method: c.req.method,
166
+ path: truncate(c.req.path, 200),
167
+ upgrade: (c.req.header('upgrade') ?? '').toLowerCase() === 'websocket',
168
+ cookieCount: cookieHeader.split(';').filter((part) => part.includes('=')).length,
169
+ };
170
+ hitsSinceLastCall = 0;
171
+ callCount++;
172
+ // `joinedRequests`: requests that shared this call (single-flight),
173
+ // counted when the call ends, so it includes every joiner.
174
+ const callEnded = (status) => {
175
+ const ended = Date.now();
176
+ lastCallEndedAt = ended;
177
+ opts.logger?.info('session check', {
178
+ ...callFields,
179
+ joinedRequests: joiners.count,
180
+ status,
181
+ elapsedMs: ended - started,
182
+ });
183
+ };
115
184
  let res;
116
185
  try {
117
186
  res = await whoamiFetch(`${authUrl}/api/whoami`, { headers: { cookie: cookieHeader } });
118
187
  }
119
188
  catch (err) {
189
+ callEnded(0);
120
190
  opts.logger?.error('auth service unreachable', { error: err });
121
- return c.json({ error: 'auth service unavailable' }, 503);
191
+ return { kind: 'unavailable' };
122
192
  }
193
+ callEnded(res.status);
123
194
  if (res.ok) {
124
195
  const data = (await res.json());
125
196
  const role = parseRole(data.role);
@@ -128,19 +199,21 @@ export function requireIdentity(opts) {
128
199
  // protocol change: a fault, not an "invited" answer. Fail closed,
129
200
  // loudly, and without caching so recovery is instant.
130
201
  opts.logger?.error('whoami answer has no role', { email: data.email, role: data.role });
131
- return c.json({ error: 'auth service unavailable' }, 503);
202
+ return { kind: 'unavailable' };
132
203
  }
133
204
  const user = { type: 'user', email: data.email, name: data.name, userId: data.user_id, role };
134
- cache.set(key, { expires: Date.now() + hitMs, user });
135
- return admit(user);
205
+ const now = Date.now();
206
+ cache.set(key, { expires: now + hitMs, filled: now, user });
207
+ return { kind: 'user', user };
136
208
  }
137
209
  if (res.status === 401) {
138
- cache.set(key, { expires: Date.now() + missMs });
139
- return unauthenticated(c);
210
+ const now = Date.now();
211
+ cache.set(key, { expires: now + missMs, filled: now });
212
+ return { kind: 'unauthenticated' };
140
213
  }
141
214
  opts.logger?.error('auth service error', { meta_status: res.status });
142
- return c.json({ error: 'auth service unavailable' }, 503);
143
- });
215
+ return { kind: 'unavailable' };
216
+ }
144
217
  }
145
218
  // The fleet-wide "not enabled" page, in the auth service's plain style so
146
219
  // every app shows the same thing. Carries nothing but the request's host,
@@ -0,0 +1,89 @@
1
+ import type pg from 'pg';
2
+ import type { Logger } from '../log.js';
3
+ /** How often the bridge proves its LISTEN connection alive with a `select
4
+ * 1`. Cloud SQL drops idle connections silently, the error surfaces only on
5
+ * the next query, and a pure listener never issues one, so without this the
6
+ * bridge would go permanently deaf while its logs claim health. */
7
+ export declare const DB_BRIDGE_HEARTBEAT_MS = 60000;
8
+ /** How long a heartbeat (or the `unlisten` in stop()) may take before the
9
+ * connection is declared lost: a half-open socket never answers and never
10
+ * errors. Generous, because a request-billed Cloud Run instance runs this
11
+ * timer with almost no CPU. */
12
+ export declare const DB_BRIDGE_HEARTBEAT_TIMEOUT_MS = 10000;
13
+ export declare const DB_BRIDGE_RECONNECT_DELAYS_MS: readonly number[];
14
+ export interface DbBroadcastBridgeOptions {
15
+ /** The app's pool. The bridge holds one of its clients for as long as it
16
+ * runs, so size `max` as working connections + 1, and stop() the bridge
17
+ * before the pool ends. */
18
+ pool: pg.Pool;
19
+ /** The NOTIFY channel, per app (`<app>_updates`): a lowercase identifier. */
20
+ channel: string;
21
+ logger: Logger;
22
+ /** Each notification's parsed JSON payload. An invalid payload is logged
23
+ * and skipped; a throw is logged and does not reach pg. */
24
+ onMessage: (message: unknown) => void;
25
+ /** Called after the listener reconnects (not on the first connect).
26
+ * Notifications sent while it was down are lost and unknowable, so the
27
+ * app re-announces availability to every local scope. */
28
+ onRecovered: () => void;
29
+ /** Skip messages whose top-level `origin` is this bridge's `originId`,
30
+ * for an app that delivers its own writes locally and publishes them via
31
+ * notify() for the other copies. Default false. */
32
+ ignoreOwn?: boolean;
33
+ heartbeatMs?: number;
34
+ heartbeatTimeoutMs?: number;
35
+ reconnectDelaysMs?: readonly number[];
36
+ }
37
+ type Queryable = Pick<pg.Pool | pg.PoolClient, 'query'>;
38
+ export declare class DbBroadcastBridge {
39
+ /** Tags this process's notify() payloads when `ignoreOwn` is on. */
40
+ readonly originId: `${string}-${string}-${string}-${string}-${string}`;
41
+ private readonly pool;
42
+ private readonly channel;
43
+ private readonly logger;
44
+ private readonly onMessage;
45
+ private readonly onRecovered;
46
+ private readonly ignoreOwn;
47
+ private readonly heartbeatMs;
48
+ private readonly heartbeatTimeoutMs;
49
+ private readonly reconnectDelaysMs;
50
+ private listener;
51
+ private stopped;
52
+ private connecting;
53
+ private connectedOnce;
54
+ private reconnectAttempt;
55
+ private reconnectTimer;
56
+ private heartbeatTimer;
57
+ constructor(options: DbBroadcastBridgeOptions);
58
+ /** Connects the listener; rejects if the first connect fails. */
59
+ start(): Promise<void>;
60
+ /** Idempotent. Releases the listener client back to the pool; call it
61
+ * before the pool ends. Nothing is delivered after it resolves. */
62
+ stop(): Promise<void>;
63
+ /** `pg_notify` on this bridge's channel. Pass the transaction's client to
64
+ * announce only on commit. With `ignoreOwn`, tags the payload with
65
+ * `originId`. */
66
+ notify(message: Record<string, unknown>, queryable?: Queryable): Promise<void>;
67
+ private connectListener;
68
+ /** pg-pool exempts its first `min` clients, counted over all of them,
69
+ * checked out or idle, from the idle timeout, and reads `options.min`
70
+ * each time it decides. The listener is one of those clients, so without
71
+ * this a pool with `min: 1` (FLEET_POOL_DEFAULTS) would reap its last
72
+ * working connection after the idle timeout: the warm connection min
73
+ * exists to keep. Raise min while the listener is held, lower it on
74
+ * release. */
75
+ private adjustPoolMin;
76
+ /** Each heartbeat is scheduled when the previous one ends, so a slow one
77
+ * never overlaps the next. */
78
+ private scheduleHeartbeat;
79
+ /** Clears the current listener, its timers and handlers, and the raised
80
+ * pool min; returns the client for the caller to release. */
81
+ private detachListener;
82
+ private onListenerLost;
83
+ private scheduleReconnect;
84
+ private onNotification;
85
+ /** A throw from the app's callback would otherwise escape through pg's
86
+ * socket handler and crash the process. */
87
+ private callApp;
88
+ }
89
+ export {};
@@ -0,0 +1,256 @@
1
+ // The cross-copy half of live delivery: one dedicated LISTEN connection per
2
+ // process relays a Postgres NOTIFY channel to the app's local fan-out. Every
3
+ // copy of a service shares the database, so a hint published by any writer
4
+ // (another copy, the old revision during a deploy, a job) reaches every
5
+ // copy's sessions. Rules: ~/plasticine-way/docs/DATABASE.md "Cross-instance
6
+ // live delivery: LISTEN/NOTIFY". Moved here from todo's src/db-broadcast.ts.
7
+ import { randomUUID } from 'node:crypto';
8
+ /** How often the bridge proves its LISTEN connection alive with a `select
9
+ * 1`. Cloud SQL drops idle connections silently, the error surfaces only on
10
+ * the next query, and a pure listener never issues one, so without this the
11
+ * bridge would go permanently deaf while its logs claim health. */
12
+ export const DB_BRIDGE_HEARTBEAT_MS = 60_000;
13
+ /** How long a heartbeat (or the `unlisten` in stop()) may take before the
14
+ * connection is declared lost: a half-open socket never answers and never
15
+ * errors. Generous, because a request-billed Cloud Run instance runs this
16
+ * timer with almost no CPU. */
17
+ export const DB_BRIDGE_HEARTBEAT_TIMEOUT_MS = 10_000;
18
+ export const DB_BRIDGE_RECONNECT_DELAYS_MS = [250, 1_000, 4_000, 15_000, 30_000];
19
+ export class DbBroadcastBridge {
20
+ /** Tags this process's notify() payloads when `ignoreOwn` is on. */
21
+ originId = randomUUID();
22
+ pool;
23
+ channel;
24
+ logger;
25
+ onMessage;
26
+ onRecovered;
27
+ ignoreOwn;
28
+ heartbeatMs;
29
+ heartbeatTimeoutMs;
30
+ reconnectDelaysMs;
31
+ listener = null;
32
+ stopped = true;
33
+ connecting = false;
34
+ connectedOnce = false;
35
+ reconnectAttempt = 0;
36
+ reconnectTimer = null;
37
+ heartbeatTimer = null;
38
+ constructor(options) {
39
+ if (!/^[a-z_][a-z0-9_]*$/.test(options.channel)) {
40
+ throw new Error(`DbBroadcastBridge: channel must be a lowercase identifier, got ${JSON.stringify(options.channel)}`);
41
+ }
42
+ this.pool = options.pool;
43
+ this.channel = options.channel;
44
+ this.logger = options.logger;
45
+ this.onMessage = options.onMessage;
46
+ this.onRecovered = options.onRecovered;
47
+ this.ignoreOwn = options.ignoreOwn ?? false;
48
+ this.heartbeatMs = options.heartbeatMs ?? DB_BRIDGE_HEARTBEAT_MS;
49
+ this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? DB_BRIDGE_HEARTBEAT_TIMEOUT_MS;
50
+ this.reconnectDelaysMs = options.reconnectDelaysMs ?? DB_BRIDGE_RECONNECT_DELAYS_MS;
51
+ }
52
+ /** Connects the listener; rejects if the first connect fails. */
53
+ async start() {
54
+ if (!this.stopped)
55
+ return;
56
+ this.stopped = false;
57
+ await this.connectListener(true);
58
+ }
59
+ /** Idempotent. Releases the listener client back to the pool; call it
60
+ * before the pool ends. Nothing is delivered after it resolves. */
61
+ async stop() {
62
+ this.stopped = true;
63
+ if (this.reconnectTimer)
64
+ clearTimeout(this.reconnectTimer);
65
+ this.reconnectTimer = null;
66
+ const listener = this.detachListener();
67
+ if (!listener)
68
+ return;
69
+ // pg emits 'error' on a client whose socket dies even mid-query, and a
70
+ // checked-out client has no pool listener: keep one until it is back.
71
+ const ignore = () => { };
72
+ listener.on('error', ignore);
73
+ try {
74
+ await queryWithin(listener, 'unlisten *', this.heartbeatTimeoutMs);
75
+ listener.removeListener('error', ignore);
76
+ listener.release();
77
+ }
78
+ catch (error) {
79
+ listener.release(toError(error));
80
+ }
81
+ }
82
+ /** `pg_notify` on this bridge's channel. Pass the transaction's client to
83
+ * announce only on commit. With `ignoreOwn`, tags the payload with
84
+ * `originId`. */
85
+ async notify(message, queryable = this.pool) {
86
+ const payload = this.ignoreOwn ? { ...message, origin: this.originId } : message;
87
+ await queryable.query('select pg_notify($1, $2)', [this.channel, JSON.stringify(payload)]);
88
+ }
89
+ async connectListener(initial) {
90
+ if (this.stopped || this.connecting || this.listener)
91
+ return;
92
+ this.connecting = true;
93
+ try {
94
+ const client = await this.pool.connect();
95
+ // A freshly checked-out pool client carries no 'error' listener — pg-pool
96
+ // strips the idle one on checkout. If Cloud SQL has silently reset this
97
+ // (reused) connection, the very next query makes pg emit an 'error' event
98
+ // on the client even while that query is in flight; with no listener Node
99
+ // escalates it to an uncaught exception and the process exits (the Reader
100
+ // 2026-09-10 crash, which hit here during a reconnect). Attach the
101
+ // handlers BEFORE issuing `listen`. In this pre-`listen` window
102
+ // this.listener is still null, so onListenerLost no-ops and the failing
103
+ // `listen` query below drives the cleanup and retry instead.
104
+ client.on('error', (error) => this.onListenerLost(client, error));
105
+ client.on('end', () => this.onListenerLost(client, new Error('database listener ended')));
106
+ try {
107
+ await client.query(`listen ${this.channel}`);
108
+ }
109
+ catch (error) {
110
+ client.removeAllListeners('error');
111
+ client.removeAllListeners('end');
112
+ client.release(toError(error));
113
+ throw error;
114
+ }
115
+ if (this.stopped) {
116
+ client.removeAllListeners('error');
117
+ client.removeAllListeners('end');
118
+ client.release();
119
+ return;
120
+ }
121
+ this.listener = client;
122
+ this.adjustPoolMin(1);
123
+ client.on('notification', (notification) => this.onNotification(notification.payload));
124
+ this.scheduleHeartbeat(client);
125
+ const recovered = this.connectedOnce;
126
+ this.connectedOnce = true;
127
+ this.reconnectAttempt = 0;
128
+ if (recovered) {
129
+ this.logger.info('database broadcast listener recovered', { channel: this.channel });
130
+ this.callApp('onRecovered', () => this.onRecovered());
131
+ }
132
+ else if (!initial) {
133
+ this.logger.info('database broadcast listener connected', { channel: this.channel });
134
+ }
135
+ }
136
+ finally {
137
+ this.connecting = false;
138
+ }
139
+ }
140
+ /** pg-pool exempts its first `min` clients, counted over all of them,
141
+ * checked out or idle, from the idle timeout, and reads `options.min`
142
+ * each time it decides. The listener is one of those clients, so without
143
+ * this a pool with `min: 1` (FLEET_POOL_DEFAULTS) would reap its last
144
+ * working connection after the idle timeout: the warm connection min
145
+ * exists to keep. Raise min while the listener is held, lower it on
146
+ * release. */
147
+ adjustPoolMin(delta) {
148
+ this.pool.options.min = (this.pool.options.min ?? 0) + delta;
149
+ }
150
+ /** Each heartbeat is scheduled when the previous one ends, so a slow one
151
+ * never overlaps the next. */
152
+ scheduleHeartbeat(client) {
153
+ this.heartbeatTimer = setTimeout(() => {
154
+ this.heartbeatTimer = null;
155
+ if (this.listener !== client)
156
+ return;
157
+ queryWithin(client, 'select 1', this.heartbeatTimeoutMs).then(() => {
158
+ if (this.listener === client)
159
+ this.scheduleHeartbeat(client);
160
+ }, (error) => this.onListenerLost(client, toError(error)));
161
+ }, this.heartbeatMs);
162
+ }
163
+ /** Clears the current listener, its timers and handlers, and the raised
164
+ * pool min; returns the client for the caller to release. */
165
+ detachListener() {
166
+ const listener = this.listener;
167
+ if (!listener)
168
+ return null;
169
+ this.listener = null;
170
+ if (this.heartbeatTimer)
171
+ clearTimeout(this.heartbeatTimer);
172
+ this.heartbeatTimer = null;
173
+ listener.removeAllListeners('notification');
174
+ listener.removeAllListeners('error');
175
+ listener.removeAllListeners('end');
176
+ this.adjustPoolMin(-1);
177
+ return listener;
178
+ }
179
+ onListenerLost(client, error) {
180
+ if (this.stopped || this.listener !== client)
181
+ return;
182
+ this.detachListener();
183
+ // The pool re-adds its idle error listener on release, so a late socket
184
+ // error on the discarded client stays handled.
185
+ client.release(error);
186
+ this.logger.warn('database broadcast listener lost', { channel: this.channel, error: String(error) });
187
+ this.scheduleReconnect();
188
+ }
189
+ scheduleReconnect() {
190
+ if (this.stopped || this.reconnectTimer)
191
+ return;
192
+ const delays = this.reconnectDelaysMs;
193
+ const delay = delays[Math.min(this.reconnectAttempt++, delays.length - 1)];
194
+ this.reconnectTimer = setTimeout(() => {
195
+ this.reconnectTimer = null;
196
+ void this.connectListener(false).catch((error) => {
197
+ this.logger.warn('database broadcast reconnect failed', { channel: this.channel, error: String(error) });
198
+ this.scheduleReconnect();
199
+ });
200
+ }, delay);
201
+ }
202
+ onNotification(payload) {
203
+ if (this.stopped || !payload)
204
+ return;
205
+ let message;
206
+ try {
207
+ message = JSON.parse(payload);
208
+ }
209
+ catch (error) {
210
+ this.logger.warn('invalid database broadcast', {
211
+ channel: this.channel,
212
+ error: String(error),
213
+ payload: payload.slice(0, 200),
214
+ });
215
+ return;
216
+ }
217
+ if (this.ignoreOwn && message?.origin === this.originId)
218
+ return;
219
+ this.callApp('onMessage', () => this.onMessage(message));
220
+ }
221
+ /** A throw from the app's callback would otherwise escape through pg's
222
+ * socket handler and crash the process. */
223
+ callApp(callback, fn) {
224
+ try {
225
+ fn();
226
+ }
227
+ catch (error) {
228
+ this.logger.warn('database broadcast handler failed', { channel: this.channel, callback, error });
229
+ }
230
+ }
231
+ }
232
+ /** Runs `sql`, failing if it has not answered within `timeoutMs`. The verdict
233
+ * waits one more event-loop turn (setImmediate runs after the poll phase),
234
+ * so a reply already buffered when a throttled instance gets CPU back is
235
+ * read before the timer, which runs first, can convict it. */
236
+ async function queryWithin(client, sql, timeoutMs) {
237
+ const query = client.query(sql);
238
+ // On a timeout the caller's release(error) destroys the socket, which
239
+ // rejects this query; nothing else awaits it.
240
+ query.catch(() => { });
241
+ let timer;
242
+ try {
243
+ await Promise.race([
244
+ query,
245
+ new Promise((_, reject) => {
246
+ timer = setTimeout(() => setImmediate(() => reject(new Error(`database listener: ${sql} timed out after ${timeoutMs} ms`))), timeoutMs);
247
+ }),
248
+ ]);
249
+ }
250
+ finally {
251
+ clearTimeout(timer);
252
+ }
253
+ }
254
+ function toError(error) {
255
+ return error instanceof Error ? error : new Error(String(error));
256
+ }
@@ -3,3 +3,4 @@ export * from './schema.js';
3
3
  export * from './engine.js';
4
4
  export * from './broadcaster.js';
5
5
  export * from './session.js';
6
+ export * from './db-bridge.js';
@@ -3,3 +3,4 @@ export * from './schema.js';
3
3
  export * from './engine.js';
4
4
  export * from './broadcaster.js';
5
5
  export * from './session.js';
6
+ export * from './db-bridge.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.43.0",
3
+ "version": "3.45.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",