create-astram-app 0.9.8 → 0.11.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.11.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 {}
@@ -80,7 +80,15 @@ the credential store, MFA, session handling and the decision authority; you writ
80
80
  ### Before you deploy with auth on
81
81
 
82
82
  - Pass an `emailSender` (or `'disabled'`): outside `NODE_ENV=development|test` boot fails
83
- without one. Set `NODE_ENV=production` explicitly; `npm start` does not set it.
83
+ without one. Set `NODE_ENV=production` explicitly; `npm start` does not set it. The easiest is
84
+ `emailSender: 'env'`: the provider (SMTP, Brevo, SendGrid or Mailgun) comes from `EMAIL_PROVIDER`
85
+ and the MESSAGING block in `server/_env.example`, with no mail code in the app. It needs
86
+ `@astram/messaging` installed (the template already lists it) and, outside development, a
87
+ `SECRETS_PROVIDER` for the credentials. Every framework email is then bounded and audited, and
88
+ admins can invite pending users from the Users page.
89
+ - Keep the framework pins in `server/package.json` exact (`@astram/core`, `engine`, `guard`,
90
+ `identity`, `migrate`): the packages pin each other exactly, and a `^` range can install a
91
+ second `@astram/core`. `npm ls @astram/core` must show one copy.
84
92
  - Set `RESET_LINK_BASE_URL` and `EMAIL_CHANGE_LINK_BASE_URL` to your public reset-password and
85
93
  confirm-email pages: with an `emailSender`, production boot fails when either is unset or
86
94
  points at localhost.
@@ -100,7 +108,7 @@ The full list, and how to upgrade an app scaffolded with an earlier version, is
100
108
  2. Register it in `server/src/app.module.ts` (add the module to `imports`, or its
101
109
  controller to `controllers`).
