create-astram-app 0.9.8 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/README.md +38 -2
- package/template/server/_env.example +11 -0
- package/template/server/package.json +5 -4
- package/template/server/src/app.module.ts +10 -4
- package/template/server/src/bootstrap-org.args.ts +87 -0
- package/template/server/src/bootstrap-org.ts +122 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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
|
@@ -59,7 +59,7 @@ wiring:
|
|
|
59
59
|
import { ShieldModule } from '@astram/guard';
|
|
60
60
|
|
|
61
61
|
@Module({
|
|
62
|
-
imports: [ShieldModule.forRoot()],
|
|
62
|
+
imports: [ShieldModule.forRoot()], // framework owns enforcement
|
|
63
63
|
controllers: [HealthController, DemoController],
|
|
64
64
|
})
|
|
65
65
|
export class AppModule {}
|
|
@@ -100,7 +100,7 @@ The full list, and how to upgrade an app scaffolded with an earlier version, is
|
|
|
100
100
|
2. Register it in `server/src/app.module.ts` (add the module to `imports`, or its
|
|
101
101
|
controller to `controllers`).
|
|
102
102
|
3. **Classify every route:** `@Public()` for open routes, `@Classified({ typeKey,
|
|
103
|
-
|
|
103
|
+
action })` for guarded ones. Anything unclassified is denied by design (403; with
|
|
104
104
|
`IdentityFeatureModule` wired, the app refuses to boot).
|
|
105
105
|
4. **Data access goes through the framework's RecordEngine** — never a raw DB pool.
|
|
106
106
|
The `server/eslint.config.mjs` wall fails lint on off-road DB access anywhere in
|
|
@@ -114,6 +114,42 @@ The full list, and how to upgrade an app scaffolded with an earlier version, is
|
|
|
114
114
|
|
|
115
115
|
`server/src/features/` starts empty — it's your space.
|
|
116
116
|
|
|
117
|
+
## Bootstrap your organisation
|
|
118
|
+
|
|
119
|
+
`npm run bootstrap:org` (in `server/`) creates your organisation (tenants, companies,
|
|
120
|
+
departments, branches, roles, users and grants) from one JSON file, through the framework's
|
|
121
|
+
own services against your `AppModule`. It needs auth on and `IdentityFeatureModule` wired in
|
|
122
|
+
`server/src/app.module.ts` (see its header), a built app (`npm run build`) and the migrations
|
|
123
|
+
applied. Without the module it exits non-zero with "wire IdentityFeatureModule in AppModule
|
|
124
|
+
before running bootstrap:org". The file format is in the framework capabilities synced into
|
|
125
|
+
`.claude/rules/astram-framework-capabilities.md` (section "Organisation bootstrap").
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
cd server
|
|
129
|
+
npm run build
|
|
130
|
+
# Dry run: prints the plan, writes nothing.
|
|
131
|
+
npm run bootstrap:org -- --file ../deploy/org.json
|
|
132
|
+
# Apply (needs ASTRAM_BOOTSTRAP_OPERATOR; safe to re-run, it converges).
|
|
133
|
+
ASTRAM_BOOTSTRAP_OPERATOR=jane.doe npm run bootstrap:org -- --file ../deploy/org.json --apply
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- Flags: `--apply`, `--claim-unowned-roles`, `--reissue-links`, `--link-ttl-hours <n>`,
|
|
137
|
+
`--links-to-stdout`, `--interactive` (prompt for root passwords without echo).
|
|
138
|
+
- Every non-root user gets a one-time set-password link, printed once, only to a terminal
|
|
139
|
+
(pass `--links-to-stdout` to print them to a pipe). Links are never logged or written to a file.
|
|
140
|
+
- Root passwords come from `ASTRAM_BOOTSTRAP_ROOT_PASSWORD_<USERNAME>` (a `secret-ref:` outside
|
|
141
|
+
development) or `--interactive`. Put the printed root uuids in `SHIELD_ROOT_IDS` and pass
|
|
142
|
+
`rootIdsFromEnv()` to both `forRoot` calls (see `server/_env.example`).
|
|
143
|
+
|
|
144
|
+
### Existing apps
|
|
145
|
+
|
|
146
|
+
An app scaffolded before this release has no `bootstrap:org`. To add it: upgrade
|
|
147
|
+
`@astram/identity` and `@astram/guard` (`npm update @astram/identity @astram/guard`), copy
|
|
148
|
+
`src/bootstrap-org.ts` and `src/bootstrap-org.args.ts` from a freshly scaffolded app (or from
|
|
149
|
+
`create-astram-app/template/server/src/` in the framework repo) into `server/src/`, and add
|
|
150
|
+
`"bootstrap:org": "node --env-file-if-exists=.env dist/bootstrap-org.js"` to the `scripts` of
|
|
151
|
+
`server/package.json`. `npm run build` compiles both files; no other change is needed.
|
|
152
|
+
|
|
117
153
|
## The frontend
|
|
118
154
|
|
|
119
155
|
With `--with-frontend` (or `npx -p create-astram-app astram-add-frontend` from this
|
|
@@ -135,6 +135,17 @@ NODE_ENV=development
|
|
|
135
135
|
# Upload size cap in bytes (default 10 MiB; above the 50 MiB ceiling fails boot).
|
|
136
136
|
# OBJECT_STORAGE_MAX_BYTES=10485760
|
|
137
137
|
|
|
138
|
+
# --- OPTIONAL: organisation bootstrap (`npm run bootstrap:org`; needs IdentityFeatureModule wired) ---
|
|
139
|
+
# Platform-root user uuids, comma-separated, lower-case canonical form. Read by rootIdsFromEnv()
|
|
140
|
+
# in src/app.module.ts (pass it to BOTH forRoot calls); bootstrap:org prints the uuid of each root.
|
|
141
|
+
# SHIELD_ROOT_IDS=
|
|
142
|
+
# Who is running the bootstrap (audit "on behalf of"). Required with --apply.
|
|
143
|
+
# ASTRAM_BOOTSTRAP_OPERATOR=jane.doe
|
|
144
|
+
# Password of each root user declared in the org file, one variable per username (upper-cased).
|
|
145
|
+
# Outside development/test it must be a `secret-ref:` reference (a literal fails the run);
|
|
146
|
+
# or pass --interactive to be prompted without echo.
|
|
147
|
+
# ASTRAM_BOOTSTRAP_ROOT_PASSWORD_ADMIN=secret-ref:bootstrap/root-password-admin
|
|
148
|
+
|
|
138
149
|
# --- Migrations (optional; @astram/identity's migrations run automatically) ---
|
|
139
150
|
# APP_MIGRATION_DIR=migrations # your app's own migrations, run after identity's
|
|
140
151
|
# APP_MIGRATION_TABLE=schema_migrations
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
10
10
|
"build": "tsc -p tsconfig.build.json",
|
|
11
11
|
"start": "node --env-file-if-exists=.env dist/main.js",
|
|
12
|
+
"bootstrap:org": "node --env-file-if-exists=.env dist/bootstrap-org.js",
|
|
12
13
|
"dev": "node scripts/dev.mjs",
|
|
13
14
|
"test": "vitest run",
|
|
14
15
|
"migration:run": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate",
|
|
@@ -21,10 +22,10 @@
|
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
24
|
"@astram/core": "^1.8.0",
|
|
24
|
-
"@astram/engine": "^0.
|
|
25
|
-
"@astram/guard": "^0.
|
|
26
|
-
"@astram/guide": "^0.2.
|
|
27
|
-
"@astram/identity": "^0.
|
|
25
|
+
"@astram/engine": "^0.10.0",
|
|
26
|
+
"@astram/guard": "^0.17.0",
|
|
27
|
+
"@astram/guide": "^0.2.8",
|
|
28
|
+
"@astram/identity": "^0.14.0",
|
|
28
29
|
"@astram/migrate": "^0.6.0",
|
|
29
30
|
"@astram/sdk": "^0.2.0",
|
|
30
31
|
"@nestjs/common": "^11.2.7",
|
|
@@ -52,13 +52,18 @@ import { DemoController } from './health/demo.controller';
|
|
|
52
52
|
* identity → guard), so the consumer wires it (ADR-0026):
|
|
53
53
|
* 1. Turn auth on (above), pass the identity permission keys (so the frontend admin
|
|
54
54
|
* nav lights up), and import the identity module:
|
|
55
|
+
* import { rootIdsFromEnv, ShieldModule } from '@astram/guard';
|
|
55
56
|
* import { identityAuthActionKeys, identityControlPlaneKeys,
|
|
56
57
|
* IdentityFeatureModule } from '@astram/identity';
|
|
57
58
|
* imports: [
|
|
58
|
-
* ShieldModule.forRoot({ auth: true,
|
|
59
|
+
* ShieldModule.forRoot({ auth: true, rootIds: rootIdsFromEnv(),
|
|
59
60
|
* identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys }),
|
|
60
|
-
*
|
|
61
|
+
* // mounts /identity/{companies,users,roles,…}
|
|
62
|
+
* IdentityFeatureModule.forRoot({ rootIds: rootIdsFromEnv() }),
|
|
61
63
|
* ],
|
|
64
|
+
* `rootIdsFromEnv()` reads `SHIELD_ROOT_IDS` (comma-separated platform-root user uuids; unset
|
|
65
|
+
* means no roots), so both calls get the same list. `npm run bootstrap:org` creates your
|
|
66
|
+
* organisation from a file and needs `IdentityFeatureModule` wired (see the README).
|
|
62
67
|
* `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
|
|
63
68
|
* (no separate engine setup) and builds the record authorization `ShieldModule` decides
|
|
64
69
|
* with, so the guard renders REAL decisions from your roles' grants. Do NOT also pass
|
|
@@ -89,8 +94,9 @@ import { DemoController } from './health/demo.controller';
|
|
|
89
94
|
// admin console (incl. the guided rule-builder + owner shares), use (see the OPTIONAL
|
|
90
95
|
// blocks above for the full form + the extraObjects note for your OWN owned types):
|
|
91
96
|
// imports: [
|
|
92
|
-
// ShieldModule.forRoot({ auth: true,
|
|
93
|
-
//
|
|
97
|
+
// ShieldModule.forRoot({ auth: true, rootIds: rootIdsFromEnv(), identityActionKeys,
|
|
98
|
+
// identityControlPlaneKeys }),
|
|
99
|
+
// IdentityFeatureModule.forRoot({ rootIds: rootIdsFromEnv() /*, extraObjects: [YourType] */ }),
|
|
94
100
|
// ],
|
|
95
101
|
imports: [ShieldModule.forRoot()],
|
|
96
102
|
controllers: [HealthController, DemoController],
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-line flags of `npm run bootstrap:org` (see `bootstrap-org.ts`).
|
|
3
|
+
*
|
|
4
|
+
* A pure module (no framework imports) so the flag parser is unit-testable. Unknown flags
|
|
5
|
+
* and stray arguments are refused: a typo such as `--aply` must never silently fall back
|
|
6
|
+
* to a dry run, and error text never echoes the offending value (it could be a secret).
|
|
7
|
+
*/
|
|
8
|
+
export interface BootstrapArgs {
|
|
9
|
+
/** Path of the org JSON file. */
|
|
10
|
+
file: string;
|
|
11
|
+
/** Write. Without it the run is a dry run. */
|
|
12
|
+
apply: boolean;
|
|
13
|
+
claimUnownedRoles: boolean;
|
|
14
|
+
reissueLinks: boolean;
|
|
15
|
+
/** Link lifetime in whole hours; the runner validates the range. */
|
|
16
|
+
linkTtlHours: number | undefined;
|
|
17
|
+
linksToStdout: boolean;
|
|
18
|
+
/** Prompt for the root passwords (no echo) instead of reading the environment. */
|
|
19
|
+
interactive: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class BootstrapArgsError extends Error {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'BootstrapArgsError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const BOOTSTRAP_USAGE =
|
|
30
|
+
'usage: npm run bootstrap:org -- --file <path> [--apply] [--claim-unowned-roles] ' +
|
|
31
|
+
'[--reissue-links] [--link-ttl-hours <n>] [--links-to-stdout] [--interactive]';
|
|
32
|
+
|
|
33
|
+
export function parseBootstrapArgs(argv: readonly string[]): BootstrapArgs {
|
|
34
|
+
const args: BootstrapArgs = {
|
|
35
|
+
file: '',
|
|
36
|
+
apply: false,
|
|
37
|
+
claimUnownedRoles: false,
|
|
38
|
+
reissueLinks: false,
|
|
39
|
+
linkTtlHours: undefined,
|
|
40
|
+
linksToStdout: false,
|
|
41
|
+
interactive: false,
|
|
42
|
+
};
|
|
43
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
44
|
+
const flag = argv[i] as string;
|
|
45
|
+
switch (flag) {
|
|
46
|
+
case '--file': {
|
|
47
|
+
const value = argv[i + 1];
|
|
48
|
+
if (value === undefined || value === '' || value.startsWith('--')) {
|
|
49
|
+
throw new BootstrapArgsError('--file needs a path');
|
|
50
|
+
}
|
|
51
|
+
args.file = value;
|
|
52
|
+
i += 1;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case '--apply':
|
|
56
|
+
args.apply = true;
|
|
57
|
+
break;
|
|
58
|
+
case '--claim-unowned-roles':
|
|
59
|
+
args.claimUnownedRoles = true;
|
|
60
|
+
break;
|
|
61
|
+
case '--reissue-links':
|
|
62
|
+
args.reissueLinks = true;
|
|
63
|
+
break;
|
|
64
|
+
case '--links-to-stdout':
|
|
65
|
+
args.linksToStdout = true;
|
|
66
|
+
break;
|
|
67
|
+
case '--interactive':
|
|
68
|
+
args.interactive = true;
|
|
69
|
+
break;
|
|
70
|
+
case '--link-ttl-hours': {
|
|
71
|
+
const value = argv[i + 1];
|
|
72
|
+
if (value === undefined || !/^\d+$/.test(value)) {
|
|
73
|
+
throw new BootstrapArgsError('--link-ttl-hours needs a whole number of hours');
|
|
74
|
+
}
|
|
75
|
+
args.linkTtlHours = Number(value);
|
|
76
|
+
i += 1;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
throw new BootstrapArgsError(
|
|
81
|
+
flag.startsWith('--') ? 'unknown option' : 'unexpected argument',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (args.file === '') throw new BootstrapArgsError('--file <path> is required');
|
|
86
|
+
return args;
|
|
87
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { Writable } from 'node:stream';
|
|
4
|
+
import { NestFactory } from '@nestjs/core';
|
|
5
|
+
import { resolveBootSecrets } from '@astram/guard';
|
|
6
|
+
import { OrgBootstrapError, runOrgBootstrap } from '@astram/identity';
|
|
7
|
+
import {
|
|
8
|
+
BOOTSTRAP_USAGE,
|
|
9
|
+
BootstrapArgsError,
|
|
10
|
+
parseBootstrapArgs,
|
|
11
|
+
type BootstrapArgs,
|
|
12
|
+
} from './bootstrap-org.args';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `npm run bootstrap:org -- --file org.json [--apply]`: creates your organisation (tenants,
|
|
16
|
+
* companies, roles, users, grants) from a declarative file, through the framework's own
|
|
17
|
+
* services, against THIS app's `AppModule` (so grants can name your own object types).
|
|
18
|
+
* Without `--apply` it is a dry run that prints the plan and writes nothing.
|
|
19
|
+
*
|
|
20
|
+
* No security logic lives here: `runOrgBootstrap` (from `@astram/identity`) does the work.
|
|
21
|
+
* Same boot order as `main.ts`: `resolveBootSecrets()` runs BEFORE `AppModule` is loaded,
|
|
22
|
+
* so `AppModule` is imported dynamically. Exit code 0 on success, non-zero otherwise.
|
|
23
|
+
*
|
|
24
|
+
* Needs `IdentityFeatureModule` wired in `AppModule` (see the header of `app.module.ts`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Prompt without echo (TTY only): the typed characters never reach the terminal. */
|
|
28
|
+
async function promptHidden(question: string): Promise<string> {
|
|
29
|
+
let muted = false;
|
|
30
|
+
const output = new Writable({
|
|
31
|
+
write(chunk, _encoding, done) {
|
|
32
|
+
if (!muted) process.stdout.write(chunk);
|
|
33
|
+
done();
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const rl = createInterface({ input: process.stdin, output, terminal: true });
|
|
37
|
+
return new Promise<string>((resolve) => {
|
|
38
|
+
rl.question(question, (answer) => {
|
|
39
|
+
rl.close();
|
|
40
|
+
process.stdout.write('\n');
|
|
41
|
+
resolve(answer);
|
|
42
|
+
});
|
|
43
|
+
muted = true;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function run(args: BootstrapArgs): Promise<number> {
|
|
48
|
+
if (args.interactive && !process.stdin.isTTY) {
|
|
49
|
+
console.error('--interactive needs a terminal');
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
await resolveBootSecrets();
|
|
53
|
+
const { AppModule } = await import('./app.module');
|
|
54
|
+
// Warn level only: the Nest boot chatter is noise, and nothing here may log a secret.
|
|
55
|
+
const app = await NestFactory.createApplicationContext(AppModule, {
|
|
56
|
+
logger: ['warn', 'error'],
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
const report = await runOrgBootstrap(app, {
|
|
60
|
+
file: args.file,
|
|
61
|
+
apply: args.apply,
|
|
62
|
+
claimUnownedRoles: args.claimUnownedRoles,
|
|
63
|
+
reissueLinks: args.reissueLinks,
|
|
64
|
+
...(args.linkTtlHours !== undefined ? { linkTtlHours: args.linkTtlHours } : {}),
|
|
65
|
+
linksToStdout: args.linksToStdout,
|
|
66
|
+
...(process.env.ASTRAM_BOOTSTRAP_OPERATOR !== undefined
|
|
67
|
+
? { operator: process.env.ASTRAM_BOOTSTRAP_OPERATOR }
|
|
68
|
+
: {}),
|
|
69
|
+
isTTY: process.stdout.isTTY === true,
|
|
70
|
+
out: console.log,
|
|
71
|
+
...(args.interactive
|
|
72
|
+
? {
|
|
73
|
+
promptRootPassword: (username: string) =>
|
|
74
|
+
promptHidden(`Password for root "${username}": `),
|
|
75
|
+
}
|
|
76
|
+
: {}),
|
|
77
|
+
});
|
|
78
|
+
// Counts only: links and passwords were already shown through `out`, never repeated.
|
|
79
|
+
console.log(
|
|
80
|
+
`${report.applied ? 'applied' : 'dry run (nothing written; pass --apply to write)'}: ` +
|
|
81
|
+
`${report.plan.entries.length} planned, ${report.links} link(s) issued, ` +
|
|
82
|
+
`${report.rootIds.length} root(s)`,
|
|
83
|
+
);
|
|
84
|
+
return 0;
|
|
85
|
+
} finally {
|
|
86
|
+
await app.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function main(): Promise<void> {
|
|
91
|
+
let args: BootstrapArgs;
|
|
92
|
+
try {
|
|
93
|
+
args = parseBootstrapArgs(process.argv.slice(2));
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err instanceof BootstrapArgsError) {
|
|
96
|
+
console.error(`${err.message}\n${BOOTSTRAP_USAGE}`);
|
|
97
|
+
process.exitCode = 2;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
process.exitCode = await run(args);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
if (err instanceof OrgBootstrapError) {
|
|
107
|
+
if (err.code === 'not_wired') {
|
|
108
|
+
console.error(
|
|
109
|
+
'wire IdentityFeatureModule in AppModule before running bootstrap:org (see the header of src/app.module.ts)',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
console.error(`bootstrap:org failed [${err.code}]: ${err.message}`);
|
|
113
|
+
} else {
|
|
114
|
+
// Generic on purpose: an unexpected error can carry values from the file or the database.
|
|
115
|
+
console.error(
|
|
116
|
+
'bootstrap:org failed: unexpected error (no details printed; see the app logs)',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
void main();
|