create-astram-app 0.9.5 → 0.9.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astram-app",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "license": "MIT",
5
5
  "description": "Scaffolder for a new Astram app — emits a running, framework-enforced NestJS server (deny-by-default, built-in auth/identity to switch on) and, with --with-frontend, a React admin/auth UI, over the immutable @astram/* framework packages.",
6
6
  "type": "module",
@@ -72,7 +72,9 @@ needs no database or Redis. To turn on login, sessions, MFA and the identity adm
72
72
  `server/src/app.module.ts`: `ShieldModule.forRoot({ auth: true, emailSender, rootIds,
73
73
  identityActionKeys, identityControlPlaneKeys })` plus
74
74
  `IdentityFeatureModule.forRoot({ rootIds, extraObjects })`, which builds the record
75
- authorization `ShieldModule` decides with (pass no `recordAuthorization`). The framework provides
75
+ authorization `ShieldModule` decides with (pass no `recordAuthorization`). With your own object
76
+ types in `extraObjects`, build the two key lists from the same array with
77
+ `buildIdentityAuthKeys(extraObjects)` so their keys are in `/auth/facts`. The framework provides
76
78
  the credential store, MFA, session handling and the decision authority; you write none of them.
77
79
 
78
80
  ### Before you deploy with auth on
@@ -9,7 +9,7 @@
9
9
  "build": "vite build",
10
10
  "preview": "vite preview",
11
11
  "lint": "eslint \"src/**/*.{js,jsx}\"",
12
- "test": "node --test src/**/*.test.mjs"
12
+ "test": "node scripts/run-tests.mjs"
13
13
  },
14
14
  "dependencies": {
15
15
  "@astram/frontend-core": "^0.6.1",
@@ -0,0 +1,34 @@
1
+ // run-tests.mjs — `npm test`: run every `*.test.mjs` under src/ with Node's built-in runner.
2
+ //
3
+ // An unquoted `node --test src/**/*.test.mjs` is expanded by the shell, and sh has no `**`, so
4
+ // it silently skipped nested folders (src/pages/*/). A quoted glob needs Node >= 21 and a bare
5
+ // directory argument is not portable across Node versions, so the files are listed here and
6
+ // passed explicitly: works the same on Node 20 and later.
7
+ import { readdirSync } from 'node:fs';
8
+ import { join, relative } from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const root = fileURLToPath(new URL('..', import.meta.url));
13
+
14
+ function findTests(dir) {
15
+ const found = [];
16
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
17
+ const path = join(dir, entry.name);
18
+ if (entry.isDirectory()) found.push(...findTests(path));
19
+ else if (entry.isFile() && entry.name.endsWith('.test.mjs')) found.push(path);
20
+ }
21
+ return found;
22
+ }
23
+
24
+ const files = findTests(join(root, 'src'))
25
+ .map((f) => relative(root, f))
26
+ .sort();
27
+
28
+ if (files.length === 0) {
29
+ console.error('run-tests: no *.test.mjs files found under src/');
30
+ process.exit(1);
31
+ }
32
+
33
+ const result = spawnSync(process.execPath, ['--test', ...files], { cwd: root, stdio: 'inherit' });
34
+ process.exit(result.status ?? 1);
@@ -22,9 +22,13 @@ import { Input } from '@/components/ui/input';
22
22
  import { Checkbox } from '@/components/ui/checkbox';
23
23
  import { SearchableSelect } from '@/components/ui/searchable-select';
24
24
  import CollapsibleGroup from '@/components/access/CollapsibleGroup';
25
+ import { extraActionsByType, matrixActions } from './accessMatrix';
25
26
 
26
27
  const ACTIONS = ['read', 'create', 'update', 'delete'];
27
28
 
29
+ // Column header for an action key: `manage_keys` → `manage keys` (cells carry the catalog label).
30
+ const actionHeading = (a) => a.replace(/_/g, ' ');
31
+
28
32
  // Object → group mapping. An unmapped typeKey falls into "Other" (fail-safe).
