create-astram-app 0.10.0 → 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.10.0",
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",
@@ -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.
@@ -134,9 +142,12 @@ ASTRAM_BOOTSTRAP_OPERATOR=jane.doe npm run bootstrap:org -- --file ../deploy/org
134
142
  ```
135
143
 
136
144
  - Flags: `--apply`, `--claim-unowned-roles`, `--reissue-links`, `--link-ttl-hours <n>`,
137
- `--links-to-stdout`, `--interactive` (prompt for root passwords without echo).
145
+ `--links-to-stdout`, `--email-links`, `--interactive` (prompt for root passwords without echo).
138
146
  - Every non-root user gets a one-time set-password link, printed once, only to a terminal
139
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.
140
151
  - Root passwords come from `ASTRAM_BOOTSTRAP_ROOT_PASSWORD_<USERNAME>` (a `secret-ref:` outside
141
152
  development) or `--interactive`. Put the printed root uuids in `SHIELD_ROOT_IDS` and pass
142
153
  `rootIdsFromEnv()` to both `forRoot` calls (see `server/_env.example`).
@@ -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,43 @@ 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
+
138
175
  # --- OPTIONAL: organisation bootstrap (`npm run bootstrap:org`; needs IdentityFeatureModule wired) ---
139
176
  # Platform-root user uuids, comma-separated, lower-case canonical form. Read by rootIdsFromEnv()
140
177
  # in src/app.module.ts (pass it to BOTH forRoot calls); bootstrap:org prints the uuid of each root.
@@ -21,12 +21,13 @@
21
21
  "postinstall": "node scripts/sync-astram-guide.mjs"
22
22
  },
23
23
  "dependencies": {
24
- "@astram/core": "^1.8.0",
25
- "@astram/engine": "^0.10.0",
26
- "@astram/guard": "^0.17.0",
24
+ "@astram/core": "1.9.0",
25
+ "@astram/engine": "0.10.1",
26
+ "@astram/guard": "0.18.0",
27
27
  "@astram/guide": "^0.2.8",
28
- "@astram/identity": "^0.14.0",
29
- "@astram/migrate": "^0.6.0",
28
+ "@astram/identity": "0.15.0",
29
+ "@astram/messaging": "^0.1.1",
30
+ "@astram/migrate": "0.6.1",
30
31
  "@astram/sdk": "^0.2.0",
31
32
  "@nestjs/common": "^11.2.7",
32
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.)
@@ -15,6 +15,8 @@ export interface BootstrapArgs {
15
15
  /** Link lifetime in whole hours; the runner validates the range. */
16
16
  linkTtlHours: number | undefined;
17
17
  linksToStdout: boolean;
18
+ /** Email the set-password links (needs a configured emailSender) instead of printing them. */
19
+ emailLinks: boolean;
18
20
  /** Prompt for the root passwords (no echo) instead of reading the environment. */
19
21
  interactive: boolean;
20
22
  }
@@ -28,7 +30,7 @@ export class BootstrapArgsError extends Error {
28
30
 
29
31
  export const BOOTSTRAP_USAGE =
30
32
  'usage: npm run bootstrap:org -- --file <path> [--apply] [--claim-unowned-roles] ' +
31
- '[--reissue-links] [--link-ttl-hours <n>] [--links-to-stdout] [--interactive]';
33
+ '[--reissue-links] [--link-ttl-hours <n>] [--links-to-stdout | --email-links] [--interactive]';
32
34
 
33
35
  export function parseBootstrapArgs(argv: readonly string[]): BootstrapArgs {
34
36
  const args: BootstrapArgs = {
@@ -38,6 +40,7 @@ export function parseBootstrapArgs(argv: readonly string[]): BootstrapArgs {
38
40
  reissueLinks: false,
39
41
  linkTtlHours: undefined,
40
42
  linksToStdout: false,
43
+ emailLinks: false,
41
44
  interactive: false,
42
45
  };
43
46
  for (let i = 0; i < argv.length; i += 1) {
@@ -64,6 +67,9 @@ export function parseBootstrapArgs(argv: readonly string[]): BootstrapArgs {
64
67
  case '--links-to-stdout':
65
68
  args.linksToStdout = true;
66
69
  break;
70
+ case '--email-links':
71
+ args.emailLinks = true;
72
+ break;
67
73
  case '--interactive':
68
74
  args.interactive = true;
69
75
  break;
@@ -83,5 +89,8 @@ export function parseBootstrapArgs(argv: readonly string[]): BootstrapArgs {
83
89
  }
84
90
  }
85
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
+ }
86
95
  return args;
87
96
  }
@@ -12,10 +12,13 @@ import {
12
12
  } from './bootstrap-org.args';
13
13
 
14
14
  /**
15
- * `npm run bootstrap:org -- --file org.json [--apply]`: creates your organisation (tenants,
15
+ * `npm run bootstrap:org -- --file org.json [--apply] [--email-links]`: creates your organisation (tenants,
16
16
  * companies, roles, users, grants) from a declarative file, through the framework's own
17
17
  * services, against THIS app's `AppModule` (so grants can name your own object types).
18
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`).
19
22
  *
20
23
  * No security logic lives here: `runOrgBootstrap` (from `@astram/identity`) does the work.
21
24
  * Same boot order as `main.ts`: `resolveBootSecrets()` runs BEFORE `AppModule` is loaded,
@@ -63,6 +66,7 @@ async function run(args: BootstrapArgs): Promise<number> {
63
66
  reissueLinks: args.reissueLinks,
64
67
  ...(args.linkTtlHours !== undefined ? { linkTtlHours: args.linkTtlHours } : {}),
65
68
  linksToStdout: args.linksToStdout,
69
+ emailLinks: args.emailLinks,
66
70
  ...(process.env.ASTRAM_BOOTSTRAP_OPERATOR !== undefined
67
71
  ? { operator: process.env.ASTRAM_BOOTSTRAP_OPERATOR }
68
72
  : {}),
@@ -78,7 +82,12 @@ async function run(args: BootstrapArgs): Promise<number> {
78
82
  // Counts only: links and passwords were already shown through `out`, never repeated.
79
83
  console.log(
80
84
  `${report.applied ? 'applied' : 'dry run (nothing written; pass --apply to write)'}: ` +
81
- `${report.plan.entries.length} planned, ${report.links} link(s) issued, ` +
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
+ ', ' +
82
91
  `${report.rootIds.length} root(s)`,
83
92
  );
84
93
  return 0;
@@ -104,7 +113,8 @@ async function main(): Promise<void> {
104
113
  } catch (err) {
105
114
  process.exitCode = 1;
106
115
  if (err instanceof OrgBootstrapError) {
107
- if (err.code === 'not_wired') {
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')) {
108
118
  console.error(
109
119
  'wire IdentityFeatureModule in AppModule before running bootstrap:org (see the header of src/app.module.ts)',
110
120
  );