@celilo/console-server 0.1.0 → 0.3.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 @@
1
+ :root{--ground:#000;--ink:#fff;--dim:#8a8f98;--dimmer:#4a4f57;--rule:#23262b;--raised:#101216;--firing:#ff5f4d;--stale:#f5a623;--unobserved:#4a4f57;--mono:"IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;--sans:Theia, "IBM Plex Sans Condensed", "Helvetica Neue", Arial, sans-serif}*{box-sizing:border-box}html,body,#root{height:100%}body{background:var(--ground);color:var(--ink);font-family:var(--mono);-webkit-font-smoothing:antialiased;margin:0;font-size:13px;line-height:1.45}*,:before,:after{animation:none!important}@media (prefers-reduced-motion:reduce){*{transition:none!important}}:focus-visible{outline:1px solid var(--ink);outline-offset:2px}.eyebrow{font-family:var(--sans);text-transform:uppercase;letter-spacing:.12em;color:var(--dim);font-size:10.5px}.scroll-x{overflow-x:auto}table{border-collapse:collapse;font-variant-numeric:tabular-nums;width:100%}th{font-family:var(--sans);text-transform:uppercase;letter-spacing:.1em;color:var(--dim);text-align:left;border-bottom:1px solid var(--rule);white-space:nowrap;padding:7px 12px;font-size:10.5px;font-weight:600}td{border-bottom:1px solid var(--rule);white-space:nowrap;padding:5px 12px}button{font:inherit;color:inherit;cursor:pointer;background:0 0;border:0;padding:0}
package/spa/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>celilo</title>
7
+ <script type="module" crossorigin src="/assets/index-CMq3qlYr.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-eLF_dgP-.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
package/src/index.ts CHANGED
@@ -6,7 +6,14 @@
6
6
  * because the e2e suite asserts against their output directly rather than
7
7
  * through HTTP, which keeps a shape assertion from also being a routing test.
8
8
  */
9
- export { serve, createHandler, type ServeOptions, type AuthGate, type AuthSubject } from './serve';
9
+ export {
10
+ serve,
11
+ createHandler,
12
+ BUNDLED_SPA_DIR,
13
+ type ServeOptions,
14
+ type AuthGate,
15
+ type AuthSubject,
16
+ } from './serve';
10
17
  export { Upstream, UpstreamUnavailable, type UnavailableReason } from './upstream';
11
18
  export {
12
19
  readTopology,
package/src/main.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The console server as a process: read the environment, build the gate, listen.
3
+ *
4
+ * `serve()` takes an `AuthGate` and has no default, on purpose — a server
5
+ * constructed without one would answer to anybody. This file is where the real
6
+ * gate is chosen for a deployed console, and it is the only place that reads
7
+ * `process.env`. Tests drive `serve()` with a stub instead, which is why the
8
+ * seam exists at all.
9
+ *
10
+ * `bun build --compile` targets THIS file. `index.ts` stays a pure export
11
+ * surface so importing the package never binds a port.
12
+ */
13
+
14
+ import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
15
+ import { type AuthGate, type AuthSubject, serve } from './serve';
16
+
17
+ /**
18
+ * Where `scripts/enrolment.ts` puts the key it generated on this system.
19
+ *
20
+ * Spelled out rather than defaulted to something plausible: the hook writes
21
+ * exactly this path, and a console pointed at any other one cannot log in.
22
+ */
23
+ const DEFAULT_IDENTITY_FILE = '/etc/celilo-web-console/id_ed25519';
24
+
25
+ /** Matches `wellspring`'s `DEFAULT_ENDPOINT`, which is the fleet's convention. */
26
+ const DEFAULT_DEST = 'celilo-api@celilo-mgr';
27
+
28
+ export interface OidcGateOptions {
29
+ /** The `iss` claim exactly — for authentik, `https://auth.<domain>/application/o/<slug>/`. */
30
+ issuer: string;
31
+ /** Also the expected audience: authentik puts the client id in `aud`. */
32
+ clientId: string;
33
+ /** Injectable so the gate's test does not need a live provider. */
34
+ fetchImpl?: typeof fetch;
35
+ }
36
+
37
+ /**
38
+ * Verify the bearer against the provider's JWKS (D8).
39
+ *
40
+ * Discovery rather than string surgery on the issuer: authentik serves its
41
+ * `jwks_uri` one level above the per-application issuer, so deriving it by
42
+ * appending a path works on some providers and silently fails on this one.
43
+ *
44
+ * Discovery is lazy and memoized rather than done at startup. Doing it eagerly
45
+ * would make the console refuse to boot whenever authentik is merely slow,
46
+ * which turns a transient dependency outage into a dead service.
47
+ */
48
+ export function oidcGate(options: OidcGateOptions): AuthGate {
49
+ const doFetch = options.fetchImpl ?? fetch;
50
+ let keys: ReturnType<typeof createRemoteJWKSet> | undefined;
51
+
52
+ const jwks = async () => {
53
+ if (keys !== undefined) return keys;
54
+ const url = new URL('.well-known/openid-configuration', options.issuer);
55
+ const response = await doFetch(url);
56
+ if (!response.ok) {
57
+ throw new Error(`OIDC discovery at ${url} returned ${response.status}`);
58
+ }
59
+ const { jwks_uri } = (await response.json()) as { jwks_uri?: string };
60
+ if (typeof jwks_uri !== 'string') {
61
+ throw new Error(`OIDC discovery at ${url} carried no jwks_uri`);
62
+ }
63
+ // The same fetch as discovery, so one injection point covers both hops and
64
+ // this gate's test never reaches the network.
65
+ keys = createRemoteJWKSet(new URL(jwks_uri), {
66
+ [customFetch]: (target, init) => doFetch(target, init),
67
+ });
68
+ return keys;
69
+ };
70
+
71
+ return async (request: Request): Promise<AuthSubject | null> => {
72
+ const header = request.headers.get('authorization');
73
+ const token = header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined;
74
+ if (token === undefined || token.length === 0) return null;
75
+
76
+ try {
77
+ const { payload } = await jwtVerify(token, await jwks(), {
78
+ issuer: options.issuer,
79
+ audience: options.clientId,
80
+ });
81
+ // `sub` is the stable id; the name is what `readSession` matches a
82
+ // `people` row against, so prefer the username a person would recognise.
83
+ const name = [payload.preferred_username, payload.name, payload.email].find(
84
+ (value): value is string => typeof value === 'string' && value.length > 0,
85
+ );
86
+ return { subject: String(payload.sub), ...(name === undefined ? {} : { name }) };
87
+ } catch {
88
+ // A bad token is a refusal, not a 500. The reason is deliberately not
89
+ // reported to the caller: "expired" versus "wrong audience" tells an
90
+ // unauthenticated prober how the gate is configured.
91
+ return null;
92
+ }
93
+ };
94
+ }
95
+
96
+ /** Read one required value, or say which one is missing and who writes it. */
97
+ function required(env: Record<string, string | undefined>, name: string): string {
98
+ const value = env[name];
99
+ if (value === undefined || value === '') {
100
+ throw new Error(
101
+ `${name} is not set. The console refuses to serve without an identity provider — see /etc/celilo-web-console-discovered.env, which the module's install hook writes once the OIDC client exists.`,
102
+ );
103
+ }
104
+ return value;
105
+ }
106
+
107
+ export function startFromEnv(env: Record<string, string | undefined> = process.env): {
108
+ port: number;
109
+ stop: () => void;
110
+ } {
111
+ const server = serve({
112
+ port: Number(env.PORT ?? 8443),
113
+ dest: env.CELILO_API_DEST ?? DEFAULT_DEST,
114
+ identityFile: env.CELILO_IDENTITY_FILE ?? DEFAULT_IDENTITY_FILE,
115
+ ...(env.CONSOLE_SPA_DIR ? { spaDir: env.CONSOLE_SPA_DIR } : {}),
116
+ authenticate: oidcGate({
117
+ issuer: required(env, 'OIDC_ISSUER'),
118
+ clientId: required(env, 'OIDC_CLIENT_ID'),
119
+ }),
120
+ });
121
+ console.log(`[console] listening on :${server.port}`);
122
+ return server;
123
+ }
124
+
125
+ if (import.meta.main) {
126
+ startFromEnv();
127
+ }
package/src/serve.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Holds no database handle. Every fact it serves comes from `Upstream`, which
5
5
  * reaches celilo-mgr over the SSH remote API as a principal granted read ops
6
- * only. That is a security boundary, not a deployment preference: a console
7
- * sharing celilo's process would hold the whole database including the
6
+ * plus `alerts:ack`. That is a security boundary, not a deployment preference: a
7
+ * console sharing celilo's process would hold the whole database including the
8
8
  * encrypted secret store, and would be constrained only by its own code.
9
9
  *
10
10
  * It is a Bun server rather than tsrpc's own `HttpServer` for one reason worth
@@ -15,23 +15,36 @@
15
15
  * get wrong. So the RPC calls are dispatched by hand onto the same handlers.
16
16
  */
17
17
 
18
+ import { join } from 'node:path';
18
19
  import type { ServiceType } from '@celilo/console-protocol';
19
20
  import { Upstream, type UpstreamOptions, UpstreamUnavailable } from './upstream';
20
21
  import {
22
+ ackAlert,
21
23
  readAlerts,
22
24
  readBackupsWithCoverage,
23
25
  readClosure,
24
26
  readModules,
27
+ readSession,
25
28
  readTopology,
26
29
  } from './verbs';
27
30
 
31
+ /**
32
+ * Where `prepack` builds the SPA: a sibling of `src/` under the package root.
33
+ * The same relative path resolves in the repo and inside the npm tarball, which
34
+ * is why the build writes here rather than being copied from `apps/console/dist`.
35
+ */
36
+ export const BUNDLED_SPA_DIR = join(import.meta.dir, '..', 'spa');
37
+
28
38
  export interface ServeOptions {
29
39
  port: number;
30
40
  /** `celilo-api@<server>`. */
31
41
  dest: string;
32
42
  identityFile: string;
33
- /** Directory holding the built SPA. */
34
- spaDir: string;
43
+ /**
44
+ * Directory holding the built SPA. Defaults to the copy `prepack` builds into
45
+ * the package, which is the only one that exists on a deployed system.
46
+ */
47
+ spaDir?: string;
35
48
  /** Decides who is asking. See `AuthGate`. */
36
49
  authenticate: AuthGate;
37
50
  cacheTtlMs?: number;
@@ -66,7 +79,25 @@ export interface AuthSubject {
66
79
  /** Every RPC path the protocol declares, and nothing else. */
67
80
  type ApiName = keyof ServiceType['api'];
68
81
 
69
- const READ_VERBS: ApiName[] = ['Topology', 'Modules', 'Closure', 'Alerts', 'Backups'];
82
+ /**
83
+ * The whole surface. Five reads, one identity read, and one write.
84
+ *
85
+ * `AckAlert` is the write, and it is here rather than absent because D8 argues
86
+ * for exactly one: an acknowledgement records that a named person has seen an
87
+ * alert, and the identity it records is the reason the console requires `idp`
88
+ * at all. Nothing else mutates. A deploy, a redeploy, a pause or a config edit
89
+ * can raise an interview question that a browser has no responder for, so those
90
+ * are not disabled here — they do not exist.
91
+ */
92
+ const SERVED: ApiName[] = [
93
+ 'Topology',
94
+ 'Modules',
95
+ 'Closure',
96
+ 'Alerts',
97
+ 'Backups',
98
+ 'Session',
99
+ 'AckAlert',
100
+ ];
70
101
 
71
102
  /**
72
103
  * Map an upstream failure onto an HTTP status and a tsrpc-shaped error.
@@ -108,17 +139,21 @@ function contentType(path: string): string {
108
139
  /**
109
140
  * Dispatch one RPC call.
110
141
  *
111
- * The request body is the tsrpc request object. Only the five read verbs are
112
- * routed. `AckAlert` is declared in the protocol and deliberately NOT wired
113
- * here: it is a write, it needs an authenticated subject that maps to a real
114
- * `people` row, and the grant to perform it does not exist yet. An unrouted
115
- * verb 404s, which is honest. Wiring it to something that silently no-ops
116
- * would be worse than not having it.
142
+ * The request body is the tsrpc request object. `subject` is whoever the gate
143
+ * let in, and it arrives here rather than being looked up per handler because
144
+ * one verb — `AckAlert` — is only allowed to run for a subject celilo has a
145
+ * `people` row for.
146
+ *
147
+ * That check happens HERE, before any command is issued, and the person's name
148
+ * comes from `readSession` rather than from the request. A browser cannot name
149
+ * the acknowledger: the protocol has no actor field, and this is the code that
150
+ * makes that structural rather than merely intended.
117
151
  */
118
152
  async function callApi(
119
153
  name: ApiName,
120
154
  body: Record<string, unknown>,
121
155
  upstream: Upstream,
156
+ subject: AuthSubject,
122
157
  ): Promise<Response> {
123
158
  switch (name) {
124
159
  case 'Topology':
@@ -142,6 +177,41 @@ async function callApi(
142
177
  }
143
178
  case 'Backups':
144
179
  return Response.json({ isSucc: true, res: await readBackupsWithCoverage(upstream) });
180
+ case 'Session':
181
+ return Response.json({ isSucc: true, res: await readSession(upstream, subject) });
182
+ case 'AckAlert': {
183
+ const alertKey = body.alertKey;
184
+ if (typeof alertKey !== 'string' || alertKey.length === 0) {
185
+ return Response.json(
186
+ { isSucc: false, err: { message: 'alertKey is required', type: 'ApiError' } },
187
+ { status: 400 },
188
+ );
189
+ }
190
+
191
+ const session = await readSession(upstream, subject);
192
+ if (session.person === null) {
193
+ // 403 rather than 400: the request is well formed and the caller is
194
+ // authenticated. What is missing is a `people` row for them, and the
195
+ // message says so, because the fix is `celilo person add` and nothing
196
+ // about a generic denial would point an operator at it.
197
+ return Response.json(
198
+ {
199
+ isSucc: false,
200
+ err: {
201
+ message: `No person in this fleet matches "${session.name ?? session.subject}". An acknowledgement records WHO saw the alert, so the console will not make one it cannot attribute. Add them with \`celilo person add\`.`,
202
+ type: 'ApiError',
203
+ code: 'unmapped-subject',
204
+ },
205
+ },
206
+ { status: 403 },
207
+ );
208
+ }
209
+
210
+ return Response.json({
211
+ isSucc: true,
212
+ res: await ackAlert(upstream, alertKey, session.person),
213
+ });
214
+ }
145
215
  default:
146
216
  return Response.json(
147
217
  { isSucc: false, err: { message: `${name} is not served here`, type: 'ApiError' } },
@@ -155,6 +225,7 @@ async function callApi(
155
225
  * drive it with a `Request` and no listening socket.
156
226
  */
157
227
  export function createHandler(options: ServeOptions): (request: Request) => Promise<Response> {
228
+ const spaDir = options.spaDir ?? BUNDLED_SPA_DIR;
158
229
  const upstream = new Upstream({
159
230
  dest: options.dest,
160
231
  identityFile: options.identityFile,
@@ -178,7 +249,7 @@ export function createHandler(options: ServeOptions): (request: Request) => Prom
178
249
 
179
250
  if (url.pathname.startsWith('/api/')) {
180
251
  const name = url.pathname.slice('/api/'.length) as ApiName;
181
- if (!READ_VERBS.includes(name)) {
252
+ if (!SERVED.includes(name)) {
182
253
  return Response.json(
183
254
  { isSucc: false, err: { message: `Unknown verb: ${name}`, type: 'ApiError' } },
184
255
  { status: 404 },
@@ -195,7 +266,7 @@ export function createHandler(options: ServeOptions): (request: Request) => Prom
195
266
  }
196
267
 
197
268
  try {
198
- return await callApi(name, body, upstream);
269
+ return await callApi(name, body, upstream, subject);
199
270
  } catch (error) {
200
271
  if (error instanceof UpstreamUnavailable) return unavailableResponse(error);
201
272
  throw error;
@@ -205,18 +276,18 @@ export function createHandler(options: ServeOptions): (request: Request) => Prom
205
276
  // Static, then the SPA catch-all. The order matters: a request for
206
277
  // `/assets/index.js` must find the file, and a request for `/backups` must
207
278
  // find `index.html`, because the route belongs to the client-side router.
208
- const asset = Bun.file(`${options.spaDir}${url.pathname}`);
279
+ const asset = Bun.file(`${spaDir}${url.pathname}`);
209
280
  if (url.pathname !== '/' && (await asset.exists())) {
210
281
  return new Response(asset, { headers: { 'content-type': contentType(url.pathname) } });
211
282
  }
212
283
 
213
- const shell = Bun.file(`${options.spaDir}/index.html`);
284
+ const shell = Bun.file(`${spaDir}/index.html`);
214
285
  if (!(await shell.exists())) {
215
286
  // Said out loud rather than 404ing. A console server with no SPA built
216
287
  // into it is a packaging failure, and a bare 404 sends the reader looking
217
288
  // for a routing bug instead.
218
289
  return new Response(
219
- `No SPA found at ${options.spaDir}. The console server ships the built client inside its package, and this one was started without it.`,
290
+ `No SPA found at ${spaDir}. The console server ships the built client inside its package, and this one was started without it.`,
220
291
  { status: 500 },
221
292
  );
222
293
  }
package/src/upstream.ts CHANGED
@@ -2,11 +2,16 @@
2
2
  * How the console reaches celilo.
3
3
  *
4
4
  * The console server holds NO database handle. Every fact it serves arrives over
5
- * the SSH remote API as a principal whose grants are read-only, the same way
6
- * `@celilo/mcp` reaches celilo-mgr. That is a security boundary rather than a
7
- * deployment preference: a console sharing celilo's process would hold the whole
8
- * database including the encrypted secret store, and would be constrained only
9
- * by its own code.
5
+ * the SSH remote API, the same way `@celilo/mcp` reaches celilo-mgr. That is a
6
+ * security boundary rather than a deployment preference: a console sharing
7
+ * celilo's process would hold the whole database including the encrypted secret
8
+ * store, and would be constrained only by its own code.
9
+ *
10
+ * Its principal is granted the read ops and `alerts:ack`, and nothing else. The
11
+ * read ops are DERIVED from celilo's command registry rather than listed, so a
12
+ * new write verb is never granted however it is named; `alerts:ack` is the one
13
+ * explicit exception, argued for in D8 and useless without an identity to
14
+ * attribute the acknowledgement to.
10
15
  *
11
16
  * Two policies here differ deliberately from the MCP's client.
12
17
  *
@@ -92,12 +97,39 @@ export class Upstream {
92
97
  return value;
93
98
  }
94
99
 
95
- /** Drop everything cached. Used when the operator asks for a hard refresh. */
100
+ /**
101
+ * Run one command that is NOT a read, and return what it said.
102
+ *
103
+ * Uncached and unparsed. The console's only write is `alerts ack`, which
104
+ * answers with a sentence rather than JSON, and a write must never be served
105
+ * from a cache — the whole point of issuing it is that it changes something.
106
+ *
107
+ * Failures classify exactly as a read's do, so a console whose principal was
108
+ * never granted `alerts:ack` renders a denial rather than a fault.
109
+ */
110
+ async run(argv: readonly string[]): Promise<string> {
111
+ return this.runText(argv);
112
+ }
113
+
114
+ /** Drop everything cached. Used after a write, and on a hard refresh. */
96
115
  invalidate(): void {
97
116
  this.cache.clear();
98
117
  }
99
118
 
100
119
  private async runJson<T>(argv: readonly string[]): Promise<T> {
120
+ const output = await this.runText(argv);
121
+ try {
122
+ return JSON.parse(output) as T;
123
+ } catch {
124
+ throw new UpstreamUnavailable(
125
+ 'malformed',
126
+ argv,
127
+ `\`${argv.join(' ')}\` succeeded but did not return JSON.`,
128
+ );
129
+ }
130
+ }
131
+
132
+ private async runText(argv: readonly string[]): Promise<string> {
101
133
  const lines: string[] = [];
102
134
  const out: DisplayWriter = {
103
135
  write: (s: string) => {
@@ -145,15 +177,7 @@ export class Upstream {
145
177
  throw new UpstreamUnavailable(classifyFailure(outcome.exitCode, output), argv, output);
146
178
  }
147
179
 
148
- try {
149
- return JSON.parse(output) as T;
150
- } catch {
151
- throw new UpstreamUnavailable(
152
- 'malformed',
153
- argv,
154
- `\`${argv.join(' ')}\` succeeded but did not return JSON.`,
155
- );
156
- }
180
+ return output;
157
181
  }
158
182
  }
159
183
 
package/src/verbs.ts CHANGED
@@ -14,9 +14,11 @@
14
14
  * the console draws that differently from "nothing is wrong". Returning `[]` for
15
15
  * a read that did not happen is the bug this console was specified around.
16
16
  *
17
- * Nothing here writes. The console's API principal is granted read ops only, so
18
- * a mutating verb added to this file would be refused at the API boundary rather
19
- * than running — but it would still be a lie in the source, so it stays absent.
17
+ * Exactly ONE thing here writes, and `ackAlert` is it. Everything else is a
18
+ * read, and the console's API principal is granted read ops only — so a second
19
+ * mutating verb added to this file would be refused at the API boundary rather
20
+ * than running. Ack is the one write D8 argues for, and it is the reason the
21
+ * console needs an identity at all.
20
22
  */
21
23
 
22
24
  import type {
@@ -25,13 +27,15 @@ import type {
25
27
  BackupRunProjection,
26
28
  ClosureNode,
27
29
  ModuleProjection,
30
+ ResAckAlert,
28
31
  ResAlerts,
29
32
  ResBackups,
30
33
  ResClosure,
31
34
  ResModules,
35
+ ResSession,
32
36
  ResTopology,
33
37
  } from '@celilo/console-protocol';
34
- import type { Upstream } from './upstream';
38
+ import { type Upstream, UpstreamUnavailable } from './upstream';
35
39
 
36
40
  /**
37
41
  * How far back the backups read asks for.
@@ -64,15 +68,15 @@ interface CliConsoleStatus {
64
68
  /**
65
69
  * `celilo console get <id> --json`, verbatim.
66
70
  *
67
- * Note what is NOT here: delegation chains. D6 establishes that the firewall
68
- * chain is derivable (a provider's capability data names the zone it delegates
69
- * to) and that the derivation must be verified against a live
70
- * `celilo capability info firewall` before anything is built on it. That
71
- * verification has not happened and `computeClosure` does not produce chains,
72
- * so this server has none to serve.
71
+ * `chains` is OPTIONAL here and required in the protocol, and the gap between
72
+ * them is a celilo-mgr old enough not to compute them. That server answers the
73
+ * verb successfully and simply omits the field, so the type has to admit it
74
+ * (D7's older-upstream rule). `readClosure` turns the absence into an empty
75
+ * list, which under-draws the picture rather than misdrawing it.
73
76
  */
74
77
  interface CliClosure {
75
78
  nodes: { moduleId: string; hop: number; optional: boolean; via: string[] }[];
79
+ chains?: { capability: string; moduleIds: string[] }[];
76
80
  depth: number;
77
81
  truncated: boolean;
78
82
  }
@@ -98,6 +102,12 @@ interface CliAlert {
98
102
  message: string;
99
103
  }
100
104
 
105
+ /** `celilo person list --json`, verbatim. */
106
+ interface CliPerson {
107
+ name: string;
108
+ timezone: string;
109
+ }
110
+
101
111
  /** `celilo backup list --json`, verbatim. */
102
112
  interface CliBackups {
103
113
  asOf: number;
@@ -207,12 +217,11 @@ export async function readClosure(
207
217
 
208
218
  return {
209
219
  nodes,
210
- // EMPTY BECAUSE THERE IS NO PRODUCER, not because this fleet has no
211
- // delegation. On the live fleet `iptables` delegates upstream to `axon`, so
212
- // an empty list here is wrong rather than merely unpopulated. It is left
213
- // empty rather than faked, and no console surface renders it today — the
214
- // moment one does, it must say "not computed" rather than "none".
215
- chains: [],
220
+ // Served as celilo computed them. A chain naming a module the roster does
221
+ // not have is kept rather than dropped, unlike a node: the chain is a claim
222
+ // about the packet's path, and silently shortening it would say the packet
223
+ // stops somewhere it does not.
224
+ chains: closure.chains ?? [],
216
225
  depth: closure.depth,
217
226
  truncated: closure.truncated,
218
227
  };
@@ -377,3 +386,86 @@ export async function readBackupsWithCoverage(upstream: Upstream): Promise<ResBa
377
386
 
378
387
  return { ...backups, backups: [...withStaleness, ...unhooked] };
379
388
  }
389
+
390
+ /**
391
+ * Who is looking, and whether celilo has a `people` row for them.
392
+ *
393
+ * The mapping is by NAME. celilo's `people.name` is a kebab-case handle an
394
+ * operator chose (`peter`), and the claim that carries the same thing from an
395
+ * identity provider is the display name or preferred username. The opaque `sub`
396
+ * is tried second, for a provider configured to put the handle there.
397
+ *
398
+ * Compared case-insensitively, because a provider that answers `Peter` to a
399
+ * fleet that spells it `peter` is not a different person, and the alternative
400
+ * is an operator staring at a console that will not let them acknowledge
401
+ * anything with nothing on screen saying why.
402
+ *
403
+ * A subject that maps to nobody yields `person: null`, which is an answer. The
404
+ * console draws no ack control for it. What must NEVER happen is this function
405
+ * guessing: `celilo alerts ack` falls back to `people[0]` when no one is named,
406
+ * so a guess here would silently attribute an acknowledgement to a real person
407
+ * who did not make it.
408
+ */
409
+ export async function readSession(
410
+ upstream: Upstream,
411
+ identity: { subject: string; name?: string },
412
+ ): Promise<ResSession> {
413
+ const people = await upstream.read<CliPerson[]>(['person', 'list', '--json']);
414
+ const byName = new Map(people.map((p) => [p.name.toLowerCase(), p.name]));
415
+
416
+ const candidates = [identity.name, identity.subject].filter(
417
+ (value): value is string => typeof value === 'string' && value.length > 0,
418
+ );
419
+ const matched = candidates.map((c) => byName.get(c.toLowerCase())).find((n) => n !== undefined);
420
+
421
+ return {
422
+ subject: identity.subject,
423
+ name: identity.name ?? null,
424
+ person: matched ?? null,
425
+ };
426
+ }
427
+
428
+ /**
429
+ * Acknowledge one alert AS a named person.
430
+ *
431
+ * `person` is not optional and there is no path through this function that
432
+ * omits `--as`. That is the whole requirement: `handleAlertsAck` falls back to
433
+ * `people[0]` when the flag is absent, which is reasonable for a CLI on a
434
+ * one-operator fleet and a lie in a browser. Making the parameter required
435
+ * means the fallback is unreachable from here by construction rather than by
436
+ * everyone remembering to pass it.
437
+ *
438
+ * The caller is responsible for having resolved `person` from an authenticated
439
+ * subject via `readSession`. This function will not accept a name a browser
440
+ * supplied, because it never sees one — the protocol has no actor field.
441
+ *
442
+ * `ackedAt` is re-read from celilo rather than stamped here. The console server
443
+ * and celilo-mgr are different machines, and a console that reported its own
444
+ * clock would be the alert-age bug again one layer down.
445
+ */
446
+ export async function ackAlert(
447
+ upstream: Upstream,
448
+ alertKey: string,
449
+ person: string,
450
+ ): Promise<ResAckAlert> {
451
+ await upstream.run(['alerts', 'ack', alertKey, '--as', person]);
452
+
453
+ // The write landed, so every cached read is now wrong. Dropped before the
454
+ // re-read below, which would otherwise be served the pre-ack list.
455
+ upstream.invalidate();
456
+
457
+ const rows = await upstream.read<CliAlert[]>(['alerts', 'list', '--json']);
458
+ const acked = rows.find((row) => row.key === alertKey);
459
+ if (!acked || acked.ackedAt === null) {
460
+ // celilo said it acknowledged and its own list disagrees. Reported rather
461
+ // than papered over with `Date.now()`: a console that invents the timestamp
462
+ // would render a successful ack for a write that did not take.
463
+ throw new UpstreamUnavailable(
464
+ 'malformed',
465
+ ['alerts', 'ack', alertKey],
466
+ `celilo acknowledged ${alertKey} but does not report it as acknowledged.`,
467
+ );
468
+ }
469
+
470
+ return { ackedAt: acked.ackedAt, ackedByName: acked.ackedBy ?? person };
471
+ }