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.
- package/README.md +256 -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 +82 -20
- package/template/frontend/src/auth/AuthContext.jsx +141 -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/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 +5 -2
- 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/ServiceAccountsPage.jsx +77 -18
- package/template/frontend/src/pages/service-accounts/accessModel.js +22 -0
- package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +28 -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 +18 -0
- package/template/frontend/src/platform/httpClient.test.mjs +60 -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 +1 -3
- package/template/frontend/vite.config.js +19 -1
- package/template/server/_env.example +22 -1
- package/template/server/eslint.config.mjs +314 -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 +16 -7
- package/template/server/src/health/demo.controller.ts +16 -12
- package/template/server/src/main.ts +23 -1
- package/template/server/eslint.config.js +0 -95
|
@@ -0,0 +1,314 @@
|
|
|
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), and the package entry points (`@astram/engine`,
|
|
15
|
+
// `@astram/identity`, `@astram/guard`) export no raw pool or unit of work. Those live only
|
|
16
|
+
// on `@astram/*/internal` subpaths (`createIdentityAuthzPool`, `MariaDbUnitOfWork`, …),
|
|
17
|
+
// which only the composition root (`src/app.module.ts`, `src/main.ts`) may import.
|
|
18
|
+
//
|
|
19
|
+
// THIS lint config is the BACKSTOP: it fails the build if any app file under `src/**`
|
|
20
|
+
// (`.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`) imports a raw DB symbol or an `/internal`
|
|
21
|
+
// subpath, or calls a raw DB method — also as `x['query']()` or a destructured `{ query }`.
|
|
22
|
+
// The composition root may wire the framework but may not re-export anything (no
|
|
23
|
+
// `export … from`, no `export { X as Y }`, no exported variables), and features may not
|
|
24
|
+
// import it. Tests (`*.spec.*`) are exempt from the DB rules but NOT from the `/internal`
|
|
25
|
+
// ban. Keep raw-DB wiring in the composition root; a `src/lib/db.ts` helper is linted like
|
|
26
|
+
// a feature. `scripts/` (dev runner, guide sync) is build tooling that never ships in
|
|
27
|
+
// `dist/` or runs in the request path, so it is not walled.
|
|
28
|
+
//
|
|
29
|
+
// What lint cannot see: a name built at runtime (`ns['MariaDb' + 'UnitOfWork']`). That is
|
|
30
|
+
// moot because the package entry points no longer carry those symbols (the lookup finds
|
|
31
|
+
// `undefined`). Deliberately reaching into framework internals some other way (a deep
|
|
32
|
+
// `node_modules` path via a computed `require`, a private field) is a code-review matter.
|
|
33
|
+
//
|
|
34
|
+
// The old names (`createIdentityAuthzPool`, `MariaDbUnitOfWork`, `SHIELD_DB_POOL`, …) are
|
|
35
|
+
// still banned by NAME below, so an app pinned to an older framework is caught too.
|
|
36
|
+
//
|
|
37
|
+
// If you hit this rule because the pipe can't do what you need: STOP. Do NOT work
|
|
38
|
+
// around it. Record the gap in `.astram/guide/gaps.md` and raise it with the
|
|
39
|
+
// framework — the capability must be added to @astram/engine (the gap-flag loop).
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
import js from '@eslint/js';
|
|
43
|
+
import tseslint from 'typescript-eslint';
|
|
44
|
+
|
|
45
|
+
/** Raw DB packages a feature must never import directly. */
|
|
46
|
+
const FORBIDDEN_DB_IMPORTS = [
|
|
47
|
+
{
|
|
48
|
+
name: 'mariadb',
|
|
49
|
+
message: 'Wall: features never open a DB pool — use the RecordEngine (@astram/engine).',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'mysql',
|
|
53
|
+
message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'mysql2',
|
|
57
|
+
message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
|
|
58
|
+
},
|
|
59
|
+
{ name: 'typeorm', message: 'Wall: no ORM in features — use the RecordEngine (@astram/engine).' },
|
|
60
|
+
{
|
|
61
|
+
name: '@nestjs/typeorm',
|
|
62
|
+
message: 'Wall: no ORM in features — use the RecordEngine (@astram/engine).',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'pg',
|
|
66
|
+
message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'knex',
|
|
70
|
+
message: 'Wall: no query builder in features — use the RecordEngine (@astram/engine).',
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** Driver/ORM SUBPATHS (`paths` above matches exact specifiers only). */
|
|
75
|
+
const FORBIDDEN_DB_SUBPATHS = {
|
|
76
|
+
group: [
|
|
77
|
+
'mariadb/*',
|
|
78
|
+
'mysql/*',
|
|
79
|
+
'mysql2/*',
|
|
80
|
+
'typeorm/*',
|
|
81
|
+
'@nestjs/typeorm/*',
|
|
82
|
+
'pg/*',
|
|
83
|
+
'pg-*',
|
|
84
|
+
'knex/*',
|
|
85
|
+
],
|
|
86
|
+
message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Named imports that hand a feature raw DB access even from an ALLOWED package.
|
|
91
|
+
* `SHIELD_DB_POOL` is exported from `@astram/guard` (a package a feature legitimately
|
|
92
|
+
* imports for other reasons), so it must be blocked by NAME on that path — the engine
|
|
93
|
+
* owns the pool; feature scope must never import the pool token. The same goes for the
|
|
94
|
+
* composition-only factories/adapters of `@astram/identity` and `@astram/engine`.
|
|
95
|
+
*/
|
|
96
|
+
const FORBIDDEN_DB_NAMED_FROM = [
|
|
97
|
+
{
|
|
98
|
+
name: '@astram/guard',
|
|
99
|
+
importNames: ['SHIELD_DB_POOL', 'PersistenceModule'],
|
|
100
|
+
message:
|
|
101
|
+
'Wall: the DB pool is sealed inside the engine — features use the RecordEngine (@astram/engine).',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: '@astram/identity',
|
|
105
|
+
importNames: ['createIdentityAuthzPool'],
|
|
106
|
+
message:
|
|
107
|
+
'Wall: createIdentityAuthzPool is a raw pool for app.module.ts wiring only — features use the RecordEngine (@astram/engine).',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: '@astram/engine',
|
|
111
|
+
importNames: ['MariaDbUnitOfWork', 'MariaDbAuditSink', 'RecordEngineImpl', 'TableRegistry'],
|
|
112
|
+
message:
|
|
113
|
+
'Wall: engine internals (unit of work, audit sink, impl, registry) bypass the gate and audit — inject RECORD_ENGINE / RecordsService instead.',
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
/** Every name above, for the syntax ban that also catches `ns.X`, `require(…).X`, `(await import(…)).X`. */
|
|
118
|
+
const FORBIDDEN_FRAMEWORK_NAMES = FORBIDDEN_DB_NAMED_FROM.flatMap((e) => e.importNames);
|
|
119
|
+
|
|
120
|
+
/** Driver specifiers for dynamic `import()` / `require()` (static imports use the lists above). */
|
|
121
|
+
const DRIVER_SPECIFIER = '/^(mariadb|mysql2?|pg|typeorm|knex|@nestjs.typeorm)(\\W|$)/';
|
|
122
|
+
|
|
123
|
+
/** The framework's composition-only subpaths (`@astram/engine/internal`, …). */
|
|
124
|
+
const INTERNAL_SUBPATHS = {
|
|
125
|
+
group: ['@astram/*/internal', '@astram/*/internal/**'],
|
|
126
|
+
message:
|
|
127
|
+
'Wall: @astram/*/internal (raw pools, units of work) is for src/app.module.ts and src/main.ts only — features use the RecordEngine (@astram/engine).',
|
|
128
|
+
};
|
|
129
|
+
const INTERNAL_SPECIFIER = '/^@astram\\u002F[^\\u002F]+\\u002Finternal(\\u002F|$)/';
|
|
130
|
+
/** `import('@astram/x/internal')` / `require('@astram/x/internal')` — not seen by no-restricted-imports. */
|
|
131
|
+
const INTERNAL_DYNAMIC = [
|
|
132
|
+
{
|
|
133
|
+
selector: `ImportExpression[source.value=${INTERNAL_SPECIFIER}]`,
|
|
134
|
+
message: INTERNAL_SUBPATHS.message,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
selector: `CallExpression[callee.name='require'][arguments.0.value=${INTERNAL_SPECIFIER}]`,
|
|
138
|
+
message: INTERNAL_SUBPATHS.message,
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
/** The composition root, where the framework (and its pools) are wired. Features never import it. */
|
|
143
|
+
const COMPOSITION_ROOT_IMPORTS = {
|
|
144
|
+
group: ['**/app.module', '**/app.module.*', '**/main', '**/main.*'],
|
|
145
|
+
message:
|
|
146
|
+
'Wall: features never import the composition root (app.module/main) — it wires raw framework pools. Use the RecordEngine (@astram/engine).',
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Raw driver methods: banned as calls, also reached by a computed key or destructuring. */
|
|
150
|
+
const RAW_METHODS = ['query', 'getConnection', 'createPool', 'createQueryRunner', 'getRepository'];
|
|
151
|
+
const RAW_METHOD_RE = `/^(${RAW_METHODS.join('|')})$/`;
|
|
152
|
+
const RAW_METHOD_MESSAGE =
|
|
153
|
+
'Wall: no raw driver method (query/getConnection/createPool/createQueryRunner/getRepository) in app code, however it is reached — use the RecordEngine (@astram/engine).';
|
|
154
|
+
|
|
155
|
+
/** App source files: TypeScript and plain JavaScript in every module flavour. */
|
|
156
|
+
const SRC_FILES = ['src/**/*.{ts,mts,cts,js,mjs,cjs}'];
|
|
157
|
+
const COMPOSITION_ROOT = ['src/app.module.ts', 'src/main.ts'];
|
|
158
|
+
const SPECS = ['**/*.spec.*', '**/*.test.*'];
|
|
159
|
+
|
|
160
|
+
export default tseslint.config(
|
|
161
|
+
js.configs.recommended,
|
|
162
|
+
...tseslint.configs.recommended,
|
|
163
|
+
{
|
|
164
|
+
ignores: ['dist/**', 'node_modules/**'],
|
|
165
|
+
},
|
|
166
|
+
// ── The wall: all app code except the composition root and tests ──────────
|
|
167
|
+
{
|
|
168
|
+
files: SRC_FILES,
|
|
169
|
+
ignores: [...COMPOSITION_ROOT, ...SPECS],
|
|
170
|
+
rules: {
|
|
171
|
+
'no-restricted-imports': [
|
|
172
|
+
'error',
|
|
173
|
+
{
|
|
174
|
+
paths: [...FORBIDDEN_DB_IMPORTS, ...FORBIDDEN_DB_NAMED_FROM],
|
|
175
|
+
patterns: [
|
|
176
|
+
FORBIDDEN_DB_SUBPATHS,
|
|
177
|
+
INTERNAL_SUBPATHS,
|
|
178
|
+
COMPOSITION_ROOT_IMPORTS,
|
|
179
|
+
{
|
|
180
|
+
group: ['**/adapters/mariadb-*', '@astram/*/dist/**', '@astram/*/src/**'],
|
|
181
|
+
message:
|
|
182
|
+
'Wall: framework internals (pool/uow/audit adapters, deep dist/src paths) are off-limits — features use the RecordEngine facade only.',
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
// Block raw query-runner / repository calls even if a pool were obtained.
|
|
188
|
+
'no-restricted-syntax': [
|
|
189
|
+
'error',
|
|
190
|
+
{
|
|
191
|
+
selector: "CallExpression[callee.property.name='createQueryRunner']",
|
|
192
|
+
message: 'Wall: no QueryRunner in features — use the RecordEngine (@astram/engine).',
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
selector: "CallExpression[callee.property.name='getRepository']",
|
|
196
|
+
message:
|
|
197
|
+
'Wall: no repository access in features — use the RecordEngine (@astram/engine).',
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
selector: "CallExpression[callee.property.name='createPool']",
|
|
201
|
+
message: 'Wall: features never create a DB pool — use the RecordEngine (@astram/engine).',
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
selector: "CallExpression[callee.property.name='query']",
|
|
205
|
+
message: 'Wall: no raw .query() in app code — use the RecordEngine (@astram/engine).',
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
selector: "CallExpression[callee.property.name='getConnection']",
|
|
209
|
+
message:
|
|
210
|
+
'Wall: no raw .getConnection() in app code — use the RecordEngine (@astram/engine).',
|
|
211
|
+
},
|
|
212
|
+
// The same methods by COMPUTED key: `pool['query']()`, pool[`getConnection`]().
|
|
213
|
+
{
|
|
214
|
+
selector: `CallExpression > MemberExpression.callee[computed=true][property.value=${RAW_METHOD_RE}]`,
|
|
215
|
+
message: RAW_METHOD_MESSAGE,
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
selector: `CallExpression > MemberExpression.callee[computed=true] > TemplateLiteral.property[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
|
|
219
|
+
message: RAW_METHOD_MESSAGE,
|
|
220
|
+
},
|
|
221
|
+
// …and DESTRUCTURED off anything: `const { query } = pool`, `{ ['query']: q } = pool`.
|
|
222
|
+
{
|
|
223
|
+
selector: `ObjectPattern > Property[computed=false][key.name=${RAW_METHOD_RE}]`,
|
|
224
|
+
message: RAW_METHOD_MESSAGE,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
selector: `ObjectPattern > Property[computed=true][key.value=${RAW_METHOD_RE}]`,
|
|
228
|
+
message: RAW_METHOD_MESSAGE,
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
selector: `ObjectPattern > Property[computed=true] > TemplateLiteral.key[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
|
|
232
|
+
message: RAW_METHOD_MESSAGE,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
selector: `ImportExpression[source.value=${DRIVER_SPECIFIER}]`,
|
|
236
|
+
message: 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
selector: `CallExpression[callee.name='require'][arguments.0.value=${DRIVER_SPECIFIER}]`,
|
|
240
|
+
message: 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
|
|
241
|
+
},
|
|
242
|
+
...INTERNAL_DYNAMIC,
|
|
243
|
+
// The banned framework names however they are reached: `ns.SHIELD_DB_POOL`,
|
|
244
|
+
// `require('@astram/guard').SHIELD_DB_POOL`, `(await import('@astram/engine')).TableRegistry`.
|
|
245
|
+
// (A named import is already reported by no-restricted-imports, so it is skipped here.)
|
|
246
|
+
...FORBIDDEN_FRAMEWORK_NAMES.map((name) => ({
|
|
247
|
+
selector: `Identifier[name='${name}']:not(ImportSpecifier > Identifier)`,
|
|
248
|
+
message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
|
|
249
|
+
})),
|
|
250
|
+
// …and reached by STRING: `ns['SHIELD_DB_POOL']`, ns[`TableRegistry`],
|
|
251
|
+
// `const { ['MariaDbUnitOfWork']: x } = ns`. (Import sources are other strings, so the
|
|
252
|
+
// module specifiers themselves never match.)
|
|
253
|
+
...FORBIDDEN_FRAMEWORK_NAMES.flatMap((name) => [
|
|
254
|
+
{
|
|
255
|
+
selector: `Literal[value='${name}']`,
|
|
256
|
+
message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
selector: `TemplateLiteral[expressions.length=0] > TemplateElement[value.cooked='${name}']`,
|
|
260
|
+
message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
|
|
261
|
+
},
|
|
262
|
+
]),
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
// ── Tests: exempt from the DB wall, but not from the /internal ban ──────────
|
|
267
|
+
// (A spec that re-exported an internal pool factory would launder it to feature code.)
|
|
268
|
+
{
|
|
269
|
+
files: ['src/**/*.{spec,test}.{ts,mts,cts,js,mjs,cjs}', 'test/**/*.{ts,mts,cts,js,mjs,cjs}'],
|
|
270
|
+
rules: {
|
|
271
|
+
'no-restricted-imports': ['error', { patterns: [INTERNAL_SUBPATHS] }],
|
|
272
|
+
'no-restricted-syntax': ['error', ...INTERNAL_DYNAMIC],
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
// ── The composition root: may wire raw pools, may not hand them out ────────
|
|
276
|
+
// No re-exports (`export … from`, `export *`, `export { X as Y }`), no exported variables
|
|
277
|
+
// and no `export default <expression>`: only declared classes/functions/types. Features may
|
|
278
|
+
// not import these files either (see COMPOSITION_ROOT_IMPORTS above).
|
|
279
|
+
{
|
|
280
|
+
files: COMPOSITION_ROOT,
|
|
281
|
+
rules: {
|
|
282
|
+
'no-restricted-syntax': [
|
|
283
|
+
'error',
|
|
284
|
+
{
|
|
285
|
+
selector: 'ExportAllDeclaration',
|
|
286
|
+
message: 'Wall: the composition root re-exports nothing (no `export * from`).',
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
selector: 'ExportNamedDeclaration[source]',
|
|
290
|
+
message: 'Wall: the composition root re-exports nothing (no `export { … } from`).',
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
selector: 'ExportSpecifier',
|
|
294
|
+
message:
|
|
295
|
+
'Wall: the composition root re-exports nothing (no `export { X }` / `export { X as Y }`).',
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
selector: 'ExportNamedDeclaration > VariableDeclaration',
|
|
299
|
+
message:
|
|
300
|
+
'Wall: the composition root exports no variables (a wired pool must stay inside it).',
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
selector:
|
|
304
|
+
'ExportDefaultDeclaration:not([declaration.type=/^(ClassDeclaration|FunctionDeclaration)$/])',
|
|
305
|
+
message: 'Wall: the composition root default-exports only a class or function.',
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
selector: 'TSExportAssignment',
|
|
309
|
+
message: 'Wall: the composition root re-exports nothing (no `export =`).',
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
);
|
|
@@ -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,js,mjs,cjs}\" \"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.1
|
|
26
|
-
"@astram/identity": "^0.
|
|
27
|
-
"@astram/migrate": "^0.
|
|
22
|
+
"@astram/core": "^1.4.0",
|
|
23
|
+
"@astram/engine": "^0.5.0",
|
|
24
|
+
"@astram/guard": "^0.9.0",
|
|
25
|
+
"@astram/guide": "^0.2.1",
|
|
26
|
+
"@astram/identity": "^0.6.0",
|
|
27
|
+
"@astram/migrate": "^0.3.1",
|
|
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);
|
|
@@ -17,9 +17,16 @@
|
|
|
17
17
|
*
|
|
18
18
|
* The app root is the monorepo root (one level up from server/), because CLAUDE.md +
|
|
19
19
|
* .claude/ live at the app root and Claude walks UP the tree from wherever you work.
|
|
20
|
+
*
|
|
21
|
+
* SAFETY (it runs on every install, including in CI and Docker builds):
|
|
22
|
+
* - It only writes when that root is recognisably an Astram app (has `CLAUDE.md` or
|
|
23
|
+
* `.astram/`). A `server/` copied alone (e.g. `COPY server/ /app` in a Dockerfile, or
|
|
24
|
+
* a standalone clone) is skipped, so it never writes to `/` or to `~/.claude/rules/`.
|
|
25
|
+
* - `ASTRAM_GUIDE_SYNC=0` skips it (CI, production images).
|
|
26
|
+
* - It never fails the install: any error is reported and the exit code stays 0.
|
|
20
27
|
*/
|
|
21
28
|
|
|
22
|
-
import { cpSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
23
30
|
import { dirname, join, resolve } from 'node:path';
|
|
24
31
|
import { fileURLToPath } from 'node:url';
|
|
25
32
|
import { createRequire } from 'node:module';
|
|
@@ -29,7 +36,20 @@ const here = dirname(fileURLToPath(import.meta.url)); // server/scripts
|
|
|
29
36
|
const appRoot = resolve(here, '..', '..'); // monorepo root
|
|
30
37
|
const rulesDir = join(appRoot, '.claude', 'rules');
|
|
31
38
|
|
|
39
|
+
function log(msg) {
|
|
40
|
+
process.stdout.write(`[sync-astram-guide] ${msg}\n`);
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
function main() {
|
|
44
|
+
if (process.env.ASTRAM_GUIDE_SYNC === '0') {
|
|
45
|
+
log('ASTRAM_GUIDE_SYNC=0 — skipped.');
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
if (!existsSync(join(appRoot, 'CLAUDE.md')) && !existsSync(join(appRoot, '.astram'))) {
|
|
49
|
+
log(`${appRoot} has no CLAUDE.md or .astram/ — not an Astram app root, skipped.`);
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
let guide;
|
|
34
54
|
try {
|
|
35
55
|
guide = require('@astram/guide');
|
|
@@ -40,24 +60,30 @@ function main() {
|
|
|
40
60
|
return 0;
|
|
41
61
|
}
|
|
42
62
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
join(rulesDir, '.astram-guide-version'),
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
65
|
+
// Copy the framework docs into .claude/rules/ (overwrite — they are the framework's
|
|
66
|
+
// to own; on upgrade the newer versions replace the older ones cleanly).
|
|
67
|
+
cpSync(guide.FRAMEWORK_RULES_PATH, join(rulesDir, 'astram-framework-rules.md'));
|
|
68
|
+
cpSync(guide.CAPABILITIES_PATH, join(rulesDir, 'astram-framework-capabilities.md'));
|
|
69
|
+
|
|
70
|
+
// Stamp the synced version so a human/AI can see which framework version's rules
|
|
71
|
+
// are present (and notice when an upgrade changed them).
|
|
72
|
+
writeFileSync(join(rulesDir, '.astram-guide-version'), `${guide.version}\n`, 'utf8');
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Best-effort: a read-only or odd filesystem must never fail `npm install`.
|
|
75
|
+
const reason = err && typeof err === 'object' && 'code' in err ? err.code : 'error';
|
|
76
|
+
log(`could not sync into ${rulesDir} (${reason}) — skipped. Run \`npm run guide:sync\` later.`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
log(`framework rules synced (v${guide.version}) → .claude/rules/`);
|
|
60
81
|
return 0;
|
|
61
82
|
}
|
|
62
83
|
|
|
63
|
-
|
|
84
|
+
try {
|
|
85
|
+
process.exitCode = main();
|
|
86
|
+
} catch {
|
|
87
|
+
log('unexpected error — skipped.');
|
|
88
|
+
process.exitCode = 0;
|
|
89
|
+
}
|
|
@@ -17,12 +17,16 @@ import { DemoController } from './health/demo.controller';
|
|
|
17
17
|
* 2. Import that module here (add it to `imports`) or list its controller in
|
|
18
18
|
* `controllers`.
|
|
19
19
|
* 3. Classify every route: `@Public()` for open routes, `@Classified(...)` for
|
|
20
|
-
* guarded ones. Anything left unclassified is DENIED
|
|
20
|
+
* guarded ones. Anything left unclassified is DENIED by design: the global guard
|
|
21
|
+
* answers 403, and once `IdentityFeatureModule` is wired the app refuses to BOOT
|
|
22
|
+
* (`verifyGatable` aborts on any unclassified route).
|
|
21
23
|
*
|
|
22
|
-
* `HealthController` + `DemoController` below are starter routes
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* `HealthController` + `DemoController` below are starter routes, all `@Public()`
|
|
25
|
+
* (`/health`, `/demo/public` → 200). The scaffold ships NO unclassified route (the
|
|
26
|
+
* demo one is commented out, so `/demo/unclassified` is a 404); `npm test`
|
|
27
|
+
* (`test/enforcement.e2e.spec.ts`) proves deny-by-default with a test-only
|
|
28
|
+
* controller. Keep the health probes; the demo controller is illustrative — delete
|
|
29
|
+
* it once you've seen enforcement work.
|
|
26
30
|
*
|
|
27
31
|
* ── OPTIONAL: real authentication (login/MFA/refresh/logout) ──────────────────
|
|
28
32
|
* The framework ships a complete Auth surface you can turn on WITHOUT writing any
|
|
@@ -32,6 +36,8 @@ import { DemoController } from './health/demo.controller';
|
|
|
32
36
|
* 2. Provide the DB/Redis/HMAC config in `.env` (see the AUTH block in
|
|
33
37
|
* `.env.example`) — auth needs a real MariaDB + Redis and a refresh HMAC key.
|
|
34
38
|
* 3. Create a user in the `shield_identity.identity_credential` table.
|
|
39
|
+
* 4. Outside development/test (`NODE_ENV`), `auth: true` also requires an
|
|
40
|
+
* `emailSender` (or `emailSender: 'disabled'`) — otherwise boot fails closed.
|
|
35
41
|
* You then get framework-owned `POST /auth/{login,mfa,refresh,logout}` for free —
|
|
36
42
|
* no controllers to write. (Leaving `auth` off keeps the zero-config enforcement
|
|
37
43
|
* boot; turning it on requires the config above, by design — fail-closed.)
|
|
@@ -46,8 +52,11 @@ import { DemoController } from './health/demo.controller';
|
|
|
46
52
|
* nav lights up), AND pass `recordAuthorization` (so the decision guard actually
|
|
47
53
|
* GRANTS — without it, decide() denies every classified route):
|
|
48
54
|
* import { identityAuthActionKeys, identityControlPlaneKeys, IdentityFeatureModule,
|
|
49
|
-
*
|
|
50
|
-
*
|
|
55
|
+
* buildIdentityRecordAuthorization } from '@astram/identity';
|
|
56
|
+
* import { createIdentityAuthzPool } from '@astram/identity/internal';
|
|
57
|
+
* const authzPool = createIdentityAuthzPool(); // keep it in this file: never export it
|
|
58
|
+
* (`@astram/identity/internal` holds the raw-pool factory; the lint wall lets only this file
|
|
59
|
+
* and `main.ts` import it, and forbids re-exporting anything from them.)
|
|
51
60
|
* imports: [
|
|
52
61
|
* ShieldModule.forRoot({ auth: true,
|
|
53
62
|
* identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys,
|
|
@@ -8,14 +8,18 @@ import { Public } from '@astram/guard';
|
|
|
8
8
|
* deny-by-default global guard.
|
|
9
9
|
*
|
|
10
10
|
* Deny-by-default has TWO layers, and they differ in WHEN they bite:
|
|
11
|
-
* 1. **
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* 1. **Request-time (always on):** the global guard wired by `ShieldModule.forRoot()`
|
|
12
|
+
* denies any route that is neither `@Public()` nor `@Classified(...)` with 403,
|
|
13
|
+
* before its handler runs.
|
|
14
|
+
* 2. **Boot-time (once `IdentityFeatureModule` is wired):** the identity boot check
|
|
15
|
+
* (`verifyGatable`) scans every route and ABORTS BOOT if any non-`@Public` route
|
|
16
|
+
* lacks `@Classified`. A forgotten declaration then means the app won't start.
|
|
17
|
+
*
|
|
18
|
+
* The illustrative "unclassified route" below is a COMMENT, so this scaffold ships no
|
|
19
|
+
* unclassified route and `GET /demo/unclassified` is a 404. Uncomment it to see the
|
|
20
|
+
* denial: 403 with the default `ShieldModule.forRoot()`, a boot abort once
|
|
21
|
+
* `IdentityFeatureModule` is imported. Then re-comment it (or classify it).
|
|
22
|
+
* `test/enforcement.e2e.spec.ts` proves both layers with a test-only controller.
|
|
19
23
|
*
|
|
20
24
|
* This controller is illustrative — delete it once you've seen enforcement work; a real
|
|
21
25
|
* feature classifies every route with `@Public()` or `@Classified(...)`.
|
|
@@ -28,10 +32,10 @@ export class DemoController {
|
|
|
28
32
|
return { message: 'public route reachable — explicit @Public() opt-out' };
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
// Intentionally NO @Public() / @Classified(). Kept as a COMMENT
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
+
// Intentionally NO @Public() / @Classified(). Kept as a COMMENT so the scaffold ships no
|
|
36
|
+
// unclassified route: live, it is denied 403 by the guard, and with IdentityFeatureModule
|
|
37
|
+
// wired `verifyGatable` aborts boot. Uncomment to witness the denial, then re-comment or
|
|
38
|
+
// classify it.
|
|
35
39
|
// @Get('unclassified')
|
|
36
40
|
// unclassifiedRoute(): { message: string } {
|
|
37
41
|
// return { message: 'this body must never be reached — the guard denies first' };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import { NestFactory } from '@nestjs/core';
|
|
3
|
+
import type { NestExpressApplication } from '@nestjs/platform-express';
|
|
3
4
|
import { loadAppConfig } from '@astram/guard';
|
|
4
5
|
import { AppModule } from './app.module';
|
|
5
6
|
|
|
@@ -14,11 +15,32 @@ import { AppModule } from './app.module';
|
|
|
14
15
|
*/
|
|
15
16
|
export async function bootstrap(): Promise<void> {
|
|
16
17
|
const config = loadAppConfig();
|
|
17
|
-
const app = await NestFactory.create(AppModule, { bufferLogs: false });
|
|
18
|
+
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bufferLogs: false });
|
|
19
|
+
// TRUST_PROXY = the number of reverse-proxy hops in front of this app: 0 = no proxy
|
|
20
|
+
// (exposed directly), 1 = behind the Astram Traefik. It must be your REAL hop count:
|
|
21
|
+
// too high and a client can spoof its IP via X-Forwarded-For; too low and every client
|
|
22
|
+
// shares the proxy's IP. Unset → `trust proxy` stays unset and @astram/guard turns its
|
|
23
|
+
// per-IP limiters (login, forgot-password, reset-password) OFF, with a boot warning;
|
|
24
|
+
// the per-account limits stay on.
|
|
25
|
+
const trustProxy = parseTrustProxy(process.env.TRUST_PROXY);
|
|
26
|
+
if (trustProxy !== undefined) app.set('trust proxy', trustProxy);
|
|
18
27
|
await app.listen(config.port);
|
|
19
28
|
console.log(`[__APP_NAME__] listening on :${config.port} (${config.environment})`);
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Parse TRUST_PROXY: unset/blank → `undefined` (leave Express's default); a non-negative
|
|
33
|
+
* integer → that hop count; anything else fails boot (a guessed value could let clients
|
|
34
|
+
* spoof their IP).
|
|
35
|
+
*/
|
|
36
|
+
export function parseTrustProxy(raw: string | undefined): number | undefined {
|
|
37
|
+
if (raw === undefined || raw.trim() === '') return undefined;
|
|
38
|
+
if (!/^\d+$/.test(raw.trim())) {
|
|
39
|
+
throw new Error(`TRUST_PROXY must be a non-negative integer hop count (got "${raw}")`);
|
|
40
|
+
}
|
|
41
|
+
return Number(raw.trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
22
44
|
// Only auto-start when run directly (not when imported by tests).
|
|
23
45
|
if (process.env.NODE_ENV !== 'test') {
|
|
24
46
|
void bootstrap();
|