@antasphere/chassis-server 1.2.0 → 1.4.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.
@@ -103,6 +103,46 @@ export const DEFAULT_GRANT_DIALS: HubGrantDials = {
103
103
  lockWatchdogMs: 12_000
104
104
  };
105
105
 
106
+ /**
107
+ * The background renewal of grants about to expire (PRDCT-3584). Hub access
108
+ * tokens live 15 minutes, so a person who comes back to a tool after a
109
+ * coffee met the token endpoint INSIDE their first request: the refresh
110
+ * (the hub's rotation and the row write) stood between the click and the
111
+ * page. The sweep renews, off any request, the token of every person seen
112
+ * on this replica within `activeWithinMs` whose cached token expires within
113
+ * `renewAheadMs`; a person not seen for an hour is forgotten and renews on
114
+ * demand as before. It goes through `renew()`, the same acquisition as a
115
+ * request's (the single-flight, the advisory lock, the re-read and the
116
+ * presentation ledger stay the only writers of the refresh chain), with two
117
+ * differences the verifier of 18 October 2026 asked for: the cached token is
118
+ * served until the rotation's result is known, and a sibling replica's
119
+ * fresh token is adopted instead of rotated again. The timer never holds
120
+ * the process open and skips while the replica drains.
121
+ */
122
+ export interface GrantSweepDials {
123
+ /** How often the sweep runs. */
124
+ intervalMs: number;
125
+ /** Renew a cached token this long before it expires. */
126
+ renewAheadMs: number;
127
+ /** Only people seen on this replica within this window are renewed. */
128
+ activeWithinMs: number;
129
+ }
130
+
131
+ export const DEFAULT_GRANT_SWEEP_DIALS: GrantSweepDials = {
132
+ intervalMs: 60_000,
133
+ renewAheadMs: 3 * 60_000,
134
+ activeWithinMs: 60 * 60_000
135
+ };
136
+
137
+ /** What `startSweep` hands back: the timer (never holding the process open) and its stop. */
138
+ export interface GrantSweepHandle {
139
+ timer: NodeJS.Timeout;
140
+ stop(): void;
141
+ }
142
+
143
+ /** Bound the seen-map — a user-id flood must never balloon memory. */
144
+ const MAX_SEEN_USERS = 10_000;
145
+
106
146
  /**
107
147
  * One answer about a user's hub access.
108
148
  * - 'ok': a hub-callable access token.
@@ -175,12 +215,38 @@ export function isLikelyEncrypted(token: string): boolean {
175
215
  return token.length % 2 === 0 && /^[0-9a-f]+$/i.test(token);
176
216
  }
177
217
 
218
+ /**
219
+ * What the hub said when it refused: the RFC 6749 `error_description` and
220
+ * the HTTP status, kept for the log line and nothing else (PRDCT-3584). A
221
+ * refusal read as `invalid_grant` has four different causes at the pinned
222
+ * plugin (an unknown token, an expired one, a rotated-out one, a refresh
223
+ * the pre-flight refused), and only this sentence tells them apart.
224
+ */
225
+ export interface RefusalReason {
226
+ status: number;
227
+ description: string | null;
228
+ }
229
+
178
230
  type RefreshOutcome =
179
231
  | { kind: 'ok'; accessToken: string; expiresAt: Date; refreshToken: string | null }
180
- | { kind: 'invalid_grant' }
181
- | { kind: 'invalid_client' }
232
+ | ({ kind: 'invalid_grant' } & RefusalReason)
233
+ | ({ kind: 'invalid_client' } & RefusalReason)
182
234
  | { kind: 'transient' };
183
235
 
