create-astram-app 0.5.0 → 0.7.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 (83) hide show
  1. package/README.md +256 -38
  2. package/dist/add-frontend.js.map +1 -1
  3. package/dist/index.js +14 -9
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/template/README.md +51 -20
  7. package/template/frontend/README.md +21 -11
  8. package/template/frontend/index.html +16 -0
  9. package/template/frontend/package.json +1 -1
  10. package/template/frontend/src/App.jsx +82 -20
  11. package/template/frontend/src/auth/AuthContext.jsx +141 -21
  12. package/template/frontend/src/auth/routeGate.js +31 -0
  13. package/template/frontend/src/auth/routeGate.test.mjs +59 -0
  14. package/template/frontend/src/auth/sessionFlow.js +27 -0
  15. package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
  16. package/template/frontend/src/components/EntityList.jsx +1 -7
  17. package/template/frontend/src/components/PageHeader.jsx +5 -4
  18. package/template/frontend/src/components/ShareDialog.jsx +3 -5
  19. package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
  20. package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
  21. package/template/frontend/src/components/ui/alert.tsx +17 -23
  22. package/template/frontend/src/components/ui/avatar.tsx +8 -20
  23. package/template/frontend/src/components/ui/badge.tsx +17 -26
  24. package/template/frontend/src/components/ui/button.tsx +22 -24
  25. package/template/frontend/src/components/ui/card.tsx +19 -36
  26. package/template/frontend/src/components/ui/checkbox.tsx +7 -10
  27. package/template/frontend/src/components/ui/date-field.tsx +26 -27
  28. package/template/frontend/src/components/ui/dialog.tsx +24 -40
  29. package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
  30. package/template/frontend/src/components/ui/input-otp.tsx +1 -4
  31. package/template/frontend/src/components/ui/label.tsx +10 -12
  32. package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
  33. package/template/frontend/src/components/ui/select.tsx +31 -43
  34. package/template/frontend/src/components/ui/separator.tsx +6 -6
  35. package/template/frontend/src/components/ui/sheet.tsx +29 -38
  36. package/template/frontend/src/components/ui/skeleton.tsx +3 -3
  37. package/template/frontend/src/components/ui/sonner.tsx +7 -7
  38. package/template/frontend/src/components/ui/table.tsx +56 -70
  39. package/template/frontend/src/components/ui/tooltip.tsx +7 -11
  40. package/template/frontend/src/config/branding.js +1 -4
  41. package/template/frontend/src/hooks/useMobile.tsx +4 -6
  42. package/template/frontend/src/index.css +5 -2
  43. package/template/frontend/src/pages/Dashboard.jsx +13 -7
  44. package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
  45. package/template/frontend/src/pages/Login.jsx +85 -70
  46. package/template/frontend/src/pages/ResetPassword.jsx +9 -2
  47. package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
  48. package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
  49. package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
  50. package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
  51. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
  52. package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
  53. package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
  54. package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
  55. package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
  56. package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
  57. package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
  58. package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
  59. package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +77 -18
  60. package/template/frontend/src/pages/service-accounts/accessModel.js +22 -0
  61. package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +28 -0
  62. package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
  63. package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
  64. package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
  65. package/template/frontend/src/platform/httpClient.js +18 -0
  66. package/template/frontend/src/platform/httpClient.test.mjs +60 -0
  67. package/template/frontend/src/platform/sessionStore.js +55 -13
  68. package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
  69. package/template/frontend/src/platform/shieldTransport.js +6 -0
  70. package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
  71. package/template/frontend/src/shell/AppShell.jsx +10 -12
  72. package/template/frontend/src/shell/Sidebar.jsx +15 -3
  73. package/template/frontend/src/shell/nav.js +1 -3
  74. package/template/frontend/vite.config.js +19 -1
  75. package/template/server/_env.example +22 -1
  76. package/template/server/eslint.config.mjs +314 -0
  77. package/template/server/package.json +11 -11
  78. package/template/server/scripts/dev.mjs +17 -6
  79. package/template/server/scripts/sync-astram-guide.mjs +45 -19
  80. package/template/server/src/app.module.ts +16 -7
  81. package/template/server/src/health/demo.controller.ts +16 -12
  82. package/template/server/src/main.ts +23 -1
  83. package/template/server/eslint.config.js +0 -95