29
33
  const GROUPS = [
30
34
  {
@@ -86,6 +90,14 @@ export default function AccessForm() {
86
90
  const keyExists = useCallback((key) => Boolean(labelOf[key]), [labelOf]);
87
91
  const cellKey = useCallback((t, a) => `${moduleName}:${t}:${a}`, [moduleName]);
88
92
 
93
+ // Declared extra (non-CRUD) actions per type, catalog order. A type without extras keeps
94
+ // exactly the four CRUD cells.
95
+ const extrasByType = useMemo(() => extraActionsByType(catalog), [catalog]);
96
+ const actionsOf = useCallback(
97
+ (t) => [...ACTIONS, ...(extrasByType.get(t) ?? [])],
98
+ [extrasByType],
99
+ );
100
+
89
101
  const keyHeld = useCallback((key, set) => {
90
102
  if (set.has(key)) return true;
91
103
  const [mod, type] = key.split(':');
@@ -180,7 +192,7 @@ export default function AccessForm() {
180
192
 
181
193
  const toggleGroupAll = (group) => {
182
194
  const groupKeys = group.types
183
- .flatMap((t) => ACTIONS.map((a) => cellKey(t, a)))
195
+ .flatMap((t) => actionsOf(t).map((a) => cellKey(t, a)))
184
196
  .filter(keyExists);
185
197
  const allHeld = groupKeys.every((k) => keyHeld(k, working));
186
198
  setWorking((prev) => {
@@ -219,14 +231,14 @@ export default function AccessForm() {
219
231
  const { permCount, objectCount } = useMemo(() => {
220
232
  const held = new Set();
221
233
  for (const t of typeKeys) {
222
- for (const a of ACTIONS) {
234
+ for (const a of actionsOf(t)) {
223
235
  const k = cellKey(t, a);
224
236
  if (keyExists(k) && keyHeld(k, working)) held.add(k);
225
237
  }
226
238
  }
227
239
  const types = new Set([...held].map((k) => k.split(':')[1]));
228
240
  return { permCount: held.size, objectCount: types.size };
229
- }, [working, typeKeys, cellKey, keyExists, keyHeld]);
241
+ }, [working, typeKeys, actionsOf, cellKey, keyExists, keyHeld]);
230
242
 
231
243
  // The applied presets (derived) — shown in the footer. Multiple can be applied.
232
244
  const appliedPresetLabels = bundles.filter(isBundleApplied).map((b) => b.label);
@@ -358,8 +370,11 @@ export default function AccessForm() {
358
370
  const visibleTypes = group.types.filter(matchesSearch);
359
371
  if (visibleTypes.length === 0) return null;
360
372
  const groupCells = group.types
361
- .flatMap((t) => ACTIONS.map((a) => cellKey(t, a)))
373
+ .flatMap((t) => actionsOf(t).map((a) => cellKey(t, a)))
362
374
  .filter(keyExists);
375
+ // Extra-action columns this group needs (union over its types); empty for a
376
+ // CRUD-only group, which then renders exactly as before.
377
+ const columns = [...ACTIONS, ...matrixActions(catalog, group.types)];
363
378
  const heldCount = groupCells.filter((k) => keyHeld(k, working)).length;
364
379
  return (
365
380
  <CollapsibleGroup
@@ -396,9 +411,9 @@ export default function AccessForm() {
396
411
  <thead>
397
412
  <tr className="text-left text-xs uppercase tracking-wide text-muted-foreground">
398
413
  <th className="py-1 pr-3 font-medium">Object</th>
399
- {ACTIONS.map((a) => (
414
+ {columns.map((a) => (
400
415
  <th key={a} className="px-2 py-1 text-center font-medium capitalize">
401
- {a}
416
+ {actionHeading(a)}
402
417
  </th>
403
418
  ))}
404
419
  </tr>
@@ -407,7 +422,7 @@ export default function AccessForm() {
407
422
  {visibleTypes.map((t) => (
408
423
  <tr key={t} className="border-t border-border">
409
424
  <td className="py-1.5 pr-3 text-foreground">{t}</td>
410
- {ACTIONS.map((a) => {
425
+ {columns.map((a) => {
411
426
  const key = cellKey(t, a);
412
427
  const exists = keyExists(key);
413
428
  const covered = keyHeld(key, working) && !working.has(key);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * accessMatrix — the pure (no React, no I/O) logic behind the role access matrix's extra,
3
+ * non-CRUD action columns (custom-object-actions Phase 5). An object type may declare its own
4
+ * actions (`activate`, `approve`, `manage_keys`, …); the permission catalog lists one row per
5
+ * `<module>:<type>:<action>` key. These helpers work out which extra actions each type shows,
6
+ * in catalog order. Kept separate so it is unit-tested with `node --test` (accessMatrix.test.mjs).
7
+ *
8
+ * UX only: the server catalog and the grant ceiling stay the authority. Wildcard rows
9
+ * (`<module>:*`, `<module>:<type>:*`) are never columns; their coverage is the form's `keyHeld`.
10
+ */
11
+
12
+ /** The CRUD actions the matrix always renders as its fixed columns. */
13
+ export const CRUD_ACTIONS = ['read', 'create', 'update', 'delete'];
14
+
15
+ const CRUD = new Set(CRUD_ACTIONS);
16
+
17
+ const isExtraRow = (row) =>
18
+ Boolean(row) &&
19
+ typeof row.typeKey === 'string' &&
20
+ typeof row.action === 'string' &&
21
+ row.typeKey !== '' &&
22
+ row.action !== '' &&
23
+ row.typeKey !== '*' &&
24
+ row.action !== '*' &&
25
+ !CRUD.has(row.action);
26
+
27
+ /**
28
+ * typeKey → its extra (non-CRUD) actions, in first-seen catalog order, no duplicates.
29
+ * Types with only CRUD rows are absent. Malformed rows are skipped; the input is not mutated.
30
+ */
31
+ export function extraActionsByType(catalog) {
32
+ const out = new Map();
33
+ for (const row of Array.isArray(catalog) ? catalog : []) {
34
+ if (!isExtraRow(row)) continue;
35
+ const list = out.get(row.typeKey) ?? [];
36
+ if (!list.includes(row.action)) list.push(row.action);
37
+ out.set(row.typeKey, list);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * Union of extra actions across `types` (all types when omitted), first-seen catalog order,
44
+ * no duplicates — the extra columns a group of types needs.
45
+ */
46
+ export function matrixActions(catalog, types) {
47
+ const scope = Array.isArray(types) ? new Set(types) : null;
48
+ const out = [];
49
+ for (const row of Array.isArray(catalog) ? catalog : []) {
50
+ if (!isExtraRow(row)) continue;
51
+ if (scope && !scope.has(row.typeKey)) continue;
52
+ if (!out.includes(row.action)) out.push(row.action);
53
+ }
54
+ return out;
55
+ }
@@ -0,0 +1,168 @@
1
+ // accessMatrix.test.mjs — custom-object-actions Phase 5: the role access matrix's pure logic
2
+ // (which extra, non-CRUD action columns each type shows). No React/DOM; runs under Node's
3
+ // built-in runner with zero dependencies:
4
+ //
5
+ // node --test src/pages/access/accessMatrix.test.mjs
6
+ import { test, describe } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { extraActionsByType, matrixActions } from './accessMatrix.js';
9
+
10
+ const row = (typeKey, action, label = `${typeKey} ${action}`) => ({
11
+ key: `app:${typeKey}:${action}`,
12
+ label,
13
+ module: 'app',
14
+ typeKey,
15
+ action,
16
+ });
17
+
18
+ // extraActionsByType may return a Map or a plain object; read either.
19
+ const extrasOf = (res, typeKey) => {
20
+ const v = res instanceof Map ? res.get(typeKey) : res?.[typeKey];
21
+ return v === undefined ? [] : [...v];
22
+ };
23
+ const typesWithExtras = (res) =>
24
+ (res instanceof Map ? [...res.keys()] : Object.keys(res)).filter(
25
+ (t) => extrasOf(res, t).length > 0,
26
+ );
27
+
28
+ describe('extraActionsByType', () => {
29
+ test('no extras (CRUD only) => no type has extras', () => {
30
+ const catalog = ['read', 'create', 'update', 'delete'].map((a) => row('invoice', a));
31
+ assert.deepEqual(typesWithExtras(extraActionsByType(catalog)), []);
32
+ });
33
+
34
+ test('empty, null and undefined catalogs => nothing, no throw', () => {
35
+ for (const c of [[], null, undefined]) {
36
+ assert.deepEqual(typesWithExtras(extraActionsByType(c)), []);
37
+ assert.deepEqual([...matrixActions(c)], []);
38
+ }
39
+ });
40
+
41
+ test('extras per type, CRUD never listed as an extra', () => {
42
+ const catalog = [
43
+ row('invoice', 'read'),
44
+ row('invoice', 'create'),
45
+ row('invoice', 'update'),
46
+ row('invoice', 'delete'),
47
+ row('invoice', 'activate'),
48
+ row('invoice', 'approve'),
49
+ row('contact', 'read'),
50
+ ];
51
+ const res = extraActionsByType(catalog);
52
+ assert.deepEqual(extrasOf(res, 'invoice'), ['activate', 'approve']);
53
+ assert.deepEqual(extrasOf(res, 'contact'), []);
54
+ });
55
+
56
+ test('crudActions:false type (only its own actions in the catalog) shows only those', () => {
57
+ const catalog = [row('workflow', 'activate'), row('workflow', 'publish')];
58
+ const res = extraActionsByType(catalog);
59
+ assert.deepEqual(extrasOf(res, 'workflow'), ['activate', 'publish']);
60
+ });
61
+
62
+ test('extras keep catalog order (stable, not alphabetical)', () => {
63
+ const catalog = [row('t', 'zeta'), row('t', 'alpha'), row('t', 'mid')];
64
+ assert.deepEqual(extrasOf(extraActionsByType(catalog), 't'), ['zeta', 'alpha', 'mid']);
65
+ });
66
+
67
+ test('stable: same input twice gives the same output; input is not mutated', () => {
68
+ const catalog = [row('t', 'b'), row('u', 'a'), row('t', 'a')];
69
+ const snapshot = JSON.stringify(catalog);
70
+ const a = extraActionsByType(catalog);
71
+ const b = extraActionsByType(catalog);
72
+ assert.deepEqual(extrasOf(a, 't'), extrasOf(b, 't'));
73
+ assert.deepEqual(extrasOf(a, 't'), ['b', 'a']);
74
+ assert.equal(JSON.stringify(catalog), snapshot);
75
+ });
76
+
77
+ test('duplicate rows do not duplicate an extra', () => {
78
+ const catalog = [row('t', 'x'), row('t', 'x')];
79
+ assert.deepEqual(extrasOf(extraActionsByType(catalog), 't'), ['x']);
80
+ });
81
+
82
+ test('identity extras appear (manage_keys, share/accept/revoke)', () => {
83
+ const catalog = [
84
+ { key: 'identity:service_account:read', typeKey: 'service_account', action: 'read' },
85
+ {
86
+ key: 'identity:service_account:manage_keys',
87
+ typeKey: 'service_account',
88
+ action: 'manage_keys',
89
+ },
90
+ { key: 'identity:record_share:share', typeKey: 'record_share', action: 'share' },
91
+ {
92
+ key: 'identity:cross_tenant_grant:accept',
93
+ typeKey: 'cross_tenant_grant',
94
+ action: 'accept',
95
+ },
96
+ {
97
+ key: 'identity:cross_tenant_grant:revoke',
98
+ typeKey: 'cross_tenant_grant',
99
+ action: 'revoke',
100
+ },
101
+ ];
102
+ const res = extraActionsByType(catalog);
103
+ assert.deepEqual(extrasOf(res, 'service_account'), ['manage_keys']);
104
+ assert.deepEqual(extrasOf(res, 'record_share'), ['share']);
105
+ assert.deepEqual(extrasOf(res, 'cross_tenant_grant'), ['accept', 'revoke']);
106
+ });
107
+
108
+ test('wildcard rows are not extra columns (coverage is the form`s keyHeld concern)', () => {
109
+ const catalog = [
110
+ { key: 'app:*', typeKey: '*', action: '*' },
111
+ { key: 'app:invoice:*', typeKey: 'invoice', action: '*' },
112
+ row('invoice', 'approve'),
113
+ ];
114
+ const res = extraActionsByType(catalog);
115
+ assert.deepEqual(extrasOf(res, 'invoice'), ['approve']);
116
+ assert.ok(!typesWithExtras(res).includes('*'));
117
+ });
118
+
119
+ test('unknown or missing fields are tolerated and skipped', () => {
120
+ const catalog = [
121
+ null,
122
+ undefined,
123
+ {},
124
+ { key: 'app:x:y' },
125
+ { typeKey: 'x' },
126
+ { action: 'y' },
127
+ { typeKey: 7, action: 'y' },
128
+ { typeKey: 'x', action: null },
129
+ { ...row('invoice', 'approve'), somethingNew: { nested: true } },
130
+ ];
131
+ const res = extraActionsByType(catalog);
132
+ assert.deepEqual(extrasOf(res, 'invoice'), ['approve']);
133
+ assert.deepEqual(typesWithExtras(res), ['invoice']);
134
+ });
135
+ });
136
+
137
+ describe('matrixActions', () => {
138
+ const catalog = [
139
+ row('invoice', 'read'),
140
+ row('invoice', 'approve'),
141
+ row('invoice', 'activate'),
142
+ row('contact', 'read'),
143
+ row('contact', 'activate'),
144
+ row('contact', 'archive'),
145
+ row('workflow', 'publish'),
146
+ ];
147
+
148
+ test('union of extras across all types, first-seen catalog order, no CRUD, no dupes', () => {
149
+ assert.deepEqual([...matrixActions(catalog)], ['approve', 'activate', 'archive', 'publish']);
150
+ });
151
+
152
+ test('scoped to a group of types', () => {
153
+ assert.deepEqual([...matrixActions(catalog, ['contact'])], ['activate', 'archive']);
154
+ assert.deepEqual(
155
+ [...matrixActions(catalog, ['invoice', 'workflow'])],
156
+ ['approve', 'activate', 'publish'],
157
+ );
158
+ });
159
+
160
+ test('group with no extras or unknown types => empty', () => {
161
+ assert.deepEqual([...matrixActions(catalog, ['nope'])], []);
162
+ assert.deepEqual([...matrixActions(catalog, [])], []);
163
+ });
164
+
165
+ test('tolerates malformed rows', () => {
166
+ assert.deepEqual([...matrixActions([null, {}, row('t', 'go')])], ['go']);
167
+ });
168
+ });
@@ -79,6 +79,39 @@ NODE_ENV=development
79
79
  # RESET_LINK_BASE_URL=https://app.example.com/reset-password
80
80
  # EMAIL_CHANGE_LINK_BASE_URL=https://app.example.com/confirm-email
81
81
 
82
+ # --- OPTIONAL: secret references (ADR-0032) ---
83
+ # Any variable above or below may hold `secret-ref:<path>` instead of a value, e.g.
84
+ # APP_DB_PASSWORD=secret-ref:db/app-password
85
+ # REFRESH_HMAC_KEY=secret-ref:auth/refresh-hmac-key
86
+ # src/main.ts calls resolveBootSecrets() before AppModule loads and replaces each reference
87
+ # with the secret from the provider below. An unresolvable reference, or a reference with no
88
+ # provider configured, fails boot. Nothing is logged.
89
+ # Provider: none (default; references are refused) | encrypted-file (`vault` is reserved).
90
+ # SECRETS_PROVIDER=encrypted-file
91
+ # Encrypted secrets store, and the secrets DEK wrapped by the KEK.
92
+ # SECRETS_FILE=/etc/astram/secrets.enc.json
93
+ # SECRETS_DEK_FILE=/etc/astram/secrets.dek
94
+ # The 32-byte KEK is a FILE mounted by the platform (mode 0400), never an env value.
95
+ # SECRETS_KEK_FILE=/run/secrets/astram-kek
96
+
97
+ # --- OPTIONAL: file storage (S3-compatible bucket) ---
98
+ # Read only when src/app.module.ts passes ShieldModule.forRoot({ storage: 'env' }), and even then
99
+ # storage stays OFF while OBJECT_STORAGE_BUCKET is unset (FilesService then answers 501).
100
+ # Used by FileIntake (@astram/guard) and FilesService / GET /files/:uuid (@astram/identity).
101
+ # The bucket is private with SSE-S3; the app checks it at boot and fails if unreachable.
102
+ # Endpoint must be https outside development/test (a loopback host is allowed).
103
+ # OBJECT_STORAGE_ENDPOINT=https://s3.example.com
104
+ # OBJECT_STORAGE_BUCKET=prod-documents
105
+ # OBJECT_STORAGE_REGION=us-east-1
106
+ # Credentials: outside development/test they MUST be `secret-ref:` references (a literal
107
+ # value fails boot).
108
+ # OBJECT_STORAGE_ACCESS_KEY_ID=secret-ref:object-storage/access-key-id
109
+ # OBJECT_STORAGE_SECRET_ACCESS_KEY=secret-ref:object-storage/secret-access-key
110
+ # Server-side encryption: AES256 (SSE-S3, the default and only accepted value).
111
+ # OBJECT_STORAGE_SSE=AES256
112
+ # Upload size cap in bytes (default 10 MiB; above the 50 MiB ceiling fails boot).
113
+ # OBJECT_STORAGE_MAX_BYTES=10485760
114
+
82
115
  # --- Migrations (optional; @astram/identity's migrations run automatically) ---
83
116
  # APP_MIGRATION_DIR=migrations # your app's own migrations, run after identity's
84
117
  # APP_MIGRATION_TABLE=schema_migrations
@@ -30,6 +30,11 @@
30
30
  // (`scripts/check-wall.mjs`, part of `npm run lint`) also greps `src/features/**` for any
31
31
  // `/internal` or `node_modules/@astram` specifier, independent of this config.
32
32
  //
33
+ // FILES: under `src/features/**` the wall also bans `multer`, `@aws-sdk/*`, `minio`, `fs`,
34
+ // `node:fs` (and `fs/promises`) in every import form. Uploads come in through `FileIntake`
35
+ // (@astram/guard) and are stored/read through `FilesService` (@astram/identity), which gate,
36
+ // tenant-scope and audit every file; `lint:wall` greps for the same specifiers.
37
+ //
33
38
  // SCOPE: the wall catches ACCIDENTAL use. Deliberate bypass is a CODE-REVIEW matter, not a
34
39
  // lint matter — no static rule can stop code that sets out to evade it. Known, by design
35
40
  // not linted: indirect calls (`fn.call(pool, …)`, `.apply`, `.bind`, `Reflect.apply`),
@@ -262,11 +267,138 @@ const UUID_INPUTS = [
262
267
  },
263
268
  ];
264
269
 
270
+ /**
271
+ * File storage (FILE_STORAGE SPEC §11): features never touch the bucket, the multipart parser
272
+ * or the filesystem directly. Uploads arrive through `FileIntake` (@astram/guard) and are
273
+ * stored/read through `FilesService` (@astram/identity), which gate, tenant-scope and audit
274
+ * every access. Banned under `src/features/**` in every import form (static, `export … from`,
275
+ * `import()`, `require()`, TS `import x = require()`): `multer`, `@aws-sdk/*`, `minio`, `fs`,
276
+ * `node:fs`, `fs/promises`, `node:fs/promises` (and their subpaths).
277
+ */
278
+ const STORAGE_SPECIFIER =
279
+ '/^(multer|minio|(node\\u003A)?fs|@aws-sdk\\u002F[^\\u002F]+)(\\u002F|$)/';
280
+ const STORAGE_BANS = specifierBan(
281
+ STORAGE_SPECIFIER,
282
+ 'Wall: features never import multer, an S3/MinIO client or fs — take uploads with FileIntake (@astram/guard) and store/read them with FilesService (@astram/identity).',
283
+ { statics: true },
284
+ );
285
+
265
286
  /** App source files: TypeScript and plain JavaScript in every module flavour. */
266
287
  const SRC_FILES = ['src/**/*.{ts,mts,cts,js,mjs,cjs}'];
288
+ const FEATURE_FILES = ['src/features/**/*.{ts,mts,cts,js,mjs,cjs}'];
267
289
  const COMPOSITION_ROOT = ['src/app.module.ts', 'src/main.ts'];
268
290
  const SPECS = ['**/*.spec.*', '**/*.test.*'];
269
291
 
292
+ /** The wall's import options (shared by the src/** and src/features/** blocks). */
293
+ const WALL_IMPORTS = [
294
+ 'error',
295
+ {
296
+ paths: [...FORBIDDEN_DB_IMPORTS, ...FORBIDDEN_DB_NAMED_FROM],
297
+ patterns: [
298
+ FORBIDDEN_DB_SUBPATHS,
299
+ INTERNAL_SUBPATHS,
300
+ COMPOSITION_ROOT_IMPORTS,
301
+ {
302
+ // (Deep `@astram/*/dist|src` and `node_modules/` paths: DEEP_PATHS, every import form.)
303
+ group: ['**/adapters/mariadb-*'],
304
+ message:
305
+ 'Wall: framework internals (pool/uow/audit adapters) are off-limits — features use the RecordEngine facade only.',
306
+ },
307
+ ],
308
+ },
309
+ ];
310
+
311
+ /**
312
+ * The wall's syntax selectors. A later flat-config block REPLACES a rule's options for the
313
+ * files it matches (it does not merge), so the src/features/** block below repeats these and
314
+ * appends STORAGE_BANS.
315
+ */
316
+ const WALL_SYNTAX = [
317
+ // Block raw query-runner / repository calls even if a pool were obtained.
318
+ {
319
+ selector: "CallExpression[callee.property.name='createQueryRunner']",
320
+ message: 'Wall: no QueryRunner in features — use the RecordEngine (@astram/engine).',
321
+ },
322
+ {
323
+ selector: "CallExpression[callee.property.name='getRepository']",
324
+ message: 'Wall: no repository access in features — use the RecordEngine (@astram/engine).',
325
+ },
326
+ {
327
+ selector: "CallExpression[callee.property.name='createPool']",
328
+ message: 'Wall: features never create a DB pool — use the RecordEngine (@astram/engine).',
329
+ },
330
+ {
331
+ selector: "CallExpression[callee.property.name='query']",
332
+ message: 'Wall: no raw .query() in app code — use the RecordEngine (@astram/engine).',
333
+ },
334
+ {
335
+ selector: "CallExpression[callee.property.name='getConnection']",
336
+ message: 'Wall: no raw .getConnection() in app code — use the RecordEngine (@astram/engine).',
337
+ },
338
+ {
339
+ selector: `CallExpression > MemberExpression.callee[computed=false][property.name=${MORE_RAW_METHOD_RE}]`,
340
+ message: RAW_METHOD_MESSAGE,
341
+ },
342
+ // The same methods by COMPUTED key: `pool['query']()`, pool[`getConnection`]().
343
+ {
344
+ selector: `CallExpression > MemberExpression.callee[computed=true][property.value=${RAW_METHOD_RE}]`,
345
+ message: RAW_METHOD_MESSAGE,
346
+ },
347
+ {
348
+ selector: `CallExpression > MemberExpression.callee[computed=true] > TemplateLiteral.property[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
349
+ message: RAW_METHOD_MESSAGE,
350
+ },
351
+ // …and DESTRUCTURED off anything: `const { query } = pool`, `{ ['query']: q } = pool`.
352
+ {
353
+ selector: `ObjectPattern > Property[computed=false][key.name=${RAW_METHOD_RE}]`,
354
+ message: RAW_METHOD_MESSAGE,
355
+ },
356
+ {
357
+ selector: `ObjectPattern > Property[computed=true][key.value=${RAW_METHOD_RE}]`,
358
+ message: RAW_METHOD_MESSAGE,
359
+ },
360
+ {
361
+ selector: `ObjectPattern > Property[computed=true] > TemplateLiteral.key[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
362
+ message: RAW_METHOD_MESSAGE,
363
+ },
364
+ ...specifierBan(
365
+ DRIVER_SPECIFIER,
366
+ 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
367
+ ),
368
+ ...INTERNAL_DYNAMIC,
369
+ ...DEEP_PATHS,
370
+ ...NON_STATIC_SPECIFIERS,
371
+ ...UUID_INPUTS,
372
+ // The banned framework names however they are reached: `ns.SHIELD_DB_POOL`,
373
+ // `require('@astram/guard').SHIELD_DB_POOL`, `(await import('@astram/engine')).TableRegistry`.
374
+ // (A named import from the listed package is already reported by no-restricted-imports,
375
+ // so plain import specifiers are skipped here; an ALIASED one is reported below, from
376
+ // any module, because the alias hides the name from every later use.)
377
+ ...FORBIDDEN_FRAMEWORK_NAMES.flatMap((name) => [
378
+ {
379
+ selector: `Identifier[name='${name}']:not(ImportSpecifier > Identifier)`,
380
+ message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
381
+ },
382
+ {
383
+ selector: `ImportSpecifier[imported.name='${name}'][local.name!='${name}']`,
384
+ message: `Wall: ${name} is framework-internal DB access (imported under an alias) — use the RecordEngine (@astram/engine).`,
385
+ },
386
+ ]),
387
+ // …and reached by STRING: `ns['SHIELD_DB_POOL']`, ns[`TableRegistry`],
388
+ // `const { ['MariaDbUnitOfWork']: x } = ns`. (Import sources are other strings, so the
389
+ // module specifiers themselves never match.)
390
+ ...FORBIDDEN_FRAMEWORK_NAMES.flatMap((name) => [
391
+ {
392
+ selector: `Literal[value='${name}']`,
393
+ message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
394
+ },
395
+ {
396
+ selector: `TemplateLiteral[expressions.length=0] > TemplateElement[value.cooked='${name}']`,
397
+ message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
398
+ },
399
+ ]),
400
+ ];
401
+
270
402
  export default tseslint.config(
271
403
  js.configs.recommended,
272
404
  ...tseslint.configs.recommended,
@@ -278,111 +410,16 @@ export default tseslint.config(
278
410
  files: SRC_FILES,
279
411
  ignores: [...COMPOSITION_ROOT, ...SPECS],
280
412
  rules: {
281
- 'no-restricted-imports': [
282
- 'error',
283
- {
284
- paths: [...FORBIDDEN_DB_IMPORTS, ...FORBIDDEN_DB_NAMED_FROM],
285
- patterns: [
286
- FORBIDDEN_DB_SUBPATHS,
287
- INTERNAL_SUBPATHS,
288
- COMPOSITION_ROOT_IMPORTS,
289
- {
290
- // (Deep `@astram/*/dist|src` and `node_modules/` paths: DEEP_PATHS, every import form.)
291
- group: ['**/adapters/mariadb-*'],
292
- message:
293
- 'Wall: framework internals (pool/uow/audit adapters) are off-limits — features use the RecordEngine facade only.',
294
- },
295
- ],
296
- },
297
- ],
298
- // Block raw query-runner / repository calls even if a pool were obtained.
299
- 'no-restricted-syntax': [
300
- 'error',
301
- {
302
- selector: "CallExpression[callee.property.name='createQueryRunner']",
303
- message: 'Wall: no QueryRunner in features — use the RecordEngine (@astram/engine).',
304
- },
305
- {
306
- selector: "CallExpression[callee.property.name='getRepository']",
307
- message:
308
- 'Wall: no repository access in features — use the RecordEngine (@astram/engine).',
309
- },
310
- {
311
- selector: "CallExpression[callee.property.name='createPool']",
312
- message: 'Wall: features never create a DB pool — use the RecordEngine (@astram/engine).',
313
- },
314
- {
315
- selector: "CallExpression[callee.property.name='query']",
316
- message: 'Wall: no raw .query() in app code — use the RecordEngine (@astram/engine).',
317
- },
318
- {
319
- selector: "CallExpression[callee.property.name='getConnection']",
320
- message:
321
- 'Wall: no raw .getConnection() in app code — use the RecordEngine (@astram/engine).',
322
- },
323
- {
324
- selector: `CallExpression > MemberExpression.callee[computed=false][property.name=${MORE_RAW_METHOD_RE}]`,
325
- message: RAW_METHOD_MESSAGE,
326
- },
327
- // The same methods by COMPUTED key: `pool['query']()`, pool[`getConnection`]().
328
- {
329
- selector: `CallExpression > MemberExpression.callee[computed=true][property.value=${RAW_METHOD_RE}]`,
330
- message: RAW_METHOD_MESSAGE,
331
- },
332
- {
333
- selector: `CallExpression > MemberExpression.callee[computed=true] > TemplateLiteral.property[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
334
- message: RAW_METHOD_MESSAGE,
335
- },
336
- // …and DESTRUCTURED off anything: `const { query } = pool`, `{ ['query']: q } = pool`.
337
- {
338
- selector: `ObjectPattern > Property[computed=false][key.name=${RAW_METHOD_RE}]`,
339
- message: RAW_METHOD_MESSAGE,
340
- },
341
- {
342
- selector: `ObjectPattern > Property[computed=true][key.value=${RAW_METHOD_RE}]`,
343
- message: RAW_METHOD_MESSAGE,
344
- },
345
- {
346
- selector: `ObjectPattern > Property[computed=true] > TemplateLiteral.key[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
347
- message: RAW_METHOD_MESSAGE,
348
- },
349
- ...specifierBan(
350
- DRIVER_SPECIFIER,
351
- 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
352
- ),
353
- ...INTERNAL_DYNAMIC,
354
- ...DEEP_PATHS,
355
- ...NON_STATIC_SPECIFIERS,
356
- ...UUID_INPUTS,
357
- // The banned framework names however they are reached: `ns.SHIELD_DB_POOL`,
358
- // `require('@astram/guard').SHIELD_DB_POOL`, `(await import('@astram/engine')).TableRegistry`.
359
- // (A named import from the listed package is already reported by no-restricted-imports,
360
- // so plain import specifiers are skipped here; an ALIASED one is reported below, from
361
- // any module, because the alias hides the name from every later use.)
362
- ...FORBIDDEN_FRAMEWORK_NAMES.flatMap((name) => [
363
- {
364
- selector: `Identifier[name='${name}']:not(ImportSpecifier > Identifier)`,
365
- message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
366
- },
367
- {
368
- selector: `ImportSpecifier[imported.name='${name}'][local.name!='${name}']`,
369
- message: `Wall: ${name} is framework-internal DB access (imported under an alias) — use the RecordEngine (@astram/engine).`,
370
- },
371
- ]),
372
- // …and reached by STRING: `ns['SHIELD_DB_POOL']`, ns[`TableRegistry`],
373
- // `const { ['MariaDbUnitOfWork']: x } = ns`. (Import sources are other strings, so the
374
- // module specifiers themselves never match.)
375
- ...FORBIDDEN_FRAMEWORK_NAMES.flatMap((name) => [
376
- {
377
- selector: `Literal[value='${name}']`,
378
- message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
379
- },
380
- {
381
- selector: `TemplateLiteral[expressions.length=0] > TemplateElement[value.cooked='${name}']`,
382
- message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
383
- },
384
- ]),
385
- ],
413
+ 'no-restricted-imports': WALL_IMPORTS,
414
+ 'no-restricted-syntax': ['error', ...WALL_SYNTAX],
415
+ },
416
+ },
417
+ // ── Features: the wall above plus the file-storage bans ────────────────────
418
+ {
419
+ files: FEATURE_FILES,
420
+ ignores: SPECS,
421
+ rules: {
422
+ 'no-restricted-syntax': ['error', ...WALL_SYNTAX, ...STORAGE_BANS],
386
423
  },
387
424
  },
388
425
  // ── Tests: exempt from the DB wall, but not from the /internal and deep-path bans ──
@@ -20,11 +20,11 @@
20
20
  "postinstall": "node scripts/sync-astram-guide.mjs"
21
21
  },
22
22
  "dependencies": {
23
- "@astram/core": "^1.5.0",
24
- "@astram/engine": "^0.6.0",
25
- "@astram/guard": "^0.13.0",
26
- "@astram/guide": "^0.2.4",
27
- "@astram/identity": "^0.10.0",
23
+ "@astram/core": "^1.6.0",
24
+ "@astram/engine": "^0.7.0",
25
+ "@astram/guard": "^0.14.0",
26
+ "@astram/guide": "^0.2.5",
27
+ "@astram/identity": "^0.11.0",
28
28
  "@astram/migrate": "^0.4.0",
29
29
  "@astram/sdk": "^0.2.0",
30
30
  "@nestjs/common": "^11.2.7",
@@ -5,7 +5,9 @@
5
5
  * an `@astram/<pkg>/internal` subpath, or any path through `node_modules/@astram`
6
6
  * (e.g. `../../../node_modules/@astram/identity/dist/internal.js`). Those carry the raw
7
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).
8
+ * `src/main.ts`) may wire; features use the RecordEngine (@astram/engine). It also fails on
9
+ * `multer`, `@aws-sdk/*`, `minio`, `fs`, `node:fs` (and `/promises` subpaths): uploads go
10
+ * through FileIntake (@astram/guard) and FilesService (@astram/identity).
9
11
  *
10
12
  * This is a plain text scan, independent of the ESLint config, so a weakened or
11
13
  * mis-scoped eslint.config.mjs still cannot let such an import through. It reads every
@@ -22,8 +24,23 @@ import { join, relative, resolve } from 'node:path';
22
24
  const EXTENSIONS = /\.(ts|mts|cts|tsx|js|mjs|cjs|jsx)$/;
23
25
  /** A module specifier: the quoted string after `from` / `import` / `require(` / `import(`. */
24
26
  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
+ /** What a feature may never name: framework internals, and the storage/fs back doors. */
28
+ const FORBIDDEN = [
29
+ {
30
+ re: /^@astram\/[^/]+\/internal(\/|\.|$)|node_modules\/@astram(\/|$)/,
31
+ why: 'framework internal (use the RecordEngine, @astram/engine)',
32
+ },
33
+ {
34
+ // FILE_STORAGE SPEC §11: uploads go through FileIntake (@astram/guard) and FilesService
35
+ // (@astram/identity); features never parse multipart, reach the bucket or the filesystem.
36
+ // Feature specs (`*.spec.*` / `*.test.*`) may read fixtures with fs, as in eslint.config.mjs.
37
+ re: /^(multer|minio|(node:)?fs|@aws-sdk\/[^/]+)(\/|$)/,
38
+ why: 'storage/fs access (use FileIntake from @astram/guard and FilesService from @astram/identity)',
39
+ specsExempt: true,
40
+ },
41
+ ];
42
+ /** Spec/test files (exempt from the storage ban only). */
43
+ const SPEC_FILE = /\.(spec|test)\.[^/.]+$/;
27
44
 
28
45
  function walk(dir, out) {
29
46
  for (const name of readdirSync(dir)) {
@@ -47,9 +64,12 @@ for (const root of roots) {
47
64
  const text = readFileSync(file, 'utf8');
48
65
  for (const m of text.matchAll(SPECIFIER)) {
49
66
  const spec = m[2].replace(/\\/g, '/');
50
- if (FORBIDDEN.test(spec)) {
67
+ const rule = FORBIDDEN.find(
68
+ (f) => f.re.test(spec) && !(f.specsExempt && SPEC_FILE.test(file)),
69
+ );
70
+ if (rule) {
51
71
  const line = text.slice(0, m.index).split('\n').length;
52
- hits.push(`${relative(process.cwd(), file)}:${line} ${spec}`);
72
+ hits.push(`${relative(process.cwd(), file)}:${line} ${spec} — ${rule.why}`);
53
73
  }
54
74
  }
55
75
  }
@@ -57,10 +77,13 @@ for (const root of roots) {
57
77
 
58
78
  if (hits.length > 0) {
59
79
  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).',
80
+ 'lint:wall: features may not import @astram/*/internal or node_modules/@astram paths ' +
81
+ '(those are for src/app.module.ts and src/main.ts only; use the RecordEngine, @astram/engine), ' +
82
+ 'nor multer, @aws-sdk/*, minio or fs (use FileIntake / FilesService).',
62
83
  );
63
84
  for (const h of hits) console.error(` ${h}`);
64
85
  process.exit(1);
65
86
  }
66
- console.log('lint:wall: no /internal or node_modules/@astram imports under src/features');
87
+ console.log(
88
+ 'lint:wall: no /internal or node_modules/@astram imports, and no multer/@aws-sdk/minio/fs imports, under src/features',
89
+ );
@@ -68,10 +68,20 @@ import { DemoController } from './health/demo.controller';
68
68
  *
69
69
  * ⛔ IF YOU REGISTER YOUR OWN OWNED OBJECT TYPES (e.g. a `Note`): pass them to
70
70
  * `IdentityFeatureModule.forRoot({ extraObjects: [Note] })` (the ONE place: their action
71
- * keys then project into gate-5 grants, and the GUIDED RULE-BUILDER schema, the
72
- * OWNER-SHARE resolver and cross-tenant grants know them — else a record of your type is
73
- * denied, NOT policy-authorable and NOT shareable: "type '…' is not registered — cannot
74
- * share"). The frontend ships the matching pages/widget (RecordPoliciesPage, ShareDialog)
71
+ * keys then project into gate-5 grants, the permission catalog (so their keys are
72
+ * grantable), the GUIDED RULE-BUILDER schema, the OWNER-SHARE resolver and cross-tenant
73
+ * grants — else a record of your type is denied, NOT grantable, NOT policy-authorable and
74
+ * NOT shareable: "type '…' is not registered — cannot share"). Build the auth-facts keys
75
+ * from the SAME list so `/auth/facts` knows them too:
76
+ * import { buildIdentityAuthKeys, IdentityFeatureModule } from '@astram/identity';
77
+ * const extraObjects = [Note];
78
+ * const { actionKeys, controlPlaneKeys } = buildIdentityAuthKeys(extraObjects);
79
+ * imports: [
80
+ * ShieldModule.forRoot({ auth: true, identityActionKeys: actionKeys,
81
+ * identityControlPlaneKeys: controlPlaneKeys }),
82
+ * IdentityFeatureModule.forRoot({ extraObjects }),
83
+ * ],
84
+ * The frontend ships the matching pages/widget (RecordPoliciesPage, ShareDialog)
75
85
  * already wired to `useAuth().recordPolicy` / `.recordShare`.
76
86
  */
77
87
  @Module({
@@ -1,8 +1,7 @@
1
1
  import 'reflect-metadata';
2
2
  import { NestFactory } from '@nestjs/core';
3
3
  import type { NestExpressApplication } from '@nestjs/platform-express';
4
- import { loadAppConfig } from '@astram/guard';
5
- import { AppModule } from './app.module';
4
+ import { loadAppConfig, resolveBootSecrets } from '@astram/guard';
6
5
 
7
6
  /**
8
7
  * HTTP bootstrap for this Astram consumer app.
@@ -12,8 +11,16 @@ import { AppModule } from './app.module';
12
11
  * guard) and starts the HTTP listener. No security logic lives here —
13
12
  * `loadAppConfig` comes from the installed `@astram/guard` framework package and
14
13
  * reads PORT / ENVIRONMENT from the environment (see `.env.example`).
14
+ *
15
+ * Boot order matters: `resolveBootSecrets()` replaces every `secret-ref:<path>` env value
16
+ * (see `SECRETS_*` in `.env.example`) with the resolved secret BEFORE `AppModule` is
17
+ * loaded, because its `forRoot()` calls read the environment when the module file is
18
+ * evaluated. That is why `AppModule` is imported dynamically below, never statically at
19
+ * the top of this file. An unresolvable reference aborts boot.
15
20
  */
16
21
  export async function bootstrap(): Promise<void> {
22
+ await resolveBootSecrets();
23
+ const { AppModule } = await import('./app.module');
17
24
  const config = loadAppConfig();
18
25
  const app = await NestFactory.create<NestExpressApplication>(AppModule, { bufferLogs: false });
19
26
  // TRUST_PROXY = the number of reverse-proxy hops in front of this app: 0 = no proxy