102
110
  3. **Classify every route:** `@Public()` for open routes, `@Classified({ typeKey,
103
- action })` for guarded ones. Anything unclassified is denied by design (403; with
111
+ action })` for guarded ones. Anything unclassified is denied by design (403; with
104
112
  `IdentityFeatureModule` wired, the app refuses to boot).
105
113
  4. **Data access goes through the framework's RecordEngine** — never a raw DB pool.
106
114
  The `server/eslint.config.mjs` wall fails lint on off-road DB access anywhere in
@@ -114,6 +122,45 @@ The full list, and how to upgrade an app scaffolded with an earlier version, is
114
122
 
115
123
  `server/src/features/` starts empty — it's your space.
116
124
 
125
+ ## Bootstrap your organisation
126
+
127
+ `npm run bootstrap:org` (in `server/`) creates your organisation (tenants, companies,
128
+ departments, branches, roles, users and grants) from one JSON file, through the framework's
129
+ own services against your `AppModule`. It needs auth on and `IdentityFeatureModule` wired in
130
+ `server/src/app.module.ts` (see its header), a built app (`npm run build`) and the migrations
131
+ applied. Without the module it exits non-zero with "wire IdentityFeatureModule in AppModule
132
+ before running bootstrap:org". The file format is in the framework capabilities synced into
133
+ `.claude/rules/astram-framework-capabilities.md` (section "Organisation bootstrap").
134
+
135
+ ```bash
136
+ cd server
137
+ npm run build
138
+ # Dry run: prints the plan, writes nothing.
139
+ npm run bootstrap:org -- --file ../deploy/org.json
140
+ # Apply (needs ASTRAM_BOOTSTRAP_OPERATOR; safe to re-run, it converges).
141
+ ASTRAM_BOOTSTRAP_OPERATOR=jane.doe npm run bootstrap:org -- --file ../deploy/org.json --apply
142
+ ```
143
+
144
+ - Flags: `--apply`, `--claim-unowned-roles`, `--reissue-links`, `--link-ttl-hours <n>`,
145
+ `--links-to-stdout`, `--email-links`, `--interactive` (prompt for root passwords without echo).
146
+ - Every non-root user gets a one-time set-password link, printed once, only to a terminal
147
+ (pass `--links-to-stdout` to print them to a pipe). Links are never logged or written to a file.
148
+ With `emailSender` configured, `--email-links` emails each link to its user instead. Root
149
+ users' links are never emailed (a root link is a platform takeover): they are printed to a
150
+ terminal as before, or reported as not delivered.
151
+ - Root passwords come from `ASTRAM_BOOTSTRAP_ROOT_PASSWORD_<USERNAME>` (a `secret-ref:` outside
152
+ development) or `--interactive`. Put the printed root uuids in `SHIELD_ROOT_IDS` and pass
153
+ `rootIdsFromEnv()` to both `forRoot` calls (see `server/_env.example`).
154
+
155
+ ### Existing apps
156
+
157
+ An app scaffolded before this release has no `bootstrap:org`. To add it: upgrade
158
+ `@astram/identity` and `@astram/guard` (`npm update @astram/identity @astram/guard`), copy
159
+ `src/bootstrap-org.ts` and `src/bootstrap-org.args.ts` from a freshly scaffolded app (or from
160
+ `create-astram-app/template/server/src/` in the framework repo) into `server/src/`, and add
161
+ `"bootstrap:org": "node --env-file-if-exists=.env dist/bootstrap-org.js"` to the `scripts` of
162
+ `server/package.json`. `npm run build` compiles both files; no other change is needed.
163
+
117
164
  ## The frontend
118
165
 
119
166
  With `--with-frontend` (or `npx -p create-astram-app astram-add-frontend` from this
@@ -0,0 +1,28 @@
1
+ // resetWording.js — the reset page's words (G-010 R11, AC-15). An invite link carries
2
+ // `purpose=invite`; it opens the same page and posts the same token to the same
3
+ // `POST /auth/reset-password`, so the marker changes wording only and grants nothing.
4
+
5
+ const RESET = Object.freeze({
6
+ isInvite: false,
7
+ title: 'Choose a new password',
8
+ subtitle: 'Enter a new password for your account.',
9
+ submit: 'Reset password',
10
+ success: 'Your password has been reset. Please sign in with your new password.',
11
+ deadLink: 'Request a new reset link',
12
+ });
13
+
14
+ const INVITE = Object.freeze({
15
+ isInvite: true,
16
+ title: 'Set your password',
17
+ subtitle: 'Choose a password to finish setting up your account.',
18
+ submit: 'Set password',
19
+ success: 'Your password is set. Please sign in.',
20
+ deadLink: 'Ask your administrator for a new invite',
21
+ });
22
+
23
+ /**
24
+ * @param {string | null | undefined} purpose the link's `purpose` query value
25
+ */
26
+ export function resetWording(purpose) {
27
+ return purpose === 'invite' ? INVITE : RESET;
28
+ }
@@ -0,0 +1,35 @@
1
+ // resetWording.test.mjs — G-010 AC-15: an invite link (`purpose=invite`) opens the same reset
2
+ // page with "Set your password" wording; any other link keeps the reset wording. The marker only
3
+ // changes words: the page posts the same token to the same route either way.
4
+ //
5
+ // node --test src/auth/resetWording.test.mjs
6
+ import { test, describe } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { resetWording } from './resetWording.js';
9
+
10
+ describe('resetWording', () => {
11
+ test('purpose=invite → set-password wording', () => {
12
+ const w = resetWording('invite');
13
+ assert.equal(w.isInvite, true);
14
+ assert.equal(w.title, 'Set your password');
15
+ assert.equal(w.subtitle, 'Choose a password to finish setting up your account.');
16
+ assert.equal(w.submit, 'Set password');
17
+ assert.equal(w.success, 'Your password is set. Please sign in.');
18
+ assert.equal(w.deadLink, 'Ask your administrator for a new invite');
19
+ });
20
+
21
+ test('anything else → today’s reset wording', () => {
22
+ for (const purpose of [null, undefined, '', 'reset', 'INVITE', 'invite ']) {
23
+ const w = resetWording(purpose);
24
+ assert.equal(w.isInvite, false);
25
+ assert.equal(w.title, 'Choose a new password');
26
+ assert.equal(w.subtitle, 'Enter a new password for your account.');
27
+ assert.equal(w.submit, 'Reset password');
28
+ assert.equal(
29
+ w.success,
30
+ 'Your password has been reset. Please sign in with your new password.',
31
+ );
32
+ assert.equal(w.deadLink, 'Request a new reset link');
33
+ }
34
+ });
35
+ });
@@ -18,6 +18,7 @@ import { Label } from '@/components/ui/label';
18
18
  import { Card, CardContent } from '@/components/ui/card';
19
19
  import ThemeToggle from '@/theme/ThemeToggle';
20
20
  import { branding } from '@/config/branding';
21
+ import { resetWording } from '@/auth/resetWording';
21
22
 
22
23
  const MIN_PASSWORD_LEN = 8;
23
24
 
@@ -26,6 +27,8 @@ export default function ResetPassword() {
26
27
  const navigate = useNavigate();
27
28
  const [params] = useSearchParams();
28
29
  const token = params.get('token') ?? '';
30
+ // G-010: invite links carry `purpose=invite` (wording only; the same token, the same route).
31
+ const words = resetWording(params.get('purpose'));
29
32
 
30
33
  const [next, setNext] = useState('');
31
34
  const [confirm, setConfirm] = useState('');
@@ -59,7 +62,7 @@ export default function ResetPassword() {
59
62
  setBusy(false);
60
63
 
61
64
  if (r.ok) {
62
- toast.success('Your password has been reset. Please sign in with your new password.');
65
+ toast.success(words.success);
63
66
  navigate('/login', { replace: true });
64
67
  return;
65
68
  }
@@ -89,10 +92,8 @@ export default function ResetPassword() {
89
92
  className="hidden h-9 w-auto dark:block"
90
93
  />
91
94
  <div className="text-center">
92
- <h1 className="text-xl font-bold tracking-tight">Choose a new password</h1>
93
- <p className="mt-1 text-sm text-muted-foreground">
94
- Enter a new password for your account.
95
- </p>
95
+ <h1 className="text-xl font-bold tracking-tight">{words.title}</h1>
96
+ <p className="mt-1 text-sm text-muted-foreground">{words.subtitle}</p>
96
97
  </div>
97
98
  </div>
98
99
 
@@ -105,12 +106,16 @@ export default function ResetPassword() {
105
106
 
106
107
  {linkDead ? (
107
108
  <div className="text-center">
108
- <Link
109
- to="/forgot-password"
110
- className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
111
- >
112
- Request a new reset link
113
- </Link>
109
+ {words.isInvite ? (
110
+ <p className="text-sm text-muted-foreground">{words.deadLink}</p>
111
+ ) : (
112
+ <Link
113
+ to="/forgot-password"
114
+ className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
115
+ >
116
+ {words.deadLink}
117
+ </Link>
118
+ )}
114
119
  </div>
115
120
  ) : (
116
121
  <form onSubmit={onSubmit} className="space-y-4" noValidate>
@@ -143,7 +148,7 @@ export default function ResetPassword() {
143
148
  ) : (
144
149
  <>
145
150
  <KeyRound className="size-4" />
146
- Reset password
151
+ {words.submit}
147
152
  </>
148
153
  )}
149
154
  </Button>
@@ -8,18 +8,24 @@
8
8
  * The user EDIT page (/users/edit/:uuid) is where the Assignments (membership + posting)
9
9
  * editor will live in Phase 5 — UserForm leaves a clear placeholder for it.
10
10
  *
11
+ * Invite (G-010): a "Sign-in" column shows whether each user has set a password yet
12
+ * (`row.signInStatus`, computed server-side), with an Invite button for pending users when the
13
+ * caller holds identity:user:invite (`inviteAction.js`; UX only, the server decides).
14
+ *
11
15
  * ⛔ No raw UUIDs: columns render username/email/status; `row.id` is a key/route param only.
12
16
  */
13
- import { useCallback, useEffect, useState } from 'react';
17
+ import { useCallback, useEffect, useMemo, useState } from 'react';
14
18
  import { Outlet, useNavigate, useSearchParams } from 'react-router-dom';
15
- import { Users } from 'lucide-react';
19
+ import { Send, Users } from 'lucide-react';
16
20
  import { toast } from 'sonner';
17
- import { useAuth } from '@/auth/AuthContext';
21
+ import { useAuth, useCan } from '@/auth/AuthContext';
18
22
  import AppShell from '@/shell/AppShell';
19
23
  import PageHeader from '@/components/PageHeader';
20
24
  import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
21
25
  import ConfirmDialog from '@/components/ConfirmDialog';
22
26
  import { Badge } from '@/components/ui/badge';
27
+ import { Button } from '@/components/ui/button';
28
+ import { INVITE_PERMISSION, canInvite, inviteOutcome } from './inviteAction';
23
29
 
24
30
  /** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
25
31
  function formatDate(v) {
@@ -35,6 +41,38 @@ function formatDate(v) {
35
41
  }
36
42
  }
37
43
 
44
+ /** Sign-in status badge text (server-computed `signInStatus`). */
45
+ const SIGN_IN_LABEL = { pending: 'Pending', active: 'Active', none: 'No sign-in' };
46
+
47
+ /** The Sign-in column: status badge + Invite for pending users (G-010). Not sortable. */
48
+ function signInColumn(holdsInvite, invitingId, onInvite) {
49
+ return {
50
+ key: 'signInStatus',
51
+ label: 'Sign-in',
52
+ sortable: false,
53
+ render: (row) => (
54
+ <div className="flex items-center gap-2">
55
+ <Badge variant={row.signInStatus === 'active' ? 'success' : 'secondary'}>
56
+ {SIGN_IN_LABEL[row.signInStatus] ?? '—'}
57
+ </Badge>
58
+ {canInvite(row, holdsInvite) && (
59
+ <Button
60
+ type="button"
61
+ variant="ghost"
62
+ size="sm"
63
+ disabled={invitingId === row.id}
64
+ onClick={() => onInvite(row)}
65
+ aria-label={`Invite ${row.fields?.username || 'this user'}`}
66
+ >
67
+ <Send className="size-4" />
68
+ Invite
69
+ </Button>
70
+ )}
71
+ </div>
72
+ ),
73
+ };
74
+ }
75
+
38
76
  // Sortable columns (server-side): Username, Email, Status, Created.
39
77
  const COLUMNS = [
40
78
  {
@@ -121,6 +159,9 @@ export default function UsersPage() {
121
159
  setSearchParams(p, { replace: true });
122
160
  }, [query, setSearchParams]);
123
161
 
162
+ const holdsInvite = useCan(INVITE_PERMISSION);
163
+ const [invitingId, setInvitingId] = useState(null);
164
+
124
165
  const [deleteTarget, setDeleteTarget] = useState(null);
125
166
  const [deleteBusy, setDeleteBusy] = useState(false);
126
167
 
@@ -152,6 +193,40 @@ export default function UsersPage() {
152
193
  const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
153
194
  const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
154
195
 
196
+ const onInvite = useCallback(
197
+ async (row) => {
198
+ if (!token || invitingId) return;
199
+ setInvitingId(row.id);
200
+ const outcome = inviteOutcome(await identity.inviteUser(token, row.id));
201
+ setInvitingId(null);
202
+ if (outcome.kind === 'error') {
203
+ toast.error(outcome.text);
204
+ return;
205
+ }
206
+ if (outcome.link) {
207
+ // The one-time link is shown nowhere else: copy it, or show it once if the copy fails.
208
+ try {
209
+ await navigator.clipboard.writeText(outcome.link);
210
+ toast.success(outcome.text);
211
+ } catch {
212
+ toast.success(outcome.text.replace('Link copied', 'Copy this link now'), {
213
+ description: outcome.link,
214
+ duration: 60_000,
215
+ });
216
+ }
217
+ } else {
218
+ toast.success(outcome.text);
219
+ }
220
+ },
221
+ [identity, token, invitingId],
222
+ );
223
+
224
+ const columns = useMemo(() => {
225
+ const out = [...COLUMNS];
226
+ out.splice(3, 0, signInColumn(holdsInvite, invitingId, onInvite));
227
+ return out;
228
+ }, [holdsInvite, invitingId, onInvite]);
229
+
155
230
  const onConfirmDelete = async () => {
156
231
  if (!deleteTarget) return;
157
232
  setDeleteBusy(true);
@@ -185,7 +260,7 @@ export default function UsersPage() {
185
260
  />
186
261
 
187
262
  <EntityList
188
- columns={COLUMNS}
263
+ columns={columns}
189
264
  rows={rows}
190
265
  search={query.search}
191
266
  sortBy={query.sortBy}
@@ -0,0 +1,37 @@
1
+ // inviteAction.js — the Users page's Invite action (G-010 R13, AC-16). Pure logic, tested by
2
+ // inviteAction.test.mjs; UsersPage renders from it. Showing or hiding the action is UX only: the
3
+ // server decides (`POST /identity/users/:uuid/invite` needs identity:user:invite, the tenant wall
4
+ // and the dominance ceiling).
5
+
6
+ /** The permission key the invite route is gated on. */
7
+ export const INVITE_PERMISSION = 'identity:user:invite';
8
+
9
+ /**
10
+ * Show Invite for this row? Only for users whose sign-in is still pending (created or invited,
11
+ * no password yet) and only when the caller holds {@link INVITE_PERMISSION}.
12
+ * @param {{ signInStatus?: string } | null | undefined} row
13
+ * @param {boolean} holdsInvite
14
+ */
15
+ export function canInvite(row, holdsInvite) {
16
+ return Boolean(holdsInvite && row && row.signInStatus === 'pending');
17
+ }
18
+
19
+ /**
20
+ * The toast for an `identity.inviteUser` result. A returned one-time link (only when the app opted
21
+ * in to `returnLinkToAdmin`) is handed back for the page to copy to the clipboard.
22
+ * @param {{ ok: boolean, emailed?: boolean, link?: string, error?: string }} result
23
+ * @returns {{ kind: 'success' | 'error', text: string, link?: string }}
24
+ */
25
+ export function inviteOutcome(result) {
26
+ if (!result || !result.ok) {
27
+ return { kind: 'error', text: (result && result.error) || 'Could not invite the user.' };
28
+ }
29
+ if (result.link) {
30
+ return {
31
+ kind: 'success',
32
+ text: result.emailed ? 'Invite sent · Link copied' : 'Link copied',
33
+ link: result.link,
34
+ };
35
+ }
36
+ return { kind: 'success', text: 'Invite sent' };
37
+ }
@@ -0,0 +1,63 @@
1
+ // inviteAction.test.mjs — G-010 AC-16: the Users page shows Invite only for users whose sign-in
2
+ // is still pending, and only to callers holding identity:user:invite (UX only; the server decides).
3
+ //
4
+ // node --test src/pages/users/inviteAction.test.mjs
5
+ import { test, describe } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { INVITE_PERMISSION, canInvite, inviteOutcome } from './inviteAction.js';
8
+
9
+ describe('canInvite', () => {
10
+ test('the permission key is identity:user:invite', () => {
11
+ assert.equal(INVITE_PERMISSION, 'identity:user:invite');
12
+ });
13
+
14
+ test('pending users, for a caller who holds the key', () => {
15
+ assert.equal(canInvite({ id: 'u1', signInStatus: 'pending' }, true), true);
16
+ });
17
+
18
+ test('never for active users, users with no credential, or unknown status', () => {
19
+ for (const signInStatus of ['active', 'none', undefined, 'PENDING']) {
20
+ assert.equal(canInvite({ id: 'u1', signInStatus }, true), false);
21
+ }
22
+ });
23
+
24
+ test('never without the permission, or without a row', () => {
25
+ assert.equal(canInvite({ id: 'u1', signInStatus: 'pending' }, false), false);
26
+ assert.equal(canInvite(null, true), false);
27
+ });
28
+ });
29
+
30
+ describe('inviteOutcome', () => {
31
+ test('emailed → "Invite sent"', () => {
32
+ assert.deepEqual(inviteOutcome({ ok: true, emailed: true }), {
33
+ kind: 'success',
34
+ text: 'Invite sent',
35
+ });
36
+ });
37
+
38
+ test('a returned link → copy it, "Link copied"; emailed too → both', () => {
39
+ const link = 'https://app.example/reset-password?token=t&purpose=invite';
40
+ assert.deepEqual(inviteOutcome({ ok: true, emailed: false, link }), {
41
+ kind: 'success',
42
+ text: 'Link copied',
43
+ link,
44
+ });
45
+ assert.deepEqual(inviteOutcome({ ok: true, emailed: true, link }), {
46
+ kind: 'success',
47
+ text: 'Invite sent · Link copied',
48
+ link,
49
+ });
50
+ });
51
+
52
+ test('failures show the service message (409 reasons included)', () => {
53
+ const reason = 'This user has already set a password. Use forgot-password instead.';
54
+ assert.deepEqual(inviteOutcome({ ok: false, status: 409, error: reason }), {
55
+ kind: 'error',
56
+ text: reason,
57
+ });
58
+ assert.deepEqual(inviteOutcome({ ok: false, status: 500, error: '' }), {
59
+ kind: 'error',
60
+ text: 'Could not invite the user.',
61
+ });
62
+ });
63
+ });
@@ -135,6 +135,54 @@ 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: MESSAGING — email delivery (G-010) ---
139
+ # Read only when src/app.module.ts passes ShieldModule.forRoot({ auth: true, emailSender: 'env' })
140
+ # (install @astram/messaging). Password resets, admin invites, email-change confirmation and
141
+ # security notices go out through this provider; every send is bounded and audited.
142
+ # One of smtp | brevo | sendgrid | mailgun | disabled (no default: unset fails boot).
143
+ # EMAIL_PROVIDER=brevo
144
+ # EMAIL_FROM_ADDRESS=no-reply@example.com
145
+ # EMAIL_FROM_NAME=Example
146
+ # Provider credentials: outside development/test they MUST be `secret-ref:` references, which
147
+ # needs SECRETS_PROVIDER configured (above). A literal value fails boot.
148
+ # BREVO_API_KEY=secret-ref:mail/brevo-api-key
149
+ # SENDGRID_API_KEY=secret-ref:mail/sendgrid-api-key
150
+ # MAILGUN_API_KEY=secret-ref:mail/mailgun-api-key
151
+ # MAILGUN_DOMAIN=mg.example.com
152
+ # MAILGUN_REGION=us
153
+ # Optional https base for an HTTP provider (a relay or test stub); http is refused.
154
+ # EMAIL_API_BASE_URL=
155
+ # SMTP: TLS is starttls (default, port 587) or implicit (465); none only to a loopback host
156
+ # outside development/test. Certificates are always verified; SMTP_TLS_CA adds a private CA.
157
+ # SMTP_HOST=smtp.example.com
158
+ # SMTP_PORT=587
159
+ # SMTP_TLS=starttls
160
+ # SMTP_USER=mailer
161
+ # SMTP_PASSWORD=secret-ref:mail/smtp-password
162
+ # SMTP_TLS_CA=/etc/ssl/private-ca.pem
163
+ # Per-send bound in ms (default 10000, max 30000; one retry on a temporary failure inside it).
164
+ # MESSAGE_SEND_TIMEOUT_MS=10000
165
+ # 1 = check reachability and credentials at boot without sending (a failure refuses boot).
166
+ # MESSAGE_BOOT_PROBE=1
167
+ # SMS and WhatsApp: ports only this release; anything but `disabled` fails boot.
168
+ # SMS_PROVIDER=disabled
169
+ # WHATSAPP_PROVIDER=disabled
170
+ # Admin invites (POST /identity/users/:uuid/invite): link lifetime in hours (1-168, default 72)
171
+ # and invites per admin per hour (default 30).
172
+ # INVITE_LINK_TTL_HOURS=72
173
+ # INVITE_RATE_PER_ADMIN=30
174
+
175
+ # --- OPTIONAL: organisation bootstrap (`npm run bootstrap:org`; needs IdentityFeatureModule wired) ---
176
+ # Platform-root user uuids, comma-separated, lower-case canonical form. Read by rootIdsFromEnv()
177
+ # in src/app.module.ts (pass it to BOTH forRoot calls); bootstrap:org prints the uuid of each root.
178
+ # SHIELD_ROOT_IDS=
179
+ # Who is running the bootstrap (audit "on behalf of"). Required with --apply.
180
+ # ASTRAM_BOOTSTRAP_OPERATOR=jane.doe
181
+ # Password of each root user declared in the org file, one variable per username (upper-cased).
182
+ # Outside development/test it must be a `secret-ref:` reference (a literal fails the run);
183
+ # or pass --interactive to be prompted without echo.
184
+ # ASTRAM_BOOTSTRAP_ROOT_PASSWORD_ADMIN=secret-ref:bootstrap/root-password-admin
185
+
138
186
  # --- Migrations (optional; @astram/identity's migrations run automatically) ---
139
187
  # APP_MIGRATION_DIR=migrations # your app's own migrations, run after identity's
140
188
  # 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",
@@ -20,12 +21,13 @@
20
21
  "postinstall": "node scripts/sync-astram-guide.mjs"
21
22
  },
22
23
  "dependencies": {
23
- "@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",
28
- "@astram/migrate": "^0.6.0",
24
+ "@astram/core": "1.9.0",
25
+ "@astram/engine": "0.10.1",
26
+ "@astram/guard": "0.18.0",
27
+ "@astram/guide": "^0.2.8",
28
+ "@astram/identity": "0.15.0",
29
+ "@astram/messaging": "^0.1.1",
30
+ "@astram/migrate": "0.6.1",
29
31
  "@astram/sdk": "^0.2.0",
30
32
  "@nestjs/common": "^11.2.7",
31
33
  "@nestjs/core": "^11.2.7",
@@ -39,7 +39,14 @@ import { DemoController } from './health/demo.controller';
39
39
  * 4. Outside development/test (`NODE_ENV`), `auth: true` also requires an
40
40
  * `emailSender` (or `emailSender: 'disabled'`) — otherwise boot fails closed —
41
41
  * and, with a sender, public `RESET_LINK_BASE_URL` / `EMAIL_CHANGE_LINK_BASE_URL`
42
- * (not localhost; see `.env.example`).
42
+ * (not localhost; see `.env.example`). The simplest sender is configuration only:
43
+ * `ShieldModule.forRoot({ auth: true, emailSender: 'env' })` with `EMAIL_PROVIDER`
44
+ * (smtp | brevo | sendgrid | mailgun | disabled), `EMAIL_FROM_ADDRESS` and the
45
+ * provider's variables (MESSAGING block in `.env.example`; credentials must be
46
+ * `secret-ref:` values outside development/test, so set up `SECRETS_PROVIDER` first).
47
+ * Admins can then invite users (`POST /identity/users/:uuid/invite`, the Users page
48
+ * Invite button); `IdentityFeatureModule.forRoot({ invite: { returnLinkToAdmin: true } })`
49
+ * also shows the one-time link to the admin once.
43
50
  * You then get framework-owned `POST /auth/{login,mfa,refresh,logout}` for free —
44
51
  * no controllers to write. (Leaving `auth` off keeps the zero-config enforcement
45
52
  * boot; turning it on requires the config above, by design — fail-closed.)
@@ -52,13 +59,18 @@ import { DemoController } from './health/demo.controller';
52
59
  * identity → guard), so the consumer wires it (ADR-0026):
53
60
  * 1. Turn auth on (above), pass the identity permission keys (so the frontend admin
54
61
  * nav lights up), and import the identity module:
62
+ * import { rootIdsFromEnv, ShieldModule } from '@astram/guard';
55
63
  * import { identityAuthActionKeys, identityControlPlaneKeys,
56
64
  * IdentityFeatureModule } from '@astram/identity';
57
65
  * imports: [
58
- * ShieldModule.forRoot({ auth: true,
66
+ * ShieldModule.forRoot({ auth: true, rootIds: rootIdsFromEnv(),
59
67
  * identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys }),
60
- * IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles,…}
68
+ * // mounts /identity/{companies,users,roles,…}
69
+ * IdentityFeatureModule.forRoot({ rootIds: rootIdsFromEnv() }),
61
70
  * ],
71
+ * `rootIdsFromEnv()` reads `SHIELD_ROOT_IDS` (comma-separated platform-root user uuids; unset
72
+ * means no roots), so both calls get the same list. `npm run bootstrap:org` creates your
73
+ * organisation from a file and needs `IdentityFeatureModule` wired (see the README).
62
74
  * `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