@@ -3,6 +3,11 @@
3
3
  * contract. Uses the browser `fetch`, same-origin `/api` (proxied by Vite to the
4
4
  * server's /auth/* routes). A mobile app provides its OWN adapter (RN fetch + its
5
5
  * base URL) implementing the same shape; the shared logic is identical for both.
6
+ *
7
+ * This is the RAW transport: it does not refresh tokens. AuthContext wraps it with
8
+ * @astram/frontend-core's `createRefreshingHttp` (single-flight refresh on a 401, one retry,
9
+ * cross-tab lock) and hands only the wrapped client to the services and the shield
10
+ * transport. Use the raw client directly only for the refresh call itself.
6
11
  */
7
12
 
8
13
  const BASE = '/api';
@@ -17,6 +22,14 @@ async function toResponse(res) {
17
22
  return { ok: res.ok, status: res.status, data };
18
23
  }
19
24
 
25
+ /**
26
+ * Forward the caller's abort signal to fetch (a stale search, or the refresh timeout in
27
+ * `createRefreshingHttp`). An abort rejects fetch, which every method maps to status 0.
28
+ */
29
+ function withSignal(opts) {
30
+ return opts && opts.signal ? { signal: opts.signal } : {};
31
+ }
32
+
20
33
  /**
21
34
  * The app-wide enforced-re-enroll interceptor. When a request is rejected with a 403
22
35
  * whose body is EXACTLY `{ error: 'reenrollment_required' }` (the server's re-enroll
@@ -60,6 +73,7 @@ export function createWebHttpClient(config = {}) {
60
73
  ...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
61
74
  },
62
75
  body: JSON.stringify(body),
76
+ ...withSignal(opts),
63
77
  });
64
78
  return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
65
79
  } catch {
@@ -75,6 +89,7 @@ export function createWebHttpClient(config = {}) {
75
89
  ...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
76
90
  },
77
91
  body: JSON.stringify(body),
92
+ ...withSignal(opts),
78
93
  });
79
94
  return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
80
95
  } catch {
@@ -90,6 +105,7 @@ export function createWebHttpClient(config = {}) {
90
105
  ...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
91
106
  },
92
107
  body: JSON.stringify(body),
108
+ ...withSignal(opts),
93
109
  });
94
110
  return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
95
111
  } catch {
@@ -105,6 +121,7 @@ export function createWebHttpClient(config = {}) {
105
121
  ...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
106
122
  },
107
123
  body: JSON.stringify(body),
124
+ ...withSignal(opts),
108
125
  });
109
126
  return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
110
127
  } catch {
@@ -115,6 +132,7 @@ export function createWebHttpClient(config = {}) {
115
132
  try {
116
133
  const res = await fetch(`${BASE}${path}`, {
117
134
  headers: opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {},
135
+ ...withSignal(opts),
118
136
  });
119
137
  return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
120
138
  } catch {
@@ -0,0 +1,60 @@
1
+ // httpClient.test.mjs — the web HttpClient forwards `opts.signal` to fetch on every
2
+ // method (Phase 8 #6), so the refresh timeout in @astram/frontend-core can abort a hung
3
+ // /auth/refresh, and an aborted request maps to the transport-failure shape.
4
+ //
5
+ // node --test src/platform/httpClient.test.mjs
6
+ //
7
+ // `fetch` is stubbed on globalThis, so this runs under plain Node.
8
+ import { test, describe, beforeEach, afterEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { createWebHttpClient } from './httpClient.js';
11
+
12
+ let realFetch;
13
+ let seen;
14
+
15
+ beforeEach(() => {
16
+ realFetch = globalThis.fetch;
17
+ seen = [];
18
+ globalThis.fetch = async (url, init = {}) => {
19
+ seen.push({ url, init });
20
+ if (init.signal?.aborted) {
21
+ throw Object.assign(new Error('aborted'), { name: 'AbortError' });
22
+ }
23
+ return { ok: true, status: 200, json: async () => ({ ok: true }) };
24
+ };
25
+ });
26
+
27
+ afterEach(() => {
28
+ globalThis.fetch = realFetch;
29
+ });
30
+
31
+ describe('createWebHttpClient — abort signal forwarding', () => {
32
+ const methods = [
33
+ ['get', (c, o) => c.get('/x', o)],
34
+ ['post', (c, o) => c.post('/x', {}, o)],
35
+ ['patch', (c, o) => c.patch('/x', {}, o)],
36
+ ['put', (c, o) => c.put('/x', {}, o)],
37
+ ['delete', (c, o) => c.delete('/x', {}, o)],
38
+ ];
39
+
40
+ for (const [name, call] of methods) {
41
+ test(`${name} passes opts.signal to fetch`, async () => {
42
+ const ctl = new AbortController();
43
+ const r = await call(createWebHttpClient(), { signal: ctl.signal });
44
+ assert.equal(r.status, 200);
45
+ assert.equal(seen[0].init.signal, ctl.signal);
46
+ });
47
+
48
+ test(`${name} with an aborted signal resolves { ok: false, status: 0 } (never throws)`, async () => {
49
+ const ctl = new AbortController();
50
+ ctl.abort();
51
+ const r = await call(createWebHttpClient(), { signal: ctl.signal });
52
+ assert.deepEqual(r, { ok: false, status: 0, data: null });
53
+ });
54
+
55
+ test(`${name} without a signal sends no signal`, async () => {
56
+ await call(createWebHttpClient(), undefined);
57
+ assert.equal('signal' in seen[0].init, false);
58
+ });
59
+ }
60
+ });
@@ -2,28 +2,70 @@
2
2
  * The WEB session store — the platform half of @astram/frontend-core's SessionStore contract,
3
3
  * backed by localStorage. A mobile app provides its own (AsyncStorage). The shared
4
4
  * session helpers never touch storage directly; the app injects this.
5
+ *
6
+ * localStorage is shared by every tab of the app, so a token rotation or logout in one tab
7
+ * is visible to the others: `createRefreshingHttp` re-reads it inside its cross-tab lock,
8
+ * and `subscribe` turns the browser's `storage` event (fired in the OTHER tabs) into a
9
+ * session-change callback for AuthContext.
10
+ *
11
+ * Token storage note (D11): the refresh token sits in localStorage, readable by any script
12
+ * on the origin, which is why index.html ships a strict Content-Security-Policy. Moving the
13
+ * refresh token to an HttpOnly cookie is a planned follow-up ADR.
5
14
  */
