create-astram-app 0.4.0 → 0.5.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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * ServiceAccountsPage — the Service Accounts admin screen (ADR-0033): machine identities (AI agents,
3
+ * MCP servers, scripts) that authenticate with an `ask_…` API key. List the tenant's accounts (with
4
+ * their roles + scope BY NAME), create one with roles + scope, edit its access, and per-account
5
+ * Issue / Rotate / Revoke its keys (+ Enable/Disable). Create/edit live in
6
+ * {@link ServiceAccountAccessDialog} (pickers limited to what you may grant — Phase 4).
7
+ *
8
+ * The KEY lifecycle is the sensitive part: issue/rotate reveal the raw key EXACTLY ONCE via
9
+ * {@link KeyRevealDialog}; rotate/revoke/disable go through {@link ConfirmDialog} (they're
10
+ * destructive). Accounts are shown by LABEL (+ created-by name); the id is a handle only (ADR-0012).
11
+ *
12
+ * The list is served whole by the server (no server-side paging on this surface), so this page filters
13
+ * by search client-side and does not use the EntityList pager.
14
+ */
15
+ import { useCallback, useEffect, useMemo, useState } from 'react';
16
+ import { Bot, KeyRound } from 'lucide-react';
17
+ import { toast } from 'sonner';
18
+ import { useAuth } from '@/auth/AuthContext';
19
+ import AppShell from '@/shell/AppShell';
20
+ import PageHeader from '@/components/PageHeader';
21
+ import EntityList, { EntityListControls } from '@/components/EntityList';
22
+ import ConfirmDialog from '@/components/ConfirmDialog';
23
+ import { Badge } from '@/components/ui/badge';
24
+ import ServiceAccountActionsMenu from './ServiceAccountActionsMenu';
25
+ import ServiceAccountAccessDialog from './ServiceAccountAccessDialog';
26
+ import KeyRevealDialog from './KeyRevealDialog';
27
+ import { summarizeNames } from './accessModel';
28
+
29
+ function formatDate(ms) {
30
+ if (!ms) return '—';
31
+ try {
32
+ return new Date(ms).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
33
+ } catch {
34
+ return '—';
35
+ }
36
+ }
37
+
38
+ export default function ServiceAccountsPage() {
39
+ const { tokens, serviceAccounts } = useAuth();
40
+ const token = tokens?.accessToken;
41
+
42
+ const [accounts, setAccounts] = useState([]);
43
+ const [loading, setLoading] = useState(true);
44
+ const [error, setError] = useState('');
45
+ const [search, setSearch] = useState('');
46
+
47
+ // Create / edit-access dialog: `null` closed, `{ account: null }` create, `{ account }` edit.
48
+ const [accessDialog, setAccessDialog] = useState(null);
49
+
50
+ // One-time key reveal + destructive-confirm state.
51
+ const [issuedKey, setIssuedKey] = useState(null);
52
+ const [pending, setPending] = useState(null); // { kind: 'rotate'|'revoke'|'toggle', account }
53
+ const [pendingBusy, setPendingBusy] = useState(false);
54
+
55
+ const load = useCallback(async () => {
56
+ if (!token) return;
57
+ setLoading(true);
58
+ const r = await serviceAccounts.list(token);
59
+ if (r.ok) {
60
+ setAccounts(r.accounts);
61
+ setError('');
62
+ } else {
63
+ setAccounts([]);
64
+ setError(r.error || 'Could not load service accounts.');
65
+ }
66
+ setLoading(false);
67
+ }, [serviceAccounts, token]);
68
+
69
+ useEffect(() => {
70
+ load();
71
+ }, [load]);
72
+
73
+ const rows = useMemo(() => {
74
+ const q = search.trim().toLowerCase();
75
+ // Match the name OR any role/company name shown on the row.
76
+ const filtered = q
77
+ ? accounts.filter((a) =>
78
+ [a.label, ...(a.roleLabels ?? []), ...(a.scopeLabels ?? [])].some((s) =>
79
+ (s || '').toLowerCase().includes(q),
80
+ ),
81
+ )
82
+ : accounts;
83
+ // Adapt to the EntityList `{ id, fields }` row shape; the account rides along for the menu.
84
+ return filtered.map((a) => ({ id: a.id, fields: a, account: a }));
85
+ }, [accounts, search]);
86
+
87
+ const onSaved = async () => {
88
+ setAccessDialog(null);
89
+ await load();
90
+ };
91
+
92
+ const onIssueKey = async (account) => {
93
+ const r = await serviceAccounts.issueKey(token, account.id);
94
+ if (!r.ok) {
95
+ toast.error(r.error || 'Could not issue a key.');
96
+ return;
97
+ }
98
+ setIssuedKey(r.key); // reveal ONCE
99
+ };
100
+
101
+ const confirmPending = async () => {
102
+ if (!pending) return;
103
+ setPendingBusy(true);
104
+ const { kind, account } = pending;
105
+ let r;
106
+ if (kind === 'rotate') r = await serviceAccounts.rotateKey(token, account.id);
107
+ else if (kind === 'revoke') r = await serviceAccounts.revokeKeys(token, account.id);
108
+ else r = await serviceAccounts.update(token, account.id, { status: account.status === 'disabled' ? 'active' : 'disabled' });
109
+ setPendingBusy(false);
110
+ setPending(null);
111
+ if (!r.ok) {
112
+ toast.error(r.error || 'The action could not be completed.');
113
+ return;
114
+ }
115
+ if (kind === 'rotate') {
116
+ setIssuedKey(r.key); // reveal the NEW key ONCE
117
+ toast.success('Key rotated — the previous key no longer works.');
118
+ } else if (kind === 'revoke') {
119
+ toast.success('All keys revoked.');
120
+ } else {
121
+ toast.success(account.status === 'disabled' ? 'Account enabled.' : 'Account disabled.');
122
+ }
123
+ await load();
124
+ };
125
+
126
+ const COLUMNS = [
127
+ {
128
+ key: 'label',
129
+ label: 'Name',
130
+ render: (row) => (
131
+ <div className="flex items-center gap-2">
132
+ <span className="inline-flex size-8 items-center justify-center rounded-full bg-secondary text-muted-foreground">
133
+ <Bot className="size-4" />
134
+ </span>
135
+ <div className="min-w-0 font-medium">
136
+ <div className="truncate">{row.fields.label}</div>
137
+ </div>
138
+ </div>
139
+ ),
140
+ },
141
+ {
142
+ key: 'roles',
143
+ label: 'Roles',
144
+ render: (row) => {
145
+ const { shown, more } = summarizeNames(row.fields.roleLabels);
146
+ if (shown.length === 0) {
147
+ return (
148
+ <Badge variant="outline" className="text-muted-foreground" title="Denied everything until it has a role">
149
+ No roles
150
+ </Badge>
151
+ );
152
+ }
153
+ return (
154
+ <div className="flex flex-wrap items-center gap-1">
155
+ {shown.map((name, i) => (
156
+ <Badge key={`${name}-${i}`} variant="secondary">
157
+ {name}
158
+ </Badge>
159
+ ))}
160
+ {more > 0 && (
161
+ <Badge variant="outline" title={row.fields.roleLabels.slice(3).join(', ')}>
162
+ +{more}
163
+ </Badge>
164
+ )}
165
+ </div>
166
+ );
167
+ },
168
+ },
169
+ {
170
+ key: 'scope',
171
+ label: 'Scope',
172
+ render: (row) => {
173
+ const names = row.fields.scopeLabels ?? [];
174
+ const { shown, more } = summarizeNames(names);
175
+ if (shown.length === 0) return <span className="text-muted-foreground">—</span>;
176
+ return (
177
+ <span className="text-sm text-muted-foreground" title={names.join(', ')}>
178
+ {shown.join(', ')}
179
+ {more > 0 ? ` +${more}` : ''}
180
+ </span>
181
+ );
182
+ },
183
+ },
184
+ {
185
+ key: 'status',
186
+ label: 'Status',
187
+ render: (row) =>
188
+ row.fields.status === 'disabled' ? (
189
+ <Badge variant="outline" className="text-muted-foreground">Disabled</Badge>
190
+ ) : (
191
+ <Badge>Active</Badge>
192
+ ),
193
+ },
194
+ {
195
+ key: 'createdBy',
196
+ label: 'Created by',
197
+ render: (row) => row.fields.createdByName || '—',
198
+ },
199
+ {
200
+ key: 'created',
201
+ label: 'Created',
202
+ render: (row) => formatDate(row.fields.createdAtMs),
203
+ },
204
+ {
205
+ key: 'actions',
206
+ label: '',
207
+ render: (row) => (
208
+ <div className="flex justify-end">
209
+ <ServiceAccountActionsMenu
210
+ account={row.account}
211
+ onEditAccess={(a) => setAccessDialog({ account: a })}
212
+ onIssueKey={onIssueKey}
213
+ onRotateKey={(a) => setPending({ kind: 'rotate', account: a })}
214
+ onToggleStatus={(a) => setPending({ kind: 'toggle', account: a })}
215
+ onRevokeKeys={(a) => setPending({ kind: 'revoke', account: a })}
216
+ />
217
+ </div>
218
+ ),
219
+ },
220
+ ];
221
+
222
+ const confirmCopy = (() => {
223
+ if (!pending) return { title: '', message: '', confirmLabel: 'Confirm', destructive: false };
224
+ const name = pending.account.label;
225
+ if (pending.kind === 'rotate') {
226
+ return {
227
+ title: `Rotate the key for “${name}”?`,
228
+ message:
229
+ 'A new key is generated and shown once. The current key stops working immediately — update any client using it.',
230
+ confirmLabel: 'Rotate key',
231
+ destructive: true,
232
+ };
233
+ }
234
+ if (pending.kind === 'revoke') {
235
+ return {
236
+ title: `Revoke all keys for “${name}”?`,
237
+ message:
238
+ 'Every key for this account stops working immediately. Any agent using it will lose access until you issue a new key.',
239
+ confirmLabel: 'Revoke keys',
240
+ destructive: true,
241
+ };
242
+ }
243
+ const disabling = pending.account.status !== 'disabled';
244
+ return {
245
+ title: disabling ? `Disable “${name}”?` : `Enable “${name}”?`,
246
+ message: disabling
247
+ ? 'A disabled account cannot authenticate. Its keys are kept and start working again when you enable it.'
248
+ : 'The account can authenticate again with its existing keys.',
249
+ confirmLabel: disabling ? 'Disable' : 'Enable',
250
+ destructive: disabling,
251
+ };
252
+ })();
253
+
254
+ return (
255
+ <AppShell section="Identity" title="Service accounts">
256
+ <div className="p-4">
257
+ <PageHeader
258
+ section="Identity"
259
+ title="Service accounts"
260
+ icon={KeyRound}
261
+ actions={
262
+ <EntityListControls
263
+ search={search}
264
+ onSearchChange={setSearch}
265
+ searchPlaceholder="Search service accounts…"
266
+ addLabel="New service account"
267
+ onAdd={() => setAccessDialog({ account: null })}
268
+ />
269
+ }
270
+ />
271
+
272
+ <EntityList
273
+ columns={COLUMNS}
274
+ rows={rows}
275
+ search={search}
276
+ loading={loading}
277
+ error={error}
278
+ emptyLabel="No service accounts yet. Create one to give an agent or script scoped API access."
279
+ />
280
+ </div>
281
+
282
+ {/* Create + edit access (roles + scope, limited to what you may grant) */}
283
+ {accessDialog && (
284
+ <ServiceAccountAccessDialog
285
+ key={accessDialog.account?.id ?? 'new'}
286
+ open
287
+ account={accessDialog.account}
288
+ onClose={() => setAccessDialog(null)}
289
+ onSaved={onSaved}
290
+ />
291
+ )}
292
+
293
+ {/* One-time key reveal (issue + rotate) */}
294
+ <KeyRevealDialog issued={issuedKey} onClose={() => setIssuedKey(null)} />
295
+
296
+ {/* Destructive confirms: rotate / revoke / disable */}
297
+ <ConfirmDialog
298
+ open={!!pending}
299
+ title={confirmCopy.title}
300
+ message={confirmCopy.message}
301
+ confirmLabel={confirmCopy.confirmLabel}
302
+ destructive={confirmCopy.destructive}
303
+ busy={pendingBusy}
304
+ onConfirm={confirmPending}
305
+ onCancel={() => setPending(null)}
306
+ />
307
+ </AppShell>
308
+ );
309
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * accessModel — the pure (no React, no I/O) logic behind the service-account access dialog
3
+ * (Phase 4): turn the server's `assignable` set + an existing account into picker options, work out
4
+ * which of the agent's items are LOCKED (assigned by someone with more access), validate, and build
5
+ * a minimal PATCH. Kept separate so it is unit-tested with `node --test` (accessModel.test.mjs).
6
+ *
7
+ * The server is the authority (ADR-0029 ceiling): `assignable` is computed from the same ceiling the
8
+ * write enforces. An agent's existing role/node that is NOT assignable to the editor is shown checked
9
+ * + disabled and is NEVER sent — a non-root PATCH keeps it server-side (sending it would be a 403).
10
+ * Options carry `name` for display; `id` is only the value sent back (never rendered, ADR-0012).
11
+ */
12
+
13
+ export const LOCKED_HINT = 'assigned by someone with more access';
14
+ export const NO_ROLES_WARNING = 'This agent will be denied everything until it has a role.';
15
+ export const SCOPE_REQUIRED = 'Choose at least one scope node.';
16
+
17
+ /** Depth of each node by walking `parentId` inside the set (cycle-safe; unknown parent ⇒ top). */
18
+ export function withDepth(nodes) {
19
+ const byId = new Map(nodes.map((n) => [n.id, n]));
20
+ const depthOf = (n) => {
21
+ let depth = 0;
22
+ const seen = new Set([n.id]);
23
+ let p = n.parentId;
24
+ while (p && byId.has(p) && !seen.has(p)) {
25
+ seen.add(p);
26
+ depth += 1;
27
+ p = byId.get(p).parentId;
28
+ }
29
+ return depth;
30
+ };
31
+ return nodes.map((n) => ({ id: n.id, name: n.name, depth: depthOf(n) }));
32
+ }
33
+
34
+ /** Items the agent holds that the editor cannot grant, labelled from the view (same order as ids). */
35
+ function lockedItems(ids, labels, assignableIds) {
36
+ const out = [];
37
+ (ids || []).forEach((id, i) => {
38
+ if (!assignableIds.has(id))
39
+ out.push({ id, name: (labels && labels[i]) || 'Unnamed', depth: 0, locked: true });
40
+ });
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Build the picker model. `account` is null for create.
46
+ * - create: no roles ticked; every assignable scope node ticked ("defaults to everything you can reach").
47
+ * - edit: the agent's assignable items ticked; its non-assignable items appended as locked.
48
+ */
49
+ export function buildAccessModel(assignable, account) {
50
+ const roles = (assignable && assignable.roles) || [];
51
+ const nodes = (assignable && assignable.scopeNodes) || [];
52
+ const roleIds = new Set(roles.map((r) => r.id));
53
+ const nodeIds = new Set(nodes.map((n) => n.id));
54
+
55
+ const roleOptions = roles.map((r) => ({ id: r.id, name: r.name, depth: 0 }));
56
+ const scopeOptions = withDepth(nodes);
57
+
58
+ if (!account) {
59
+ return {
60
+ roleOptions,
61
+ scopeOptions,
62
+ initialRoles: [],
63
+ initialScope: nodes.map((n) => n.id),
64
+ };
65
+ }
66
+ const lockedRoles = lockedItems(account.roleIds, account.roleLabels, roleIds);
67
+ const lockedScope = lockedItems(account.scopeSubtreeNodeIds, account.scopeLabels, nodeIds);
68
+ return {
69
+ roleOptions: [...roleOptions, ...lockedRoles],
70
+ scopeOptions: [...scopeOptions, ...lockedScope],
71
+ initialRoles: (account.roleIds || []).filter((id) => roleIds.has(id)),
72
+ initialScope: (account.scopeSubtreeNodeIds || []).filter((id) => nodeIds.has(id)),
73
+ };
74
+ }
75
+
76
+ /** Number of locked options (always held by the agent, never sent). */
77
+ export function lockedCount(options) {
78
+ return options.filter((o) => o.locked).length;
79
+ }
80
+
81
+ /** Order-insensitive equality of two id lists. */
82
+ export function sameIds(a, b) {
83
+ const sa = new Set(a);
84
+ const sb = new Set(b);
85
+ if (sa.size !== sb.size) return false;
86
+ for (const x of sa) if (!sb.has(x)) return false;
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Field errors for the form (empty object ⇒ valid). When the scope will be sent (`requireScope`,
92
+ * default true) it needs ≥1 node — the server rejects an empty scope on edit with a 400.
93
+ */
94
+ export function validateAccess({ label, scope, requireScope = true }) {
95
+ const errors = {};
96
+ if (!label || !label.trim()) errors.label = 'Give the service account a name.';
97
+ if (requireScope && (!scope || scope.length === 0)) errors.scope = SCOPE_REQUIRED;
98
+ return errors;
99
+ }
100
+
101
+ /** Does the agent end up with no role at all (ticked + locked)? → show the non-blocking warning. */
102
+ export function hasNoRoles(roles, roleOptions) {
103
+ return roles.length === 0 && lockedCount(roleOptions) === 0;
104
+ }
105
+
106
+ /** Create body: always sends roles + ≥1 scope node (only assignable ids — never locked ones). */
107
+ export function createBody({ label, roles, scope }) {
108
+ return { label: label.trim(), roleIds: [...roles], scopeSubtreeNodeIds: [...scope] };
109
+ }
110
+
111
+ /**
112
+ * Minimal PATCH for an edit: only the fields that changed. `roles` / `scope` hold assignable ids
113
+ * only (locked items are preserved by the server). Returns `{}` when nothing changed.
114
+ */
115
+ export function editPatch({ account, label, roles, scope, initialRoles, initialScope }) {
116
+ const patch = {};
117
+ const nextLabel = label.trim();
118
+ if (nextLabel !== account.label) patch.label = nextLabel;
119
+ if (!sameIds(roles, initialRoles)) patch.roleIds = [...roles];
120
+ if (!sameIds(scope, initialScope)) patch.scopeSubtreeNodeIds = [...scope];
121
+ return patch;
122
+ }
123
+
124
+ /** Route a 400 server message to the field it is about (else the form-level error). */
125
+ export function fieldOfServerError(message) {
126
+ const m = (message || '').toLowerCase();
127
+ if (m.includes('scope')) return 'scope';
128
+ if (m.includes('label') || m.includes('name')) return 'label';
129
+ return null;
130
+ }
131
+
132
+ /** First `max` names + a "+N" overflow count, for the row badges. */
133
+ export function summarizeNames(names, max = 3) {
134
+ const list = names || [];
135
+ return { shown: list.slice(0, max), more: Math.max(0, list.length - max) };
136
+ }
@@ -0,0 +1,161 @@
1
+ // accessModel.test.mjs — Phase 4: the service-account access dialog's pure logic (picker
2
+ // options, locked items, validation, minimal PATCH). No React/DOM; runs under Node's built-in
3
+ // runner with zero dependencies:
4
+ //
5
+ // node --test src/pages/service-accounts/accessModel.test.mjs
6
+ import { test, describe } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import {
9
+ withDepth,
10
+ buildAccessModel,
11
+ lockedCount,
12
+ sameIds,
13
+ validateAccess,
14
+ hasNoRoles,
15
+ createBody,
16
+ editPatch,
17
+ fieldOfServerError,
18
+ summarizeNames,
19
+ SCOPE_REQUIRED,
20
+ } from './accessModel.js';
21
+
22
+ const ASSIGNABLE = {
23
+ roles: [
24
+ { id: 'r-ingest', name: 'Ingest' },
25
+ { id: 'r-audit', name: 'Auditor' },
26
+ ],
27
+ scopeNodes: [
28
+ { id: 'n-acme', name: 'Acme', parentId: null },
29
+ { id: 'n-east', name: 'Acme East', parentId: 'n-acme' },
30
+ { id: 'n-east-1', name: 'East 1', parentId: 'n-east' },
31
+ ],
32
+ };
33
+
34
+ const ACCOUNT = {
35
+ id: 'sa-1',
36
+ label: 'Ingest Bot',
37
+ roleIds: ['r-ingest', 'r-finance'],
38
+ roleLabels: ['Ingest', 'Finance Admin'],
39
+ scopeSubtreeNodeIds: ['n-east', 'n-beta'],
40
+ scopeLabels: ['Acme East', 'Beta'],
41
+ };
42
+
43
+ describe('withDepth', () => {
44
+ test('indents by parent chain inside the set', () => {
45
+ assert.deepEqual(
46
+ withDepth(ASSIGNABLE.scopeNodes).map((n) => n.depth),
47
+ [0, 1, 2],
48
+ );
49
+ });
50
+ test('a parent outside the set, or a cycle, does not loop or over-indent', () => {
51
+ const d = withDepth([
52
+ { id: 'a', name: 'A', parentId: 'b' },
53
+ { id: 'b', name: 'B', parentId: 'a' },
54
+ { id: 'c', name: 'C', parentId: 'zzz' },
55
+ ]);
56
+ assert.equal(d.find((n) => n.id === 'c').depth, 0);
57
+ assert.ok(d.every((n) => n.depth <= 1));
58
+ });
59
+ });
60
+
61
+ describe('buildAccessModel', () => {
62
+ test('create: no roles ticked, every reachable node ticked, nothing locked', () => {
63
+ const m = buildAccessModel(ASSIGNABLE, null);
64
+ assert.deepEqual(m.initialRoles, []);
65
+ assert.deepEqual(m.initialScope, ['n-acme', 'n-east', 'n-east-1']);
66
+ assert.equal(lockedCount(m.roleOptions) + lockedCount(m.scopeOptions), 0);
67
+ });
68
+
69
+ test('edit: assignable items ticked; out-of-ceiling items appended LOCKED with their names', () => {
70
+ const m = buildAccessModel(ASSIGNABLE, ACCOUNT);
71
+ assert.deepEqual(m.initialRoles, ['r-ingest']);
72
+ assert.deepEqual(m.initialScope, ['n-east']);
73
+ const lockedRole = m.roleOptions.find((o) => o.locked);
74
+ assert.deepEqual(lockedRole, {
75
+ id: 'r-finance',
76
+ name: 'Finance Admin',
77
+ depth: 0,
78
+ locked: true,
79
+ });
80
+ const lockedNode = m.scopeOptions.find((o) => o.locked);
81
+ assert.equal(lockedNode.name, 'Beta');
82
+ });
83
+
84
+ test('edit by root (everything assignable): nothing locked', () => {
85
+ const all = {
86
+ roles: [...ASSIGNABLE.roles, { id: 'r-finance', name: 'Finance Admin' }],
87
+ scopeNodes: [...ASSIGNABLE.scopeNodes, { id: 'n-beta', name: 'Beta', parentId: null }],
88
+ };
89
+ const m = buildAccessModel(all, ACCOUNT);
90
+ assert.equal(lockedCount(m.roleOptions) + lockedCount(m.scopeOptions), 0);
91
+ assert.deepEqual(m.initialRoles.sort(), ['r-finance', 'r-ingest']);
92
+ });
93
+ });
94
+
95
+ describe('validation + warnings', () => {
96
+ test('a name and at least one scope node are required', () => {
97
+ assert.deepEqual(validateAccess({ label: ' ', scope: [] }), {
98
+ label: 'Give the service account a name.',
99
+ scope: SCOPE_REQUIRED,
100
+ });
101
+ assert.deepEqual(validateAccess({ label: 'Bot', scope: ['n-acme'] }), {});
102
+ });
103
+ test('an unsent (unchanged) scope is not validated — rename of an all-locked agent still works', () => {
104
+ assert.deepEqual(validateAccess({ label: 'Bot', scope: [], requireScope: false }), {});
105
+ });
106
+ test('no-roles warning only when nothing is ticked AND nothing is locked', () => {
107
+ const create = buildAccessModel(ASSIGNABLE, null);
108
+ assert.equal(hasNoRoles([], create.roleOptions), true);
109
+ const edit = buildAccessModel(ASSIGNABLE, ACCOUNT);
110
+ assert.equal(hasNoRoles([], edit.roleOptions), false); // the locked role still applies
111
+ });
112
+ });
113
+
114
+ describe('request bodies never carry locked items', () => {
115
+ test('createBody sends trimmed name + roles + scope', () => {
116
+ assert.deepEqual(createBody({ label: ' Bot ', roles: ['r-ingest'], scope: ['n-acme'] }), {
117
+ label: 'Bot',
118
+ roleIds: ['r-ingest'],
119
+ scopeSubtreeNodeIds: ['n-acme'],
120
+ });
121
+ });
122
+
123
+ test('editPatch: unchanged → {}; only changed fields are sent; locked ids never appear', () => {
124
+ const m = buildAccessModel(ASSIGNABLE, ACCOUNT);
125
+ const base = { account: ACCOUNT, initialRoles: m.initialRoles, initialScope: m.initialScope };
126
+ assert.deepEqual(
127
+ editPatch({ ...base, label: 'Ingest Bot', roles: ['r-ingest'], scope: ['n-east'] }),
128
+ {},
129
+ );
130
+ assert.deepEqual(
131
+ editPatch({ ...base, label: 'Renamed', roles: ['r-ingest'], scope: ['n-east'] }),
132
+ {
133
+ label: 'Renamed',
134
+ },
135
+ );
136
+ const p = editPatch({ ...base, label: 'Ingest Bot', roles: [], scope: ['n-east', 'n-acme'] });
137
+ assert.deepEqual(p, { roleIds: [], scopeSubtreeNodeIds: ['n-east', 'n-acme'] });
138
+ assert.ok(!JSON.stringify(p).includes('r-finance'));
139
+ assert.ok(!JSON.stringify(p).includes('n-beta'));
140
+ });
141
+
142
+ test('sameIds is order-insensitive', () => {
143
+ assert.equal(sameIds(['a', 'b'], ['b', 'a']), true);
144
+ assert.equal(sameIds(['a'], ['a', 'b']), false);
145
+ });
146
+ });
147
+
148
+ describe('misc', () => {
149
+ test('fieldOfServerError routes 400s to the right field', () => {
150
+ assert.equal(fieldOfServerError('choose at least one scope node'), 'scope');
151
+ assert.equal(fieldOfServerError('label cannot be empty'), 'label');
152
+ assert.equal(fieldOfServerError('no fields to update'), null);
153
+ });
154
+ test('summarizeNames: first 3 + overflow count', () => {
155
+ assert.deepEqual(summarizeNames(['a', 'b', 'c', 'd', 'e']), {
156
+ shown: ['a', 'b', 'c'],
157
+ more: 2,
158
+ });
159
+ assert.deepEqual(summarizeNames(undefined), { shown: [], more: 0 });
160
+ });
161
+ });
@@ -16,6 +16,9 @@
16
16
  * via `GET access/role-grants` + `GET permission-catalog`, BOTH gated
17
17
  * `role:read` — "managing/reading grants IS role administration")
18
18
  * - Audit → `identity:role:read` (audit.controller.ts `GET audit`)
19
+ * - Service accounts → `identity:service_account:read`
20
+ * (service-account.controller.ts `GET service-accounts`; create/edit are
21
+ * service_account:create|update, keys are service_account:manage_keys)
19
22
  * - Cross-tenant → `identity:role:read` (cross-tenant-grant.controller.ts `GET
20
23
  * cross-tenant-grants` / `GET .../inbox` / `GET .../offerable-types`,
21
24
  * all gated `role:read` — reading/managing cross-tenant grants IS role
@@ -24,6 +27,7 @@
24
27
  * caller may reach (no admin-only list route backs them).
25
28
  */
26
29
  import {
30
+ Bot,
27
31
  Building2,
28
32
  Handshake,
29
33
  History,
@@ -82,6 +86,14 @@ export const NAV_SECTIONS = [
82
86
  matchPrefix: true,
83
87
  gate: 'identity:role:read',
84
88
  },
89
+ {
90
+ icon: Bot,
91
+ label: 'Service accounts',
92
+ path: '/service-accounts',
93
+ badge: null,
94
+ matchPrefix: true,
95
+ gate: 'identity:service_account:read',
96
+ },
85
97
  {
86
98
  icon: Handshake,
87
99
  label: 'Cross-tenant',
@@ -19,11 +19,11 @@
19
19
  "postinstall": "node scripts/sync-astram-guide.mjs"
20
20
  },
21
21
  "dependencies": {
22
- "@astram/core": "^1.2.0",
23
- "@astram/engine": "^0.2.0",
24
- "@astram/guard": "^0.6.0",
22
+ "@astram/core": "^1.3.0",
23
+ "@astram/engine": "^0.3.0",
24
+ "@astram/guard": "^0.7.0",
25
25
  "@astram/guide": "^0.1.0",
26
- "@astram/identity": "^0.3.1",
26
+ "@astram/identity": "^0.4.0",
27
27
  "@astram/migrate": "^0.2.0",
28
28
  "@astram/sdk": "^0.2.0",
29
29
  "@nestjs/common": "^11.0.0",