@bevel-software/platform-core-backend 0.9.1 → 0.11.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 +16 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +25 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +45 -1
- 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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +54 -28
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +129 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +435 -66
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts.map +1 -1
- package/dist/modules/access/access-declarations.js +5 -3
- package/dist/modules/access/access-declarations.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +39 -6
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +78 -18
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +31 -4
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -16
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +204 -82
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +134 -0
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
- package/dist/modules/access/admin-locked-commit.js +277 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -0
- package/dist/modules/access/admin-route-helpers.d.ts +32 -0
- package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
- package/dist/modules/access/admin-route-helpers.js +44 -0
- package/dist/modules/access/admin-route-helpers.js.map +1 -0
- package/dist/modules/access/capability-registry.d.ts +41 -0
- package/dist/modules/access/capability-registry.d.ts.map +1 -0
- package/dist/modules/access/capability-registry.js +46 -0
- package/dist/modules/access/capability-registry.js.map +1 -0
- package/dist/modules/access/directory-sync-bot.d.ts +13 -0
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
- package/dist/modules/access/directory-sync-bot.js +64 -0
- package/dist/modules/access/directory-sync-bot.js.map +1 -0
- package/dist/modules/access/group-files.d.ts +83 -0
- package/dist/modules/access/group-files.d.ts.map +1 -0
- package/dist/modules/access/group-files.js +167 -0
- package/dist/modules/access/group-files.js.map +1 -0
- package/dist/modules/access/groups-admin.routes.d.ts +19 -0
- package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.routes.js +98 -0
- package/dist/modules/access/groups-admin.routes.js.map +1 -0
- package/dist/modules/access/groups-admin.service.d.ts +166 -0
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.service.js +442 -0
- package/dist/modules/access/groups-admin.service.js.map +1 -0
- package/dist/modules/access/groups-edit.d.ts +58 -0
- package/dist/modules/access/groups-edit.d.ts.map +1 -0
- package/dist/modules/access/groups-edit.js +162 -0
- package/dist/modules/access/groups-edit.js.map +1 -0
- package/dist/modules/access/reference-scan.d.ts +141 -0
- package/dist/modules/access/reference-scan.d.ts.map +1 -0
- package/dist/modules/access/reference-scan.js +440 -0
- package/dist/modules/access/reference-scan.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +88 -119
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +230 -384
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/roles-edit.d.ts +51 -25
- package/dist/modules/access/roles-edit.d.ts.map +1 -1
- package/dist/modules/access/roles-edit.js +133 -59
- package/dist/modules/access/roles-edit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.d.ts +28 -0
- package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-committer.js +139 -0
- package/dist/modules/access/synced-groups-committer.js.map +1 -0
- package/dist/modules/access/synced-groups-writer.d.ts +78 -0
- package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-writer.js +219 -0
- package/dist/modules/access/synced-groups-writer.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +17 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +9 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +9 -3
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +126 -2
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +14 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -1
- package/dist/modules/mcp/mcp.service.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/file-lock.service.d.ts +11 -1
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +15 -1
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +34 -11
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +173 -37
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/workflow/locking-filesystem.js +181 -28
- package/dist/modules/workflow/locking-filesystem.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +19 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +17 -2
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +113 -19
- package/dist/modules/workflow/workflow.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/kb-template/AGENTS.md +4 -1
- package/migrations/0004_file_lock_mode.sql +2 -0
- package/migrations/meta/0004_snapshot.json +1564 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +4 -4
- package/src/__tests__/core-config.domain.test.ts +92 -0
- package/src/core/create-core-server.ts +21 -0
- package/src/core/create-core-services.ts +82 -0
- package/src/core-config.ts +48 -4
- package/src/index.ts +10 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
- package/src/modules/access/__tests__/access-groups.test.ts +427 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
- package/src/modules/access/__tests__/access-splice.test.ts +65 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
- package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
- package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
- package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
- package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
- package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
- package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
- package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
- package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
- package/src/modules/access/access-control.interface.ts +66 -32
- package/src/modules/access/access-control.service.ts +536 -73
- package/src/modules/access/access-declarations.ts +5 -2
- package/src/modules/access/access-mutation.service.ts +88 -14
- package/src/modules/access/access-splice.ts +55 -17
- package/src/modules/access/access.routes.ts +227 -93
- package/src/modules/access/admin-locked-commit.ts +331 -0
- package/src/modules/access/admin-route-helpers.ts +55 -0
- package/src/modules/access/capability-registry.ts +74 -0
- package/src/modules/access/directory-sync-bot.ts +76 -0
- package/src/modules/access/group-files.ts +212 -0
- package/src/modules/access/groups-admin.routes.ts +113 -0
- package/src/modules/access/groups-admin.service.ts +551 -0
- package/src/modules/access/groups-edit.ts +187 -0
- package/src/modules/access/reference-scan.ts +513 -0
- package/src/modules/access/roles-admin.service.ts +290 -418
- package/src/modules/access/roles-edit.ts +134 -61
- package/src/modules/access/synced-groups-committer.ts +177 -0
- package/src/modules/access/synced-groups-writer.ts +303 -0
- package/src/modules/database/core-schema.ts +9 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
- package/src/modules/mcp/mcp.routes.ts +137 -2
- package/src/modules/mcp/mcp.service.ts +6 -1
- 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/__tests__/locking-filesystem.test.ts +336 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
- package/src/modules/workflow/file-lock.service.ts +15 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
- package/src/modules/workflow/git/git.service.ts +182 -35
- package/src/modules/workflow/locking-filesystem.ts +188 -26
- package/src/modules/workflow/pending-commits.service.ts +27 -1
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
- package/src/modules/workflow/workflow.service.ts +140 -20
- package/src/version.ts +28 -0
|
@@ -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
|
+
}
|