6
15
 
7
- const KEY = 'app-session';
16
+ export const SESSION_STORAGE_KEY = 'app-session';
17
+
18
+ /**
19
+ * @param {{ storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>,
20
+ * events?: Pick<EventTarget, 'addEventListener' | 'removeEventListener'> }} [deps]
21
+ * Injected for tests; default to the browser's `localStorage` and `window`.
22
+ * @returns {import('@astram/frontend-core').SessionStore & {
23
+ * subscribe: (listener: (state: object | null) => void) => () => void }}
24
+ */
25
+ export function createWebSessionStore(deps = {}) {
26
+ const storage = () => deps.storage ?? globalThis.localStorage;
27
+ const events = () => deps.events ?? globalThis.window;
28
+
29
+ const load = () => {
30
+ try {
31
+ const raw = storage().getItem(SESSION_STORAGE_KEY);
32
+ return raw ? JSON.parse(raw) : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ };
8
37
 
9
- /** @returns {import('@astram/frontend-core').SessionStore} */
10
- export function createWebSessionStore() {
11
38
  return {
12
- load() {
13
- try {
14
- const raw = localStorage.getItem(KEY);
15
- return raw ? JSON.parse(raw) : null;
16
- } catch {
17
- return null;
18
- }
19
- },
39
+ load,
20
40
  save(state) {
21
41
  try {
22
- if (state) localStorage.setItem(KEY, JSON.stringify(state));
23
- else localStorage.removeItem(KEY);
42
+ if (state) storage().setItem(SESSION_STORAGE_KEY, JSON.stringify(state));
43
+ else storage().removeItem(SESSION_STORAGE_KEY);
24
44
  } catch {
25
45
  /* storage unavailable — session stays in memory only */
26
46
  }
27
47
  },
48
+ /**
49
+ * Call `listener(state | null)` when ANOTHER tab changes the session (rotation, logout,
50
+ * a different sign-in). Returns an unsubscribe function.
51
+ */
52
+ subscribe(listener) {
53
+ const target = events();
54
+ if (!target || typeof target.addEventListener !== 'function') return () => {};
55
+ const onStorage = (event) => {
56
+ // `key === null` means the other tab called localStorage.clear().
57
+ if (event.key !== SESSION_STORAGE_KEY && event.key !== null) return;
58
+ let area;
59
+ try {
60
+ area = storage();
61
+ } catch {
62
+ return;
63
+ }
64
+ if (event.storageArea && event.storageArea !== area) return;
65
+ listener(load());
66
+ };
67
+ target.addEventListener('storage', onStorage);
68
+ return () => target.removeEventListener('storage', onStorage);
69
+ },
28
70
  };
29
71
  }
@@ -0,0 +1,89 @@
1
+ // sessionStore.test.mjs — the web session store's cross-tab half (#12): a logout or a
2
+ // token rotation in one tab must reach the others through the `storage` event.
3
+ //
4
+ // node --test src/platform/sessionStore.test.mjs
5
+ //
6
+ // Storage and the event target are injected, so this runs under plain Node.
7
+ import { test, describe } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { createWebSessionStore, SESSION_STORAGE_KEY } from './sessionStore.js';
10
+
11
+ function memoryStorage() {
12
+ const m = new Map();
13
+ return {
14
+ getItem: (k) => (m.has(k) ? m.get(k) : null),
15
+ setItem: (k, v) => m.set(k, String(v)),
16
+ removeItem: (k) => m.delete(k),
17
+ };
18
+ }
19
+
20
+ function eventTarget() {
21
+ const listeners = new Set();
22
+ return {
23
+ addEventListener: (type, fn) => type === 'storage' && listeners.add(fn),
24
+ removeEventListener: (type, fn) => type === 'storage' && listeners.delete(fn),
25
+ fire: (event) => [...listeners].forEach((fn) => fn(event)),
26
+ get size() {
27
+ return listeners.size;
28
+ },
29
+ };
30
+ }
31
+
32
+ const fake = (label) => `fake-${label}`;
33
+ const STATE = {
34
+ session: { sessionId: 's1' },
35
+ tokens: { accessToken: fake('a2'), refreshToken: fake('r2') },
36
+ };
37
+
38
+ describe('createWebSessionStore.subscribe — cross-tab session changes', () => {
39
+ test('another tab rotated the tokens → listener gets the new state', () => {
40
+ const storage = memoryStorage();
41
+ const events = eventTarget();
42
+ const store = createWebSessionStore({ storage, events });
43
+ const seen = [];
44
+ store.subscribe((s) => seen.push(s));
45
+
46
+ storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(STATE)); // the other tab's write
47
+ events.fire({ key: SESSION_STORAGE_KEY, storageArea: storage });
48
+
49
+ assert.deepEqual(seen, [STATE]);
50
+ });
51
+
52
+ test('another tab logged out (key removed, or storage cleared) → listener gets null', () => {
53
+ const storage = memoryStorage();
54
+ const events = eventTarget();
55
+ const store = createWebSessionStore({ storage, events });
56
+ const seen = [];
57
+ store.subscribe((s) => seen.push(s));
58
+
59
+ events.fire({ key: SESSION_STORAGE_KEY, storageArea: storage });
60
+ events.fire({ key: null, storageArea: storage }); // localStorage.clear()
61
+
62
+ assert.deepEqual(seen, [null, null]);
63
+ });
64
+
65
+ test('unrelated keys and other storage areas are ignored; unsubscribe detaches', () => {
66
+ const storage = memoryStorage();
67
+ const events = eventTarget();
68
+ const store = createWebSessionStore({ storage, events });
69
+ const seen = [];
70
+ const off = store.subscribe((s) => seen.push(s));
71
+
72
+ events.fire({ key: 'app-return-path', storageArea: storage });
73
+ events.fire({ key: SESSION_STORAGE_KEY, storageArea: memoryStorage() }); // sessionStorage
74
+ assert.deepEqual(seen, []);
75
+
76
+ off();
77
+ assert.equal(events.size, 0);
78
+ });
79
+
80
+ test('load/save round-trip through the injected storage', () => {
81
+ const storage = memoryStorage();
82
+ const store = createWebSessionStore({ storage, events: eventTarget() });
83
+ assert.equal(store.load(), null);
84
+ store.save(STATE);
85
+ assert.deepEqual(store.load(), STATE);
86
+ store.save(null);
87
+ assert.equal(store.load(), null);
88
+ });
89
+ });
@@ -22,6 +22,12 @@
22
22
  * deny-everything projection past a token refresh. Treated as TRANSIENT (throw), so
23
23
  * the engine holds its last-good/empty projection and the NEXT session-established
24
24
  * fetch (post-refresh/re-login) supersedes it, rather than baking in a deny.
25
+ * AuthContext hands this transport the REFRESHING http client (`createRefreshingHttp`,
26
+ * #12), so an expired access token is refreshed and the call retried BEFORE a status
27
+ * reaches here: a 401 seen here means the refresh itself failed (the session is being
28
+ * cleared → login) or the session was replaced, and a refresh outage arrives as a 503.
29
+ * Never build this transport over the raw client: a 401 would then be retried forever
30
+ * as "transient" instead of refreshing.
25
31
  * - a real 403 (or any other non-5xx !ok) → TERMINAL deny → return an EMPTY projection
26
32
  * `{ principalId:'', facts:[], projectedAt: Date.now() }` (deny-all).
27
33
  * - 200 with a body → return `r.data` (the projection) AS-IS.
@@ -13,6 +13,7 @@ import { test, describe } from 'node:test';
13
13
  import assert from 'node:assert/strict';
14
14
  import { createWebShieldTransport } from './shieldTransport.js';
15
15
  import { TransportUnavailableError } from '@astram/sdk';
16
+ import { createRefreshingHttp } from '@astram/frontend-core';
16
17
 
17
18
  /** A mock `http` whose `.get` resolves exactly the given `{ok, status, data}` result —
18
19
  * mirrors `createWebHttpClient`'s contract (never throws). */
@@ -50,7 +51,11 @@ describe('shieldTransport.fetchProjection — HTTP -> outcome classification', (
50
51
 
51
52
  test('a 503 (our own loadCoarseGrants outage mapping) -> throws TransportUnavailableError', async () => {
52
53
  const transport = createWebShieldTransport({
53
- http: mockHttp({ ok: false, status: 503, data: { message: 'permission facts store unavailable' } }),
54
+ http: mockHttp({
55
+ ok: false,
56
+ status: 503,
57
+ data: { message: 'permission facts store unavailable' },
58
+ }),
54
59
  getToken: () => 'tok',
55
60
  });
56
61
  await assert.rejects(
@@ -148,11 +153,10 @@ describe('shieldTransport — deferred capabilities stay fail-closed stubs (slic
148
153
  });
149
154
  assert.deepEqual(result, { challengeId: 'c1', satisfied: false });
150
155
  });
151
-
152
156
  });
153
157
 
154
158
  describe('shieldTransport.currentVersion — ADR-0011 bump-driven refetch probe (Phase E slice 2)', () => {
155
- test('is implemented (opts this transport into the SDK\'s isVersionAware duck-type)', () => {
159
+ test("is implemented (opts this transport into the SDK's isVersionAware duck-type)", () => {
156
160
  const transport = createWebShieldTransport({ http: mockHttp({}), getToken: () => 'tok' });
157
161
  assert.equal(typeof transport.currentVersion, 'function');
158
162
  });
@@ -186,7 +190,11 @@ describe('shieldTransport.currentVersion — ADR-0011 bump-driven refetch probe
186
190
 
187
191
  test('a 503 (permission-version store outage) -> resolves +Infinity, does NOT throw', async () => {
188
192
  const transport = createWebShieldTransport({
189
- http: mockHttp({ ok: false, status: 503, data: { message: 'permission version store unavailable' } }),
193
+ http: mockHttp({
194
+ ok: false,
195
+ status: 503,
196
+ data: { message: 'permission version store unavailable' },
197
+ }),
190
198
  getToken: () => 'tok',
191
199
  });
192
200
  const version = await transport.currentVersion();
@@ -236,10 +244,81 @@ describe('shieldTransport.currentVersion — ADR-0011 bump-driven refetch probe
236
244
  // SDK's client.ts calls currentVersion() completely unguarded (no try/catch at
237
245
  // its call site), so a throw here would escape getAuthFacts uncaught.
238
246
  const transport = createWebShieldTransport({
239
- http: { get: async () => { throw new Error('boom'); } },
247
+ http: {
248
+ get: async () => {
249
+ throw new Error('boom');
250
+ },
251
+ },
240
252
  getToken: () => 'tok',
241
253
  });
242
254
  const version = await transport.currentVersion();
243
255
  assert.equal(version, Number.POSITIVE_INFINITY);
244
256
  });
245
257
  });
258
+
259
+ describe('shieldTransport over the refreshing http client (#12 wiring, as in AuthContext)', () => {
260
+ const fake = (label) => `fake-${label}`;
261
+ const OLD = fake('old-access');
262
+ const NEW = fake('new-access');
263
+
264
+ function setup(refreshResult) {
265
+ let stored = {
266
+ session: { sessionId: 's1' },
267
+ tokens: { accessToken: OLD, refreshToken: fake('rt') },
268
+ };
269
+ const store = { load: () => stored, save: (s) => (stored = s) };
270
+ const raw = {
271
+ get: async (path, opts) =>
272
+ opts?.bearerToken === NEW
273
+ ? { ok: true, status: 200, data: { principalId: 'p1', facts: [], projectedAt: 1 } }
274
+ : { ok: false, status: 401, data: null },
275
+ };
276
+ let expired = 0;
277
+ let current = OLD;
278
+ const http = createRefreshingHttp({
279
+ http: raw,
280
+ store,
281
+ locks: null,
282
+ refresh: async () => refreshResult,
283
+ onRefreshed: (next) => (current = next.tokens.accessToken),
284
+ onSessionExpired: () => (expired += 1),
285
+ });
286
+ const transport = createWebShieldTransport({ http, getToken: () => current });
287
+ return { transport, store: () => stored, expired: () => expired };
288
+ }
289
+
290
+ test('an expired token is refreshed and the projection fetched — never a transient 401', async () => {
291
+ const t = setup({
292
+ ok: true,
293
+ session: { sessionId: 's1' },
294
+ tokens: { accessToken: NEW, refreshToken: fake('rt2') },
295
+ });
296
+ const projection = await t.transport.fetchProjection('p1');
297
+ assert.deepEqual(projection, { principalId: 'p1', facts: [], projectedAt: 1 });
298
+ assert.equal(t.store().tokens.accessToken, NEW);
299
+ });
300
+
301
+ test('a rejected refresh clears the session and surfaces as transient (no cached deny)', async () => {
302
+ const t = setup({ ok: false, status: 401, error: 'Session expired. Please sign in again.' });
303
+ await assert.rejects(
304
+ () => t.transport.fetchProjection('p1'),
305
+ (e) => e instanceof TransportUnavailableError,
306
+ );
307
+ assert.equal(t.store(), null);
308
+ assert.equal(t.expired(), 1);
309
+ });
310
+
311
+ test('a refresh outage (503) keeps the session and surfaces as transient', async () => {
312
+ const t = setup({
313
+ ok: false,
314
+ status: 503,
315
+ error: 'Authentication is temporarily unavailable. Please try again.',
316
+ });
317
+ await assert.rejects(
318
+ () => t.transport.fetchProjection('p1'),
319
+ (e) => e instanceof TransportUnavailableError,
320
+ );
321
+ assert.equal(t.store().tokens.accessToken, OLD);
322
+ assert.equal(t.expired(), 0);
323
+ });
324
+ });
@@ -6,16 +6,7 @@
6
6
  import { useCallback, useEffect, useState } from 'react';
7
7
  import { useLocation, useNavigate } from 'react-router-dom';
8
8
  import { saveReturnPath } from '@/platform/returnPathStore';
9
- import {
10
- Bell,
11
- HelpCircle,
12
- LogOut,
13
- Menu,
14
- PanelLeft,
15
- Search,
16
- Settings,
17
- X,
18
- } from 'lucide-react';
9
+ import { Bell, HelpCircle, LogOut, Menu, PanelLeft, Search, Settings, X } from 'lucide-react';
19
10
  import { useAuth } from '@/auth/AuthContext';
20
11
  import { Button } from '@/components/ui/button';
21
12
  import { Input } from '@/components/ui/input';
@@ -103,7 +94,11 @@ export default function AppShell({ children, title: _title, section: _section })
103
94
  const companyName = profile?.company?.name || '';
104
95
  // Prefer the part before "@" for a cleaner label + initials.
105
96
  const shortName = displayName.includes('@') ? displayName.split('@')[0] : displayName;
106
- const initials = shortName.replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || 'U';
97
+ const initials =
98
+ shortName
99
+ .replace(/[^a-zA-Z0-9]/g, '')
100
+ .slice(0, 2)
101
+ .toUpperCase() || 'U';
107
102
 
108
103
  return (
109
104
  <div className="flex h-screen overflow-hidden bg-background">
@@ -221,7 +216,10 @@ export default function AppShell({ children, title: _title, section: _section })
221
216
  <ThemeToggle />
222
217
  </div>
223
218
  <DropdownMenuSeparator />
224
- <DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
219
+ <DropdownMenuItem
220
+ onClick={onLogout}
221
+ className="text-destructive focus:text-destructive"
222
+ >
225
223
  <LogOut className="mr-2 size-4" />
226
224
  Sign out
227
225
  </DropdownMenuItem>
@@ -21,9 +21,17 @@ import { NAV_SECTIONS } from './nav';
21
21
  function initialsOf(name) {
22
22
  if (!name) return 'U';
23
23
  const clean = name.includes('@') ? name.split('@')[0] : name;
24
- const parts = clean.trim().split(/[\s._-]+/).filter(Boolean);
24
+ const parts = clean
25
+ .trim()
26
+ .split(/[\s._-]+/)
27
+ .filter(Boolean);
25
28
  const letters = parts.length >= 2 ? parts[0][0] + parts[1][0] : clean.slice(0, 2);
26
- return letters.replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || 'U';
29
+ return (
30
+ letters
31
+ .replace(/[^a-zA-Z0-9]/g, '')
32
+ .slice(0, 2)
33
+ .toUpperCase() || 'U'
34
+ );
27
35
  }
28
36
 
29
37
  /**
@@ -114,7 +122,11 @@ function BrandTile({ collapsed }) {
114
122
  // Theme-adaptive logo: the COLOR logo on the light rail, the WHITE variant on
115
123
  // the dark rail (the sidebar surface now follows the theme).
116
124
  <>
117
- <img src={branding.logo.light} alt={branding.appName} className="h-7 w-auto dark:hidden" />
125
+ <img
126
+ src={branding.logo.light}
127
+ alt={branding.appName}
128
+ className="h-7 w-auto dark:hidden"
129
+ />
118
130
  <img
119
131
  src={branding.logo.dark}
120
132
  alt={branding.appName}
@@ -116,8 +116,6 @@ export const NAV_SECTIONS = [
116
116
  label: 'Settings',
117
117
  // `/account` redirects to `/account/profile`; `matchPrefix` keeps the nav item
118
118
  // highlighted across ALL /account/* sub-tabs (profile / password / 2fa).
119
- items: [
120
- { icon: UserCog, label: 'Account', path: '/account', badge: null, matchPrefix: true },
121
- ],
119
+ items: [{ icon: UserCog, label: 'Account', path: '/account', badge: null, matchPrefix: true }],
122
120
  },
123
121
  ];
@@ -3,6 +3,24 @@ import react from '@vitejs/plugin-react';
3
3
  import tailwindcss from '@tailwindcss/vite';
4
4
  import { fileURLToPath, URL } from 'node:url';
5
5
 
6
+ /**
7
+ * Dev-server only: relax the Content-Security-Policy in index.html just enough for Vite.
8
+ * `@vitejs/plugin-react` injects an inline React-refresh preamble script, and HMR talks over
9
+ * a websocket. `vite build` / `vite preview` never run this, so the built app ships the
10
+ * strict policy unchanged.
11
+ */
12
+ function devCsp() {
13
+ return {
14
+ name: 'astram-dev-csp',
15
+ apply: 'serve',
16
+ transformIndexHtml(html) {
17
+ return html
18
+ .replace("script-src 'self'", "script-src 'self' 'unsafe-inline'")
19
+ .replace("connect-src 'self'", "connect-src 'self' ws: wss:");
20
+ },
21
+ };
22
+ }
23
+
6
24
  /**
7
25
  * __APP_NAME__ frontend — Vite config (shadcn/ui + Tailwind v4).
8
26
  *
@@ -11,7 +29,7 @@ import { fileURLToPath, URL } from 'node:url';
11
29
  * `/auth/login`. `@` aliases `src/` (the shadcn import convention).
12
30
  */
13
31
  export default defineConfig({
14
- plugins: [react(), tailwindcss()],
32
+ plugins: [react(), tailwindcss(), devCsp()],
15
33
  resolve: {
16
34
  alias: {
17
35
  '@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -7,7 +7,20 @@
7
7
  # HTTP port the app listens on (default: 3000).
8
8
  PORT=3000
9
9
 
10
- # Runtime environment: development | production | test (default: development).
10
+ # Reverse-proxy hop count in front of this app (read by src/main.ts → Express
11
+ # `trust proxy`). 0 = no proxy (app exposed directly); 1 = behind the Astram Traefik.
12
+ # Must be the REAL count: too high lets clients spoof their IP via X-Forwarded-For.
13
+ # Unset → `trust proxy` stays unset and the framework's per-IP limiters (login,
14
+ # forgot-password, reset-password) are OFF (boot warning); per-account limits still apply.
15
+ # TRUST_PROXY=1
16
+
17
+ # Runtime environment: development | production | test.
18
+ # `npm run dev` sets NODE_ENV=development itself when your shell has not set it.
19
+ # With auth on and no `emailSender`, the framework uses its dev email logger (prints
20
+ # password-reset links to stdout) ONLY when NODE_ENV is exactly development or test;
21
+ # any other value, INCLUDING unset, fails boot until you pass
22
+ # ShieldModule.forRoot({ auth: true, emailSender: <your sender> }) or
23
+ # `emailSender: 'disabled'` (no reset emails; forgot-password still answers 204).
11
24
  NODE_ENV=development
12
25
 
13
26
  # --- ADR-0018 connection-pool budget (all optional; framework defaults apply) ---
@@ -38,7 +51,15 @@ NODE_ENV=development
38
51
  #
39
52
  # Redis + refresh-token secret:
40
53
  # REDIS_URL=redis://127.0.0.1:6379
54
+ # Redis command timeout (ms). A Redis outage then fails requests closed within this bound instead
55
+ # of hanging (default 2000).
56
+ # REDIS_COMMAND_TIMEOUT_MS=2000
41
57
  # REFRESH_HMAC_KEY=change-me-to-a-long-random-secret # REQUIRED when auth is on
58
+ #
59
+ # Per-client-IP login failure limit (default 50 failures per 15 min). Enforced ONLY when
60
+ # TRUST_PROXY (above) is set, so the client IP is real rather than the proxy's.
61
+ # LOGIN_IP_MAX_FAILURES=50
62
+ # LOGIN_IP_WINDOW_MS=900000
42
63
 
43
64
  # --- Migrations (optional; @astram/identity's migrations run automatically) ---
44
65
  # APP_MIGRATION_DIR=migrations # your app's own migrations, run after identity's