@celilo/console-server 0.2.0 → 0.4.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 +5 -2
- package/spa/assets/index-CMq3qlYr.js +26 -0
- package/spa/assets/index-eLF_dgP-.css +1 -0
- package/spa/index.html +13 -0
- package/src/index.ts +8 -1
- package/src/main.ts +127 -0
- package/src/serve.ts +87 -16
- package/src/upstream.ts +39 -15
- package/src/verbs.ts +108 -16
|
@@ -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 {
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
34
|
-
|
|
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
|
-
|
|
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.
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* `people` row
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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 (!
|
|
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(`${
|
|
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(`${
|
|
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 ${
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
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
|
+
}
|