@celilo/console-server 0.5.0 → 0.6.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/spa/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>celilo</title>
7
- <script type="module" crossorigin src="/assets/index-DJJXEYrW.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-xOtoHA1j.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-eLF_dgP-.css">
9
9
  </head>
10
10
  <body>
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { startFromEnv } from './main';
3
+
4
+ /**
5
+ * The console used to fall back to the bare destination `celilo-api@celilo-mgr`
6
+ * when CELILO_API_DEST was unset. That name resolves only on celilo-mgr itself,
7
+ * so on the console's own box every /api read 502'd while `celilo module health`
8
+ * reported VERIFIED — it drives an OIDC login and never touches the upstream.
9
+ * celilo#1453.
10
+ *
11
+ * A silent fallback to an unreachable default is worse than no default: it turns
12
+ * a missing value into a runtime symptom three layers from its cause. So the
13
+ * behaviour under test is the REFUSAL, not the presence of a constant.
14
+ */
15
+ const OIDC_ENV = {
16
+ OIDC_ISSUER: 'https://auth.example.test/application/o/console/',
17
+ OIDC_CLIENT_ID: 'console-web',
18
+ OIDC_CLIENT_SECRET: 'not-a-real-secret',
19
+ };
20
+
21
+ describe('startFromEnv — the upstream destination', () => {
22
+ test('refuses to start when CELILO_API_DEST is unset', () => {
23
+ expect(() => startFromEnv({ ...OIDC_ENV })).toThrow(/CELILO_API_DEST is not set/);
24
+ });
25
+
26
+ test('refuses to start when CELILO_API_DEST is empty', () => {
27
+ expect(() => startFromEnv({ ...OIDC_ENV, CELILO_API_DEST: '' })).toThrow(
28
+ /CELILO_API_DEST is not set/,
29
+ );
30
+ });
31
+
32
+ test('says what a usable destination looks like, so the message is actionable', () => {
33
+ // An error naming only the variable sends the reader to grep. This one has to
34
+ // carry the shape of the answer, because the shape IS the bug: a bare
35
+ // hostname looks correct and resolves nowhere.
36
+ expect(() => startFromEnv({ ...OIDC_ENV })).toThrow(/\.infra\.<domain>/);
37
+ });
38
+ });
package/src/main.ts CHANGED
@@ -23,8 +23,18 @@ import { type AuthGate, type AuthSubject, serve } from './serve';
23
23
  */
24
24
  const DEFAULT_IDENTITY_FILE = '/etc/celilo-web-console/id_ed25519';
25
25
 
26
- /** Matches `wellspring`'s `DEFAULT_ENDPOINT`, which is the fleet's convention. */
27
- const DEFAULT_DEST = 'celilo-api@celilo-mgr';
26
+ // There is deliberately no DEFAULT_DEST. It used to be `celilo-api@celilo-mgr`,
27
+ // borrowed from wellspring's DEFAULT_ENDPOINT — a bare name that resolves only on
28
+ // celilo-mgr itself, via its own /etc/hosts. No system in the fleet carries a DNS
29
+ // search domain, so on the console's own box it resolved nowhere, and the server
30
+ // fell back to it silently whenever CELILO_API_DEST was unset. The result was
31
+ // `ssh: Could not resolve hostname celilo-mgr` twice a second and a 502 on every
32
+ // /api read, while `celilo module health` still reported VERIFIED because it
33
+ // drives a login and never touches the upstream. celilo#1453.
34
+ //
35
+ // A missing destination is a misconfiguration, so it fails loudly. The fleet's
36
+ // real name for a system is `<hostname>.infra.<domain>`, which technitium
37
+ // registers for every system (on-system-event.ts:120).
28
38
 