236
+ /** The `error_description` of an RFC 6749 error payload, when it is a non-empty string. */
237
+ export function refusalDescription(payload: unknown): string | null {
238
+ const d = (payload as { error_description?: unknown } | null)?.error_description;
239
+ return typeof d === 'string' && d.trim() ? d.trim().slice(0, 300) : null;
240
+ }
241
+
242
+ /**
243
+ * How a token is acquired: on `demand` (serve a fresh stored one, else
244
+ * present); by `force` (the hub refused the current one: present, after the
245
+ * cache was dropped by the caller); or as a `renew` (the background sweep:
246
+ * nothing dropped up front, a sibling's fresh stored token adopted).
247
+ */
248
+ type AcquireMode = 'demand' | 'force' | 'renew';
249
+
184
250
  /** The RFC 7662 probe's answer about a refresh token the hub never confirmed consuming. */
185
251
  type ProbeOutcome = 'active' | 'inactive' | 'transient';
186
252
 
@@ -188,6 +254,8 @@ export class HubGrantService {
188
254
  /** userId → fresh access token (in-memory only; the row is the durable store). */
189
255
  private readonly cache = new Map<string, { token: string; expiresAtMs: number }>();
190
256
  private readonly inFlight = new Map<string, Promise<GrantAccess>>();
257
+ /** userId → when this replica last read a token for them (the sweep's population). */
258
+ private readonly lastSeen = new Map<string, number>();
191
259
  private readonly tokenUrl: string;
192
260
  private readonly introspectUrl: string;
193
261
  private readonly fetchImpl: typeof fetch;
@@ -302,11 +370,12 @@ export class HubGrantService {
302
370
 
303
371
  /** A hub-callable access token for the user, refresh-on-demand. */
304
372
  async accessToken(userId: string): Promise<GrantAccess> {
373
+ this.markSeen(userId);
305
374
  const cached = this.cache.get(userId);
306
375
  if (cached && cached.expiresAtMs - this.now() > this.dials.accessSkewMs) {
307
376
  return { kind: 'ok', accessToken: cached.token };
308
377
  }
309
- return this.acquire(userId, false);
378
+ return this.acquire(userId, 'demand');
310
379
  }
311
380
 
312
381
  /**
@@ -318,13 +387,95 @@ export class HubGrantService {
318
387
  this.cache.delete(userId);
319
388
  const existing = this.inFlight.get(userId);
320
389
  if (existing) await existing.catch(() => {});
321
- return this.acquire(userId, true);
390
+ return this.acquire(userId, 'force');
391
+ }
392
+
393
+ private markSeen(userId: string): void {
394
+ if (!this.lastSeen.has(userId) && this.lastSeen.size >= MAX_SEEN_USERS) {
395
+ // Drop the oldest entry (Map keeps insertion order); a re-seen user is
396
+ // re-inserted at the end below.
397
+ const oldest = this.lastSeen.keys().next().value;
398
+ if (oldest !== undefined) this.lastSeen.delete(oldest);
399
+ }
400
+ this.lastSeen.delete(userId);
401
+ this.lastSeen.set(userId, this.now());
402
+ }
403
+
404
+ /**
405
+ * One pass of the background renewal (GrantSweepDials): renew the cached
406
+ * token of every recently seen person whose token expires within the
407
+ * window, one at a time, through `renew()`. Answers how many renewals
408
+ * were attempted. Never throws: a failed renewal is the next request's
409
+ * on-demand refresh, exactly as before the sweep existed.
410
+ */
411
+ async sweepExpiring(
412
+ dials: Omit<GrantSweepDials, 'intervalMs'> = DEFAULT_GRANT_SWEEP_DIALS
413
+ ): Promise<number> {
414
+ const now = this.now();
415
+ let attempted = 0;
416
+ for (const [userId, seenAt] of [...this.lastSeen]) {
417
+ if (now - seenAt > dials.activeWithinMs) {
418
+ this.lastSeen.delete(userId);
419
+ continue;
420
+ }
421
+ const cached = this.cache.get(userId);
422
+ if (!cached || cached.expiresAtMs - now > dials.renewAheadMs) continue;
423
+ if (this.inFlight.has(userId)) continue;
424
+ attempted += 1;
425
+ try {
426
+ const out = await this.renew(userId, dials.renewAheadMs);
427
+ if (out.kind !== 'ok') {
428
+ this.opts.logger.info({ userId, outcome: out.kind }, 'hub grant: background renewal did not renew');
429
+ }
430
+ } catch (err) {
431
+ this.opts.logger.warn(
432
+ { err, userId },
433
+ 'hub grant: background renewal failed — the next request refreshes on demand'
434
+ );
435
+ }
436
+ }
437
+ return attempted;
438
+ }
439
+
440
+ /**
441
+ * Run the sweep on a timer that never holds the process open and skips
442
+ * while the replica drains. Returns the function that stops it.
443
+ */
444
+ startSweep(
445
+ options: { isDraining?: () => boolean; dials?: Partial<GrantSweepDials> } = {}
446
+ ): GrantSweepHandle {
447
+ const dials = { ...DEFAULT_GRANT_SWEEP_DIALS, ...options.dials };
448
+ let running = false;
449
+ const timer = setInterval(() => {
450
+ // One pass at a time: a slow hub must never stack passes.
451
+ if (running || options.isDraining?.()) return;
452
+ running = true;
453
+ void this.sweepExpiring(dials).finally(() => {
454
+ running = false;
455
+ });
456
+ }, dials.intervalMs);
457
+ timer.unref();
458
+ return { timer, stop: () => clearInterval(timer) };
459
+ }
460
+
461
+ /**
462
+ * Renew the grant in the background (the sweep's path, PRDCT-3584): the
463
+ * cached token keeps being served until the rotation's result is known
464
+ * (nothing is evicted up front, unlike `refresh()`), and a token a sibling
465
+ * replica stored with at least `minLeftMs` of life left is adopted instead
466
+ * of rotating a second time. Joins an acquisition already in flight.
467
+ */
468
+ async renew(
469
+ userId: string,
470
+ minLeftMs: number = DEFAULT_GRANT_SWEEP_DIALS.renewAheadMs
471
+ ): Promise<GrantAccess> {
472
+ return this.acquire(userId, 'renew', minLeftMs);
322
473
  }
323
474
 
324
- private acquire(userId: string, force: boolean): Promise<GrantAccess> {
475
+ private acquire(userId: string, mode: AcquireMode, minLeftMs?: number): Promise<GrantAccess> {
325
476
  let flight = this.inFlight.get(userId);
326
477
  if (!flight) {
327
- flight = this.doAcquire(userId, force).finally(() => {
478
+ flight = this.doAcquire(userId, mode, minLeftMs).finally(() => {
328
479
  if (this.inFlight.get(userId) === flight) this.inFlight.delete(userId);
329
480
  });
330
481
  this.inFlight.set(userId, flight);
@@ -332,15 +483,21 @@ export class HubGrantService {
332
483
  return flight;
333
484
  }
334
485
 
335
- private async doAcquire(userId: string, force: boolean): Promise<GrantAccess> {
486
+ private async doAcquire(userId: string, mode: AcquireMode, minLeftMs?: number): Promise<GrantAccess> {
336
487
  try {
337
488
  const first = await this.readRow(userId);
338
489
  if (!first) return { kind: 'no_link' };
339
490
  if (!first.refreshToken) return { kind: 'grant_dead' };
340
491
 
341
- if (!force) {
492
+ if (mode === 'demand') {
342
493
  const stored = await this.storedAccess(userId, first);
343
494
  if (stored) return { kind: 'ok', accessToken: stored };
495
+ } else if (mode === 'renew') {
496
+ // A sibling replica (or a login) already renewed: adopt its token
497
+ // without taking the lock (verifier round 6: the common case on
498
+ // several replicas was paying a dedicated connection for nothing).
499
+ const stored = await this.storedAccess(userId, first, minLeftMs);
500
+ if (stored) return { kind: 'ok', accessToken: stored };
344
501
  }
345
502
 
346
503
  // Slow path: rotate under the cross-replica lock.
@@ -354,9 +511,15 @@ export class HubGrantService {
354
511
  const row = await this.readRow(userId);
355
512
  if (!row) return { kind: 'no_link' };
356
513
  if (!row.refreshToken) return { kind: 'grant_dead' };
357
- if (!force || row.refreshToken !== first.refreshToken) {
514
+ if (mode === 'demand' || row.refreshToken !== first.refreshToken) {
358
515
  const stored = await this.storedAccess(userId, row);
359
516
  if (stored) return { kind: 'ok', accessToken: stored };
517
+ } else if (mode === 'renew') {
518
+ // A renewal rotates only what is about to expire: a stored token
519
+ // with more life than the renewal window is a sibling replica's
520
+ // fresh one (or a login's), adopted rather than rotated again.
521
+ const stored = await this.storedAccess(userId, row, minLeftMs);
522
+ if (stored) return { kind: 'ok', accessToken: stored };
360
523
  }
361
524
 
362
525
  let refreshToken: string;
@@ -416,7 +579,7 @@ export class HubGrantService {
416
579
  this.cache.delete(userId);
417
580
  this.refreshes.inc({ outcome: 'invalid_grant' });
418
581
  this.opts.logger.warn(
419
- { userId },
582
+ { userId, status: outcome.status, reason: outcome.description },
420
583
  'hub grant: token endpoint answered invalid_grant — grant marked dead; a browser SSO login heals it'
421
584
  );
422
585
  return { kind: 'grant_dead' };
@@ -429,6 +592,7 @@ export class HubGrantService {
429
592
  await this.clearPresentation(row.id);
430
593
  this.refreshes.inc({ outcome: 'invalid_client' });
431
594
  this.opts.logger.error(
595
+ { status: outcome.status, reason: outcome.description },
432
596
  'hub grant: token endpoint answered invalid_client — HUB_CLIENT_ID/HUB_CLIENT_SECRET are broken; ' +
433
597
  'refreshes are failing transient (grants are NOT being killed); fix the client credentials'
434
598
  );
@@ -564,10 +728,14 @@ export class HubGrantService {
564
728
  }
565
729
 
566
730
  /** Decrypt-and-cache the row's access token when it is still fresh. */
567
- private async storedAccess(userId: string, row: GrantRow): Promise<string | null> {
731
+ private async storedAccess(
732
+ userId: string,
733
+ row: GrantRow,
734
+ minLeftMs: number = this.dials.accessSkewMs
735
+ ): Promise<string | null> {
568
736
  if (!row.accessToken || !row.accessTokenExpiresAt) return null;
569
737
  const expiresAtMs = row.accessTokenExpiresAt.getTime();
570
- if (expiresAtMs - this.now() <= this.dials.accessSkewMs) return null;
738
+ if (expiresAtMs - this.now() <= minLeftMs) return null;
571
739
  try {
572
740
  const token = await this.decrypt(row.accessToken);
573
741
  this.cache.set(userId, { token, expiresAtMs });
@@ -657,9 +825,13 @@ export class HubGrantService {
657
825
  // RFC 6749 errors ride 400/401; anything else (5xx…) is transient.
658
826
  if (res.status === 400 || res.status === 401) {
659
827
  const error = (payload as { error?: unknown } | null)?.error;
660
- if (error === 'invalid_grant') return { kind: 'invalid_grant' };
661
- if (error === 'invalid_client') return { kind: 'invalid_client' };
662
- this.opts.logger.warn({ status: res.status, error }, 'hub grant: unrecognized token-endpoint error');
828
+ const description = refusalDescription(payload);
829
+ if (error === 'invalid_grant') return { kind: 'invalid_grant', status: res.status, description };
830
+ if (error === 'invalid_client') return { kind: 'invalid_client', status: res.status, description };
831
+ this.opts.logger.warn(
832
+ { status: res.status, error, description },
833
+ 'hub grant: unrecognized token-endpoint error'
834
+ );
663
835
  return { kind: 'transient' };
664
836
  }
665
837
  this.opts.logger.warn({ status: res.status }, 'hub grant: token endpoint answered non-2xx');
@@ -23,9 +23,14 @@ export {
23
23
  } from './demo-pass-rules.js';
24
24
  export {
25
25
  DEFAULT_GRANT_DIALS,
26
+ DEFAULT_GRANT_SWEEP_DIALS,
26
27
  type GrantAccess,
28
+ type GrantSweepDials,
29
+ type GrantSweepHandle,
27
30
  HubGrantService,
28
- LOCK_WATCHDOG_HEADROOM_MS
31
+ LOCK_WATCHDOG_HEADROOM_MS,
32
+ type RefusalReason,
33
+ refusalDescription
29
34
  } from './hub-grant.js';
30
35
  export { HubJwtVerifier } from './hub-jwt.js';
31
36
  export { HubLogoutService, hintCookieClearHeader } from './hub-logout.js';
@@ -31,3 +31,14 @@ export { MAX_JSON_DEPTH, exceedsJsonDepth, jsonDepthLimit } from './json-depth.j
31
31
  export { noStoreAuthenticated } from './no-store.js';
32
32
  export { isPublicOauthPath, oauthPublicEndpoints } from './oauth-public.js';
33
33
  export { buildCsp, hstsValue, inlineScriptHashes, securityHeaders } from './security-headers.js';
34
+ export {
35
+ IMMUTABLE_ASSET_PREFIX,
36
+ IMMUTABLE_CACHE_CONTROL,
37
+ REVALIDATE_CACHE_CONTROL,
38
+ type SpaFallback,
39
+ type StaticAssetsOptions,
40
+ acceptedEncodings,
41
+ loadSpaFallback,
42
+ staticAssets,
43
+ staticCacheControl
44
+ } from './static-assets.js';
@@ -0,0 +1,144 @@
1
+ import type { Context, MiddlewareHandler } from 'hono';
2
+ import { serveStatic } from '@hono/node-server/serve-static';
3
+ import { createHash } from 'node:crypto';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+
7
+ /**
8
+ * The dashboard build, served so the browser keeps it (PRDCT-3584).
9
+ *
10
+ * Before this middleware the SPA's files went out with no `Cache-Control`,
11
+ * no validator and no compression: every visit to a tool re-downloaded the
12
+ * whole dashboard (about 1.2 MB of JavaScript in a hundred files on the
13
+ * team stack, measured 2026-10-17), and the splash the person stared at
14
+ * was that download. Two rules fix it, both on the build's own shape:
15
+ *
16
+ * - `/_app/immutable/*` is CONTENT-HASHED by SvelteKit: a changed file has a
17
+ * new name, so the cached copy can never be stale. It is served with
18
+ * `public, max-age=31536000, immutable`: a second visit transfers none of
19
+ * it. Every other static path (the HTML, the icons, the manifest) is
20
+ * `no-cache`: kept, but revalidated on every use, so a release reaches
21
+ * the browser on its next load.
22
+ * - A `.br`, `.zst` or `.gz` sibling written at build time (the dashboard's
23
+ * adapter has `precompress: true`) is served in place of the file when
24
+ * the request accepts that encoding, with `Content-Encoding` and `Vary:
25
+ * Accept-Encoding`. The server compresses nothing at runtime, and the
26
+ * API is untouched: this middleware runs on the static files alone,
27
+ * after every credentialed route has answered.
28
+ *
29
+ * Nothing here is a security input. `immutable` never reaches a response
30
+ * that is not a hashed asset, so no user-bearing answer can be cached; the
31
+ * authenticated JSON keeps its `no-store` (middleware/no-store.ts).
32
+ */
33
+
34
+ /** The content-hashed prefix of a SvelteKit build. */
35
+ export const IMMUTABLE_ASSET_PREFIX = '/_app/immutable/';
36
+ export const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
37
+ export const REVALIDATE_CACHE_CONTROL = 'no-cache';
38
+
39
+ /** The `Cache-Control` a static path of the build is served with. */
40
+ export function staticCacheControl(requestPath: string): string {
41
+ return requestPath.startsWith(IMMUTABLE_ASSET_PREFIX) ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL;
42
+ }
43
+
44
+ export interface StaticAssetsOptions {
45
+ /** The directory holding the built dashboard (`index.html`, `_app/`). */
46
+ publicDir: string;
47
+ }
48
+
49
+ /**
50
+ * Serve `publicDir` with the cache policy above and the precompressed
51
+ * siblings. Falls through (`next()`) on a path that is not a file there, so
52
+ * the SPA fallback behind it answers the application routes.
53
+ */
54
+ export function staticAssets(options: StaticAssetsOptions): MiddlewareHandler {
55
+ const serve = serveStatic({ root: options.publicDir, precompressed: true });
56
+ return async (c, next) => {
57
+ // The application's HTML itself (`/`, `/index.html`) is the SPA
58
+ // fallback's to answer, with its validator and its sibling; the static
59
+ // server would hand it out with neither (verifier round 2).
60
+ if (c.req.path === '/' || c.req.path === '/index.html') return next();
61
+ const res = await serve(c, next);
62
+ // serveStatic answers a Response for a file it found and the result of
63
+ // `next()` otherwise; only the file gets the policy.
64
+ if (res instanceof Response) {
65
+ res.headers.set('Cache-Control', staticCacheControl(c.req.path));
66
+ if (!res.headers.has('Vary')) res.headers.set('Vary', 'Accept-Encoding');
67
+ }
68
+ return res;
69
+ };
70
+ }
71
+
72
+ // ── The SPA fallback ────────────────────────────────────────────────────────
73
+
74
+ /** The encodings a precompressed sibling may carry, in the order they are preferred. */
75
+ const FALLBACK_ENCODINGS = [
76
+ { name: 'br', ext: '.br' },
77
+ { name: 'gzip', ext: '.gz' }
78
+ ] as const;
79
+ type FallbackEncoding = (typeof FALLBACK_ENCODINGS)[number]['name'];
80
+
81
+ /** The encodings a request accepts, read from `Accept-Encoding` with any `;q=` weight ignored. */
82
+ export function acceptedEncodings(header: string | undefined): Set<string> {
83
+ return new Set(
84
+ (header ?? '')
85
+ .split(',')
86
+ .map((part) => part.split(';')[0]!.trim().toLowerCase())
87
+ .filter(Boolean)
88
+ );
89
+ }
90
+
91
+ export interface SpaFallback {
92
+ /** The strong ETag of the plain HTML (the variants derive theirs from it). */
93
+ readonly etag: string;
94
+ /** Answer the application's HTML for a route the build has no file for. */
95
+ respond(c: Context): Response;
96
+ }
97
+
98
+ /**
99
+ * The application's HTML under any route the build has no file for, served
100
+ * with the two things the bare `c.html()` lacked (the tier 1 verifier's two
101
+ * caveats, PRDCT-3584): a strong `ETag` so `no-cache` means "revalidate"
102
+ * (a `304` costs no bytes), and the `.br` / `.gz` sibling of `index.html`
103
+ * when the build wrote one and the request accepts it, so a deep link costs
104
+ * the same as the root on a front that compresses nothing (Cloud Run, a
105
+ * self-hosted instance with no proxy). The HTML is read once at boot, like
106
+ * `indexHtml` itself.
107
+ */
108
+ export async function loadSpaFallback(publicDir: string, html: string): Promise<SpaFallback> {
109
+ const plain = Buffer.from(html, 'utf8');
110
+ const etag = `"${createHash('sha256').update(plain).digest('base64url').slice(0, 27)}"`;
111
+ const variants = new Map<FallbackEncoding, Buffer>();
112
+ for (const { name, ext } of FALLBACK_ENCODINGS) {
113
+ try {
114
+ variants.set(name, await readFile(join(publicDir, `index.html${ext}`)));
115
+ } catch {
116
+ // no sibling of that encoding: the plain HTML answers those requests
117
+ }
118
+ }
119
+ return {
120
+ etag,
121
+ respond(c) {
122
+ const accepted = acceptedEncodings(c.req.header('accept-encoding'));
123
+ const encoding = FALLBACK_ENCODINGS.find((e) => accepted.has(e.name) && variants.has(e.name))?.name;
124
+ // One validator per representation: a cached brotli body is never
125
+ // confirmed against a plain one.
126
+ const variantEtag = encoding ? etag.replace(/"$/, `-${encoding}"`) : etag;
127
+ // The type rides the 304 too: the node server would otherwise stamp a
128
+ // text/plain default on it (verifier round 2), and RFC 9110 allows the
129
+ // 304 to repeat what the 200 carries.
130
+ const headers: Record<string, string> = {
131
+ 'Cache-Control': REVALIDATE_CACHE_CONTROL,
132
+ 'Content-Type': 'text/html; charset=utf-8',
133
+ ETag: variantEtag,
134
+ Vary: 'Accept-Encoding'
135
+ };
136
+ if (c.req.header('if-none-match') === variantEtag) {
137
+ return c.body(null, 304, headers);
138
+ }
139
+ if (encoding) headers['Content-Encoding'] = encoding;
140
+ const body = encoding ? variants.get(encoding)! : plain;
141
+ return c.body(new Uint8Array(body), 200, headers);
142
+ }
143
+ };
144
+ }
@@ -503,3 +503,100 @@ describe('ambiguous outcomes: the presentation record + the RFC 7662 probe (PRDC
503
503
  expect((await makeService(FAST).accessToken(aliceId)).kind).toBe('ok');
504
504
  });
505
505
  });
506
+
507
+ /** Bring the stored access token to the renewal window (two minutes left) — the row is what a renewal judges. */
508
+ async function nearExpiryStoredAccess(): Promise<void> {
509
+ await app.db.pool.query(
510
+ `UPDATE account SET access_token_expires_at = now() + interval '2 minutes'
511
+ WHERE provider_id = 'antasphere' AND user_id = $1`,
512
+ [aliceId]
513
+ );
514
+ }
515
+
516
+ describe('the background renewal (PRDCT-3584, verifier round 5)', () => {
517
+ it('keeps serving the cached token while the hub answers the rotation', async () => {
518
+ const service = makeService();
519
+ await sso.ssoLogin(app, hub, alice);
520
+ const row = await grantRow();
521
+ const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
522
+ // the token is about to expire: inside the renewal window, outside the on-demand skew
523
+ service.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
524
+ await nearExpiryStoredAccess();
525
+ hub.tokenDelayMs = 1_200;
526
+ try {
527
+ const before = hub.refreshCount();
528
+ const renewal = service.renew(aliceId);
529
+ const t0 = Date.now();
530
+ const during = await service.accessToken(aliceId);
531
+ const waited = Date.now() - t0;
532
+ expect(during).toEqual({ kind: 'ok', accessToken: current });
533
+ expect(waited).toBeLessThan(300);
534
+ const out = await renewal;
535
+ expect(out.kind).toBe('ok');
536
+ expect(hub.refreshCount()).toBe(before + 1);
537
+ // the renewed token is what the next demand serves, without a hub call
538
+ const after = await service.accessToken(aliceId);
539
+ expect(after.kind).toBe('ok');
540
+ expect((after as { accessToken: string }).accessToken).not.toBe(current);
541
+ expect(hub.refreshCount()).toBe(before + 1);
542
+ } finally {
543
+ hub.tokenDelayMs = 0;
544
+ }
545
+ });
546
+
547
+ it('a transient answer leaves the cached token in place', async () => {
548
+ const service = makeService();
549
+ await sso.ssoLogin(app, hub, alice);
550
+ const row = await grantRow();
551
+ const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
552
+ service.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
553
+ await nearExpiryStoredAccess();
554
+ hub.tokenMode = 'http500';
555
+ try {
556
+ expect(await service.renew(aliceId)).toEqual({ kind: 'inconclusive' });
557
+ } finally {
558
+ hub.tokenMode = 'ok';
559
+ }
560
+ const before = hub.refreshCount();
561
+ expect(await service.accessToken(aliceId)).toEqual({ kind: 'ok', accessToken: current });
562
+ expect(hub.refreshCount()).toBe(before);
563
+ });
564
+
565
+ it('a sibling replica adopts the fresh token the first one stored instead of rotating again', async () => {
566
+ const a = makeService();
567
+ const b = makeService();
568
+ await sso.ssoLogin(app, hub, alice);
569
+ const row = await grantRow();
570
+ const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
571
+ a.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
572
+ b.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
573
+ await nearExpiryStoredAccess();
574
+ const before = hub.refreshCount();
575
+ expect((await a.renew(aliceId)).kind).toBe('ok');
576
+ expect(hub.refreshCount()).toBe(before + 1);
577
+ const adopted = await b.renew(aliceId);
578
+ expect(adopted.kind).toBe('ok');
579
+ expect(hub.refreshCount()).toBe(before + 1); // no second rotation
580
+ const fresh = await grantRow();
581
+ expect((adopted as { accessToken: string }).accessToken).toBe(
582
+ await symmetricDecrypt({ key: AUTH_SECRET, data: fresh.access_token! })
583
+ );
584
+ });
585
+
586
+ it('a refused renewal marks the grant dead like a refused refresh', async () => {
587
+ const service = makeService();
588
+ await sso.ssoLogin(app, hub, alice);
589
+ const row = await grantRow();
590
+ const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
591
+ service.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
592
+ await nearExpiryStoredAccess();
593
+ hub.tokenMode = 'invalid_grant';
594
+ try {
595
+ expect(await service.renew(aliceId)).toEqual({ kind: 'grant_dead' });
596
+ } finally {
597
+ hub.tokenMode = 'ok';
598
+ }
599
+ expect(await service.accessToken(aliceId)).toEqual({ kind: 'grant_dead' });
600
+ await sso.ssoLogin(app, hub, alice); // heal for any later suite
601
+ });
602
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { refusalDescription } from '../../src/identity/hub-grant.js';
3
+
4
+ /**
5
+ * PRDCT-3584. The hub's `invalid_grant` has four causes at the pinned
6
+ * plugin (unknown token, expired, rotated out, refused by the pre-flight)
7
+ * and the tool used to drop the sentence that tells them apart. The reader
8
+ * keeps it for the log line, bounded and trimmed, and nothing else.
9
+ */
10
+ describe('refusalDescription', () => {
11
+ it('reads a non-empty error_description', () => {
12
+ expect(refusalDescription({ error: 'invalid_grant', error_description: 'session not found' })).toBe(
13
+ 'session not found'
14
+ );
15
+ });
16
+ it('trims and bounds it', () => {
17
+ expect(refusalDescription({ error_description: ' invalid refresh token \n' })).toBe(
18
+ 'invalid refresh token'
19
+ );
20
+ expect(refusalDescription({ error_description: 'x'.repeat(1000) })?.length).toBe(300);
21
+ });
22
+ it('answers null for anything else', () => {
23
+ expect(refusalDescription({ error: 'invalid_grant' })).toBeNull();
24
+ expect(refusalDescription({ error_description: '' })).toBeNull();
25
+ expect(refusalDescription({ error_description: 42 })).toBeNull();
26
+ expect(refusalDescription(null)).toBeNull();
27
+ expect(refusalDescription('invalid_grant')).toBeNull();
28
+ });
29
+ });