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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astram-app",
3
- "version": "0.9.8",
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",
@@ -59,7 +59,7 @@ wiring:
59
59
  import { ShieldModule } from '@astram/guard';
60
60
 
61
61
  @Module({
62
- imports: [ShieldModule.forRoot()], // framework owns enforcement
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
- action })` for guarded ones. Anything unclassified is denied by design (403; with
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.9.0",
25
- "@astram/guard": "^0.16.0",
26
- "@astram/guide": "^0.2.7",
27
- "@astram/identity": "^0.13.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
- * IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles,…}
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, identityActionKeys, identityControlPlaneKeys }),
93
- // IdentityFeatureModule.forRoot({ /* extraObjects: [YourType] */ }),
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();