29
39
  export interface OidcGateOptions {
30
40
  /** The `iss` claim exactly — for authentik, `https://auth.<domain>/application/o/<slug>/`. */
@@ -95,12 +105,17 @@ export function oidcGate(options: OidcGateOptions): AuthGate {
95
105
  }
96
106
 
97
107
  /** Read one required value, or say which one is missing and who writes it. */
98
- function required(env: Record<string, string | undefined>, name: string): string {
108
+ const OIDC_UNSET =
109
+ "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.";
110
+
111
+ function required(
112
+ env: Record<string, string | undefined>,
113
+ name: string,
114
+ why: string = OIDC_UNSET,
115
+ ): string {
99
116
  const value = env[name];
100
117
  if (value === undefined || value === '') {
101
- throw new Error(
102
- `${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.`,
103
- );
118
+ throw new Error(`${name} is not set. ${why}`);
104
119
  }
105
120
  return value;
106
121
  }
@@ -126,7 +141,11 @@ export function startFromEnv(env: Record<string, string | undefined> = process.e
126
141
  );
127
142
  const server = serve({
128
143
  port: Number(env.PORT ?? 8443),
129
- dest: env.CELILO_API_DEST ?? DEFAULT_DEST,
144
+ dest: required(
145
+ env,
146
+ 'CELILO_API_DEST',
147
+ 'The console has no celilo to read through. The module writes it into /etc/celilo-web-console.env from its `api_dest` config; it must be a resolvable destination such as celilo-api@celilo-mgr.infra.<domain>, never a bare hostname.',
148
+ ),
130
149
  identityFile: env.CELILO_IDENTITY_FILE ?? DEFAULT_IDENTITY_FILE,
131
150
  ...(env.CONSOLE_SPA_DIR ? { spaDir: env.CONSOLE_SPA_DIR } : {}),
132
151
  authenticate: login.authenticate,
package/src/serve.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { join } from 'node:path';
19
+ import { API_BASE_PATH } from '@celilo/console-protocol';
19
20
  import type { ServiceType } from '@celilo/console-protocol';
20
21
  import { CALLBACK_PATH } from './oidc-login';
21
22
  import { Upstream, type UpstreamOptions, UpstreamUnavailable } from './upstream';
@@ -267,7 +268,11 @@ export function createHandler(options: ServeOptions): (request: Request) => Prom
267
268
  // bundle even with every API call refused.
268
269
  const subject = await options.authenticate(request);
269
270
  if (subject === null) {
270
- if (options.login && !url.pathname.startsWith('/api/') && url.pathname !== CALLBACK_PATH) {
271
+ if (
272
+ options.login &&
273
+ !url.pathname.startsWith(`${API_BASE_PATH}/`) &&
274
+ url.pathname !== CALLBACK_PATH
275
+ ) {
271
276
  return options.login.redirectToLogin(request);
272
277
  }
273
278
  return new Response('Unauthorized', {
@@ -276,8 +281,8 @@ export function createHandler(options: ServeOptions): (request: Request) => Prom
276
281
  });
277
282
  }
278
283
 
279
- if (url.pathname.startsWith('/api/')) {
280
- const name = url.pathname.slice('/api/'.length) as ApiName;
284
+ if (url.pathname.startsWith(`${API_BASE_PATH}/`)) {
285
+ const name = url.pathname.slice(API_BASE_PATH.length + 1) as ApiName;
281
286
  if (!SERVED.includes(name)) {
282
287
  return Response.json(
283
288
  { isSucc: false, err: { message: `Unknown verb: ${name}`, type: 'ApiError' } },
package/src/verbs.ts CHANGED
@@ -81,7 +81,23 @@ interface CliClosure {
81
81
  truncated: boolean;
82
82
  }
83
83
 
84
- /** `celilo alerts --json`, verbatim. */
84
+ /**
85
+ * `celilo alerts list --json`, verbatim — which is NOT the protocol's shape.
86
+ *
87
+ * Every timestamp is `unknown` deliberately. celilo stores them as Drizzle
88
+ * `mode: 'timestamp'` columns, so its rows hold `Date` objects and `--json`
89
+ * prints ISO STRINGS, while the protocol declares epoch ms (also deliberate:
90
+ * ages are computed against the server's `asOf`, so a skewed browser clock
91
+ * cannot answer "how long has this been firing" wrongly).
92
+ *
93
+ * Typing them `number` here declared a shape nobody checked. `upstream.read<T>`
94
+ * is a type ASSERTION, so the strings passed through onto the wire, the
95
+ * browser's own protocol validation rejected the payload, and the dock rendered
96
+ * one word — "unavailable" — for a fleet with six firing alerts (celilo#1455).
97
+ * `unknown` forces every one of them through `epochMs` below, which converts and
98
+ * VALIDATES at the ingest boundary, so an upstream shape change fails here
99
+ * naming the field instead of three layers away as a single word.
100
+ */
85
101
  interface CliAlert {
86
102
  id: string;
87
103
  key: string;
@@ -90,18 +106,45 @@ interface CliAlert {
90
106
  monitor: string | null;
91
107
  escalationPolicy: string | null;
92
108
  escalationStep: number;
93
- nextEscalationAt: number | null;
94
- firstFiredAt: number;
95
- lastSeenAt: number;
109
+ nextEscalationAt: unknown;
110
+ firstFiredAt: unknown;
111
+ lastSeenAt: unknown;
96
112
  suppressedByAlertId: string | null;
97
113
  suppressedByWindowId: string | null;
98
114
  awaitingConfirmation: boolean;
99
115
  ackedBy: string | null;
100
- ackedAt: number | null;
101
- silencedUntil: number | null;
116
+ ackedAt: unknown;
117
+ silencedUntil: unknown;
102
118
  message: string;
103
119
  }
104
120
 
121
+ const ALERTS_ARGV = ['alerts', 'list', '--json'] as const;
122
+
123
+ /**
124
+ * An upstream timestamp as epoch ms, or a `malformed` failure naming the field.
125
+ *
126
+ * Accepts both forms on purpose: epoch ms is what the protocol wants and what an
127
+ * older or future celilo may print directly, and an ISO string is what today's
128
+ * Drizzle-serialised rows carry.
129
+ */
130
+ function epochMs(value: unknown, field: string): number {
131
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
132
+ if (typeof value === 'string') {
133
+ const parsed = Date.parse(value);
134
+ if (!Number.isNaN(parsed)) return parsed;
135
+ }
136
+ throw new UpstreamUnavailable(
137
+ 'malformed',
138
+ ALERTS_ARGV,
139
+ `\`celilo alerts list --json\` returned ${field}=${JSON.stringify(value)}; expected epoch ms or an ISO timestamp.`,
140
+ );
141
+ }
142
+
143
+ /** As `epochMs`, for a field celilo legitimately leaves unset. */
144
+ function epochMsOrNull(value: unknown, field: string): number | null {
145
+ return value === null || value === undefined ? null : epochMs(value, field);
146
+ }
147
+
105
148
  /** `celilo person list --json`, verbatim. */
106
149
  interface CliPerson {
107
150
  name: string;
@@ -278,17 +321,17 @@ export async function readAlerts(upstream: Upstream, moduleId?: string): Promise
278
321
  state: alertState(row.state),
279
322
  severity: severity(row.severity),
280
323
  policy: row.escalationPolicy,
281
- firstFiredAt: row.firstFiredAt,
282
- lastSeenAt: row.lastSeenAt,
324
+ firstFiredAt: epochMs(row.firstFiredAt, 'firstFiredAt'),
325
+ lastSeenAt: epochMs(row.lastSeenAt, 'lastSeenAt'),
283
326
  suppressedByAlertId: row.suppressedByAlertId,
284
327
  suppressedByWindowId: row.suppressedByWindowId,
285
- silencedUntil: row.silencedUntil,
328
+ silencedUntil: epochMsOrNull(row.silencedUntil, 'silencedUntil'),
286
329
  // `alerts --json` resolves the person to a name. There is no id on the
287
330
  // wire, so the name serves as both — which is what the console shows.
288
331
  ackedBy: row.ackedBy ? { id: row.ackedBy, name: row.ackedBy } : null,
289
- ackedAt: row.ackedAt,
332
+ ackedAt: epochMsOrNull(row.ackedAt, 'ackedAt'),
290
333
  escalationStep: row.escalationStep,
291
- nextEscalationAt: row.nextEscalationAt,
334
+ nextEscalationAt: epochMsOrNull(row.nextEscalationAt, 'nextEscalationAt'),
292
335
  }),
293
336
  ),
294
337
  };
@@ -467,5 +510,5 @@ export async function ackAlert(
467
510
  );
468
511
  }
469
512
 
470
- return { ackedAt: acked.ackedAt, ackedByName: acked.ackedBy ?? person };
513
+ return { ackedAt: epochMs(acked.ackedAt, 'ackedAt'), ackedByName: acked.ackedBy ?? person };
471
514
  }