@antasphere/chassis-server 1.3.0 → 1.5.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.
Files changed (55) hide show
  1. package/dist/api/create-api.d.ts.map +1 -1
  2. package/dist/api/create-api.js +17 -0
  3. package/dist/api/create-api.js.map +1 -1
  4. package/dist/apps/index.d.ts +11 -0
  5. package/dist/apps/index.d.ts.map +1 -0
  6. package/dist/apps/index.js +11 -0
  7. package/dist/apps/index.js.map +1 -0
  8. package/dist/apps/routes.d.ts +14 -0
  9. package/dist/apps/routes.d.ts.map +1 -0
  10. package/dist/apps/routes.js +23 -0
  11. package/dist/apps/routes.js.map +1 -0
  12. package/dist/apps/select.d.ts +64 -0
  13. package/dist/apps/select.d.ts.map +1 -0
  14. package/dist/apps/select.js +168 -0
  15. package/dist/apps/select.js.map +1 -0
  16. package/dist/apps/service.d.ts +74 -0
  17. package/dist/apps/service.d.ts.map +1 -0
  18. package/dist/apps/service.js +165 -0
  19. package/dist/apps/service.js.map +1 -0
  20. package/dist/boot.d.ts.map +1 -1
  21. package/dist/boot.js +5 -0
  22. package/dist/boot.js.map +1 -1
  23. package/dist/env.d.ts +2 -0
  24. package/dist/env.d.ts.map +1 -1
  25. package/dist/env.js +2 -0
  26. package/dist/env.js.map +1 -1
  27. package/dist/identity/hub-grant.d.ts +70 -0
  28. package/dist/identity/hub-grant.d.ts.map +1 -1
  29. package/dist/identity/hub-grant.js +119 -14
  30. package/dist/identity/hub-grant.js.map +1 -1
  31. package/dist/identity/index.d.ts +1 -1
  32. package/dist/identity/index.d.ts.map +1 -1
  33. package/dist/identity/index.js +1 -1
  34. package/dist/identity/index.js.map +1 -1
  35. package/dist/testing/fake-hub.d.ts +20 -0
  36. package/dist/testing/fake-hub.d.ts.map +1 -1
  37. package/dist/testing/fake-hub.js +73 -1
  38. package/dist/testing/fake-hub.js.map +1 -1
  39. package/package.json +7 -3
  40. package/src/api/create-api.ts +21 -0
  41. package/src/apps/index.ts +17 -0
  42. package/src/apps/routes.ts +30 -0
  43. package/src/apps/select.ts +175 -0
  44. package/src/apps/service.ts +198 -0
  45. package/src/boot.ts +5 -0
  46. package/src/env.ts +2 -0
  47. package/src/identity/hub-grant.ts +187 -15
  48. package/src/identity/index.ts +6 -1
  49. package/src/testing/fake-hub.ts +83 -1
  50. package/test/integration/apps-route.test.ts +235 -0
  51. package/test/integration/hub-grant.test.ts +97 -0
  52. package/test/unit/apps.test.ts +544 -0
  53. package/test/unit/fake-hub-me-apps.test.ts +71 -0
  54. package/test/unit/hub-grant-refusal.test.ts +29 -0
  55. package/test/unit/hub-grant-sweep.test.ts +151 -0
