create-astram-app 0.7.0 → 0.9.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 +155 -6
- package/package.json +1 -1
- package/template/CLAUDE.md +2 -2
- package/template/README.md +4 -3
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +7 -1
- package/template/frontend/src/auth/confirmEmailLink.js +51 -0
- package/template/frontend/src/auth/confirmEmailLink.test.mjs +108 -0
- package/template/frontend/src/auth/deviceEnrollment.js +33 -0
- package/template/frontend/src/auth/deviceEnrollment.test.mjs +56 -0
- package/template/frontend/src/auth/profileEdit.js +58 -0
- package/template/frontend/src/auth/profileEdit.test.mjs +135 -0
- package/template/frontend/src/pages/ConfirmEmail.jsx +123 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +102 -20
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +39 -4
- package/template/frontend/vite.config.js +21 -1
- package/template/server/eslint.config.mjs +150 -36
- package/template/server/package.json +7 -6
- package/template/server/scripts/check-wall.mjs +66 -0
- package/template/server/src/app.module.ts +17 -23
|
@@ -17,19 +17,26 @@
|
|
|
17
17
|
// which only the composition root (`src/app.module.ts`, `src/main.ts`) may import.
|
|
18
18
|
//
|
|
19
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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
20
|
+
// (`.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`) imports a raw DB symbol (also under an
|
|
21
|
+
// alias), an `/internal` subpath or a deep path (`node_modules/…`, `@astram/*/dist/…`), or
|
|
22
|
+
// calls a raw DB method (query, execute, batch, queryStream, beginTransaction, …) — also as
|
|
23
|
+
// `x['query']()` or a destructured `{ query }`. `import()`/`require()` must take a static
|
|
24
|
+
// specifier. The composition root may wire the framework but may not re-export anything (no
|
|
23
25
|
// `export … from`, no `export { X as Y }`, no exported variables), and features may not
|
|
24
26
|
// import it. Tests (`*.spec.*`) are exempt from the DB rules but NOT from the `/internal`
|
|
25
|
-
//
|
|
26
|
-
// a feature. `scripts/` (dev runner, guide sync) is build tooling that never
|
|
27
|
-
// `dist/` or runs in the request path, so it is not walled.
|
|
27
|
+
// and deep-path bans. Keep raw-DB wiring in the composition root; a `src/lib/db.ts` helper
|
|
28
|
+
// is linted like a feature. `scripts/` (dev runner, guide sync) is build tooling that never
|
|
29
|
+
// ships in `dist/` or runs in the request path, so it is not walled. `npm run lint:wall`
|
|
30
|
+
// (`scripts/check-wall.mjs`, part of `npm run lint`) also greps `src/features/**` for any
|
|
31
|
+
// `/internal` or `node_modules/@astram` specifier, independent of this config.
|
|
28
32
|
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
+
// SCOPE: the wall catches ACCIDENTAL use. Deliberate bypass is a CODE-REVIEW matter, not a
|
|
34
|
+
// lint matter — no static rule can stop code that sets out to evade it. Known, by design
|
|
35
|
+
// not linted: indirect calls (`fn.call(pool, …)`, `.apply`, `.bind`, `Reflect.apply`),
|
|
36
|
+
// method names computed at runtime (`pool[m]()`, `ns['MariaDb' + 'UnitOfWork']` — the
|
|
37
|
+
// latter is moot, the package entry points no longer carry those symbols), an aliased
|
|
38
|
+
// `require` (`const r = require; r(…)`), `module.require`, `createRequire`, private fields.
|
|
39
|
+
// Reviewers: treat any of these in feature code as a wall violation.
|
|
33
40
|
//
|
|
34
41
|
// The old names (`createIdentityAuthzPool`, `MariaDbUnitOfWork`, `SHIELD_DB_POOL`, …) are
|
|
35
42
|
// still banned by NAME below, so an app pinned to an older framework is caught too.
|
|
@@ -127,15 +134,69 @@ const INTERNAL_SUBPATHS = {
|
|
|
127
134
|
'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
135
|
};
|
|
129
136
|
const INTERNAL_SPECIFIER = '/^@astram\\u002F[^\\u002F]+\\u002Finternal(\\u002F|$)/';
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Selectors for a module specifier matching `re` in the forms no-restricted-imports does not
|
|
140
|
+
* see: `import(…)` and `require(…)`, each as a string or a template literal with no
|
|
141
|
+
* `${…}` (which is just a string). With `statics`, also `import`/`export … from` and TS
|
|
142
|
+
* `import x = require(…)` (for specifiers checked here instead of by no-restricted-imports).
|
|
143
|
+
*/
|
|
144
|
+
function specifierBan(re, message, { statics = false } = {}) {
|
|
145
|
+
const sources = statics
|
|
146
|
+
? 'ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration, ImportExpression'
|
|
147
|
+
: 'ImportExpression';
|
|
148
|
+
return [
|
|
149
|
+
{ selector: `:matches(${sources}) > Literal.source[value=${re}]`, message },
|
|
150
|
+
{
|
|
151
|
+
selector: `ImportExpression > TemplateLiteral.source[expressions.length=0] > TemplateElement[value.cooked=${re}]`,
|
|
152
|
+
message,
|
|
153
|
+
},
|
|
154
|
+
{ selector: `CallExpression[callee.name='require'] > Literal.arguments[value=${re}]`, message },
|
|
155
|
+
{
|
|
156
|
+
selector: `CallExpression[callee.name='require'] > TemplateLiteral.arguments[expressions.length=0] > TemplateElement[value.cooked=${re}]`,
|
|
157
|
+
message,
|
|
158
|
+
},
|
|
159
|
+
...(statics
|
|
160
|
+
? [{ selector: `TSExternalModuleReference > Literal.expression[value=${re}]`, message }]
|
|
161
|
+
: []),
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `import('@astram/x/internal')` / `require(\`@astram/x/internal\`)` — not seen by no-restricted-imports. */
|
|
166
|
+
const INTERNAL_DYNAMIC = specifierBan(INTERNAL_SPECIFIER, INTERNAL_SUBPATHS.message);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Deep paths into installed packages, in every import form: any `node_modules/` segment
|
|
170
|
+
* (`../../../node_modules/@astram/identity/dist/internal.js`, `node_modules/mariadb/…`) and
|
|
171
|
+
* `@astram/<pkg>/dist|src/…`. They skip the package `exports` map, so the `/internal` and
|
|
172
|
+
* driver bans above would never see them.
|
|
173
|
+
*/
|
|
174
|
+
const DEEP_SPECIFIER =
|
|
175
|
+
'/(^|\\u002F)node_modules(\\u002F|$)|^@astram\\u002F[^\\u002F]+\\u002F(dist|src)(\\u002F|$)/';
|
|
176
|
+
const DEEP_PATHS = specifierBan(
|
|
177
|
+
DEEP_SPECIFIER,
|
|
178
|
+
'Wall: no deep paths into installed packages (node_modules/…, @astram/*/dist|src/…) — import the package entry point; features use the RecordEngine (@astram/engine).',
|
|
179
|
+
{ statics: true },
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `import()` / `require()` in walled code take a STATIC specifier (a string, or a template
|
|
184
|
+
* literal without `${…}`): a specifier computed at runtime (`import(\`@astram/${p}/internal\`)`,
|
|
185
|
+
* `require(spec)`) cannot be checked by any rule above, so it is rejected outright. App code
|
|
186
|
+
* has no need for computed module names; a plugin loader belongs in the composition root.
|
|
187
|
+
*/
|
|
188
|
+
const NON_STATIC_SPECIFIER_MESSAGE =
|
|
189
|
+
'Wall: import()/require() in app code take a static specifier (a string literal) so the wall can check it — load computed modules from the composition root.';
|
|
190
|
+
const NON_STATIC_SPECIFIERS = [
|
|
132
191
|
{
|
|
133
|
-
selector:
|
|
134
|
-
|
|
192
|
+
selector:
|
|
193
|
+
"ImportExpression:not([source.type='Literal']):not([source.type='TemplateLiteral'][source.expressions.length=0])",
|
|
194
|
+
message: NON_STATIC_SPECIFIER_MESSAGE,
|
|
135
195
|
},
|
|
136
196
|
{
|
|
137
|
-
selector:
|
|
138
|
-
|
|
197
|
+
selector:
|
|
198
|
+
"CallExpression[callee.name='require']:not([arguments.0.type='Literal']):not([arguments.0.type='TemplateLiteral'][arguments.0.expressions.length=0])",
|
|
199
|
+
message: NON_STATIC_SPECIFIER_MESSAGE,
|
|
139
200
|
},
|
|
140
201
|
];
|
|
141
202
|
|
|
@@ -147,10 +208,51 @@ const COMPOSITION_ROOT_IMPORTS = {
|
|
|
147
208
|
};
|
|
148
209
|
|
|
149
210
|
/** Raw driver methods: banned as calls, also reached by a computed key or destructuring. */
|
|
150
|
-
|
|
211
|
+
/** (Calls to the first five also have their own messages below; the rest use RAW_METHOD_MESSAGE.) */
|
|
212
|
+
const RAW_METHODS = [
|
|
213
|
+
'query',
|
|
214
|
+
'getConnection',
|
|
215
|
+
'createPool',
|
|
216
|
+
'createQueryRunner',
|
|
217
|
+
'getRepository',
|
|
218
|
+
'execute',
|
|
219
|
+
'batch',
|
|
220
|
+
'queryStream',
|
|
221
|
+
'beginTransaction',
|
|
222
|
+
];
|
|
151
223
|
const RAW_METHOD_RE = `/^(${RAW_METHODS.join('|')})$/`;
|
|
152
|
-
const
|
|
153
|
-
|
|
224
|
+
const MORE_RAW_METHOD_RE = `/^(${RAW_METHODS.slice(5).join('|')})$/`;
|
|
225
|
+
const RAW_METHOD_MESSAGE = `Wall: no raw driver method (${RAW_METHODS.join('/')}) in app code, however it is reached — use the RecordEngine (@astram/engine).`;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* KI #82: uuid route/body inputs go through `UuidPipe` / `uuidField` / `optionalUuidField` /
|
|
229
|
+
* `uuidArrayField` (@astram/guard). MariaDB's utf8mb4_unicode_ci matches a uuid ignoring case,
|
|
230
|
+
* trailing spaces and character width, so a raw `"<id> "` or fullwidth uuid reaches the row while
|
|
231
|
+
* every exact check in JS (own root, a Set of allowed ids, a Redis key) misses it. A uuid-NAMED
|
|
232
|
+
* input (`id`, `uuid`, `…Id(s)`, `…Uuid(s)`) is flagged when it is read without the canonicaliser.
|
|
233
|
+
*/
|
|
234
|
+
const UUID_NAME_RE = '/^(id|ids|uuid|uuids|[a-z][A-Za-z0-9]*(Id|Ids|Uuid|Uuids))$/';
|
|
235
|
+
const UUID_HELPERS_RE = '/^(uuidField|optionalUuidField|uuidArrayField)$/';
|
|
236
|
+
const UUID_BODY_OBJECTS_RE = '/^(body|dto|b|payload|input)$/';
|
|
237
|
+
const UUID_MESSAGE =
|
|
238
|
+
"Wall: a uuid route/body input goes through UuidPipe (`@Param('uuid', UuidPipe)`) or uuidField / optionalUuidField / uuidArrayField from @astram/guard — the database matches uuids ignoring case, spaces and width, so a raw one slips past exact checks.";
|
|
239
|
+
const UUID_INPUTS = [
|
|
240
|
+
{
|
|
241
|
+
// @Param('uuid') / @Query('recordId') / @Body('roleUuid') with no pipe argument.
|
|
242
|
+
selector: `Decorator > CallExpression[callee.name=/^(Param|Query|Body)$/][arguments.length=1] > Literal.arguments[value=${UUID_NAME_RE}]`,
|
|
243
|
+
message: UUID_MESSAGE,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
// @Param() — the whole params object reaches the handler unvalidated.
|
|
247
|
+
selector: "Decorator > CallExpression[callee.name='Param'][arguments.length=0]",
|
|
248
|
+
message: UUID_MESSAGE,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
// body.companyUuid / body?.roleIds read raw (not as the first argument of a uuid helper).
|
|
252
|
+
selector: `MemberExpression[computed=false][object.name=${UUID_BODY_OBJECTS_RE}][property.name=${UUID_NAME_RE}]:not(CallExpression[callee.name=${UUID_HELPERS_RE}] > MemberExpression):not(CallExpression[callee.name=${UUID_HELPERS_RE}] > ChainExpression > MemberExpression)`,
|
|
253
|
+
message: UUID_MESSAGE,
|
|
254
|
+
},
|
|
255
|
+
];
|
|
154
256
|
|
|
155
257
|
/** App source files: TypeScript and plain JavaScript in every module flavour. */
|
|
156
258
|
const SRC_FILES = ['src/**/*.{ts,mts,cts,js,mjs,cjs}'];
|
|
@@ -177,9 +279,10 @@ export default tseslint.config(
|
|
|
177
279
|
INTERNAL_SUBPATHS,
|
|
178
280
|
COMPOSITION_ROOT_IMPORTS,
|
|
179
281
|
{
|
|
180
|
-
|
|
282
|
+
// (Deep `@astram/*/dist|src` and `node_modules/` paths: DEEP_PATHS, every import form.)
|
|
283
|
+
group: ['**/adapters/mariadb-*'],
|
|
181
284
|
message:
|
|
182
|
-
'Wall: framework internals (pool/uow/audit adapters
|
|
285
|
+
'Wall: framework internals (pool/uow/audit adapters) are off-limits — features use the RecordEngine facade only.',
|
|
183
286
|
},
|
|
184
287
|
],
|
|
185
288
|
},
|
|
@@ -209,6 +312,10 @@ export default tseslint.config(
|
|
|
209
312
|
message:
|
|
210
313
|
'Wall: no raw .getConnection() in app code — use the RecordEngine (@astram/engine).',
|
|
211
314
|
},
|
|
315
|
+
{
|
|
316
|
+
selector: `CallExpression > MemberExpression.callee[computed=false][property.name=${MORE_RAW_METHOD_RE}]`,
|
|
317
|
+
message: RAW_METHOD_MESSAGE,
|
|
318
|
+
},
|
|
212
319
|
// The same methods by COMPUTED key: `pool['query']()`, pool[`getConnection`]().
|
|
213
320
|
{
|
|
214
321
|
selector: `CallExpression > MemberExpression.callee[computed=true][property.value=${RAW_METHOD_RE}]`,
|
|
@@ -231,22 +338,29 @@ export default tseslint.config(
|
|
|
231
338
|
selector: `ObjectPattern > Property[computed=true] > TemplateLiteral.key[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
|
|
232
339
|
message: RAW_METHOD_MESSAGE,
|
|
233
340
|
},
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
},
|
|
341
|
+
...specifierBan(
|
|
342
|
+
DRIVER_SPECIFIER,
|
|
343
|
+
'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
|
|
344
|
+
),
|
|
242
345
|
...INTERNAL_DYNAMIC,
|
|
346
|
+
...DEEP_PATHS,
|
|
347
|
+
...NON_STATIC_SPECIFIERS,
|
|
348
|
+
...UUID_INPUTS,
|
|
243
349
|
// The banned framework names however they are reached: `ns.SHIELD_DB_POOL`,
|
|
244
350
|
// `require('@astram/guard').SHIELD_DB_POOL`, `(await import('@astram/engine')).TableRegistry`.
|
|
245
|
-
// (A named import is already reported by no-restricted-imports,
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
351
|
+
// (A named import from the listed package is already reported by no-restricted-imports,
|
|
352
|
+
// so plain import specifiers are skipped here; an ALIASED one is reported below, from
|
|
353
|
+
// any module, because the alias hides the name from every later use.)
|
|
354
|
+
...FORBIDDEN_FRAMEWORK_NAMES.flatMap((name) => [
|
|
355
|
+
{
|
|
356
|
+
selector: `Identifier[name='${name}']:not(ImportSpecifier > Identifier)`,
|
|
357
|
+
message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
selector: `ImportSpecifier[imported.name='${name}'][local.name!='${name}']`,
|
|
361
|
+
message: `Wall: ${name} is framework-internal DB access (imported under an alias) — use the RecordEngine (@astram/engine).`,
|
|
362
|
+
},
|
|
363
|
+
]),
|
|
250
364
|
// …and reached by STRING: `ns['SHIELD_DB_POOL']`, ns[`TableRegistry`],
|
|
251
365
|
// `const { ['MariaDbUnitOfWork']: x } = ns`. (Import sources are other strings, so the
|
|
252
366
|
// module specifiers themselves never match.)
|
|
@@ -263,13 +377,13 @@ export default tseslint.config(
|
|
|
263
377
|
],
|
|
264
378
|
},
|
|
265
379
|
},
|
|
266
|
-
// ── Tests: exempt from the DB wall, but not from the /internal
|
|
380
|
+
// ── Tests: exempt from the DB wall, but not from the /internal and deep-path bans ──
|
|
267
381
|
// (A spec that re-exported an internal pool factory would launder it to feature code.)
|
|
268
382
|
{
|
|
269
383
|
files: ['src/**/*.{spec,test}.{ts,mts,cts,js,mjs,cjs}', 'test/**/*.{ts,mts,cts,js,mjs,cjs}'],
|
|
270
384
|
rules: {
|
|
271
385
|
'no-restricted-imports': ['error', { patterns: [INTERNAL_SUBPATHS] }],
|
|
272
|
-
'no-restricted-syntax': ['error', ...INTERNAL_DYNAMIC],
|
|
386
|
+
'no-restricted-syntax': ['error', ...INTERNAL_DYNAMIC, ...DEEP_PATHS],
|
|
273
387
|
},
|
|
274
388
|
},
|
|
275
389
|
// ── The composition root: may wire raw pools, may not hand them out ────────
|
|
@@ -14,17 +14,18 @@
|
|
|
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,mts,cts,js,mjs,cjs}\" \"test/**/*.ts\"",
|
|
17
|
+
"lint": "node scripts/check-wall.mjs && eslint \"src/**/*.{ts,mts,cts,js,mjs,cjs}\" \"test/**/*.ts\"",
|
|
18
|
+
"lint:wall": "node scripts/check-wall.mjs",
|
|
18
19
|
"guide:sync": "node scripts/sync-astram-guide.mjs",
|
|
19
20
|
"postinstall": "node scripts/sync-astram-guide.mjs"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
23
|
"@astram/core": "^1.4.0",
|
|
23
|
-
"@astram/engine": "^0.5.
|
|
24
|
-
"@astram/guard": "^0.
|
|
25
|
-
"@astram/guide": "^0.2.
|
|
26
|
-
"@astram/identity": "^0.
|
|
27
|
-
"@astram/migrate": "^0.3.
|
|
24
|
+
"@astram/engine": "^0.5.2",
|
|
25
|
+
"@astram/guard": "^0.11.0",
|
|
26
|
+
"@astram/guide": "^0.2.3",
|
|
27
|
+
"@astram/identity": "^0.8.0",
|
|
28
|
+
"@astram/migrate": "^0.3.2",
|
|
28
29
|
"@astram/sdk": "^0.2.0",
|
|
29
30
|
"@nestjs/common": "^11.2.7",
|
|
30
31
|
"@nestjs/core": "^11.2.7",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature-wall grep — `npm run lint:wall` (also the first step of `npm run lint`).
|
|
3
|
+
*
|
|
4
|
+
* Fails when any file under `src/features/**` names a framework-internal module:
|
|
5
|
+
* an `@astram/<pkg>/internal` subpath, or any path through `node_modules/@astram`
|
|
6
|
+
* (e.g. `../../../node_modules/@astram/identity/dist/internal.js`). Those carry the raw
|
|
7
|
+
* pools and units of work that only the composition root (`src/app.module.ts`,
|
|
8
|
+
* `src/main.ts`) may wire; features use the RecordEngine (@astram/engine).
|
|
9
|
+
*
|
|
10
|
+
* This is a plain text scan, independent of the ESLint config, so a weakened or
|
|
11
|
+
* mis-scoped eslint.config.mjs still cannot let such an import through. It reads every
|
|
12
|
+
* quoted string after `from`, `import`, `require` or `import(` (comments included —
|
|
13
|
+
* a false positive there is cheap to reword). No dependencies; runs on macOS and Linux.
|
|
14
|
+
*
|
|
15
|
+
* Usage: node scripts/check-wall.mjs [appRoot ...] (default: the current directory).
|
|
16
|
+
* An app root without `src/features` is skipped. Exit 1 on any hit.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
20
|
+
import { join, relative, resolve } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const EXTENSIONS = /\.(ts|mts|cts|tsx|js|mjs|cjs|jsx)$/;
|
|
23
|
+
/** A module specifier: the quoted string after `from` / `import` / `require(` / `import(`. */
|
|
24
|
+
const SPECIFIER = /\b(?:from|import|require)\s*\(?\s*(['"`])([^'"`\n]+)\1/g;
|
|
25
|
+
/** What a feature may never name. */
|
|
26
|
+
const FORBIDDEN = /^@astram\/[^/]+\/internal(\/|\.|$)|node_modules\/@astram(\/|$)/;
|
|
27
|
+
|
|
28
|
+
function walk(dir, out) {
|
|
29
|
+
for (const name of readdirSync(dir)) {
|
|
30
|
+
if (name === 'node_modules') continue;
|
|
31
|
+
const full = join(dir, name);
|
|
32
|
+
if (statSync(full).isDirectory()) walk(full, out);
|
|
33
|
+
else if (EXTENSIONS.test(name)) out.push(full);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const roots = process.argv.length > 2 ? process.argv.slice(2) : ['.'];
|
|
39
|
+
const hits = [];
|
|
40
|
+
for (const root of roots) {
|
|
41
|
+
const features = resolve(root, 'src', 'features');
|
|
42
|
+
if (!existsSync(features)) {
|
|
43
|
+
console.log(`lint:wall: ${relative(process.cwd(), features) || features} absent, skipped`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
for (const file of walk(features, [])) {
|
|
47
|
+
const text = readFileSync(file, 'utf8');
|
|
48
|
+
for (const m of text.matchAll(SPECIFIER)) {
|
|
49
|
+
const spec = m[2].replace(/\\/g, '/');
|
|
50
|
+
if (FORBIDDEN.test(spec)) {
|
|
51
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
52
|
+
hits.push(`${relative(process.cwd(), file)}:${line} ${spec}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (hits.length > 0) {
|
|
59
|
+
console.error(
|
|
60
|
+
'lint:wall: features may not import @astram/*/internal or node_modules/@astram paths — ' +
|
|
61
|
+
'those are for src/app.module.ts and src/main.ts only; use the RecordEngine (@astram/engine).',
|
|
62
|
+
);
|
|
63
|
+
for (const h of hits) console.error(` ${h}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
console.log('lint:wall: no /internal or node_modules/@astram imports under src/features');
|
|
@@ -49,41 +49,35 @@ import { DemoController } from './health/demo.controller';
|
|
|
49
49
|
* ONE module. `@astram/guard` never imports `@astram/identity` (the dependency graph is
|
|
50
50
|
* identity → guard), so the consumer wires it (ADR-0026):
|
|
51
51
|
* 1. Turn auth on (above), pass the identity permission keys (so the frontend admin
|
|
52
|
-
* nav lights up),
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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.)
|
|
52
|
+
* nav lights up), and import the identity module:
|
|
53
|
+
* import { identityAuthActionKeys, identityControlPlaneKeys,
|
|
54
|
+
* IdentityFeatureModule } from '@astram/identity';
|
|
60
55
|
* imports: [
|
|
61
56
|
* ShieldModule.forRoot({ auth: true,
|
|
62
|
-
* identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys,
|
|
63
|
-
* recordAuthorization: buildIdentityRecordAuthorization(authzPool) }),
|
|
57
|
+
* identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys }),
|
|
64
58
|
* IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles,…}
|
|
65
59
|
* ],
|
|
66
60
|
* `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
|
|
67
|
-
* (no separate engine setup)
|
|
68
|
-
*
|
|
69
|
-
*
|
|
61
|
+
* (no separate engine setup) and builds the record authorization `ShieldModule` decides
|
|
62
|
+
* with, so the guard renders REAL decisions from your roles' grants. Do NOT also pass
|
|
63
|
+
* `recordAuthorization` or `decisionAuthority` to `ShieldModule`: boot fails (one source).
|
|
64
|
+
* Every route is `@Classified` — enforced by the same deny-by-default guard. Omit the
|
|
65
|
+
* module and login still works; the admin pages 404.
|
|
70
66
|
*
|
|
71
|
-
* ⛔ IF YOU REGISTER YOUR OWN OWNED OBJECT TYPES (e.g. a `Note`): pass them
|
|
72
|
-
* `
|
|
73
|
-
* into gate-5 grants
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* `.recordShare`.
|
|
67
|
+
* ⛔ IF YOU REGISTER YOUR OWN OWNED OBJECT TYPES (e.g. a `Note`): pass them to
|
|
68
|
+
* `IdentityFeatureModule.forRoot({ extraObjects: [Note] })` (the ONE place: their action
|
|
69
|
+
* keys then project into gate-5 grants, and the GUIDED RULE-BUILDER schema, the
|
|
70
|
+
* OWNER-SHARE resolver and cross-tenant grants know them — else a record of your type is
|
|
71
|
+
* denied, NOT policy-authorable and NOT shareable: "type '…' is not registered — cannot
|
|
72
|
+
* share"). The frontend ships the matching pages/widget (RecordPoliciesPage, ShareDialog)
|
|
73
|
+
* already wired to `useAuth().recordPolicy` / `.recordShare`.
|
|
79
74
|
*/
|
|
80
75
|
@Module({
|
|
81
76
|
// Enforcement-only by default (boots with no config). For real login + the identity
|
|
82
77
|
// admin console (incl. the guided rule-builder + owner shares), use (see the OPTIONAL
|
|
83
78
|
// blocks above for the full form + the extraObjects note for your OWN owned types):
|
|
84
79
|
// imports: [
|
|
85
|
-
// ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys,
|
|
86
|
-
// recordAuthorization: buildIdentityRecordAuthorization(authzPool, [/* YourType */]) }),
|
|
80
|
+
// ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys }),
|
|
87
81
|
// IdentityFeatureModule.forRoot({ /* extraObjects: [YourType] */ }),
|
|
88
82
|
// ],
|
|
89
83
|
imports: [ShieldModule.forRoot()],
|