create-astram-app 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +211 -38
- package/dist/add-frontend.js.map +1 -1
- package/dist/index.js +14 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/template/README.md +51 -20
- package/template/frontend/README.md +21 -11
- package/template/frontend/index.html +16 -0
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +95 -20
- package/template/frontend/src/auth/AuthContext.jsx +145 -21
- package/template/frontend/src/auth/routeGate.js +31 -0
- package/template/frontend/src/auth/routeGate.test.mjs +59 -0
- package/template/frontend/src/auth/sessionFlow.js +27 -0
- package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
- package/template/frontend/src/components/EntityList.jsx +1 -7
- package/template/frontend/src/components/MultiSelectChecklist.jsx +166 -0
- package/template/frontend/src/components/PageHeader.jsx +5 -4
- package/template/frontend/src/components/ShareDialog.jsx +3 -5
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
- package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
- package/template/frontend/src/components/ui/alert.tsx +17 -23
- package/template/frontend/src/components/ui/avatar.tsx +8 -20
- package/template/frontend/src/components/ui/badge.tsx +17 -26
- package/template/frontend/src/components/ui/button.tsx +22 -24
- package/template/frontend/src/components/ui/card.tsx +19 -36
- package/template/frontend/src/components/ui/checkbox.tsx +7 -10
- package/template/frontend/src/components/ui/date-field.tsx +26 -27
- package/template/frontend/src/components/ui/dialog.tsx +24 -40
- package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
- package/template/frontend/src/components/ui/input-otp.tsx +1 -4
- package/template/frontend/src/components/ui/label.tsx +10 -12
- package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
- package/template/frontend/src/components/ui/select.tsx +31 -43
- package/template/frontend/src/components/ui/separator.tsx +6 -6
- package/template/frontend/src/components/ui/sheet.tsx +29 -38
- package/template/frontend/src/components/ui/skeleton.tsx +3 -3
- package/template/frontend/src/components/ui/sonner.tsx +7 -7
- package/template/frontend/src/components/ui/table.tsx +56 -70
- package/template/frontend/src/components/ui/tooltip.tsx +7 -11
- package/template/frontend/src/config/branding.js +1 -4
- package/template/frontend/src/hooks/useMobile.tsx +4 -6
- package/template/frontend/src/index.css +9 -4
- package/template/frontend/src/pages/Dashboard.jsx +13 -7
- package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
- package/template/frontend/src/pages/Login.jsx +85 -70
- package/template/frontend/src/pages/ResetPassword.jsx +9 -2
- package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
- package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
- package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
- package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
- package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
- package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
- package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
- package/template/frontend/src/pages/service-accounts/KeyRevealDialog.jsx +104 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountAccessDialog.jsx +231 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountActionsMenu.jsx +56 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +368 -0
- package/template/frontend/src/pages/service-accounts/accessModel.js +158 -0
- package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +189 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
- package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
- package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
- package/template/frontend/src/platform/httpClient.js +5 -0
- package/template/frontend/src/platform/sessionStore.js +55 -13
- package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
- package/template/frontend/src/platform/shieldTransport.js +6 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
- package/template/frontend/src/shell/AppShell.jsx +10 -12
- package/template/frontend/src/shell/Sidebar.jsx +15 -3
- package/template/frontend/src/shell/nav.js +13 -3
- package/template/frontend/vite.config.js +19 -1
- package/template/server/_env.example +22 -1
- package/template/server/eslint.config.mjs +194 -0
- package/template/server/package.json +11 -11
- package/template/server/scripts/dev.mjs +17 -6
- package/template/server/scripts/sync-astram-guide.mjs +45 -19
- package/template/server/src/app.module.ts +11 -5
- package/template/server/src/health/demo.controller.ts +16 -12
- package/template/server/src/main.ts +23 -1
- package/template/server/test/enforcement.e2e.spec.ts +73 -6
- package/template/server/eslint.config.js +0 -95
|
@@ -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({
|
|
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(
|
|
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({
|
|
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: {
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* via `GET access/role-grants` + `GET permission-catalog`, BOTH gated
|
|
17
17
|
* `role:read` — "managing/reading grants IS role administration")
|
|
18
18
|
* - Audit → `identity:role:read` (audit.controller.ts `GET audit`)
|
|
19
|
+
* - Service accounts → `identity:service_account:read`
|
|
20
|
+
* (service-account.controller.ts `GET service-accounts`; create/edit are
|
|
21
|
+
* service_account:create|update, keys are service_account:manage_keys)
|
|
19
22
|
* - Cross-tenant → `identity:role:read` (cross-tenant-grant.controller.ts `GET
|
|
20
23
|
* cross-tenant-grants` / `GET .../inbox` / `GET .../offerable-types`,
|
|
21
24
|
* all gated `role:read` — reading/managing cross-tenant grants IS role
|
|
@@ -24,6 +27,7 @@
|
|
|
24
27
|
* caller may reach (no admin-only list route backs them).
|
|
25
28
|
*/
|
|
26
29
|
import {
|
|
30
|
+
Bot,
|
|
27
31
|
Building2,
|
|
28
32
|
Handshake,
|
|
29
33
|
History,
|
|
@@ -82,6 +86,14 @@ export const NAV_SECTIONS = [
|
|
|
82
86
|
matchPrefix: true,
|
|
83
87
|
gate: 'identity:role:read',
|
|
84
88
|
},
|
|
89
|
+
{
|
|
90
|
+
icon: Bot,
|
|
91
|
+
label: 'Service accounts',
|
|
92
|
+
path: '/service-accounts',
|
|
93
|
+
badge: null,
|
|
94
|
+
matchPrefix: true,
|
|
95
|
+
gate: 'identity:service_account:read',
|
|
96
|
+
},
|
|
85
97
|
{
|
|
86
98
|
icon: Handshake,
|
|
87
99
|
label: 'Cross-tenant',
|
|
@@ -104,8 +116,6 @@ export const NAV_SECTIONS = [
|
|
|
104
116
|
label: 'Settings',
|
|
105
117
|
// `/account` redirects to `/account/profile`; `matchPrefix` keeps the nav item
|
|
106
118
|
// highlighted across ALL /account/* sub-tabs (profile / password / 2fa).
|
|
107
|
-
items: [
|
|
108
|
-
{ icon: UserCog, label: 'Account', path: '/account', badge: null, matchPrefix: true },
|
|
109
|
-
],
|
|
119
|
+
items: [{ icon: UserCog, label: 'Account', path: '/account', badge: null, matchPrefix: true }],
|
|
110
120
|
},
|
|
111
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
|
-
#
|
|
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
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// ESLint flat config for an Astram consumer app.
|
|
2
|
+
//
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// THE PHYSICAL WALL — backstop (do not remove).
|
|
5
|
+
//
|
|
6
|
+
// The Astram paved road is the ONE way features touch data: inject the framework's
|
|
7
|
+
// RecordEngine (from @astram/engine) and go through the pipe (permission gate +
|
|
8
|
+
// audit are mandatory, cross-tenant isolation is enforced). A feature must NEVER
|
|
9
|
+
// reach the database directly — a raw mariadb/typeorm pool, a DataSource, a
|
|
10
|
+
// QueryRunner, or a repository `.save()`/`.query()` skips the security layer
|
|
11
|
+
// entirely (this is exactly how astram-org's ORS got bypassed 165 times).
|
|
12
|
+
//
|
|
13
|
+
// The PRIMARY wall is structural: the framework never hands feature scope a pool
|
|
14
|
+
// (EngineModule seals it). THIS lint rule is the BACKSTOP: it fails the build if any
|
|
15
|
+
// app file under `src/**` imports a raw DB symbol or calls a raw DB method, so an
|
|
16
|
+
// off-road attempt is a red build, not a silent success. Only the composition root
|
|
17
|
+
// (`src/app.module.ts`, `src/main.ts`) and tests (`*.spec.ts`) are exempt: they wire the
|
|
18
|
+
// framework (e.g. `createIdentityAuthzPool()` for ShieldModule.forRoot) and never hold
|
|
19
|
+
// feature logic. Keep raw-DB wiring there; a `src/lib/db.ts` helper is linted like a feature.
|
|
20
|
+
// `scripts/` (dev runner, guide sync) is build tooling that never ships in `dist/` or runs
|
|
21
|
+
// in the request path, so it is not walled.
|
|
22
|
+
//
|
|
23
|
+
// Some framework packages also export their internal adapters for composition and tests
|
|
24
|
+
// (`createIdentityAuthzPool`, `MariaDbUnitOfWork`, …). Those are banned by NAME below, so an
|
|
25
|
+
// import from the package root is caught, not only a deep `dist/` path.
|
|
26
|
+
//
|
|
27
|
+
// If you hit this rule because the pipe can't do what you need: STOP. Do NOT work
|
|
28
|
+
// around it. Record the gap in `.astram/guide/gaps.md` and raise it with the
|
|
29
|
+
// framework — the capability must be added to @astram/engine (the gap-flag loop).
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
import js from '@eslint/js';
|
|
33
|
+
import tseslint from 'typescript-eslint';
|
|
34
|
+
|
|
35
|
+
/** Raw DB packages a feature must never import directly. */
|
|
36
|
+
const FORBIDDEN_DB_IMPORTS = [
|
|
37
|
+
{
|
|
38
|
+
name: 'mariadb',
|
|
39
|
+
message: 'Wall: features never open a DB pool — use the RecordEngine (@astram/engine).',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'mysql',
|
|
43
|
+
message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'mysql2',
|
|
47
|
+
message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
|
|
48
|
+
},
|
|
49
|
+
{ name: 'typeorm', message: 'Wall: no ORM in features — use the RecordEngine (@astram/engine).' },
|
|
50
|
+
{
|
|
51
|
+
name: '@nestjs/typeorm',
|
|
52
|
+
message: 'Wall: no ORM in features — use the RecordEngine (@astram/engine).',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'pg',
|
|
56
|
+
message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'knex',
|
|
60
|
+
message: 'Wall: no query builder in features — use the RecordEngine (@astram/engine).',
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** Driver/ORM SUBPATHS (`paths` above matches exact specifiers only). */
|
|
65
|
+
const FORBIDDEN_DB_SUBPATHS = {
|
|
66
|
+
group: [
|
|
67
|
+
'mariadb/*',
|
|
68
|
+
'mysql/*',
|
|
69
|
+
'mysql2/*',
|
|
70
|
+
'typeorm/*',
|
|
71
|
+
'@nestjs/typeorm/*',
|
|
72
|
+
'pg/*',
|
|
73
|
+
'pg-*',
|
|
74
|
+
'knex/*',
|
|
75
|
+
],
|
|
76
|
+
message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Named imports that hand a feature raw DB access even from an ALLOWED package.
|
|
81
|
+
* `SHIELD_DB_POOL` is exported from `@astram/guard` (a package a feature legitimately
|
|
82
|
+
* imports for other reasons), so it must be blocked by NAME on that path — the engine
|
|
83
|
+
* owns the pool; feature scope must never import the pool token. The same goes for the
|
|
84
|
+
* composition-only factories/adapters of `@astram/identity` and `@astram/engine`.
|
|
85
|
+
*/
|
|
86
|
+
const FORBIDDEN_DB_NAMED_FROM = [
|
|
87
|
+
{
|
|
88
|
+
name: '@astram/guard',
|
|
89
|
+
importNames: ['SHIELD_DB_POOL'],
|
|
90
|
+
message:
|
|
91
|
+
'Wall: the DB pool is sealed inside the engine — features use the RecordEngine (@astram/engine).',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: '@astram/identity',
|
|
95
|
+
importNames: ['createIdentityAuthzPool'],
|
|
96
|
+
message:
|
|
97
|
+
'Wall: createIdentityAuthzPool is a raw pool for app.module.ts wiring only — features use the RecordEngine (@astram/engine).',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: '@astram/engine',
|
|
101
|
+
importNames: ['MariaDbUnitOfWork', 'MariaDbAuditSink', 'RecordEngineImpl', 'TableRegistry'],
|
|
102
|
+
message:
|
|
103
|
+
'Wall: engine internals (unit of work, audit sink, impl, registry) bypass the gate and audit — inject RECORD_ENGINE / RecordsService instead.',
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/** Every name above, for the syntax ban that also catches `ns.X`, `require(…).X`, `(await import(…)).X`. */
|
|
108
|
+
const FORBIDDEN_FRAMEWORK_NAMES = FORBIDDEN_DB_NAMED_FROM.flatMap((e) => e.importNames);
|
|
109
|
+
|
|
110
|
+
/** Driver specifiers for dynamic `import()` / `require()` (static imports use the lists above). */
|
|
111
|
+
const DRIVER_SPECIFIER = '/^(mariadb|mysql2?|pg|typeorm|knex|@nestjs.typeorm)(\\W|$)/';
|
|
112
|
+
|
|
113
|
+
export default tseslint.config(
|
|
114
|
+
js.configs.recommended,
|
|
115
|
+
...tseslint.configs.recommended,
|
|
116
|
+
{
|
|
117
|
+
ignores: ['dist/**', 'node_modules/**'],
|
|
118
|
+
},
|
|
119
|
+
// ── The wall: all app code except the composition root and tests ──────────
|
|
120
|
+
{
|
|
121
|
+
files: ['src/**/*.{ts,mts,cts}'],
|
|
122
|
+
ignores: ['src/app.module.ts', 'src/main.ts', '**/*.spec.ts'],
|
|
123
|
+
rules: {
|
|
124
|
+
'no-restricted-imports': [
|
|
125
|
+
'error',
|
|
126
|
+
{
|
|
127
|
+
paths: [...FORBIDDEN_DB_IMPORTS, ...FORBIDDEN_DB_NAMED_FROM],
|
|
128
|
+
patterns: [
|
|
129
|
+
FORBIDDEN_DB_SUBPATHS,
|
|
130
|
+
{
|
|
131
|
+
group: ['**/adapters/mariadb-*', '@astram/*/dist/**', '@astram/*/src/**'],
|
|
132
|
+
message:
|
|
133
|
+
'Wall: framework internals (pool/uow/audit adapters, deep dist/src paths) are off-limits — features use the RecordEngine facade only.',
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
// Block raw query-runner / repository calls even if a pool were obtained.
|
|
139
|
+
'no-restricted-syntax': [
|
|
140
|
+
'error',
|
|
141
|
+
{
|
|
142
|
+
selector: "CallExpression[callee.property.name='createQueryRunner']",
|
|
143
|
+
message: 'Wall: no QueryRunner in features — use the RecordEngine (@astram/engine).',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
selector: "CallExpression[callee.property.name='getRepository']",
|
|
147
|
+
message:
|
|
148
|
+
'Wall: no repository access in features — use the RecordEngine (@astram/engine).',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
selector: "CallExpression[callee.property.name='createPool']",
|
|
152
|
+
message: 'Wall: features never create a DB pool — use the RecordEngine (@astram/engine).',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
selector: "CallExpression[callee.property.name='query']",
|
|
156
|
+
message: 'Wall: no raw .query() in app code — use the RecordEngine (@astram/engine).',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
selector: "CallExpression[callee.property.name='getConnection']",
|
|
160
|
+
message:
|
|
161
|
+
'Wall: no raw .getConnection() in app code — use the RecordEngine (@astram/engine).',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
selector: `ImportExpression[source.value=${DRIVER_SPECIFIER}]`,
|
|
165
|
+
message: 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
selector: `CallExpression[callee.name='require'][arguments.0.value=${DRIVER_SPECIFIER}]`,
|
|
169
|
+
message: 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
|
|
170
|
+
},
|
|
171
|
+
// The banned framework names however they are reached: `ns.SHIELD_DB_POOL`,
|
|
172
|
+
// `require('@astram/guard').SHIELD_DB_POOL`, `(await import('@astram/engine')).TableRegistry`.
|
|
173
|
+
// (A named import is already reported by no-restricted-imports, so it is skipped here.)
|
|
174
|
+
...FORBIDDEN_FRAMEWORK_NAMES.map((name) => ({
|
|
175
|
+
selector: `Identifier[name='${name}']:not(ImportSpecifier > Identifier)`,
|
|
176
|
+
message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
|
|
177
|
+
})),
|
|
178
|
+
// …and reached by STRING: `ns['SHIELD_DB_POOL']`, ns[`TableRegistry`],
|
|
179
|
+
// `const { ['MariaDbUnitOfWork']: x } = ns`. (Import sources are other strings, so the
|
|
180
|
+
// module specifiers themselves never match.)
|
|
181
|
+
...FORBIDDEN_FRAMEWORK_NAMES.flatMap((name) => [
|
|
182
|
+
{
|
|
183
|
+
selector: `Literal[value='${name}']`,
|
|
184
|
+
message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
selector: `TemplateLiteral[expressions.length=0] > TemplateElement[value.cooked='${name}']`,
|
|
188
|
+
message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
|
|
189
|
+
},
|
|
190
|
+
]),
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
);
|
|
@@ -14,27 +14,27 @@
|
|
|
14
14
|
"migration:run": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate",
|
|
15
15
|
"migration:status": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate:status",
|
|
16
16
|
"migration:revert": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate:revert",
|
|
17
|
-
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
17
|
+
"lint": "eslint \"src/**/*.{ts,mts,cts}\" \"test/**/*.ts\"",
|
|
18
18
|
"guide:sync": "node scripts/sync-astram-guide.mjs",
|
|
19
19
|
"postinstall": "node scripts/sync-astram-guide.mjs"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@astram/core": "^1.
|
|
23
|
-
"@astram/engine": "^0.
|
|
24
|
-
"@astram/guard": "^0.
|
|
25
|
-
"@astram/guide": "^0.
|
|
26
|
-
"@astram/identity": "^0.
|
|
27
|
-
"@astram/migrate": "^0.
|
|
22
|
+
"@astram/core": "^1.4.0",
|
|
23
|
+
"@astram/engine": "^0.4.0",
|
|
24
|
+
"@astram/guard": "^0.8.0",
|
|
25
|
+
"@astram/guide": "^0.2.0",
|
|
26
|
+
"@astram/identity": "^0.5.0",
|
|
27
|
+
"@astram/migrate": "^0.3.0",
|
|
28
28
|
"@astram/sdk": "^0.2.0",
|
|
29
|
-
"@nestjs/common": "^11.
|
|
30
|
-
"@nestjs/core": "^11.
|
|
31
|
-
"@nestjs/platform-express": "^11.
|
|
29
|
+
"@nestjs/common": "^11.2.7",
|
|
30
|
+
"@nestjs/core": "^11.2.7",
|
|
31
|
+
"@nestjs/platform-express": "^11.2.7",
|
|
32
32
|
"reflect-metadata": "^0.2.2",
|
|
33
33
|
"rxjs": "^7.8.1"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@eslint/js": "^9.0.0",
|
|
37
|
-
"@nestjs/testing": "^11.
|
|
37
|
+
"@nestjs/testing": "^11.2.7",
|
|
38
38
|
"@types/express": "^5.0.0",
|
|
39
39
|
"@types/node": "^22.0.0",
|
|
40
40
|
"@types/supertest": "^6.0.2",
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* hot-reload AND correct DI. Same compiler as the production build, no extra deps.
|
|
11
11
|
*
|
|
12
12
|
* `.env` is loaded by the node child via --env-file-if-exists.
|
|
13
|
+
*
|
|
14
|
+
* NODE_ENV: the child runs with NODE_ENV=development unless your shell already set it.
|
|
15
|
+
* `npm run dev` is a dev box by definition, and @astram/guard keys dev-only defaults
|
|
16
|
+
* off the RAW NODE_ENV (e.g. with `auth: true` and no `emailSender`, the dev email
|
|
17
|
+
* logger that prints reset links is used ONLY under development/test; an unset
|
|
18
|
+
* NODE_ENV fails boot). `npm start` does NOT set it — production must configure a sender.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import { spawn } from 'node:child_process';
|
|
@@ -20,7 +26,13 @@ mkdirSync('dist', { recursive: true });
|
|
|
20
26
|
// 1) tsc in watch mode → dist/ (emits decorator metadata; the whole point).
|
|
21
27
|
const tsc = spawn(
|
|
22
28
|
process.execPath,
|
|
23
|
-
[
|
|
29
|
+
[
|
|
30
|
+
new URL('../node_modules/typescript/bin/tsc', import.meta.url).pathname,
|
|
31
|
+
'-w',
|
|
32
|
+
'-p',
|
|
33
|
+
'tsconfig.build.json',
|
|
34
|
+
'--preserveWatchOutput',
|
|
35
|
+
],
|
|
24
36
|
{ stdio: 'inherit' },
|
|
25
37
|
);
|
|
26
38
|
|
|
@@ -33,11 +45,10 @@ const startNode = () => {
|
|
|
33
45
|
return;
|
|
34
46
|
}
|
|
35
47
|
started = true;
|
|
36
|
-
const node = spawn(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
);
|
|
48
|
+
const node = spawn(process.execPath, ['--env-file-if-exists=.env', '--watch', 'dist/main.js'], {
|
|
49
|
+
stdio: 'inherit',
|
|
50
|
+
env: { ...process.env, NODE_ENV: process.env.NODE_ENV || 'development' },
|
|
51
|
+
});
|
|
41
52
|
node.on('exit', (code) => {
|
|
42
53
|
tsc.kill();
|
|
43
54
|
process.exit(code ?? 0);
|