@@ -0,0 +1,198 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { eq } from 'drizzle-orm';
3
+ import { workspaces, type Db } from '@antasphere/chassis-db';
4
+ import type { AppsList, Principal } from '@antasphere/chassis-contract';
5
+ import type { GrantAccess } from '../identity/hub-grant.js';
6
+ import type { Logger } from '../logger.js';
7
+ import { HUB_APP_NAME, appsFromFile, readOrganization, safeHttpUrl, selectHubApps } from './select.js';
8
+
9
+ /**
10
+ * The Apps page's list (`GET /apps`, PRDCT-3493): the other tools of the
11
+ * person's organization, and the account that signs them in.
12
+ *
13
+ * On the cloud edition the hub is asked AS THE PERSON, with their own grant
14
+ * (the chassis's `HubGrantService`, the same stored grant and the same
15
+ * cross-replica refresh lock the reconciler uses): ONE call, `GET
16
+ * <hub>/api/v1/me/apps` with `X-Workspace-Id` naming the workspace's hub
17
+ * organization (`workspaces.central_account_id`), answers the catalogue as
18
+ * the person sees it there (the restricted tools they may not see already
19
+ * left out by the hub) with the access verdict per tool; the tools not open
20
+ * are left out here. A SUSPENDED organization (the hub's `organization.status`)
21
+ * is served as an empty list carrying the organization and its status, never
22
+ * as the tools: the hub's contract says a tool's Apps service never serves
23
+ * such a list, and the dashboard says why. The answer is held five minutes
24
+ * per person and workspace. A grant that is missing or dead, a hub that does
25
+ * not answer, a malformed body, or a workspace that is no hub organization
26
+ * falls back to the operator's file, never to an error: the page is a
27
+ * convenience, not an access decision (each tool still asks the hub when it
28
+ * is opened).
29
+ *
30
+ * On a self-hosted instance the list is the operator's file (`APPS_FILE`),
31
+ * read on every call; without one the page lists nothing.
32
+ */
33
+
34
+ /** How long a person's hub answer is held. */
35
+ export const APPS_CACHE_TTL_MS = 5 * 60_000;
36
+ /** At most this many answers are held; the oldest goes first. */
37
+ const APPS_CACHE_MAX = 5_000;
38
+ /** The hub read's budget: the page waits on it. */
39
+ const HUB_TIMEOUT_MS = 3_000;
40
+
41
+ /** The as-the-person token source: what the page needs of the chassis's `HubGrantService`. */
42
+ export interface HubTokens {
43
+ accessToken(userId: string): Promise<GrantAccess>;
44
+ refresh(userId: string): Promise<GrantAccess>;
45
+ invalidateAccess(userId: string): void;
46
+ }
47
+
48
+ export interface AppsServiceOptions {
49
+ db: Db;
50
+ logger: Logger;
51
+ edition: 'oss' | 'cloud';
52
+ /** HUB_ISSUER_URL (cloud). */
53
+ hubIssuerUrl?: string | undefined;
54
+ /** APPS_FILE. */
55
+ appsFile?: string | undefined;
56
+ /** This tool's own slug (the identity's): its card says "You are here". */
57
+ selfSlug: string;
58
+ /** The person's hub grant (cloud); null on a self-hosted instance. */
59
+ tokens: HubTokens | null;
60
+ /** Test seams. */
61
+ fetchImpl?: typeof fetch;
62
+ now?: () => number;
63
+ }
64
+
65
+ type HubRead = { kind: 'ok'; status: number; body: unknown } | { kind: 'fail' };
66
+
67
+ export class AppsService {
68
+ private readonly cache = new Map<string, { at: number; value: AppsList }>();
69
+ private readonly fetchImpl: typeof fetch;
70
+ private readonly now: () => number;
71
+ private readonly hubBase: string | null;
72
+
73
+ constructor(private readonly opts: AppsServiceOptions) {
74
+ this.fetchImpl = opts.fetchImpl ?? fetch;
75
+ this.now = opts.now ?? Date.now;
76
+ this.hubBase =
77
+ opts.edition === 'cloud' && opts.hubIssuerUrl ? opts.hubIssuerUrl.replace(/\/+$/, '') : null;
78
+ }
79
+
80
+ /** The list for one person in one workspace. */
81
+ async list(principal: Pick<Principal, 'userId' | 'workspaceId'>): Promise<AppsList> {
82
+ const key = `${principal.userId}:${principal.workspaceId}`;
83
+ const held = this.cache.get(key);
84
+ if (held && this.now() - held.at < APPS_CACHE_TTL_MS) return held.value;
85
+
86
+ const fromHub = await this.fromHub(principal);
87
+ if (!fromHub) return this.fromFile();
88
+ this.cache.delete(key);
89
+ this.cache.set(key, { at: this.now(), value: fromHub });
90
+ if (this.cache.size > APPS_CACHE_MAX) {
91
+ const oldest = this.cache.keys().next().value;
92
+ if (oldest !== undefined) this.cache.delete(oldest);
93
+ }
94
+ return fromHub;
95
+ }
96
+
97
+ /** The hub's card: the issuer on the cloud edition. */
98
+ private cloudHub(): AppsList['hub'] {
99
+ const url = this.hubBase ? safeHttpUrl(this.hubBase) : null;
100
+ return url ? { url, name: HUB_APP_NAME } : null;
101
+ }
102
+
103
+ /** The operator's file; `none` without one, or when it cannot be read. */
104
+ async fromFile(): Promise<AppsList> {
105
+ const path = this.opts.appsFile;
106
+ if (!path) return { apps: [], hub: this.cloudHub(), source: 'none' };
107
+ let raw: unknown;
108
+ try {
109
+ raw = JSON.parse(await readFile(path, 'utf8'));
110
+ } catch (err) {
111
+ this.opts.logger.warn(
112
+ { err, path },
113
+ 'APPS_FILE could not be read as JSON — the Apps page lists nothing'
114
+ );
115
+ return { apps: [], hub: this.cloudHub(), source: 'none' };
116
+ }
117
+ const file = appsFromFile(raw, this.opts.selfSlug);
118
+ if (file.rejected.length > 0) {
119
+ this.opts.logger.warn({ path, rejected: file.rejected }, 'APPS_FILE entries left out of the Apps page');
120
+ }
121
+ return { apps: file.apps, hub: file.hub ?? this.cloudHub(), source: 'file' };
122
+ }
123
+
124
+ /**
125
+ * The hub's answer, or null when the hub cannot be asked for this person
126
+ * (no grant, a dead one, a workspace that is no hub organization, a hub
127
+ * that does not answer or answers another shape): the file is read instead.
128
+ */
129
+ private async fromHub(principal: Pick<Principal, 'userId' | 'workspaceId'>): Promise<AppsList | null> {
130
+ const tokens = this.opts.tokens;
131
+ if (!this.hubBase || !tokens) return null;
132
+ const [row] = await this.opts.db
133
+ .select({ hubOrgId: workspaces.centralAccountId })
134
+ .from(workspaces)
135
+ .where(eq(workspaces.id, principal.workspaceId))
136
+ .limit(1);
137
+ const hubOrgId = row?.hubOrgId ?? null;
138
+ if (!hubOrgId) return null;
139
+
140
+ const access = await tokens.accessToken(principal.userId);
141
+ if (access.kind !== 'ok') return null;
142
+
143
+ // One forced refresh and one retry on a 401/403 (the stored token may be
144
+ // stale): the chassis's own posture (hub-user-client.ts).
145
+ let res = await this.get(access.accessToken, hubOrgId);
146
+ if (res.kind === 'ok' && (res.status === 401 || res.status === 403)) {
147
+ tokens.invalidateAccess(principal.userId);
148
+ const refreshed = await tokens.refresh(principal.userId);
149
+ if (refreshed.kind !== 'ok') return null;
150
+ res = await this.get(refreshed.accessToken, hubOrgId);
151
+ }
152
+ if (res.kind !== 'ok' || res.status !== 200) {
153
+ if (res.kind === 'ok') {
154
+ this.opts.logger.warn(
155
+ { status: res.status },
156
+ 'hub /me/apps answered non-200 — the Apps page reads the file'
157
+ );
158
+ }
159
+ return null;
160
+ }
161
+ // The organization's status first: a suspended one empties the list
162
+ // whatever the body's tools say (or fail to say).
163
+ const organization = readOrganization(res.body);
164
+ if (organization?.status === 'suspended') {
165
+ this.opts.logger.info({ hubOrgId }, 'hub organization suspended — the Apps page lists nothing');
166
+ return { apps: [], hub: this.cloudHub(), source: 'hub', organization };
167
+ }
168
+ const apps = selectHubApps(res.body, this.opts.selfSlug);
169
+ if (!apps) {
170
+ this.opts.logger.warn('hub /me/apps carried no tools list — the Apps page reads the file');
171
+ return null;
172
+ }
173
+ return { apps, hub: this.cloudHub(), source: 'hub', ...(organization ? { organization } : {}) };
174
+ }
175
+
176
+ private async get(token: string, hubOrgId: string): Promise<HubRead> {
177
+ try {
178
+ const response = await this.fetchImpl(`${this.hubBase}/api/v1/me/apps`, {
179
+ headers: {
180
+ authorization: `Bearer ${token}`,
181
+ accept: 'application/json',
182
+ 'x-workspace-id': hubOrgId
183
+ },
184
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS)
185
+ });
186
+ let body: unknown = null;
187
+ try {
188
+ body = await response.json();
189
+ } catch {
190
+ body = null;
191
+ }
192
+ return { kind: 'ok', status: response.status, body };
193
+ } catch (err) {
194
+ this.opts.logger.warn({ err }, 'hub read for the Apps page failed');
195
+ return { kind: 'fail' };
196
+ }
197
+ }
198
+ }
package/src/boot.ts CHANGED
@@ -592,6 +592,11 @@ export async function bootPlatform<
592
592
  }