63
75
  * (no separate engine setup) and builds the record authorization `ShieldModule` decides
64
76
  * with, so the guard renders REAL decisions from your roles' grants. Do NOT also pass
@@ -89,8 +101,9 @@ import { DemoController } from './health/demo.controller';
89
101
  // admin console (incl. the guided rule-builder + owner shares), use (see the OPTIONAL
90
102
  // blocks above for the full form + the extraObjects note for your OWN owned types):
91
103
  // imports: [
92
- // ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys }),
93
- // IdentityFeatureModule.forRoot({ /* extraObjects: [YourType] */ }),
104
+ // ShieldModule.forRoot({ auth: true, rootIds: rootIdsFromEnv(), identityActionKeys,
105
+ // identityControlPlaneKeys }),
106
+ // IdentityFeatureModule.forRoot({ rootIds: rootIdsFromEnv() /*, extraObjects: [YourType] */ }),
94
107
  // ],
95
108
  imports: [ShieldModule.forRoot()],
96
109
  controllers: [HealthController, DemoController],
@@ -0,0 +1,96 @@
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
+ /** Email the set-password links (needs a configured emailSender) instead of printing them. */
19
+ emailLinks: boolean;
20
+ /** Prompt for the root passwords (no echo) instead of reading the environment. */
21
+ interactive: boolean;
22
+ }
23
+
24
+ export class BootstrapArgsError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = 'BootstrapArgsError';
28
+ }
29
+ }
30
+
31
+ export const BOOTSTRAP_USAGE =
32
+ 'usage: npm run bootstrap:org -- --file <path> [--apply] [--claim-unowned-roles] ' +
33
+ '[--reissue-links] [--link-ttl-hours <n>] [--links-to-stdout | --email-links] [--interactive]';
34
+
35
+ export function parseBootstrapArgs(argv: readonly string[]): BootstrapArgs {
36
+ const args: BootstrapArgs = {
37
+ file: '',
38
+ apply: false,
39
+ claimUnownedRoles: false,
40
+ reissueLinks: false,
41
+ linkTtlHours: undefined,
42
+ linksToStdout: false,
43
+ emailLinks: false,
44
+ interactive: false,
45
+ };
46
+ for (let i = 0; i < argv.length; i += 1) {
47
+ const flag = argv[i] as string;
48
+ switch (flag) {
49
+ case '--file': {
50
+ const value = argv[i + 1];
51
+ if (value === undefined || value === '' || value.startsWith('--')) {
52
+ throw new BootstrapArgsError('--file needs a path');
53
+ }
54
+ args.file = value;
55
+ i += 1;
56
+ break;
57
+ }
58
+ case '--apply':
59
+ args.apply = true;
60
+ break;
61
+ case '--claim-unowned-roles':
62
+ args.claimUnownedRoles = true;
63
+ break;
64
+ case '--reissue-links':
65
+ args.reissueLinks = true;
66
+ break;
67
+ case '--links-to-stdout':
68
+ args.linksToStdout = true;
69
+ break;
70
+ case '--email-links':
71
+ args.emailLinks = true;
72
+ break;
73
+ case '--interactive':
74
+ args.interactive = true;
75
+ break;
76
+ case '--link-ttl-hours': {
77
+ const value = argv[i + 1];
78
+ if (value === undefined || !/^\d+$/.test(value)) {
79
+ throw new BootstrapArgsError('--link-ttl-hours needs a whole number of hours');
80
+ }
81
+ args.linkTtlHours = Number(value);
82
+ i += 1;
83
+ break;
84
+ }
85
+ default:
86
+ throw new BootstrapArgsError(
87
+ flag.startsWith('--') ? 'unknown option' : 'unexpected argument',
88
+ );
89
+ }
90
+ }
91
+ if (args.file === '') throw new BootstrapArgsError('--file <path> is required');
92
+ if (args.emailLinks && args.linksToStdout) {
93
+ throw new BootstrapArgsError('--email-links cannot be combined with --links-to-stdout');
94
+ }
95
+ return args;
96
+ }
@@ -0,0 +1,132 @@
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] [--email-links]`: 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
+ * Set-password links print to the terminal; with `--email-links` (needs an `emailSender` in
20
+ * `ShieldModule.forRoot`) each one is emailed instead and none is printed, except ROOT links:
21
+ * those are never emailed and print only to a terminal (or with `--links-to-stdout`).
22
+ *
23
+ * No security logic lives here: `runOrgBootstrap` (from `@astram/identity`) does the work.
24
+ * Same boot order as `main.ts`: `resolveBootSecrets()` runs BEFORE `AppModule` is loaded,
25
+ * so `AppModule` is imported dynamically. Exit code 0 on success, non-zero otherwise.
26
+ *
27
+ * Needs `IdentityFeatureModule` wired in `AppModule` (see the header of `app.module.ts`).
28
+ */
29
+
30
+ /** Prompt without echo (TTY only): the typed characters never reach the terminal. */
31
+ async function promptHidden(question: string): Promise<string> {
32
+ let muted = false;
33
+ const output = new Writable({
34
+ write(chunk, _encoding, done) {
35
+ if (!muted) process.stdout.write(chunk);
36
+ done();
37
+ },
38
+ });
39
+ const rl = createInterface({ input: process.stdin, output, terminal: true });
40
+ return new Promise<string>((resolve) => {
41
+ rl.question(question, (answer) => {
42
+ rl.close();
43
+ process.stdout.write('\n');
44
+ resolve(answer);
45
+ });
46
+ muted = true;
47
+ });
48
+ }
49
+
50
+ async function run(args: BootstrapArgs): Promise<number> {
51
+ if (args.interactive && !process.stdin.isTTY) {
52
+ console.error('--interactive needs a terminal');
53
+ return 1;
54
+ }
55
+ await resolveBootSecrets();
56
+ const { AppModule } = await import('./app.module');
57
+ // Warn level only: the Nest boot chatter is noise, and nothing here may log a secret.
58
+ const app = await NestFactory.createApplicationContext(AppModule, {
59
+ logger: ['warn', 'error'],
60
+ });
61
+ try {
62
+ const report = await runOrgBootstrap(app, {
63
+ file: args.file,
64
+ apply: args.apply,
65
+ claimUnownedRoles: args.claimUnownedRoles,
66
+ reissueLinks: args.reissueLinks,
67
+ ...(args.linkTtlHours !== undefined ? { linkTtlHours: args.linkTtlHours } : {}),
68
+ linksToStdout: args.linksToStdout,
69
+ emailLinks: args.emailLinks,
70
+ ...(process.env.ASTRAM_BOOTSTRAP_OPERATOR !== undefined
71
+ ? { operator: process.env.ASTRAM_BOOTSTRAP_OPERATOR }
72
+ : {}),
73
+ isTTY: process.stdout.isTTY === true,
74
+ out: console.log,
75
+ ...(args.interactive
76
+ ? {
77
+ promptRootPassword: (username: string) =>
78
+ promptHidden(`Password for root "${username}": `),
79
+ }
80
+ : {}),
81
+ });
82
+ // Counts only: links and passwords were already shown through `out`, never repeated.
83
+ console.log(
84
+ `${report.applied ? 'applied' : 'dry run (nothing written; pass --apply to write)'}: ` +
85
+ `${report.plan.entries.length} planned, ${report.links} link(s) issued` +
86
+ (args.emailLinks
87
+ ? ` (${report.linksEmailed} emailed, ${report.linksPrintedRoots} root link(s) printed, ` +
88
+ `${report.linksNotEmailed} not delivered)`
89
+ : '') +
90
+ ', ' +
91
+ `${report.rootIds.length} root(s)`,
92
+ );
93
+ return 0;
94
+ } finally {
95
+ await app.close();
96
+ }
97
+ }
98
+
99
+ async function main(): Promise<void> {
100
+ let args: BootstrapArgs;
101
+ try {
102
+ args = parseBootstrapArgs(process.argv.slice(2));
103
+ } catch (err) {
104
+ if (err instanceof BootstrapArgsError) {
105
+ console.error(`${err.message}\n${BOOTSTRAP_USAGE}`);
106
+ process.exitCode = 2;
107
+ return;
108
+ }
109
+ throw err;
110
+ }
111
+ try {
112
+ process.exitCode = await run(args);
113
+ } catch (err) {
114
+ process.exitCode = 1;
115
+ if (err instanceof OrgBootstrapError) {
116
+ // `--email-links` without a sender is also `not_wired`; its own message says what to set.
117
+ if (err.code === 'not_wired' && !err.message.startsWith('--email-links')) {
118
+ console.error(
119
+ 'wire IdentityFeatureModule in AppModule before running bootstrap:org (see the header of src/app.module.ts)',
120
+ );
121
+ }
122
+ console.error(`bootstrap:org failed [${err.code}]: ${err.message}`);
123
+ } else {
124
+ // Generic on purpose: an unexpected error can carry values from the file or the database.
125
+ console.error(
126
+ 'bootstrap:org failed: unexpected error (no details printed; see the app logs)',
127
+ );
128
+ }
129
+ }
130
+ }
131
+
132
+ void main();