@bevel-software/platform-core-backend 0.9.0 → 0.10.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.
Files changed (40) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +4 -0
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts +3 -0
  6. package/dist/core/create-core-services.d.ts.map +1 -1
  7. package/dist/core/create-core-services.js +11 -0
  8. package/dist/core/create-core-services.js.map +1 -1
  9. package/dist/core-config.d.ts +19 -1
  10. package/dist/core-config.d.ts.map +1 -1
  11. package/dist/core-config.js +44 -4
  12. package/dist/core-config.js.map +1 -1
  13. package/dist/modules/update-check/update-check.routes.d.ts +13 -0
  14. package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
  15. package/dist/modules/update-check/update-check.routes.js +21 -0
  16. package/dist/modules/update-check/update-check.routes.js.map +1 -0
  17. package/dist/modules/update-check/update-check.service.d.ts +57 -0
  18. package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
  19. package/dist/modules/update-check/update-check.service.js +109 -0
  20. package/dist/modules/update-check/update-check.service.js.map +1 -0
  21. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  22. package/dist/modules/workflow/git/git.service.js +8 -0
  23. package/dist/modules/workflow/git/git.service.js.map +1 -1
  24. package/dist/version.d.ts +14 -0
  25. package/dist/version.d.ts.map +1 -1
  26. package/dist/version.js +25 -0
  27. package/dist/version.js.map +1 -1
  28. package/kb-template/AGENTS.md +11 -5
  29. package/kb-template/access.md +40 -30
  30. package/package.json +4 -4
  31. package/src/__tests__/core-config.domain.test.ts +92 -0
  32. package/src/core/create-core-server.ts +8 -0
  33. package/src/core/create-core-services.ts +14 -0
  34. package/src/core-config.ts +48 -4
  35. package/src/modules/update-check/__tests__/update-check.routes.test.ts +69 -0
  36. package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
  37. package/src/modules/update-check/update-check.routes.ts +28 -0
  38. package/src/modules/update-check/update-check.service.ts +130 -0
  39. package/src/modules/workflow/git/git.service.ts +8 -0
  40. package/src/version.ts +28 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-core-backend",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, workflow (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,7 +34,7 @@
34
34
  "@utcp/code-mode": "^1.2.12",
35
35
  "@utcp/direct-call": "^1.1.1",
36
36
  "@utcp/http": "^1.1.7",
37
- "@utcp/mcp": "^1.1.3",
37
+ "@utcp/mcp": "^1.1.4",
38
38
  "@utcp/sdk": "^1.1.1",
39
39
  "adm-zip": "^0.6.0",
40
40
  "cors": "^2.8.5",
@@ -46,8 +46,8 @@
46
46
  "pg": "^8.20.0",
47
47
  "yaml": "^2.9.0",
48
48
  "zod": "^3.24.0",
