@bevel-software/platform-core-backend 0.9.1 → 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.
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +4 -0
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +11 -0
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +19 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +44 -4
- package/dist/core-config.js.map +1 -1
- package/dist/modules/update-check/update-check.routes.d.ts +13 -0
- package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.routes.js +21 -0
- package/dist/modules/update-check/update-check.routes.js.map +1 -0
- package/dist/modules/update-check/update-check.service.d.ts +57 -0
- package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.service.js +109 -0
- package/dist/modules/update-check/update-check.service.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +8 -0
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/version.d.ts +14 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +25 -0
- package/dist/version.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/core-config.domain.test.ts +92 -0
- package/src/core/create-core-server.ts +8 -0
- package/src/core/create-core-services.ts +14 -0
- package/src/core-config.ts +48 -4
- package/src/modules/update-check/__tests__/update-check.routes.test.ts +69 -0
- package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
- package/src/modules/update-check/update-check.routes.ts +28 -0
- package/src/modules/update-check/update-check.service.ts +130 -0
- package/src/modules/workflow/git/git.service.ts +8 -0
- package/src/version.ts +28 -0
|
@@ -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,
|
package/src/core-config.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -1680,6 +1680,14 @@ export class GitService implements IGitService {
|
|
|
1680
1680
|
try {
|
|
1681
1681
|
const { stdout } = await this.git(cwd, [
|
|
1682
1682
|
'show',
|
|
1683
|
+
// Patch only, no commit header — and not for looks: whether `show`
|
|
1684
|
+
// prints the header for a commit the pathspec does not touch is
|
|
1685
|
+
// git-version-dependent (older gits simplified the commit away,
|
|
1686
|
+
// newer ones print the header over an empty diff). Suppressing the
|
|
1687
|
+
// format makes "commit didn't touch the file" mean an EMPTY string
|
|
1688
|
+
// on every git. The metadata the header carried already reaches the
|
|
1689
|
+
// history view via `logForFile`.
|
|
1690
|
+
'--format=',
|
|
1683
1691
|
sha,
|
|
1684
1692
|
'--',
|
|
1685
1693
|
repoRelativePath,
|