create-astram-app 0.9.4 → 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 +1 -1
- package/template/README.md +3 -1
- package/template/frontend/package.json +1 -1
- package/template/frontend/scripts/run-tests.mjs +34 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +22 -7
- package/template/frontend/src/pages/access/accessMatrix.js +55 -0
- package/template/frontend/src/pages/access/accessMatrix.test.mjs +168 -0
- package/template/server/_env.example +38 -0
- package/template/server/eslint.config.mjs +142 -105
- package/template/server/package.json +6 -6
- package/template/server/scripts/check-wall.mjs +31 -8
- package/template/server/src/app.module.ts +14 -4
- package/template/server/src/main.ts +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.9.
|
|
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",
|
package/template/README.md
CHANGED
|
@@ -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`).
|
|
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
|
|
@@ -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) =>
|
|
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
|
|
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) =>
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
+
});
|
|
@@ -43,6 +43,11 @@ NODE_ENV=development
|
|
|
43
43
|
# APP_DB_USER=shield_core
|
|
44
44
|
# APP_DB_PASSWORD=change-me
|
|
45
45
|
# APP_DB_NAME=shield_identity
|
|
46
|
+
# TLS to MariaDB: off (default) | on. Production DB accounts are provisioned REQUIRE SSL, so set
|
|
47
|
+
# `on` there (it always verifies the server). APP_DB_TLS_CA = PEM file of your own CA, if any.
|
|
48
|
+
# Migrations read DDL_DB_TLS / DDL_DB_TLS_CA first, then these.
|
|
49
|
+
# APP_DB_TLS=on
|
|
50
|
+
# APP_DB_TLS_CA=/etc/ssl/shield-db-ca.pem
|
|
46
51
|
#
|
|
47
52
|
# Deploy-time DDL account (CREATE/ALTER/DROP — used only by `npm run migration:run`,
|
|
48
53
|
# NEVER by the running server; least-privilege). Host/port/db inherit APP_DB_* above.
|
|
@@ -74,6 +79,39 @@ NODE_ENV=development
|
|
|
74
79
|
# RESET_LINK_BASE_URL=https://app.example.com/reset-password
|
|
75
80
|
# EMAIL_CHANGE_LINK_BASE_URL=https://app.example.com/confirm-email
|
|
76
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
|
+
|
|
77
115
|
# --- Migrations (optional; @astram/identity's migrations run automatically) ---
|
|
78
116
|
# APP_MIGRATION_DIR=migrations # your app's own migrations, run after identity's
|
|
79
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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,12 +20,12 @@
|
|
|
20
20
|
"postinstall": "node scripts/sync-astram-guide.mjs"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@astram/core": "^1.
|
|
24
|
-
"@astram/engine": "^0.
|
|
25
|
-
"@astram/guard": "^0.
|
|
26
|
-
"@astram/guide": "^0.2.
|
|
27
|
-
"@astram/identity": "^0.
|
|
28
|
-
"@astram/migrate": "^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
|
+
"@astram/migrate": "^0.4.0",
|
|
29
29
|
"@astram/sdk": "^0.2.0",
|
|
30
30
|
"@nestjs/common": "^11.2.7",
|
|
31
31
|
"@nestjs/core": "^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 =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
|
72
|
-
* OWNER-SHARE resolver and cross-tenant
|
|
73
|
-
*
|
|
74
|
-
* share").
|
|
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
|