49
- "@bevel-software/platform-mcp-core": "0.9.0",
50
- "@bevel-software/platform-shared": "0.9.0"
49
+ "@bevel-software/platform-mcp-core": "0.10.0",
50
+ "@bevel-software/platform-shared": "0.10.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/adm-zip": "^0.5.8",
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { CoreConfig } from '../core-config.js';
3
+
4
+ /**
5
+ * The public-shape derivation from `DOMAIN`.
6
+ *
7
+ * Setting `DOMAIN` declares "the bundled Caddy `https` profile fronts this
8
+ * deployment": one proxy hop, origins = the domain. The config derives
9
+ * `PUBLIC_BACKEND_URL` / `PUBLIC_FRONTEND_URL` (`https://<DOMAIN>`) and
10
+ * `TRUST_PROXY` (`1`) from it — but only as DEFAULTS: an explicit value for
11
+ * any of the three always wins, so a CDN in front of Caddy or a frontend
12
+ * served elsewhere stays expressible.
13
+ */
14
+ const REQUIRED = {
15
+ KB_REPO_URL: 'https://example.com/org/kb.git',
16
+ ADMIN_EMAIL: 'root@example.com',
17
+ ADMIN_PASSWORD: 'sup3r-secret',
18
+ JWT_SECRET: 'test-jwt-secret',
19
+ SECRETS_ENC_KEY: 'kToAi8FXWDpDn3A6yQ/60O39bv05N7XzVOIu/0CJrFc=',
20
+ };
21
+
22
+ let saved: NodeJS.ProcessEnv;
23
+
24
+ beforeEach(() => {
25
+ saved = { ...process.env };
26
+ // A clean slate for the keys under test (a developer .env loaded by
27
+ // dotenv at import time may carry any of them).
28
+ for (const key of [
29
+ 'DOMAIN',
30
+ 'PUBLIC_BACKEND_URL',
31
+ 'PUBLIC_FRONTEND_URL',
32
+ 'TRUST_PROXY',
33
+ 'NODE_ENV',
34
+ 'PORT',
35
+ ]) {
36
+ delete process.env[key];
37
+ }
38
+ Object.assign(process.env, REQUIRED);
39
+ });
40
+
41
+ afterEach(() => {
42
+ process.env = saved;
43
+ });
44
+
45
+ describe('CoreConfig — DOMAIN derives the public shape', () => {
46
+ it('DOMAIN alone yields https origins and one proxy hop', () => {
47
+ process.env.DOMAIN = 'bevel.example.com';
48
+ const config = new CoreConfig();
49
+ expect(config.publicBackendUrl).toBe('https://bevel.example.com');
50
+ expect(config.publicFrontendUrl).toBe('https://bevel.example.com');
51
+ expect(config.trustProxy).toBe('1');
52
+ });
53
+
54
+ it('explicit PUBLIC_* and TRUST_PROXY always win over DOMAIN', () => {
55
+ // The CDN-in-front-of-Caddy shape: two hops, and a frontend served from
56
+ // somewhere the bundled proxy is not.
57
+ process.env.DOMAIN = 'bevel.example.com';
58
+ process.env.PUBLIC_BACKEND_URL = 'https://api.example.com';
59
+ process.env.PUBLIC_FRONTEND_URL = 'https://app.example.com';
60
+ process.env.TRUST_PROXY = '2';
61
+ const config = new CoreConfig();
62
+ expect(config.publicBackendUrl).toBe('https://api.example.com');
63
+ expect(config.publicFrontendUrl).toBe('https://app.example.com');
64
+ expect(config.trustProxy).toBe('2');
65
+ });
66
+
67
+ it('without DOMAIN the defaults are unchanged (dev shape)', () => {
68
+ const config = new CoreConfig();
69
+ expect(config.publicBackendUrl).toBe('http://localhost:3001');
70
+ expect(config.publicFrontendUrl).toBe('http://localhost:5173');
71
+ // Unset means "directly exposed": forwarded headers stay ignored.
72
+ expect(config.trustProxy).toBe('');
73
+ });
74
+
75
+ it('without DOMAIN in production, the frontend origin is the backend origin', () => {
76
+ // The backend serves the built SPA — under docker compose (which sets
77
+ // NODE_ENV=production and no PUBLIC_* by default) this is what keeps a
78
+ // bare `up -d` bouncing logins back to the container's own origin.
79
+ process.env.NODE_ENV = 'production';
80
+ const config = new CoreConfig();
81
+ expect(config.publicBackendUrl).toBe('http://localhost:3001');
82
+ expect(config.publicFrontendUrl).toBe('http://localhost:3001');
83
+ expect(config.trustProxy).toBe('');
84
+ });
85
+
86
+ it('an explicit production backend origin carries over to the frontend default', () => {
87
+ process.env.NODE_ENV = 'production';
88
+ process.env.PUBLIC_BACKEND_URL = 'https://bevel.example.com';
89
+ const config = new CoreConfig();
90
+ expect(config.publicFrontendUrl).toBe('https://bevel.example.com');
91
+ });
92
+ });
@@ -28,6 +28,7 @@ import {
28
28
  createSecretsVaultPublicRoutes,
29
29
  } from '../modules/secrets-vault/index.js';
30
30
  import { createAdminAccessRoutes } from '../modules/admin/admin-access.routes.js';
31
+ import { createUpdateCheckRoutes } from '../modules/update-check/update-check.routes.js';
31
32
  import { createAccountRoutes } from '../modules/auth/account.routes.js';
32
33
  import { createSetupRoutes } from '../modules/settings/setup.routes.js';
33
34
  import { DEFAULT_BRANCH, PROTECTED_BRANCHES, type AuthUser } from '@bevel-software/platform-shared';
