@steve31415/baselib 3.44.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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.44.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",