@neohm/nh-cli 1.3.1 → 1.4.1
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 +42 -9
- package/package.json +1 -1
- package/src/commands/gen.js +12 -2
- package/src/lib/access.js +38 -0
- package/src/lib/constants.engine.js +65 -51
- package/src/lib/paths.js +1 -0
- package/src/lib/registry.js +59 -1
- package/src/lib/source.scan.js +132 -0
- package/src/lib/sync.engine.js +32 -14
- package/src/lib/templates.js +80 -14
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ differ only where the two packages do:
|
|
|
19
19
|
| ------------------------ | ------------------------------------------ | ----------------------------------------- |
|
|
20
20
|
| Import specifier | `@neohm/nestend` | `@neohm/nestendx` |
|
|
21
21
|
| What a row belongs to | a tenant (`tenant_id` + `TenantEntity`) | the user who created it (`created_by`) |
|
|
22
|
-
| Access check | `
|
|
22
|
+
| Access check | `validateRole(role)` (scope checked first) | `validateAccess(scope)` — there are no roles |
|
|
23
23
|
| Generated migration | FK + index on `tenant_id` | no tenant column to key |
|
|
24
24
|
|
|
25
25
|
Everything that does not write package-specific code — `sync`, `check`, `list`,
|
|
@@ -105,7 +105,8 @@ shape/
|
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
plus `src/database/migrations/<timestamp>M-AddShapeTable.ts`, and it
|
|
108
|
-
registers `ShapeModule` in `src/app.module.ts
|
|
108
|
+
registers `ShapeModule` in `src/app.module.ts` — appended to the `@Module()`
|
|
109
|
+
`imports` array without disturbing comments inside it.
|
|
109
110
|
|
|
110
111
|
It also registers the entity and its job in the project constant registries —
|
|
111
112
|
`shapeJob: 'shape.job'` in `src/constants/job.constants.ts` (which the
|
|
@@ -136,7 +137,7 @@ Generated controllers run under `@UsePipes(StrictValidationPipe)`, take ids
|
|
|
136
137
|
with `ParseUUIDPipe`, and describe every route with `@ApiOperation`.
|
|
137
138
|
|
|
138
139
|
Controllers hold no business logic. They authenticate with
|
|
139
|
-
`AuthService.validateAccess()
|
|
140
|
+
`AuthService.validateRole()` (`validateAccess()` under nhx), then delegate to the module's service — so the
|
|
140
141
|
same operation is reusable from a job, a subscriber or another service, and the
|
|
141
142
|
HTTP surface stays readable.
|
|
142
143
|
|
|
@@ -147,12 +148,14 @@ answers you already gave:
|
|
|
147
148
|
| Service method | Generated when | Controller endpoint |
|
|
148
149
|
| --- | --- | --- |
|
|
149
150
|
| `get(user, id)` | always | `GET /api/v1/shape/:id` |
|
|
151
|
+
| `remove(user, id)` | always | `DELETE /api/v1/shape/:id` |
|
|
150
152
|
| `upsert(user, data)` | data processor: Yes | `POST /api/v1/shape` |
|
|
151
153
|
| `list(user, data)` | listing processor: Yes | `POST /api/v1/shape/list` |
|
|
152
154
|
|
|
153
|
-
`get()`
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
`get()` and `remove()` keep their logic in the service — no processor: `remove()`
|
|
156
|
+
soft-deletes through the same owner-scoped lookup as `get()`. A module that
|
|
157
|
+
declines both processors still gets a service that reads and deletes (and a
|
|
158
|
+
controller with just the `GET` and `DELETE`). Declining a processor drops the matching service method
|
|
156
159
|
*and* its endpoint together, so the generated module always compiles.
|
|
157
160
|
|
|
158
161
|
`upsert()` rather than `create()` because `CommonDataDto` carries an optional
|
|
@@ -168,9 +171,39 @@ is what the tenant is called on `UserEntity`; `tenant_id` is what it is called
|
|
|
168
171
|
on the row — the same value on two sides of one relation.) The listing
|
|
169
172
|
processor applies the same scope as a private predicate.
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
`
|
|
174
|
+
### Scope and role enums
|
|
175
|
+
|
|
176
|
+
`nh gen` registers the entity's scope and default roles in auto-maintained enums
|
|
177
|
+
under `src/global/enums/` (created if missing, appended to otherwise — comments
|
|
178
|
+
and existing members are left alone). For entity `Billing`:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// scope.enum.ts
|
|
182
|
+
export enum ScopeEnum {
|
|
183
|
+
BILLING = 'billing',
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// role.enum.ts (nh only — nhx has no roles)
|
|
187
|
+
export enum RoleEnum {
|
|
188
|
+
BILLING_ADMIN = 'billing.admin', // priority 1 — the scope's top role
|
|
189
|
+
BILLING_MANAGER = 'billing.manager', // priority 2
|
|
190
|
+
BILLING_READER = 'billing.reader', // priority 3
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The controller gates each endpoint at the lowest role that should reach it. A role
|
|
195
|
+
carries its scope, so `validateRole()` first checks the organization holds the
|
|
196
|
+
role's scope, then that the caller holds the role or a more senior one:
|
|
197
|
+
|
|
198
|
+
| Endpoint | Gate |
|
|
199
|
+
| --- | --- |
|
|
200
|
+
| `GET :id`, `POST list` | `validateRole(RoleEnum.BILLING_READER)` |
|
|
201
|
+
| `POST` (upsert) | `validateRole(RoleEnum.BILLING_MANAGER)` |
|
|
202
|
+
| `DELETE :id` | `validateRole(RoleEnum.BILLING_ADMIN)` |
|
|
203
|
+
|
|
204
|
+
Under `nhx` the gate is `validateAccess(ScopeEnum.BILLING)`. The enums name the
|
|
205
|
+
identifiers only: create the `sys_scopes` row and the three `sys_roles` rows
|
|
206
|
+
(scope `billing`, priorities 1/2/3) in the back office so the checks can pass.
|
|
174
207
|
|
|
175
208
|
## Flag generation
|
|
176
209
|
|
package/package.json
CHANGED
package/src/commands/gen.js
CHANGED
|
@@ -12,7 +12,11 @@ const tpl = require("../lib/templates");
|
|
|
12
12
|
const { writeFile, report: reportFile, log, ensureDir } = require("../lib/fs.utils");
|
|
13
13
|
const sync = require("../lib/sync.engine");
|
|
14
14
|
const { formatFiles } = require("../lib/format");
|
|
15
|
-
const {
|
|
15
|
+
const {
|
|
16
|
+
registerJob,
|
|
17
|
+
registerEntity,
|
|
18
|
+
registerAccess,
|
|
19
|
+
} = require("../lib/registry");
|
|
16
20
|
|
|
17
21
|
async function gen() {
|
|
18
22
|
const paths = projectPaths();
|
|
@@ -103,6 +107,9 @@ async function gen() {
|
|
|
103
107
|
// (the generated job references JobConstants.<name>Job, so the entry must exist).
|
|
104
108
|
registerJob(paths, entityName, moduleDot, report);
|
|
105
109
|
registerEntity(paths, entityName, moduleDot, report);
|
|
110
|
+
// The entity's scope and, tenanted, its default roles (admin > manager >
|
|
111
|
+
// reader) in src/global/enums — what the generated controller gates on.
|
|
112
|
+
const enums = registerAccess(paths, entityName, report);
|
|
106
113
|
|
|
107
114
|
// 4. Optional: controller -------------------------------------------------
|
|
108
115
|
const { controller } = await inquirer.prompt([
|
|
@@ -167,7 +174,10 @@ async function gen() {
|
|
|
167
174
|
if (controller) {
|
|
168
175
|
const p = path.join(moduleDir, "controllers", n.controllerFile);
|
|
169
176
|
report(
|
|
170
|
-
writeFile(
|
|
177
|
+
writeFile(
|
|
178
|
+
p,
|
|
179
|
+
tpl.controller(entityName, { processor, listProcessor, enums }),
|
|
180
|
+
),
|
|
171
181
|
p,
|
|
172
182
|
paths.root,
|
|
173
183
|
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { toWords, toDotCase } = require('./naming');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The scope and default roles `nh gen` registers for an entity. Roles rank
|
|
7
|
+
* within the scope by priority (1 = most senior) and a senior role satisfies a
|
|
8
|
+
* junior requirement, so each endpoint is gated at the lowest role that should
|
|
9
|
+
* reach it: reader for get/list, manager for upsert, admin for delete. The
|
|
10
|
+
* admin role is the scope's top role — its holders grant the scope's roles.
|
|
11
|
+
*/
|
|
12
|
+
const ROLE_LEVELS = [
|
|
13
|
+
{ level: 'admin', priority: 1 },
|
|
14
|
+
{ level: 'manager', priority: 2 },
|
|
15
|
+
{ level: 'reader', priority: 3 },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** BILLING_ITEM */
|
|
19
|
+
function toConstantCase(input) {
|
|
20
|
+
return toWords(input).join('_').toUpperCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Enum keys and identifiers for `base`, e.g. Billing → BILLING: 'billing'. */
|
|
24
|
+
function accessNames(base) {
|
|
25
|
+
const scopeKey = toConstantCase(base);
|
|
26
|
+
const scopeValue = toDotCase(base);
|
|
27
|
+
const roles = {};
|
|
28
|
+
for (const { level, priority } of ROLE_LEVELS) {
|
|
29
|
+
roles[level] = {
|
|
30
|
+
key: `${scopeKey}_${level.toUpperCase()}`,
|
|
31
|
+
value: `${scopeValue}.${level}`,
|
|
32
|
+
priority,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { scopeKey, scopeValue, roles };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { ROLE_LEVELS, toConstantCase, accessNames };
|
|
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
6
|
const { writeFile } = require('./fs.utils');
|
|
7
|
+
const scan = require('./source.scan');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Keeps the project-level constant registries (src/constants/job.constants.ts
|
|
@@ -26,36 +27,14 @@ function ensureConstantEntry(filePath, freshContent, key, value) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// First exported object literal in the file, e.g. `export const JobConstants = {`.
|
|
34
|
-
const open = content.search(/export\s+const\s+\w+\s*(?::[^=]+)?=\s*{/);
|
|
35
|
-
if (open === -1) return 'failed';
|
|
36
|
-
const braceStart = content.indexOf('{', open);
|
|
37
|
-
|
|
38
|
-
// Walk to the matching closing brace (entries are flat string values, but
|
|
39
|
-
// stay correct if a nested object ever appears).
|
|
40
|
-
let depth = 0;
|
|
41
|
-
let close = -1;
|
|
42
|
-
for (let i = braceStart; i < content.length; i++) {
|
|
43
|
-
if (content[i] === '{') depth++;
|
|
44
|
-
else if (content[i] === '}') {
|
|
45
|
-
depth--;
|
|
46
|
-
if (depth === 0) {
|
|
47
|
-
close = i;
|
|
48
|
-
break;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
if (close === -1) return 'failed';
|
|
30
|
+
const literal = exportedLiteral(content, EXPORT_OBJECT);
|
|
31
|
+
if (!literal) return 'failed';
|
|
32
|
+
if (hasMember(content, literal, `${escapeRe(key)}\\s*:`)) return 'skipped';
|
|
53
33
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
fs.writeFileSync(filePath, next);
|
|
34
|
+
fs.writeFileSync(
|
|
35
|
+
filePath,
|
|
36
|
+
scan.appendMember(content, literal.open, literal.close, `${key}: '${value}',`, ' '),
|
|
37
|
+
);
|
|
59
38
|
return 'overwritten';
|
|
60
39
|
}
|
|
61
40
|
|
|
@@ -71,40 +50,64 @@ function ensureMapEntry(filePath, freshContent, entryKey, entryValue, imports) {
|
|
|
71
50
|
}
|
|
72
51
|
|
|
73
52
|
let content = fs.readFileSync(filePath, 'utf8');
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (open === -1) return 'failed';
|
|
53
|
+
const existing = exportedLiteral(content, EXPORT_OBJECT);
|
|
54
|
+
if (!existing) return 'failed';
|
|
55
|
+
if (hasMember(content, existing, escapeRe(entryKey))) return 'skipped';
|
|
78
56
|
|
|
79
57
|
for (const imp of imports) {
|
|
80
58
|
content = ensureImportLine(content, imp.className, imp.importPath);
|
|
81
59
|
}
|
|
82
60
|
|
|
83
61
|
// Re-locate the object after import insertion shifted offsets.
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
62
|
+
const literal = exportedLiteral(content, EXPORT_OBJECT);
|
|
63
|
+
fs.writeFileSync(
|
|
64
|
+
filePath,
|
|
65
|
+
scan.appendMember(content, literal.open, literal.close, `${entryKey}: ${entryValue},`, ' '),
|
|
87
66
|
);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
67
|
+
return 'overwritten';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Ensure `KEY = 'value',` is a member of the enum exported by `filePath`.
|
|
72
|
+
* Creates the file with `freshContent` when absent. Returns 'created' |
|
|
73
|
+
* 'skipped' | 'overwritten' | 'failed'.
|
|
74
|
+
*/
|
|
75
|
+
function ensureEnumMember(filePath, freshContent, key, value) {
|
|
76
|
+
if (!fs.existsSync(filePath)) {
|
|
77
|
+
return writeFile(filePath, freshContent);
|
|
99
78
|
}
|
|
100
|
-
if (close === -1) return 'failed';
|
|
101
79
|
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
80
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
81
|
+
const literal = exportedLiteral(content, EXPORT_ENUM);
|
|
82
|
+
if (!literal) return 'failed';
|
|
83
|
+
if (hasMember(content, literal, `${escapeRe(key)}\\s*=`)) return 'skipped';
|
|
84
|
+
|
|
85
|
+
fs.writeFileSync(
|
|
86
|
+
filePath,
|
|
87
|
+
scan.appendMember(content, literal.open, literal.close, `${key} = '${value}',`, ' '),
|
|
88
|
+
);
|
|
105
89
|
return 'overwritten';
|
|
106
90
|
}
|
|
107
91
|
|
|
92
|
+
const EXPORT_OBJECT = /export\s+const\s+\w+\s*(?::[^=]+)?=\s*{/;
|
|
93
|
+
const EXPORT_ENUM = /export\s+enum\s+\w+\s*{/;
|
|
94
|
+
|
|
95
|
+
/** Brace positions of the first exported literal matching `declaration`; null if none. */
|
|
96
|
+
function exportedLiteral(content, declaration) {
|
|
97
|
+
const mask = scan.codeMask(content);
|
|
98
|
+
const found = scan.findInCode(content, declaration, 0, mask);
|
|
99
|
+
if (!found) return null;
|
|
100
|
+
const open = content.indexOf('{', found.index);
|
|
101
|
+
const close = scan.matchingClose(content, open, mask);
|
|
102
|
+
return close === -1 ? null : { open, close };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Whether the literal's code (comments ignored) has a member starting with `pattern`. */
|
|
106
|
+
function hasMember(content, literal, pattern) {
|
|
107
|
+
const inner = scan.codeOnly(content, literal.open + 1, literal.close);
|
|
108
|
+
return new RegExp(`(^|[\\s,{])${pattern}`).test(inner);
|
|
109
|
+
}
|
|
110
|
+
|
|
108
111
|
function ensureImportLine(content, className, importPath) {
|
|
109
112
|
if (new RegExp(`from\\s+['"]${escapeRe(importPath)}['"]`).test(content)) {
|
|
110
113
|
return content;
|
|
@@ -131,6 +134,15 @@ function detectExportName(filePath) {
|
|
|
131
134
|
return match ? match[1] : null;
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
/** Name of the first `export enum X {` in the file (null if unreadable). */
|
|
138
|
+
function detectEnumName(filePath) {
|
|
139
|
+
if (!fs.existsSync(filePath)) return null;
|
|
140
|
+
const match = fs
|
|
141
|
+
.readFileSync(filePath, 'utf8')
|
|
142
|
+
.match(/export\s+enum\s+(\w+)\s*{/);
|
|
143
|
+
return match ? match[1] : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
134
146
|
/**
|
|
135
147
|
* Resolve the entity constants file, tolerating the legacy misspelled
|
|
136
148
|
* `enity.constants.ts` some projects carry — an existing registry is updated
|
|
@@ -150,6 +162,8 @@ function escapeRe(str) {
|
|
|
150
162
|
module.exports = {
|
|
151
163
|
ensureConstantEntry,
|
|
152
164
|
ensureMapEntry,
|
|
165
|
+
ensureEnumMember,
|
|
153
166
|
detectExportName,
|
|
167
|
+
detectEnumName,
|
|
154
168
|
entityConstantsPath,
|
|
155
169
|
};
|
package/src/lib/paths.js
CHANGED
|
@@ -41,6 +41,7 @@ function projectPaths(root = requireProjectRoot()) {
|
|
|
41
41
|
appModule: path.join(srcDir, 'app.module.ts'),
|
|
42
42
|
migrationsDir: path.join(srcDir, 'database', 'migrations'),
|
|
43
43
|
constantsDir: path.join(srcDir, 'constants'),
|
|
44
|
+
enumsDir: path.join(srcDir, 'global', 'enums'),
|
|
44
45
|
moduleDir: (moduleDot) => path.join(srcDir, moduleDot),
|
|
45
46
|
};
|
|
46
47
|
}
|
package/src/lib/registry.js
CHANGED
|
@@ -7,9 +7,13 @@ const { log } = require('./fs.utils');
|
|
|
7
7
|
const {
|
|
8
8
|
ensureConstantEntry,
|
|
9
9
|
ensureMapEntry,
|
|
10
|
+
ensureEnumMember,
|
|
10
11
|
detectExportName,
|
|
12
|
+
detectEnumName,
|
|
11
13
|
entityConstantsPath,
|
|
12
14
|
} = require('./constants.engine');
|
|
15
|
+
const { accessNames } = require('./access');
|
|
16
|
+
const { current } = require('./flavour');
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
19
|
* The project-wide identifier registries.
|
|
@@ -117,4 +121,58 @@ function registerEntity(paths, base, moduleDot, report) {
|
|
|
117
121
|
}
|
|
118
122
|
}
|
|
119
123
|
|
|
120
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Register `<base>`'s scope in src/global/enums/scope.enum.ts and, for the
|
|
126
|
+
* tenanted flavour, its default roles (admin, manager, reader) in role.enum.ts.
|
|
127
|
+
* Returns the enum names the generated controller imports.
|
|
128
|
+
*/
|
|
129
|
+
function registerAccess(paths, base, report) {
|
|
130
|
+
const a = accessNames(base);
|
|
131
|
+
const enums = { scope: 'ScopeEnum', role: 'RoleEnum' };
|
|
132
|
+
|
|
133
|
+
const scopePath = path.join(paths.enumsDir, 'scope.enum.ts');
|
|
134
|
+
const scope = ensureEnumMember(
|
|
135
|
+
scopePath,
|
|
136
|
+
tpl.scopeEnumFile(base),
|
|
137
|
+
a.scopeKey,
|
|
138
|
+
a.scopeValue,
|
|
139
|
+
);
|
|
140
|
+
if (scope === 'failed')
|
|
141
|
+
log.warn(
|
|
142
|
+
`Could not update ${path.relative(paths.root, scopePath)} — add ${a.scopeKey} = '${a.scopeValue}' manually.`,
|
|
143
|
+
);
|
|
144
|
+
else report(scope, scopePath, paths.root);
|
|
145
|
+
enums.scope = detectEnumName(scopePath) || enums.scope;
|
|
146
|
+
|
|
147
|
+
// The B2C model has scopes and no roles.
|
|
148
|
+
if (!current().tenantScoped) return enums;
|
|
149
|
+
|
|
150
|
+
const rolePath = path.join(paths.enumsDir, 'role.enum.ts');
|
|
151
|
+
const results = [];
|
|
152
|
+
for (const role of Object.values(a.roles)) {
|
|
153
|
+
const result = ensureEnumMember(
|
|
154
|
+
rolePath,
|
|
155
|
+
tpl.roleEnumFile(base),
|
|
156
|
+
role.key,
|
|
157
|
+
role.value,
|
|
158
|
+
);
|
|
159
|
+
if (result === 'failed') {
|
|
160
|
+
log.warn(
|
|
161
|
+
`Could not update ${path.relative(paths.root, rolePath)} — add ${role.key} = '${role.value}' manually.`,
|
|
162
|
+
);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
results.push(result);
|
|
166
|
+
}
|
|
167
|
+
// A fresh file already carries every default role, so later calls skip.
|
|
168
|
+
const summary = results.includes('created')
|
|
169
|
+
? 'created'
|
|
170
|
+
: results.includes('overwritten')
|
|
171
|
+
? 'overwritten'
|
|
172
|
+
: 'skipped';
|
|
173
|
+
if (results.length) report(summary, rolePath, paths.root);
|
|
174
|
+
enums.role = detectEnumName(rolePath) || enums.role;
|
|
175
|
+
return enums;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { registerJob, registerEntity, registerAccess };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Comment- and string-aware scanning of TypeScript source, so the CLI can edit
|
|
5
|
+
* array and object literals without being fooled by a `,`, `]` or `}` that sits
|
|
6
|
+
* inside a comment or a string. Every edit that inserts into an existing literal
|
|
7
|
+
* goes through here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** `mask[i]` is true when `content[i]` is code (not inside a comment or string). */
|
|
11
|
+
function codeMask(content) {
|
|
12
|
+
return scanMask(content, true);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `mask[i]` is true unless `content[i]` is inside a comment (strings count). */
|
|
16
|
+
function commentMask(content) {
|
|
17
|
+
return scanMask(content, false);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scanMask(content, maskStrings) {
|
|
21
|
+
const mask = new Array(content.length).fill(true);
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < content.length) {
|
|
24
|
+
const ch = content[i];
|
|
25
|
+
const next = content[i + 1];
|
|
26
|
+
let end = -1;
|
|
27
|
+
let isString = false;
|
|
28
|
+
if (ch === '/' && next === '/') {
|
|
29
|
+
end = content.indexOf('\n', i);
|
|
30
|
+
if (end === -1) end = content.length;
|
|
31
|
+
} else if (ch === '/' && next === '*') {
|
|
32
|
+
end = content.indexOf('*/', i + 2);
|
|
33
|
+
end = end === -1 ? content.length : end + 2;
|
|
34
|
+
} else if (ch === "'" || ch === '"' || ch === '`') {
|
|
35
|
+
isString = true;
|
|
36
|
+
end = i + 1;
|
|
37
|
+
while (end < content.length && content[end] !== ch) {
|
|
38
|
+
if (content[end] === '\\') end++;
|
|
39
|
+
end++;
|
|
40
|
+
}
|
|
41
|
+
end = Math.min(end + 1, content.length);
|
|
42
|
+
}
|
|
43
|
+
if (end === -1) {
|
|
44
|
+
i++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!isString || maskStrings)
|
|
48
|
+
for (let k = i; k < end; k++) mask[k] = false;
|
|
49
|
+
i = end;
|
|
50
|
+
}
|
|
51
|
+
return mask;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** First match of `regex` (global flag added) whose start is code; -1 if none. */
|
|
55
|
+
function findInCode(content, regex, from = 0, mask = codeMask(content)) {
|
|
56
|
+
const re = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
|
|
57
|
+
re.lastIndex = from;
|
|
58
|
+
let match;
|
|
59
|
+
while ((match = re.exec(content))) {
|
|
60
|
+
if (mask[match.index]) return { index: match.index, match };
|
|
61
|
+
re.lastIndex = match.index + 1;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Index of the bracket closing the one at `openIdx` (any of `([{`), skipping comments and strings. */
|
|
67
|
+
function matchingClose(content, openIdx, mask = codeMask(content)) {
|
|
68
|
+
const pairs = { '(': ')', '[': ']', '{': '}' };
|
|
69
|
+
const stack = [];
|
|
70
|
+
for (let i = openIdx; i < content.length; i++) {
|
|
71
|
+
if (!mask[i]) continue;
|
|
72
|
+
const ch = content[i];
|
|
73
|
+
if (pairs[ch]) stack.push(pairs[ch]);
|
|
74
|
+
else if (ch === ')' || ch === ']' || ch === '}') {
|
|
75
|
+
if (stack.pop() !== ch) return -1;
|
|
76
|
+
if (!stack.length) return i;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Index of the last non-whitespace character `mask` marks true in [from, to); -1 if none. */
|
|
83
|
+
function lastCodeChar(content, from, to, mask = codeMask(content)) {
|
|
84
|
+
for (let i = to - 1; i >= from; i--) {
|
|
85
|
+
if (mask[i] && !/\s/.test(content[i])) return i;
|
|
86
|
+
}
|
|
87
|
+
return -1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `content` between `from` and `to` with comments and strings blanked out. */
|
|
91
|
+
function codeOnly(content, from, to, mask = codeMask(content)) {
|
|
92
|
+
let out = '';
|
|
93
|
+
for (let i = from; i < to; i++) out += mask[i] ? content[i] : ' ';
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Insert `entryLine` (no indentation, no trailing newline) as the last member of
|
|
99
|
+
* the literal whose brackets sit at `open`/`close`. A missing comma after the
|
|
100
|
+
* current last member is added right after its code — never inside a trailing
|
|
101
|
+
* comment — and comments in the literal stay exactly where they were.
|
|
102
|
+
*/
|
|
103
|
+
function appendMember(content, open, close, entryLine, indent) {
|
|
104
|
+
const last = lastCodeChar(content, open + 1, close, commentMask(content));
|
|
105
|
+
let next = content;
|
|
106
|
+
let closeAt = close;
|
|
107
|
+
if (last !== -1 && content[last] !== ',' && last !== open) {
|
|
108
|
+
next = next.slice(0, last + 1) + ',' + next.slice(last + 1);
|
|
109
|
+
closeAt++;
|
|
110
|
+
}
|
|
111
|
+
// Insert before the whitespace that leads to the closing bracket.
|
|
112
|
+
let insertAt = closeAt;
|
|
113
|
+
while (insertAt > 0 && /[ \t]/.test(next[insertAt - 1])) insertAt--;
|
|
114
|
+
const closingIndent = next.slice(insertAt, closeAt);
|
|
115
|
+
const lead = next[insertAt - 1] === '\n' ? '' : '\n';
|
|
116
|
+
return (
|
|
117
|
+
next.slice(0, insertAt) +
|
|
118
|
+
`${lead}${indent}${entryLine}\n` +
|
|
119
|
+
closingIndent +
|
|
120
|
+
next.slice(closeAt)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
codeMask,
|
|
126
|
+
commentMask,
|
|
127
|
+
findInCode,
|
|
128
|
+
matchingClose,
|
|
129
|
+
lastCodeChar,
|
|
130
|
+
codeOnly,
|
|
131
|
+
appendMember,
|
|
132
|
+
};
|
package/src/lib/sync.engine.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
|
+
const scan = require('./source.scan');
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* The sync engine is the single brain behind `nh sync`, `nh check` and the
|
|
8
10
|
* auto-wiring step of `nh gen`. It scans scaffolded modules, regenerates each
|
|
@@ -178,21 +180,37 @@ function ensureImportLine(content, className, importPath) {
|
|
|
178
180
|
return lines.join('\n');
|
|
179
181
|
}
|
|
180
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Add `className` to the `imports: [...]` array of the @Module() decorator.
|
|
185
|
+
* The array is located and edited through the comment-aware scanner: splitting
|
|
186
|
+
* it on commas used to break any comment holding a comma, turning the rest of
|
|
187
|
+
* that comment into code.
|
|
188
|
+
*/
|
|
181
189
|
function ensureInImportsArray(content, className) {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
190
|
+
const mask = scan.codeMask(content);
|
|
191
|
+
const decorator = scan.findInCode(content, /@Module\s*\(/, 0, mask);
|
|
192
|
+
const key = scan.findInCode(
|
|
193
|
+
content,
|
|
194
|
+
/\bimports\s*:/,
|
|
195
|
+
decorator ? decorator.index : 0,
|
|
196
|
+
mask,
|
|
197
|
+
);
|
|
198
|
+
if (!key) return content;
|
|
199
|
+
const open = scan.findInCode(content, /\[/, key.index, mask);
|
|
200
|
+
if (!open) return content;
|
|
201
|
+
const close = scan.matchingClose(content, open.index, mask);
|
|
202
|
+
if (close === -1) return content;
|
|
203
|
+
|
|
204
|
+
const inner = scan.codeOnly(content, open.index + 1, close, mask);
|
|
205
|
+
if (new RegExp(`(^|[^\\w.])${escapeRe(className)}\\b`).test(inner))
|
|
206
|
+
return content;
|
|
207
|
+
return scan.appendMember(
|
|
208
|
+
content,
|
|
209
|
+
open.index,
|
|
210
|
+
close,
|
|
211
|
+
`${className},`,
|
|
212
|
+
' ',
|
|
213
|
+
);
|
|
196
214
|
}
|
|
197
215
|
|
|
198
216
|
function escapeRe(str) {
|
package/src/lib/templates.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
toCamelCase,
|
|
10
10
|
migrationClassName,
|
|
11
11
|
} = require("./naming");
|
|
12
|
+
const { accessNames } = require('./access');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Code templates. All generated code imports shared base classes from the
|
|
@@ -134,13 +135,26 @@ function attributesDto(base) {
|
|
|
134
135
|
* delegate to the module's service — so the HTTP surface stays readable and
|
|
135
136
|
* the same operation is reusable from a job, a subscriber or another service.
|
|
136
137
|
*/
|
|
137
|
-
function controller(
|
|
138
|
+
function controller(
|
|
139
|
+
base,
|
|
140
|
+
{ processor = false, listProcessor = false, enums = {} } = {},
|
|
141
|
+
) {
|
|
138
142
|
const n = names(base);
|
|
143
|
+
const a = accessNames(base);
|
|
144
|
+
const scopeEnum = enums.scope || 'ScopeEnum';
|
|
145
|
+
const roleEnum = enums.role || 'RoleEnum';
|
|
139
146
|
const svcProp = `${n.camel}Service`;
|
|
140
147
|
const hasPost = processor || listProcessor;
|
|
141
148
|
|
|
142
149
|
// @nestjs/common members, alphabetised the way prettier leaves them.
|
|
143
|
-
const nestCommon = [
|
|
150
|
+
const nestCommon = [
|
|
151
|
+
'Controller',
|
|
152
|
+
'Delete',
|
|
153
|
+
'Get',
|
|
154
|
+
'Param',
|
|
155
|
+
'ParseUUIDPipe',
|
|
156
|
+
'UsePipes',
|
|
157
|
+
];
|
|
144
158
|
if (hasPost) nestCommon.push('Body', 'Post');
|
|
145
159
|
nestCommon.sort();
|
|
146
160
|
|
|
@@ -164,19 +178,31 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
|
|
|
164
178
|
);
|
|
165
179
|
}
|
|
166
180
|
|
|
167
|
-
// Tenanted access
|
|
168
|
-
//
|
|
181
|
+
// Tenanted access names the lowest role that should reach the endpoint: the
|
|
182
|
+
// role carries its scope, so validateRole() checks the organization holds the
|
|
183
|
+
// scope first, then the role (a senior role satisfies a junior one). The B2C
|
|
184
|
+
// model has no roles, so the check is the scope alone.
|
|
169
185
|
const tenanted = current().tenantScoped;
|
|
170
|
-
|
|
186
|
+
imports.push(
|
|
171
187
|
tenanted
|
|
172
|
-
? `
|
|
173
|
-
: `
|
|
188
|
+
? `import { ${roleEnum} } from '../../global/enums/role.enum';`
|
|
189
|
+
: `import { ${scopeEnum} } from '../../global/enums/scope.enum';`,
|
|
190
|
+
);
|
|
191
|
+
const gate = (indent, level) => {
|
|
192
|
+
const [method, arg] = tenanted
|
|
193
|
+
? ['validateRole', `${roleEnum}.${a.roles[level].key}`]
|
|
194
|
+
: ['validateAccess', `${scopeEnum}.${a.scopeKey}`];
|
|
195
|
+
const call = `await this.authService.${method}(${arg})`;
|
|
196
|
+
return `${indent}const { user } = ${call};`.length <= 80
|
|
197
|
+
? call
|
|
198
|
+
: `await this.authService.${method}(\n${indent} ${arg},\n${indent})`;
|
|
199
|
+
};
|
|
174
200
|
|
|
175
201
|
const methods = [
|
|
176
202
|
` @Get(':id')
|
|
177
203
|
@ApiOperation({ summary: 'One ${n.kebab} by id.' })
|
|
178
204
|
async get${n.pascal}(@Param('id', ParseUUIDPipe) id: string) {
|
|
179
|
-
const { user } = ${gate(' ')};
|
|
205
|
+
const { user } = ${gate(' ', 'reader')};
|
|
180
206
|
|
|
181
207
|
return this.${svcProp}.get(user, id);
|
|
182
208
|
}`,
|
|
@@ -186,7 +212,7 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
|
|
|
186
212
|
methods.push(` @Post()
|
|
187
213
|
@ApiOperation({ summary: 'Create a ${n.kebab}, or update it when an id is sent.' })
|
|
188
214
|
async upsert${n.pascal}(@Body() body: ${n.dtoClass}) {
|
|
189
|
-
const { user } = ${gate(' ')};
|
|
215
|
+
const { user } = ${gate(' ', 'manager')};
|
|
190
216
|
|
|
191
217
|
return this.${svcProp}.upsert(user, body);
|
|
192
218
|
}`);
|
|
@@ -196,19 +222,22 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
|
|
|
196
222
|
methods.push(` @Post('list')
|
|
197
223
|
@ApiOperation({ summary: 'List, search and filter ${n.kebab}.' })
|
|
198
224
|
async list(@Body() body: CommonListFilterDto) {
|
|
199
|
-
const { user } = ${gate(' ')};
|
|
225
|
+
const { user } = ${gate(' ', 'reader')};
|
|
200
226
|
|
|
201
227
|
return this.${svcProp}.list(user, body);
|
|
202
228
|
}`);
|
|
203
229
|
}
|
|
204
230
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
231
|
+
methods.push(` @Delete(':id')
|
|
232
|
+
@ApiOperation({ summary: 'Delete one ${n.kebab} by id.' })
|
|
233
|
+
async remove${n.pascal}(@Param('id', ParseUUIDPipe) id: string) {
|
|
234
|
+
const { user } = ${gate(' ', 'admin')};
|
|
235
|
+
|
|
236
|
+
return this.${svcProp}.remove(user, id);
|
|
237
|
+
}`);
|
|
208
238
|
|
|
209
239
|
return `${imports.join('\n')}
|
|
210
240
|
|
|
211
|
-
${gateTodo}
|
|
212
241
|
@ApiTags('${n.kebab}')
|
|
213
242
|
@ApiBearerAuth()
|
|
214
243
|
@UsePipes(StrictValidationPipe)
|
|
@@ -283,6 +312,14 @@ function service(base, { processor = false, listProcessor = false } = {}) {
|
|
|
283
312
|
return ${n.camel};
|
|
284
313
|
}`);
|
|
285
314
|
|
|
315
|
+
// Soft delete, through the same ownership-scoped lookup as get().
|
|
316
|
+
members.push(` async remove(user: UserEntity, id: string) {
|
|
317
|
+
const ${n.camel} = await this.get(user, id);
|
|
318
|
+
await ${n.entityClass}.softRemove(${n.camel});
|
|
319
|
+
|
|
320
|
+
return { id, deleted: true, deleted_by: user.id };
|
|
321
|
+
}`);
|
|
322
|
+
|
|
286
323
|
if (processor) {
|
|
287
324
|
// Insert or update: CommonDataDto carries an optional `id`, and the data
|
|
288
325
|
// processor decides which of the two the payload asks for.
|
|
@@ -777,6 +814,33 @@ export const EntityMaps = {
|
|
|
777
814
|
`;
|
|
778
815
|
}
|
|
779
816
|
|
|
817
|
+
/** Fresh src/global/enums/scope.enum.ts (first entry included). */
|
|
818
|
+
function scopeEnumFile(base) {
|
|
819
|
+
const a = accessNames(base);
|
|
820
|
+
return `/** Scope identifiers (sys_scopes.identifier) the app checks. */
|
|
821
|
+
export enum ScopeEnum {
|
|
822
|
+
${a.scopeKey} = '${a.scopeValue}',
|
|
823
|
+
}
|
|
824
|
+
`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Fresh src/global/enums/role.enum.ts (the entity's default roles included). */
|
|
828
|
+
function roleEnumFile(base) {
|
|
829
|
+
const a = accessNames(base);
|
|
830
|
+
const members = Object.values(a.roles)
|
|
831
|
+
.map((role) => ` ${role.key} = '${role.value}',`)
|
|
832
|
+
.join('\n');
|
|
833
|
+
return `/**
|
|
834
|
+
* Tenant role identifiers (sys_roles.identifier) the app checks. Each scope's
|
|
835
|
+
* defaults rank admin (priority 1) > manager (2) > reader (3) — create the
|
|
836
|
+
* catalogue rows with those priorities under the matching scope.
|
|
837
|
+
*/
|
|
838
|
+
export enum RoleEnum {
|
|
839
|
+
${members}
|
|
840
|
+
}
|
|
841
|
+
`;
|
|
842
|
+
}
|
|
843
|
+
|
|
780
844
|
/** A freshly scaffolded module before any es6.classes content exists. */
|
|
781
845
|
function emptyEs6Classes() {
|
|
782
846
|
return `export const es6Classes = {
|
|
@@ -817,6 +881,8 @@ export class ${pascal}Module {}
|
|
|
817
881
|
|
|
818
882
|
module.exports = {
|
|
819
883
|
names,
|
|
884
|
+
scopeEnumFile,
|
|
885
|
+
roleEnumFile,
|
|
820
886
|
entity,
|
|
821
887
|
attributesDto,
|
|
822
888
|
controller,
|