@@ -426,6 +427,13 @@ export async function createCoreServer(
426
427
  // Admin-status resolver (CORE — see the note in admin-access.routes.ts;
427
428
  // the full admin router is an enterprise `ext.authed` extension).
428
429
  app.use('/api', core.authMiddleware, createAdminAccessRoutes(core.adminAccess));
430
+ // Update check (admin-only inside): the newest published release vs the
431
+ // running version, cached server-side — see update-check.service.ts.
432
+ app.use(
433
+ '/api',
434
+ core.authMiddleware,
435
+ createUpdateCheckRoutes(core.updateCheckService, core.adminAccess),
436
+ );
429
437
  // Account management (list/create password accounts, GDPR erasure) —
430
438
  // admin-gated inside.
431
439
  app.use('/api', core.authMiddleware, createAccountRoutes(
@@ -82,6 +82,8 @@ import { ToolRegistry } from '../modules/tool-registry/tool-registry.js';
82
82
  import { createToolContextResolver } from '../modules/tool-helpers/tool-context.js';
83
83
  import { createToolHandlerFactory } from '../modules/tool-helpers/tool-handler.js';
84
84
  import { TokenCrypto } from '../shared/token-crypto.js';
85
+ import { UpdateCheckService } from '../modules/update-check/update-check.service.js';
86
+ import { resolveAppVersion } from '../version.js';
85
87
  import { noopRecoveryAgent, type CorePorts } from './core-ports.js';
86
88
 
87
89
  /**
@@ -131,6 +133,8 @@ export interface CoreServices {
131
133
  pendingCommitsWorker: PendingCommitsWorker;
132
134
  recoveryBot: AuthUser;
133
135
  adminAccess: AdminAccessService;
136
+ /** Newest-release lookup behind `GET /api/update-check` (admin-only). */
137
+ updateCheckService: UpdateCheckService;
134
138
  /** Deployment settings, env-first — the KB remote resolves through these. */
135
139
  settings: DeploymentSettingsService;
136
140
  /**
@@ -499,6 +503,15 @@ export async function createCoreServices(
499
503
  [config.adminEmail],
500
504
  );
501
505
 
506
+ // In-app update check: lazily compares the running release version against
507
+ // the newest published GitHub release, only when an admin's browser asks —
508
+ // no timers, so a deployment nobody looks at makes zero calls. The flag
509
+ // removes even that (air-gapped deployments; see CoreConfig).
510
+ const updateCheckService = new UpdateCheckService({
511
+ enabled: config.updateCheckEnabled,
512
+ currentVersion: resolveAppVersion(),
513
+ });
514
+
502
515
  // Secrets Vault: the per-user store of credentials (static API keys + OAuth
503
516
  // tokens) that back UTCP tool variables (`${FOO_API_KEY}`). Encrypted at rest
504
517
  // with the connector-config key; read only by the `bevel-secrets` variable
@@ -746,6 +759,7 @@ export async function createCoreServices(
746
759
  pendingCommitsWorker,
747
760
  recoveryBot,
748
761
  adminAccess,
762
+ updateCheckService,
749
763
  secretsVaultService,
750
764
  externalApiKeyService,
751
765
  internalTokenService,
@@ -144,6 +144,16 @@ export class CoreConfig {
144
144
  * `session-ontology.gate.ts`), so it must exist wherever the tracking runs.
145
145
  */
146
146
  readonly ontologySessionBlock: boolean;
147
+ /**
148
+ * In-app update check. When true (default), `GET /api/update-check` lazily
149
+ * asks api.github.com for the newest Hexis release — only when an admin's
150
+ * browser asks, cached for hours, never on a timer — so admins see a quiet
151
+ * banner when this deployment is behind. The request carries nothing but
152
+ * the request itself: no token, no identifier, no telemetry. Set
153
+ * `UPDATE_CHECK=false` for air-gapped deployments or anyone who objects to
154
+ * the phone-home; disabled, the server never makes the call.
155
+ */
156
+ readonly updateCheckEnabled: boolean;
147
157
  /**
148
158
  * Password-login toggle for the login screen. Default enabled; set
149
159
  * `LOGIN_PASSWORD=false` to hide the method AND reject `/auth/login`
@@ -203,14 +213,22 @@ export class CoreConfig {
203
213
  * but behind a proxy it makes every client share the proxy's IP (so the
204
214
  * per-IP login rate limit would pool all users). Set it to the actual hop
205
215
  * count — never a blanket trust — so clients can't spoof X-Forwarded-For.
216
+ * With `DOMAIN` set (the bundled Caddy fronts the deployment) it defaults
217
+ * to `1` instead — see the derivation in the constructor.
206
218
  */
207
219
  readonly trustProxy: string;
208
220
  /**
209
221
  * Public base URL of THIS backend, used to build OAuth redirect URIs.
210
222
  * Must match a redirect URI registered with the OAuth provider(s).
223
+ * Defaults to `https://<DOMAIN>` when `DOMAIN` is set.
211
224
  */
212
225
  readonly publicBackendUrl: string;
213
- /** Public base URL of the frontend, where callbacks redirect post-login. */
226
+ /**
227
+ * Public base URL of the frontend, where callbacks redirect post-login.
228
+ * Defaults to `https://<DOMAIN>` when `DOMAIN` is set, else to the backend
229
+ * origin in production (the backend serves the SPA) and Vite's `:5173` in
230
+ * development.
231
+ */
214
232
  readonly publicFrontendUrl: string;
215
233
 
216
234
  constructor() {
@@ -304,6 +322,8 @@ export class CoreConfig {
304
322
  this.kbTemplateDir = process.env.KB_TEMPLATE_DIR || defaultKbTemplateDir();
305
323
  this.ontologySessionBlock =
306
324
  (process.env.ONTOLOGY_SESSION_BLOCK ?? 'true').trim().toLowerCase() !== 'false';
325
+ this.updateCheckEnabled =
326
+ (process.env.UPDATE_CHECK ?? 'true').trim().toLowerCase() !== 'false';
307
327
  this.allowedEmailDomains = (process.env.ALLOWED_EMAIL_DOMAINS || '')
308
328
  .split(/[\s,]+/)
309
329
  .map((d) => d.trim().toLowerCase().replace(/^[@.]+/, ''))
@@ -322,11 +342,35 @@ export class CoreConfig {
322
342
  );
323
343
  }
324
344
  this.internalTokenSecret = (process.env.INTERNAL_TOKEN_SECRET || '').trim();
325
- this.trustProxy = (process.env.TRUST_PROXY || '').trim();
326
- this.publicBackendUrl = (process.env.PUBLIC_BACKEND_URL || `http://localhost:${this.port}`)
345
+ // Setting DOMAIN declares "the bundled Caddy `https` profile fronts this
346
+ // deployment" — one proxy hop, and the public origin IS that domain. The
347
+ // three values below therefore default from it, so `DOMAIN=x.example.com`
348
+ // in `.env` is the whole configuration for that shape: TRUST_PROXY falls
349
+ // to 1 (Caddy is the hop) and both public URLs to `https://<DOMAIN>` (the
350
+ // backend serves the SPA, so they share an origin). Explicit
351
+ // PUBLIC_BACKEND_URL / PUBLIC_FRONTEND_URL / TRUST_PROXY always win — a
352
+ // CDN in front of Caddy (TRUST_PROXY=2) or a frontend served elsewhere
353
+ // stays expressible.
354
+ const domain = (process.env.DOMAIN || '').trim();
355
+ this.trustProxy = (process.env.TRUST_PROXY || (domain ? '1' : '')).trim();
356
+ this.publicBackendUrl = (
357
+ process.env.PUBLIC_BACKEND_URL ||
358
+ (domain ? `https://${domain}` : `http://localhost:${this.port}`)
359
+ )
327
360
  .trim()
328
361
  .replace(/\/+$/, '');
329
- this.publicFrontendUrl = (process.env.PUBLIC_FRONTEND_URL || 'http://localhost:5173')
362
+ // Unset, the frontend origin is the backend's own in production (the
363
+ // backend serves the built SPA — under docker compose this is what makes
364
+ // a bare `up -d` bounce logins back to the right place), and Vite's dev
365
+ // server in development.
366
+ this.publicFrontendUrl = (
367
+ process.env.PUBLIC_FRONTEND_URL ||
368
+ (domain
369
+ ? `https://${domain}`
370
+ : this.nodeEnv === 'production'
371
+ ? this.publicBackendUrl
372
+ : 'http://localhost:5173')
373
+ )
330
374
  .trim()
331
375
  .replace(/\/+$/, '');
332
376
  // Parse-validate so a malformed URL fails at boot rather than producing a
@@ -0,0 +1,69 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import express from 'express';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { createUpdateCheckRoutes } from '../update-check.routes.js';
6
+ import type { UpdateCheckService } from '../update-check.service.js';
7
+
8
+ /**
9
+ * GET /api/update-check — admin-only. Non-admins get the same 403 the setup
10
+ * routes give (the frontend only asks on behalf of admins anyway), and the
11
+ * service is never consulted for them.
12
+ */
13
+
14
+ let server: HttpServer | null = null;
15
+
16
+ afterEach(() => {
17
+ server?.close();
18
+ server = null;
19
+ vi.restoreAllMocks();
20
+ });
21
+
22
+ function listen(
23
+ service: Pick<UpdateCheckService, 'check'>,
24
+ opts: { email?: string; admins?: string[] } = {},
25
+ ): string {
26
+ const app = express();
27
+ const middleware: express.RequestHandler = (req, _res, next) => {
28
+ req.userEmail = opts.email ?? 'user@example.com';
29
+ next();
30
+ };
31
+ const adminAccess = {
32
+ isAdmin: async (email: string | undefined) =>
33
+ (opts.admins ?? ['admin@example.com']).includes(email ?? ''),
34
+ };
35
+ app.use('/api', middleware, createUpdateCheckRoutes(service, adminAccess));
36
+ server = app.listen(0);
37
+ const { port } = server.address() as AddressInfo;
38
+ return `http://127.0.0.1:${port}`;
39
+ }
40
+
41
+ describe('GET /update-check', () => {
42
+ it('answers the full shape for an admin', async () => {
43
+ const check = vi.fn().mockResolvedValue({
44
+ updateAvailable: true,
45
+ current: '0.9.1',
46
+ latest: '0.10.0',
47
+ notesUrl: 'https://github.com/Bevel-Software/Hexis/releases/tag/v0.10.0',
48
+ });
49
+ const base = listen({ check }, { email: 'admin@example.com' });
50
+ const res = await fetch(`${base}/api/update-check`);
51
+ expect(res.status).toBe(200);
52
+ expect(await res.json()).toEqual({
53
+ updateAvailable: true,
54
+ current: '0.9.1',
55
+ latest: '0.10.0',
56
+ notesUrl: 'https://github.com/Bevel-Software/Hexis/releases/tag/v0.10.0',
57
+ });
58
+ expect(check).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it('403s a non-admin without consulting the service', async () => {
62
+ const check = vi.fn();
63
+ const base = listen({ check }, { email: 'user@example.com' });
64
+ const res = await fetch(`${base}/api/update-check`);
65
+ expect(res.status).toBe(403);
66
+ expect(await res.json()).toEqual({ error: 'Admins only' });
67
+ expect(check).not.toHaveBeenCalled();
68
+ });
69
+ });
@@ -0,0 +1,170 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ UpdateCheckService,
4
+ isNewerVersion,
5
+ parseReleaseVersion,
6
+ } from '../update-check.service.js';
7
+
8
+ /**
9
+ * The update check's contract: lazy, cached, and SILENT on failure. The one
10
+ * thing it must never do is turn an offline deployment (or a GitHub hiccup,
11
+ * or a weird tag) into an error a user sees — every degraded path resolves to
12
+ * `updateAvailable: false`.
13
+ */
14
+
15
+ /** A fetch stub answering like the GitHub releases endpoint. */
16
+ function githubFetch(tag: string, notesUrl = 'https://github.com/Bevel-Software/Hexis/releases/tag/' + tag) {
17
+ return vi.fn().mockResolvedValue({
18
+ ok: true,
19
+ json: async () => ({ tag_name: tag, html_url: notesUrl }),
20
+ }) as unknown as typeof fetch & ReturnType<typeof vi.fn>;
21
+ }
22
+
23
+ function service(opts: {
24
+ enabled?: boolean;
25
+ current?: string;
26
+ fetchFn?: typeof fetch;
27
+ now?: () => number;
28
+ }) {
29
+ return new UpdateCheckService({
30
+ enabled: opts.enabled ?? true,
31
+ currentVersion: opts.current ?? '0.9.1',
32
+ fetchFn: opts.fetchFn,
33
+ now: opts.now,
34
+ });
35
+ }
36
+
37
+ describe('version comparison', () => {
38
+ it('compares numerically per part, not as strings', () => {
39
+ expect(isNewerVersion('0.10.0', '0.9.1')).toBe(true);
40
+ expect(isNewerVersion('1.0.0', '0.99.99')).toBe(true);
41
+ expect(isNewerVersion('0.9.2', '0.9.1')).toBe(true);
42
+ });
43
+
44
+ it('equal or older is not newer', () => {
45
+ expect(isNewerVersion('0.9.1', '0.9.1')).toBe(false);
46
+ expect(isNewerVersion('0.9.0', '0.9.1')).toBe(false);
47
+ expect(isNewerVersion('0.8.9', '0.9.1')).toBe(false);
48
+ });
49
+
50
+ it('tolerates a leading v and refuses prerelease/malformed tags', () => {
51
+ expect(parseReleaseVersion('v0.10.0')).toEqual([0, 10, 0]);
52
+ expect(parseReleaseVersion('0.10.0-rc.1')).toBeNull();
53
+ expect(parseReleaseVersion('nightly')).toBeNull();
54
+ expect(parseReleaseVersion('')).toBeNull();
55
+ expect(isNewerVersion('0.10.0-rc.1', '0.9.1')).toBe(false);
56
+ });
57
+
58
+ it('an unparseable RUNNING version never announces an update', () => {
59
+ // `resolveAppVersion` answers 'unknown' when the manifest is unreadable —
60
+ // that must fail toward silence, not toward "everything is newer".
61
+ expect(isNewerVersion('0.10.0', 'unknown')).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe('UpdateCheckService.check', () => {
66
+ it('reports an update when the published release is newer', async () => {
67
+ const fetchFn = githubFetch('v0.10.0');
68
+ const result = await service({ fetchFn }).check();
69
+ expect(result).toEqual({
70
+ updateAvailable: true,
71
+ current: '0.9.1',
72
+ latest: '0.10.0',
73
+ notesUrl: 'https://github.com/Bevel-Software/Hexis/releases/tag/v0.10.0',
74
+ });
75
+ // No credentials, ever — the request is the whole payload.
76
+ const [, init] = fetchFn.mock.calls[0] as [string, RequestInit];
77
+ expect(new Headers(init.headers).has('authorization')).toBe(false);
78
+ expect(new Headers(init.headers).get('accept')).toBe('application/vnd.github+json');
79
+ });
80
+
81
+ it('reports no update when equal or when the deployment is ahead', async () => {
82
+ expect(await service({ fetchFn: githubFetch('v0.9.1') }).check()).toMatchObject({
83
+ updateAvailable: false,
84
+ current: '0.9.1',
85
+ latest: '0.9.1',
86
+ });
87
+ expect(
88
+ await service({ fetchFn: githubFetch('v0.9.0'), current: '0.9.1' }).check(),
89
+ ).toMatchObject({ updateAvailable: false });
90
+ });
91
+
92
+ it('resolves a prerelease or malformed tag to "no update" without throwing', async () => {
93
+ const result = await service({ fetchFn: githubFetch('v0.10.0-rc.1') }).check();
94
+ expect(result.updateAvailable).toBe(false);
95
+ // A tag that didn't parse is not offered as "latest".
96
+ expect(result.latest).toBeUndefined();
97
+ });
98
+
99
+ it('refuses a double-prefixed tag — vv99.0.0 must not shed one v and pass', async () => {
100
+ // The RAW tag is what gets validated: stripping first would leave v99.0.0,
101
+ // which the parser's lenient `v?` accepts.
102
+ const result = await service({ fetchFn: githubFetch('vv99.0.0') }).check();
103
+ expect(result.updateAvailable).toBe(false);
104
+ expect(result.latest).toBeUndefined();
105
+ });
106
+
107
+ it('disabled: answers immediately and never touches the network', async () => {
108
+ const fetchFn = githubFetch('v99.0.0');
109
+ const result = await service({ enabled: false, fetchFn }).check();
110
+ expect(result).toEqual({ updateAvailable: false, current: '0.9.1' });
111
+ expect(fetchFn).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('a failed fetch is silent, and cached so a dead network is not re-probed', async () => {
115
+ let now = 0;
116
+ const fetchFn = vi.fn().mockRejectedValue(new Error('ENOTFOUND api.github.com'));
117
+ const svc = service({ fetchFn: fetchFn as unknown as typeof fetch, now: () => now });
118
+
119
+ expect(await svc.check()).toEqual({ updateAvailable: false, current: '0.9.1' });
120
+ // Within the failure TTL (~15min): served from cache.
121
+ now = 14 * 60 * 1000;
122
+ await svc.check();
123
+ expect(fetchFn).toHaveBeenCalledTimes(1);
124
+ // Past it: retried.
125
+ now = 16 * 60 * 1000;
126
+ await svc.check();
127
+ expect(fetchFn).toHaveBeenCalledTimes(2);
128
+ });
129
+
130
+ it('a success is cached for hours across requests', async () => {
131
+ let now = 0;
132
+ const fetchFn = githubFetch('v0.10.0');
133
+ const svc = service({ fetchFn, now: () => now });
134
+
135
+ const first = await svc.check();
136
+ now = 5 * 60 * 60 * 1000; // 5h — inside the 6h TTL
137
+ const second = await svc.check();
138
+ expect(second).toEqual(first);
139
+ expect(fetchFn).toHaveBeenCalledTimes(1);
140
+
141
+ now = 7 * 60 * 60 * 1000; // past the TTL — refreshed
142
+ await svc.check();
143
+ expect(fetchFn).toHaveBeenCalledTimes(2);
144
+ });
145
+
146
+ it('concurrent requests share one in-flight fetch', async () => {
147
+ let release!: (value: unknown) => void;
148
+ const gate = new Promise((resolve) => {
149
+ release = resolve;
150
+ });
151
+ const fetchFn = vi.fn().mockImplementation(async () => {
152
+ await gate;
153
+ return { ok: true, json: async () => ({ tag_name: 'v0.10.0', html_url: 'u' }) };
154
+ });
155
+ const svc = service({ fetchFn: fetchFn as unknown as typeof fetch });
156
+
157
+ const [a, b] = [svc.check(), svc.check()];
158
+ release(undefined);
159
+ expect(await a).toEqual(await b);
160
+ expect(fetchFn).toHaveBeenCalledTimes(1);
161
+ });
162
+
163
+ it('treats a non-2xx answer like any other failure: silent false', async () => {
164
+ const fetchFn = vi
165
+ .fn()
166
+ .mockResolvedValue({ ok: false, status: 403, json: async () => ({}) });
167
+ const result = await service({ fetchFn: fetchFn as unknown as typeof fetch }).check();
168
+ expect(result).toEqual({ updateAvailable: false, current: '0.9.1' });
169
+ });
170
+ });
@@ -0,0 +1,28 @@
1
+ import express from 'express';
2
+ import type { IAdminAccessService } from '../admin/admin.interface.js';
3
+ import type { UpdateCheckService } from './update-check.service.js';
4
+ import '../auth/auth.middleware.js'; // Express Request augmentation
5
+
6
+ /**
7
+ * `GET /api/update-check` — ADMIN-ONLY (same `requireAdmin` shape as the setup
8
+ * routes): admins are the audience of the banner and the only ones who can act
9
+ * on it, and there is no reason to narrate the deployment's patch level to
10
+ * everyone else. The service behind it does the caching, so this is safe to
11
+ * call as often as the frontend likes.
12
+ */
13
+ export function createUpdateCheckRoutes(
14
+ service: Pick<UpdateCheckService, 'check'>,
15
+ adminAccess: IAdminAccessService,
16
+ ): express.Router {
17
+ const router = express.Router();
18
+
19
+ router.get('/update-check', async (req, res) => {
20
+ if (!(await adminAccess.isAdmin(req.userEmail))) {
21
+ res.status(403).json({ error: 'Admins only' });
22
+ return;
23
+ }
24
+ res.json(await service.check());
25
+ });
26
+
27
+ return router;
28
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * In-app update check — the server learns the newest published Hexis release
3
+ * and tells admins when the running deployment is behind (the pattern Immich
4
+ * and Gitea use: a quiet banner, no auto-anything).
5
+ *
6
+ * Deliberate shape:
7
+ *
8
+ * - LAZY, never on a timer. The first `check()` triggers the fetch; a
9
+ * deployment nobody looks at makes zero calls. Results are cached (~6h),
10
+ * concurrent callers share one in-flight request.
11
+ * - The request goes to api.github.com and carries NOTHING but itself — no
12
+ * auth token (public repo; this must never hold credentials), no
13
+ * identifier, no telemetry. `UPDATE_CHECK=false` removes even that.
14
+ * - FAILURE IS SILENT. Offline and air-gapped deployments must stay quiet,
15
+ * not broken: a failed fetch is cached briefly (~15min, so a dead network
16
+ * isn't re-probed per request) and reported as "no update available".
17
+ * - Only a clean `x.y.z` newer than the running version counts. Prerelease
18
+ * or malformed tags — and a running version we can't parse — resolve to
19
+ * "no update" rather than a guess or a throw.
20
+ */
21
+
22
+ export interface UpdateCheckResult {
23
+ updateAvailable: boolean;
24
+ /** The running version (this build's release version). */
25
+ current: string;
26
+ /** Newest published release, when the check reached GitHub and parsed one. */
27
+ latest?: string;
28
+ /** The release-notes page of that release. */
29
+ notesUrl?: string;
30
+ }
31
+
32
+ const RELEASES_LATEST_URL =
33
+ 'https://api.github.com/repos/Bevel-Software/Hexis/releases/latest';
34
+ const SUCCESS_TTL_MS = 6 * 60 * 60 * 1000;
35
+ const FAILURE_TTL_MS = 15 * 60 * 1000;
36
+ const FETCH_TIMEOUT_MS = 5_000;
37
+
38
+ /** `v1.2.3` / `1.2.3` → `[1,2,3]`; anything else (prerelease included) → null. */
39
+ export function parseReleaseVersion(raw: string): [number, number, number] | null {
40
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(raw.trim());
41
+ if (!m) return null;
42
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
43
+ }
44
+
45
+ /**
46
+ * Whether `latest` is STRICTLY newer than `current`. Numeric per-part compare
47
+ * (no string compare — `0.10.0` > `0.9.1`). Either side failing to parse is
48
+ * `false`: an answer we can't trust is not an update announcement.
49
+ */
50
+ export function isNewerVersion(latest: string, current: string): boolean {
51
+ const a = parseReleaseVersion(latest);
52
+ const b = parseReleaseVersion(current);
53
+ if (!a || !b) return false;
54
+ for (let i = 0; i < 3; i++) {
55
+ if (a[i] !== b[i]) return a[i] > b[i];
56
+ }
57
+ return false;
58
+ }
59
+
60
+ export class UpdateCheckService {
61
+ private cached: { result: UpdateCheckResult; expiresAt: number } | null = null;
62
+ private inFlight: Promise<UpdateCheckResult> | null = null;
63
+
64
+ constructor(
65
+ private readonly opts: {
66
+ /** `UPDATE_CHECK` — false means no network, ever. */
67
+ enabled: boolean;
68
+ /** The running release version (see `resolveAppVersion`). */
69
+ currentVersion: string;
70
+ /** Injectable for tests; defaults to global fetch. */
71
+ fetchFn?: typeof fetch;
72
+ /** Injectable for tests; defaults to the GitHub releases endpoint. */
73
+ releasesUrl?: string;
74
+ /** Injectable clock for TTL tests; defaults to Date.now. */
75
+ now?: () => number;
76
+ },
77
+ ) {}
78
+
79
+ async check(): Promise<UpdateCheckResult> {
80
+ if (!this.opts.enabled) {
81
+ return { updateAvailable: false, current: this.opts.currentVersion };
82
+ }
83
+ const now = (this.opts.now ?? Date.now)();
84
+ if (this.cached && now < this.cached.expiresAt) return this.cached.result;
85
+ // One request serves every concurrent caller; `finally` clears the slot so
86
+ // the NEXT check after settlement reads the cache (or starts a fresh
87
+ // fetch once the TTL lapses).
88
+ this.inFlight ??= this.fetchAndCache().finally(() => {
89
+ this.inFlight = null;
90
+ });
91
+ return this.inFlight;
92
+ }
93
+
94
+ private async fetchAndCache(): Promise<UpdateCheckResult> {
95
+ const current = this.opts.currentVersion;
96
+ const nowFn = this.opts.now ?? Date.now;
97
+ try {
98
+ const fetchFn = this.opts.fetchFn ?? fetch;
99
+ const res = await fetchFn(this.opts.releasesUrl ?? RELEASES_LATEST_URL, {
100
+ headers: { Accept: 'application/vnd.github+json' },
101
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
102
+ });
103
+ if (!res.ok) throw new Error(`GitHub answered ${res.status}`);
104
+ const body = (await res.json()) as { tag_name?: unknown; html_url?: unknown };
105
+ const tag = typeof body.tag_name === 'string' ? body.tag_name.trim() : '';
106
+ // Validate the RAW tag before normalizing: a malformed `vv1.2.3` would
107
+ // otherwise shed one prefix and land on `v1.2.3`, which the parser's
108
+ // lenient `v?` then accepts — announcing a tag we should distrust.
109
+ const latest = parseReleaseVersion(tag) ? tag.replace(/^v/, '') : '';
110
+ const notesUrl = typeof body.html_url === 'string' ? body.html_url : undefined;
111
+ const result: UpdateCheckResult = isNewerVersion(latest, current)
112
+ ? { updateAvailable: true, current, latest, notesUrl }
113
+ : // Equal, older, prerelease or malformed: all "no update". `latest`
114
+ // rides along only when it parsed — a garbage tag is not an answer.
115
+ {
116
+ updateAvailable: false,
117
+ current,
118
+ ...(parseReleaseVersion(latest) ? { latest } : {}),
119
+ };
120
+ this.cached = { result, expiresAt: nowFn() + SUCCESS_TTL_MS };
121
+ return result;
122
+ } catch {
123
+ // Silent by design — no log spam, no error to the UI. The short TTL is
124
+ // what keeps a dead network from being probed on every request.
125
+ const result: UpdateCheckResult = { updateAvailable: false, current };
126
+ this.cached = { result, expiresAt: nowFn() + FAILURE_TTL_MS };
127
+ return result;
128
+ }
129
+ }
130
+ }