593
593
  })
594
594
  : undefined;
595
+ // The grants of the people this replica saw in the last hour are renewed
596
+ // in the background before they expire (PRDCT-3584), so a request never
597
+ // waits on the hub's token endpoint when the sweep got there first. The
598
+ // timer never holds the process open and pauses while draining.
599
+ hubGrant?.startSweep({ isDraining: () => state.draining });
595
600
  // RP-initiated logout against the hub (SL-2, cloud only): builds the
596
601
  // end-session URL /sso/logout returns. Same key seam as the grant store
597
602
  // (it only decrypts if a future Better Auth starts encrypting id_tokens);
package/src/env.ts CHANGED
@@ -222,6 +222,8 @@ const chassisEnvShape = (version: string) => ({
222
222
  HSTS_MAX_AGE: numeric(z.coerce.number().int().min(0).default(15552000)),
223
223
  /** Instance-level cap read by the default AllowAllEntitlements. */
224
224
  MAX_FILE_SIZE_MB: numeric(z.coerce.number().int().min(1).default(100)),
225
+ /** A JSON file listing the other tools of the organization for the Apps page (PRDCT-3493): `[{ "slug": "finance", "name": "Antasphere Finance", "launchUrl": "https://finance.example.com", "description": "optional sentence", "icon": "landmark", "drawing": "ledger", "accent": "#B5542A" }]`, the last four the hub's descriptor keys (icon and drawing from the contract's closed lists; an invalid descriptor is dropped, the entry kept). Read on a self-hosted instance (`EDITION=oss`), and on the cloud edition when the hub cannot be asked (the cloud edition otherwise asks the hub as the person, `GET /me/apps`). Every `launchUrl` must be http(s); an entry that is not is left out, with a warning in the log. Absent: the Apps page lists no other tool. */
226
+ APPS_FILE: optionalString(z.string().min(1).max(1000)),
225
227
  /** How many workspaces ONE person may own on this instance — the cap on creating another workspace from the dashboard (the first one comes from setup). Any signed-in member who is not a guest may create one until they own this many. 0 closes workspace creation for everyone. On the cloud edition the count is Antasphere's own organization limit; only the 0 switch applies here. */
226
228
  MAX_WORKSPACES_PER_USER: numeric(z.coerce.number().int().min(0).max(1000).default(10)),
227
229
  /** General per-principal API quota: sustained requests/minute allowed to every authenticated /api/v1 principal (API key, OAuth token, session). 0 disables the general limiter. */
@@ -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';
@@ -5,7 +5,8 @@ import {
5
5
  usageCheckSchema,
6
6
  usageEventSchema,
7
7
  usageEventsBatchSchema,
8
- usageIngestResultSchema
8
+ usageIngestResultSchema,
9
+ meAppsSchema
9
10
  } from '@antasphere/chassis-contract';
10
11
  import { randomUUID } from 'node:crypto';
11
12
  import { exportJWK, generateKeyPair, SignJWT, type JWK } from 'jose';
@@ -111,6 +112,14 @@ export interface HubUserFixture {
111
112
  overrides?: HubTokenOverrides | undefined;
112
113
  }
113
114
 
115
+ /** One tool of the fake's catalogue (what GET /me/apps serves, PRDCT-3493). */
116
+ export interface FakeHubTool {
117
+ slug: string;
118
+ name: string;
119
+ launchUrl: string;
120
+ descriptor?: Record<string, unknown> | undefined;
121
+ }
122
+
114
123
  /** One entry of a user's org registry (what GET /orgs serves). */
115
124
  export interface HubOrgEntry {
116
125
  name: string | null;
@@ -180,6 +189,11 @@ export class FakeHub {
180
189
  private readonly orgTeamRegistry = new Map<string, Array<{ id: string; slug: string; name: string }>>();
181
190
  /** sub → the orgs that do NOT open the tool to them (GET /orgs' top-level `denied`). */
182
191
  private readonly userDenied = new Map<string, Array<{ id: string; name: string }>>();
192
+ /** The catalogue GET /me/apps serves (PRDCT-3493): the registry's tools, in order, with their descriptor. */
193
+ private tools: FakeHubTool[] = [];
194
+ /** Per user and tool, the organizations that do NOT open the tool to them (absent = open everywhere). */
195
+ private readonly userToolClosed = new Map<string, Set<string>>();
196
+ readonly meAppsRequests: Array<{ auth: string | null; orgId: string | null }> = [];
183
197
  /** Every JWT access token this fake minted (bearer lookup for /api/v1). */
184
198
  private readonly accessTokens = new Map<string, AccessTokenRecord>();
185
199
  /** Live + rotated-out refresh tokens (rotation families). */
@@ -550,6 +564,9 @@ export class FakeHub {
550
564
  if (req.method === 'GET' && url.pathname === '/api/v1/teams') {
551
565
  return this.handleTeams(req, res, url);
552
566
  }
567
+ if (req.method === 'GET' && url.pathname === '/api/v1/me/apps') {
568
+ return this.handleMeApps(req, res);
569
+ }
553
570
  if (req.method === 'POST' && url.pathname === '/api/v1/orgs') {
554
571
  return this.handleOrgCreate(req, res);
555
572
  }
@@ -880,6 +897,71 @@ export class FakeHub {
880
897
  }
881
898
 
882
899
  // ── GET /api/v1/orgs: the caller-scoped org list ──────────────────────
900
+ /** The tools GET /me/apps lists, in this order. */
901
+ setTools(tools: FakeHubTool[]): void {
902
+ this.tools = tools;
903
+ }
904
+
905
+ /** Close one tool to one user in one organization (the real hub's teams rule, reduced to its verdict). */
906
+ setToolClosed(sub: string, slug: string, orgId: string, closed = true): void {
907
+ const key = `${sub}:${slug}`;
908
+ const set = this.userToolClosed.get(key) ?? new Set<string>();
909
+ if (closed) set.add(orgId);
910
+ else set.delete(orgId);
911
+ this.userToolClosed.set(key, set);
912
+ }
913
+
914
+ // ── GET /api/v1/me/apps: the catalogue as one person sees it (PRDCT-3493) ─
915
+ // Mirrors the hub's api/me-apps.ts: the same bearer rule as /orgs, the
916
+ // organization the `X-Workspace-Id` header names (else the user's first org,
917
+ // the real hub's default), every registered tool with `open` from the
918
+ // per-user closures, `connected` false (the fake keeps no connection facts).
919
+ private handleMeApps(req: IncomingMessage, res: ServerResponse): void {
920
+ const header = req.headers['x-workspace-id'];
921
+ const orgId = (Array.isArray(header) ? header[0] : header) ?? null;
922
+ this.meAppsRequests.push({ auth: req.headers.authorization ?? null, orgId });
923
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null;
924
+ const record = bearer ? this.accessTokens.get(bearer) : undefined;
925
+ if (!record || record.expMs <= Date.now() || !record.aud.includes(this.apiResource)) {
926
+ return sendJson(res, 401, { error: { code: 'invalid_token', message: 'Unauthorized' } });
927
+ }
928
+ const orgs = this.userOrgs.get(record.sub) ?? new Map<string, HubOrgEntry>();
929
+ const resolved = orgId ?? orgs.keys().next().value ?? null;
930
+ if (!resolved || !orgs.has(resolved)) {
931
+ return sendJson(res, 401, { error: { code: 'unauthenticated', message: 'Authentication required' } });
932
+ }
933
+ const tools = this.tools.map((tool) => ({
934
+ slug: tool.slug,
935
+ clientId: `tool-${tool.slug}`,
936
+ name: tool.name,
937
+ launchUrl: tool.launchUrl,
938
+ returnOrigins: [new URL(tool.launchUrl).origin],
939
+ connected: false,
940
+ connectedAt: null,
941
+ lastUsedAt: null,
942
+ ...(tool.descriptor ? { descriptor: tool.descriptor } : {}),
943
+ open: !(this.userToolClosed.get(`${record.sub}:${tool.slug}`)?.has(resolved) ?? false)
944
+ }));
945
+ const entry = orgs.get(resolved);
946
+ const body = {
947
+ organization: { id: resolved, name: entry?.name ?? '', status: entry?.status ?? 'active' },
948
+ tools
949
+ };
950
+ // The fake answers the hub's published shape and nothing else: a body the
951
+ // real hub would never send is a test proving nothing, so it is refused
952
+ // here, as a 500 naming the drift (a thrown error would only hang the socket).
953
+ const checked = meAppsSchema.safeParse(body);
954
+ if (!checked.success) {
955
+ return sendJson(res, 500, {
956
+ error: {
957
+ code: 'fake_hub_drift',
958
+ message: `/me/apps body is off the hub contract: ${checked.error.message}`
959
+ }
960
+ });
961
+ }
962
+ return sendJson(res, 200, body);
963
+ }
964
+
883
965
  private async handleOrgs(req: IncomingMessage, res: ServerResponse): Promise<void> {
884
966
  this.orgsRequests.push({ auth: req.headers.authorization ?? null });
885
967
  if (this.orgsDelayMs > 0) await new Promise((r) => setTimeout(r, this.orgsDelayMs));