diff --git a/backend/src/__tests__/build-info-route.test.ts b/backend/src/__tests__/build-info-route.test.ts new file mode 100644 index 00000000..1b6a5bbc --- /dev/null +++ b/backend/src/__tests__/build-info-route.test.ts @@ -0,0 +1,225 @@ +/** + * Route coverage for the canonical build identity: + * + * - GET /api/build-info is proxy-exempt (always served by the control + * instance), requires a signed-in human session (rejects machine / API-token + * credentials), redacts hardened image references to non-admins via + * `restricted: true`, and never mislabels a redacted field "Unknown". + * - GET /api/meta exposes only the bounded `buildChannel` enum on the public + * surface and never leaks the running image reference. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets'; + +let tmpDir: string; +let app: import('express').Express; +let adminAuth: string; +let viewerAuth: string; +let machineAuth: string; +let remoteNodeId: number; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let SelfIdentityService: typeof import('../services/SelfIdentityService').default; + +const IMAGE_ID = 'b'.repeat(64); +const DIGEST = 'a'.repeat(64); + +function mockBuildInfo(over: Record = {}) { + const svc = SelfIdentityService.getInstance(); + vi.spyOn(svc, 'getBuildInfo').mockReturnValue({ + version: '0.97.1', + channel: 'dev', + imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev-abc1234', + imageId: IMAGE_ID, + revision: 'dev-abc1234', + ...over, + }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ DatabaseService } = await import('../services/DatabaseService')); + SelfIdentityService = (await import('../services/SelfIdentityService')).default; + + const db = DatabaseService.getInstance(); + remoteNodeId = db.addNode({ + name: 'build-info-remote', + type: 'remote', + compose_dir: '/tmp', + is_default: false, + api_url: 'http://127.0.0.1:1', + api_token: 'build-info-remote-token', + }); + // A signed-in non-admin human session (role resolved from the DB row). + db.addUser({ + username: 'build-info-viewer', + password_hash: await bcrypt.hash('pw', 1), + role: 'viewer', + }); + + adminAuth = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; + viewerAuth = `Bearer ${jwt.sign({ username: 'build-info-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; + // node_proxy machine credential: authMiddleware maps it to role admin with + // userId 0, so requireUserSession must reject it as not a human session. + machineAuth = `Bearer ${jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +afterEach(() => vi.restoreAllMocks()); + +describe('GET /api/build-info auth', () => { + it('requires authentication', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/build-info'); + expect(res.status).toBe(401); + }); + + it('rejects node_proxy machine credentials (not a human session)', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/build-info').set('Authorization', machineAuth); + expect(res.status).toBe(403); + expect(res.body.code).toBe('SESSION_REQUIRED'); + }); +}); + +describe('GET /api/build-info is proxy-exempt', () => { + it('serves locally even when x-node-id targets a remote node', async () => { + mockBuildInfo(); + // The remote node's api_url is a closed loopback port. A 502 would mean the + // proxy intercepted the request; anything else proves the local handler + // matched, exactly as the existing /api/nodes proxy-exempt test asserts. + const res = await withLoopbackTargetProtection(() => request(app) + .get('/api/build-info') + .set('Authorization', adminAuth) + .set('x-node-id', String(remoteNodeId))); + expect(res.status).not.toBe(502); + expect(res.body.channel).toBe('dev'); + }); +}); + +describe('GET /api/build-info as admin', () => { + it('returns the full dev identity for a dev image (regression: semver still previous stable)', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/build-info').set('Authorization', adminAuth); + expect(res.status).toBe(200); + expect(res.body.version).toBe('0.97.1'); + expect(res.body.channel).toBe('dev'); + expect(res.body.imageChannel).toBe('community'); + expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev-abc1234'); + expect(res.body.imageId).toBe(IMAGE_ID); + expect(res.body.revision).toBe('dev-abc1234'); + expect(res.body.restricted).toBe(false); + }); + + it('returns the bounded imageChannel for a hardened image', async () => { + mockBuildInfo({ + channel: 'stable', + imageRef: 'ghcr.io/studio-saelix/sencho-hardened:0.97.1', + }); + const res = await request(app).get('/api/build-info').set('Authorization', adminAuth); + expect(res.status).toBe(200); + expect(res.body.channel).toBe('stable'); + expect(res.body.imageChannel).toBe('hardened'); + expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-hardened:0.97.1'); + expect(res.body.restricted).toBe(false); + }); +}); + +describe('GET /api/build-info as a non-admin', () => { + it('redacts a hardened image reference and revision to a non-admin via restricted:true', async () => { + mockBuildInfo({ + channel: 'stable', + imageRef: 'ghcr.io/studio-saelix/sencho-hardened:0.97.1', + revision: `sha256:${DIGEST}`, + }); + const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth); + expect(res.status).toBe(200); + // The build channel stays stable; the procurement channel is what gates + // redaction. Both reference fields are nulled with restricted:true so the + // UI can label them "Restricted", never "Unknown". + expect(res.body.channel).toBe('stable'); + expect(res.body.imageChannel).toBe('hardened'); + expect(res.body.imageRef).toBeNull(); + expect(res.body.revision).toBeNull(); + expect(res.body.restricted).toBe(true); + // The image ID is not a registry reference and is always returned. + expect(res.body.imageId).toBe(IMAGE_ID); + }); + + it('does not redact a community image for a non-admin', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth); + expect(res.status).toBe(200); + expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev-abc1234'); + expect(res.body.revision).toBe('dev-abc1234'); + expect(res.body.restricted).toBe(false); + }); + + it('reports unknown procurement channel on bare metal without a running reference', async () => { + mockBuildInfo({ channel: 'unknown', imageRef: null, revision: null }); + const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth); + expect(res.status).toBe(200); + // No reference means no classification, and no redaction can apply. + expect(res.body.channel).toBe('unknown'); + expect(res.body.imageChannel).toBe('unknown'); + expect(res.body.imageRef).toBeNull(); + expect(res.body.revision).toBeNull(); + expect(res.body.restricted).toBe(false); + }); +}); + +describe('GET /api/build-info awaits revision enrichment', () => { + it('blocks the response until the enrichment settle promise resolves', async () => { + mockBuildInfo(); + const svc = SelfIdentityService.getInstance(); + let release!: () => void; + vi.spyOn(svc, 'whenRevisionResolved').mockImplementation( + () => new Promise((res) => { release = res; }), + ); + + let settled = false; + const pending = request(app) + .get('/api/build-info') + .set('Authorization', adminAuth) + .then((res) => { settled = true; return res; }); + + // Give the route a tick to reach the await. It must not have responded yet, + // proving a transient null is never the settled value of a success. + await new Promise((r) => setTimeout(r, 10)); + expect(settled).toBe(false); + + release(); + const res = await pending; + expect(res.status).toBe(200); + expect(res.body.revision).toBe('dev-abc1234'); + }); +}); + +describe('GET /api/meta buildChannel', () => { + it('exposes the bounded build channel on the public endpoint', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/meta'); + expect(res.status).toBe(200); + expect(res.body.buildChannel).toBe('dev'); + }); + + it('never leaks the running image reference on the public endpoint', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/meta'); + const body = JSON.stringify(res.body); + expect(body).not.toContain('ghcr.io/studio-saelix/sencho-dev'); + expect(body).not.toContain('dev-abc1234'); + }); + + it('omits buildChannel when the running image reference is unknown', async () => { + mockBuildInfo({ imageRef: null, channel: 'unknown', revision: null }); + const res = await request(app).get('/api/meta'); + expect(res.status).toBe(200); + expect(res.body.buildChannel).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/backend/src/__tests__/classify-build-channel.test.ts b/backend/src/__tests__/classify-build-channel.test.ts new file mode 100644 index 00000000..825fa1d4 --- /dev/null +++ b/backend/src/__tests__/classify-build-channel.test.ts @@ -0,0 +1,42 @@ +/** + * Truth table for classifyBuildChannel, the canonical build-identity classifier. + * Answers "is this a dev, preview, or stable build" from the image reference + * alone, independent of the packaged semver. + */ +import { describe, it, expect } from 'vitest'; +import { classifyBuildChannel } from '../helpers/selfUpdateCompose'; + +describe('classifyBuildChannel', () => { + it('classifies the dev repository as dev regardless of tag', () => { + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:dev')).toBe('dev'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:dev-abc1234')).toBe('dev'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:latest')).toBe('dev'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev@sha256:abc')).toBe('dev'); + }); + + it('classifies stable-repo preview tags as preview', () => { + expect(classifyBuildChannel('saelix/sencho:pr-42')).toBe('preview'); + expect(classifyBuildChannel('saelix/sencho:preview-abc1234')).toBe('preview'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho:pr-7')).toBe('preview'); + }); + + it('classifies stable-repo release/floating tags as stable', () => { + expect(classifyBuildChannel('saelix/sencho:0.97.1')).toBe('stable'); + expect(classifyBuildChannel('saelix/sencho:latest')).toBe('stable'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho:v1.2.3')).toBe('stable'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-hardened:1.2.3')).toBe('stable'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-hardened:latest')).toBe('stable'); + }); + + it('treats a dev- tag as preview-matching-tolerant (still dev repo wins)', () => { + // dev repo takes precedence over the preview/stable tag classification + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:pr-42')).toBe('dev'); + }); + + it('classifies unknown repositories as unknown', () => { + expect(classifyBuildChannel('ubuntu:22.04')).toBe('unknown'); + expect(classifyBuildChannel('registry.example.com/private/app:1.0')).toBe('unknown'); + expect(classifyBuildChannel('')).toBe('unknown'); + expect(classifyBuildChannel(' ')).toBe('unknown'); + }); +}); \ No newline at end of file diff --git a/backend/src/__tests__/monitor-service.test.ts b/backend/src/__tests__/monitor-service.test.ts index a35e387b..2a1bea18 100644 --- a/backend/src/__tests__/monitor-service.test.ts +++ b/backend/src/__tests__/monitor-service.test.ts @@ -1621,6 +1621,41 @@ describe('MonitorService - Sencho dev build check', () => { expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything()); }); + + // C1: update eligibility is compose-declared. A dev *running* identity must + // not make a stable-declared pin eligible for the dev-build detector, and an + // unknown running identity must still gate before any registry call. These + // two disagreement directions pin that the running identity surfaced by + // SelfIdentityService never changes update behavior. + it('does not treat a stable-declared pin as eligible even when the running identity is a dev build', async () => { + // beforeEach() already mints a dev running imageId; the declared pin is stable. + // Reset the version-update inputs: an earlier suppression test queues a + // once-value that a stable-pin run would otherwise drain into the version + // path, which is not what this test observes. + mockGetLatestVersionInfo.mockReset(); + mockGetPinInfo.mockResolvedValue(STABLE_PIN); + + await runEvaluate(); + + expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled(); + expect(devBuildCalls()).toHaveLength(0); + expect(mockSetSystemState).not.toHaveBeenCalledWith('sencho_dev_build_available_digest', expect.anything()); + }); + + it('still gates the registry comparison on a known running image id for an eligible dev-declared pin', async () => { + mockGetPinInfo.mockResolvedValue(DEV_PIN); + mockGetIdentity.mockReturnValue({ + containerId: null, containerName: null, composeProjectName: null, + imageId: null, networkNames: [], volumeNames: [], + }); + + await runEvaluate(); + + expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled(); + expect(devBuildCalls()).toHaveLength(0); + // Unknown running id takes the retry-sooner path, not the detector path. + expect((MonitorService.getInstance() as any).lastDevBuildCheckGateMs).toBe(5 * 60 * 1000); + }); }); // ── Per-container parallel fan-out ──────────────────────────────────── diff --git a/backend/src/__tests__/self-identity-buildinfo.test.ts b/backend/src/__tests__/self-identity-buildinfo.test.ts new file mode 100644 index 00000000..e5445e5b --- /dev/null +++ b/backend/src/__tests__/self-identity-buildinfo.test.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for SelfIdentityService.getBuildInfo(): the canonical runtime + * build identity (version, channel, imageRef, imageId, revision), the detached + * bounded revision enrichment, and the failure-isolation guarantee. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── Hoisted mocks ────────────────────────────────────────────────────── + +const { mockContainer, mockDocker, mockInspectImage, mockGetSenchoVersion } = vi.hoisted(() => { + const mockContainer = { inspect: vi.fn() }; + const mockDocker = { + getContainer: vi.fn(() => mockContainer), + getImage: vi.fn(), + listImages: vi.fn().mockResolvedValue([]), + listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }), + listNetworks: vi.fn().mockResolvedValue([]), + listContainers: vi.fn().mockResolvedValue([]), + }; + return { + mockContainer, + mockDocker, + mockInspectImage: vi.fn(), + mockGetSenchoVersion: vi.fn(() => '0.97.1'), + }; +}); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getDocker: () => mockDocker, + getDefaultNodeId: () => 1, + }), + }, +})); + +// Replace defaultInspectImage so enrichment is deterministic and isolated. +vi.mock('../services/selfDevBuildDetect', () => ({ + defaultInspectImage: (...args: unknown[]) => mockInspectImage(...args), +})); + +vi.mock('../services/CapabilityRegistry', () => ({ + getSenchoVersion: () => mockGetSenchoVersion(), +})); + +vi.mock('child_process', () => ({ exec: vi.fn(), execFile: vi.fn() })); +vi.mock('util', () => ({ promisify: () => vi.fn() })); + +import SelfIdentityService from '../services/SelfIdentityService'; + +const FULL_IMAGE_ID_HEX = 'b'.repeat(64); +const DIGEST = 'a'.repeat(64); + +const originalHostname = process.env.HOSTNAME; + +beforeEach(() => { + vi.clearAllMocks(); + mockContainer.inspect.mockReset(); + mockDocker.getImage.mockReset(); + mockInspectImage.mockReset(); + mockGetSenchoVersion.mockReturnValue('0.97.1'); + SelfIdentityService.getInstance().resetForTesting(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (originalHostname === undefined) delete process.env.HOSTNAME; + else process.env.HOSTNAME = originalHostname; +}); + +async function initWith(configImage: string | undefined): Promise { + process.env.HOSTNAME = 'sencho-1'; + mockContainer.inspect.mockResolvedValue({ + Id: 'a'.repeat(64), + Name: '/sencho', + Image: 'sha256:' + FULL_IMAGE_ID_HEX, + ...(configImage !== undefined ? { Config: { Image: configImage } } : {}), + NetworkSettings: { Networks: {} }, + Mounts: [], + }); + const svc = SelfIdentityService.getInstance(); + await svc.initialize(); + return svc; +} + +/** Enrichment runs detached; poll until the condition holds so assertions are stable. */ +async function until(assert: () => void): Promise { + await vi.waitFor(assert, { timeout: 2000 }); +} + +describe('SelfIdentityService.getBuildInfo', () => { + it('identifies a dev image as DEV even when the packaged semver matches the previous stable', async () => { + // The regression case from the ticket: dev image, version still 0.97.1. + // The inspect mock must be set before initialize(): enrichment fires + // detached during initialize(), before the awaited call returns. + mockInspectImage.mockResolvedValue({ + RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`], + Os: 'linux', + Architecture: 'amd64', + }); + const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev'); + await until(() => expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`)); + + const info = svc.getBuildInfo(); + expect(info.version).toBe('0.97.1'); + expect(info.channel).toBe('dev'); + expect(info.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev'); + expect(info.imageId).toBe(FULL_IMAGE_ID_HEX); + }); + + it('derives the revision from a pinned dev- tag without an image inspect', async () => { + const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev-abc1234'); + await until(() => expect(svc.getBuildInfo().revision).toBe('dev-abc1234')); + + const info = svc.getBuildInfo(); + expect(info.channel).toBe('dev'); + expect(mockInspectImage).not.toHaveBeenCalled(); + }); + + it('classifies a stable image as stable', async () => { + mockInspectImage.mockResolvedValue({ + RepoDigests: [`ghcr.io/studio-saelix/sencho@sha256:${DIGEST}`], + Os: 'linux', + Architecture: 'amd64', + }); + const svc = await initWith('ghcr.io/studio-saelix/sencho:0.97.1'); + await until(() => expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`)); + + expect(svc.getBuildInfo().channel).toBe('stable'); + }); + + it('reads unknown for partial metadata (no running image reference)', async () => { + const svc = await initWith(undefined); + + const info = svc.getBuildInfo(); + expect(info.imageRef).toBeNull(); + expect(info.channel).toBe('unknown'); + expect(info.revision).toBeNull(); + expect(info.imageId).toBe(FULL_IMAGE_ID_HEX); + }); + + it('keeps the dev channel but null revision when image inspection fails', async () => { + mockInspectImage.mockRejectedValue(new Error('docker daemon unreachable')); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev'); + await until(() => expect(warnSpy).toHaveBeenCalled()); + + const info = svc.getBuildInfo(); + expect(info.channel).toBe('dev'); + expect(info.revision).toBeNull(); + // C2: a failed enrichment must not corrupt the already-captured core identity. + expect(info.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev'); + expect(info.imageId).toBe(FULL_IMAGE_ID_HEX); + }); + + it('never inspects the image again on repeated reads', async () => { + mockInspectImage.mockResolvedValue({ + RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`], + Os: 'linux', + Architecture: 'amd64', + }); + const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev'); + await until(() => expect(mockInspectImage).toHaveBeenCalledTimes(1)); + + for (let i = 0; i < 5; i++) svc.getBuildInfo(); + expect(mockInspectImage).toHaveBeenCalledTimes(1); + }); + + it('exposes revision only after enrichment settles, and whenRevisionResolved awaits that', async () => { + let resolveInspect!: (v: { RepoDigests: string[]; Os: string; Architecture: string }) => void; + mockInspectImage.mockReturnValue(new Promise((res) => { resolveInspect = res; })); + + const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev'); + // initialize() returned without awaiting the detached enrichment, so the + // revision is still transiently null and the settle promise is pending. + expect(svc.getBuildInfo().revision).toBeNull(); + + // A reader that awaits the settle promise (the build-info route) blocks + // until enrichment lands, then observes the resolved digest, so a single + // successful read never freezes a transient null. + const settled = svc.whenRevisionResolved(); + resolveInspect({ + RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`], + Os: 'linux', + Architecture: 'amd64', + }); + await settled; + expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`); + }); + + it('resolves whenRevisionResolved immediately when no enrichment ever started', async () => { + process.env.HOSTNAME = undefined; + const svc = SelfIdentityService.getInstance(); + await svc.whenRevisionResolved(); + expect(svc.getBuildInfo().revision).toBeNull(); + }); +}); diff --git a/backend/src/helpers/proxyExemptPaths.ts b/backend/src/helpers/proxyExemptPaths.ts index 1223d95f..ccad56b1 100644 --- a/backend/src/helpers/proxyExemptPaths.ts +++ b/backend/src/helpers/proxyExemptPaths.ts @@ -13,6 +13,7 @@ export const PROXY_EXEMPT_PREFIXES: readonly string[] = [ '/api/fleet/', '/api/webhooks', '/api/meta', + '/api/build-info', ]; /** Returns true when the path should bypass the remote proxy (handled locally). */ diff --git a/backend/src/helpers/selfUpdateCompose.ts b/backend/src/helpers/selfUpdateCompose.ts index 2c5f9c17..2ce0f100 100644 --- a/backend/src/helpers/selfUpdateCompose.ts +++ b/backend/src/helpers/selfUpdateCompose.ts @@ -104,13 +104,50 @@ export function isSenchoDevFloatingTag(imageRef: string): boolean { // A digest pin disqualifies the reference (e.g., `@sha256:...`) if (ref.includes('@sha256:') || ref.startsWith('sha256:')) return false; - // Extract the tag using the same logic as classifyImagePin. + const tag = extractTagFromRef(ref); + return tag === 'dev'; +} + +/** + * Build channel of a running or declared image, derived from the image + * reference alone. This is the canonical build identity classifier used by + * `/api/build-info` and the shell/About surfaces: it answers "is this a dev, + * preview, or stable build" from the ref, independent of the packaged semver + * (a dev image carries the last released version, so version alone cannot + * identify it). + * + * - dev repo (`ghcr.io/studio-saelix/sencho-dev`): always `'dev'`. An + * arbitrary `:dev-` tag still reads `dev` (a deliberate operator + * choice); the immutable revision is surfaced separately from the digest. + * - stable repos (`ghcr.io/studio-saelix/sencho-hardened`, `saelix/sencho`, + * `ghcr.io/studio-saelix/sencho`): `pr-` and `preview-` tags are + * `'preview'` (CI builds on the stable repo that are not releases); + * everything else is `'stable'`. + * - any other repository: `'unknown'`. + */ +export type BuildChannel = 'stable' | 'dev' | 'preview' | 'unknown'; + +export function classifyBuildChannel(imageRef: string): BuildChannel { + const repository = normalizeImageRepository(imageRef); + if (repository === 'ghcr.io/studio-saelix/sencho-dev') return 'dev'; + if ( + repository === 'ghcr.io/studio-saelix/sencho-hardened' || + repository === 'saelix/sencho' || + repository === 'ghcr.io/studio-saelix/sencho' + ) { + const tag = extractTagFromRef(imageRef.trim()); + if (tag && (/^pr-\d+$/.test(tag) || /^preview-[0-9a-f]{7,40}$/.test(tag))) return 'preview'; + return 'stable'; + } + return 'unknown'; +} + +/** Extract the tag portion of an image ref (`.../repo:tag`), or '' when absent. */ +function extractTagFromRef(ref: string): string { const lastSlash = ref.lastIndexOf('/'); const lastColon = ref.lastIndexOf(':'); // A colon after the last slash is a tag separator; before it is a registry port. - const tag = lastColon > lastSlash ? ref.slice(lastColon + 1) : ''; - - return tag === 'dev'; + return lastColon > lastSlash ? ref.slice(lastColon + 1) : ''; } /** diff --git a/backend/src/index.ts b/backend/src/index.ts index 2fe9e10d..587e5240 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -19,6 +19,7 @@ import { mfaRouter } from './routes/mfa'; import { ssoRouter } from './routes/sso'; import { licenseRouter, systemUpdateRouter } from './routes/license'; import { imageChannelRouter } from './routes/imageChannel'; +import { buildInfoRouter } from './routes/buildInfo'; import { webhooksRouter } from './routes/webhooks'; import { usersRouter } from './routes/users'; import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources'; @@ -115,6 +116,7 @@ app.use('/api/', createRemoteProxyMiddleware()); app.use('/api/license', licenseRouter); app.use('/api/license/image-channel', imageChannelRouter); +app.use('/api/build-info', buildInfoRouter); app.use('/api/system', systemUpdateRouter); app.use('/api/permissions', permissionsRouter); app.use('/api/convert', convertRouter); diff --git a/backend/src/routes/buildInfo.ts b/backend/src/routes/buildInfo.ts new file mode 100644 index 00000000..7d999166 --- /dev/null +++ b/backend/src/routes/buildInfo.ts @@ -0,0 +1,47 @@ +import { Router, type Request, type Response } from 'express'; +import { requireUserSession } from '../middleware/tierGates'; +import { classifyImageChannel, type ImageChannel } from '../helpers/imageChannel'; +import type { BuildChannel } from '../helpers/selfUpdateCompose'; +import SelfIdentityService from '../services/SelfIdentityService'; + +export const buildInfoRouter = Router(); + +/** Wire shape of GET /api/build-info. `restricted: true` implies `imageRef` and + * `revision` are nulled for a hardened image viewed by a non-admin. */ +interface BuildInfoResponse { + version: string | null; + channel: BuildChannel; + imageChannel: ImageChannel; + imageRef: string | null; + imageId: string | null; + revision: string | null; + restricted: boolean; +} + +// Canonical runtime build identity of the control instance. Proxy-exempt (see +// helpers/proxyExemptPaths.ts) so it is always served by the local hub, never +// forwarded to a remote node. The running image reference can carry a private +// registry/repository name, so the endpoint requires a human session and +// redacts hardened-image references to non-admins via `restricted: true` (the +// UI shows "Restricted", never "Unknown", when set). +buildInfoRouter.get('/', async (req: Request, res: Response): Promise => { + if (!requireUserSession(req, res)) return; + const service = SelfIdentityService.getInstance(); + // Await the detached revision enrichment so a transient null is never the + // settled value of a successful response. Bounded by the inspect timeout and + // never rejects, so this adds at most a short wait on the first read. + await service.whenRevisionResolved(); + const identity = service.getBuildInfo(); + const isAdmin = req.user?.role === 'admin'; + const imageChannel = identity.imageRef ? classifyImageChannel(identity.imageRef) : 'unknown'; + const restricted = !isAdmin && imageChannel === 'hardened'; + res.json({ + version: identity.version, + channel: identity.channel, + imageChannel, + imageRef: restricted ? null : identity.imageRef, + imageId: identity.imageId, + revision: restricted ? null : identity.revision, + restricted, + } satisfies BuildInfoResponse); +}); \ No newline at end of file diff --git a/backend/src/routes/meta.ts b/backend/src/routes/meta.ts index 93dcba01..3e6d8402 100644 --- a/backend/src/routes/meta.ts +++ b/backend/src/routes/meta.ts @@ -1,9 +1,10 @@ import { Router, type Request, type Response } from 'express'; import { getActiveCapabilities, getSenchoVersion } from '../services/CapabilityRegistry'; import { classifyImageChannel } from '../helpers/imageChannel'; -import { isRepinBlocked } from '../helpers/selfUpdateCompose'; +import { classifyBuildChannel, isRepinBlocked } from '../helpers/selfUpdateCompose'; import { MeshService } from '../services/MeshService'; import SelfUpdateService from '../services/SelfUpdateService'; +import SelfIdentityService from '../services/SelfIdentityService'; // Captured at boot. Exposed via /api/health and /api/meta so the Fleet update // overlay can distinguish a brand-new process from the old one still mid-pull. @@ -41,6 +42,7 @@ metaRouter.get('/meta', async (_req: Request, res: Response): Promise => { const updateError = selfUpdate.getLastError(); const pin = await selfUpdate.getPinInfo({ cacheOnly: true }); const updateBlocked = pin ? isRepinBlocked(pin.pinKind) : false; + const runningRef = SelfIdentityService.getInstance().getBuildInfo().imageRef; res.json({ version: getSenchoVersion(), capabilities: getActiveCapabilities(), @@ -50,6 +52,10 @@ metaRouter.get('/meta', async (_req: Request, res: Response): Promise => { imagePinKind: pin.pinKind, imageChannel: classifyImageChannel(pin.composeImageRef), } : {}), + // Bounded build channel of the RUNNING image (stable|dev|preview|unknown). + // Like imagePinKind, this is a non-sensitive enum; no image reference is + // ever exposed on this public endpoint. + ...(runningRef ? { buildChannel: classifyBuildChannel(runningRef) } : {}), updateBlocked, ...(updateError ? { updateError: 'update_failed' } : {}), }); diff --git a/backend/src/services/SelfIdentityService.ts b/backend/src/services/SelfIdentityService.ts index e134bb01..29454db5 100644 --- a/backend/src/services/SelfIdentityService.ts +++ b/backend/src/services/SelfIdentityService.ts @@ -1,5 +1,10 @@ import fs from 'fs/promises'; import DockerController from './DockerController'; +import { classifyBuildChannel, isSenchoDevRepository, type BuildChannel } from '../helpers/selfUpdateCompose'; +import { defaultInspectImage } from './selfDevBuildDetect'; +import { parseImageRef, selectLocalRepoDigest } from './registry-api'; +import { getSenchoVersion } from './CapabilityRegistry'; +import { withTimeout } from '../utils/withTimeout'; /** * Identifies the Docker resources that belong to the running Sencho container @@ -20,17 +25,32 @@ import DockerController from './DockerController'; * stays in its empty state, every `isOwn*()` returns false, and today's * behavior is preserved. */ +/** Canonical runtime build identity of the running Sencho container. */ +export interface BuildInfo { + version: string | null; + channel: BuildChannel; + /** The image reference the running container was started with, null when unknown. */ + imageRef: string | null; + /** Running image sha256 hex (no prefix), null when unknown. */ + imageId: string | null; + /** Validated registry digest or pinned `dev-` tag, null when unknown. */ + revision: string | null; +} + class SelfIdentityService { private static instance: SelfIdentityService; private containerId: string | null = null; private containerName: string | null = null; private composeProjectName: string | null = null; private imageIdHex: string | null = null; + private imageRef: string | null = null; + private revision: string | null = null; private networkIds = new Set(); private networkNames = new Set(); private volumeNames = new Set(); private initialized = false; private initializePromise: Promise | null = null; + private enrichmentPromise: Promise | null = null; public static getInstance(): SelfIdentityService { if (!SelfIdentityService.instance) { @@ -58,6 +78,14 @@ class SelfIdentityService { this.containerName = (info.Name || '').replace(/^\//, '') || null; this.composeProjectName = info.Config?.Labels?.['com.docker.compose.project'] ?? null; this.imageIdHex = SelfIdentityService.stripSha(info.Image ?? '') || null; + this.imageRef = info.Config?.Image ?? null; + // Bounded revision enrichment runs detached so it never blocks the callers + // awaiting initialize() (Docker event monitoring, resources discovery). Core + // identity above is already captured; enrichment only adds the registry + // digest / pinned dev- and is failure-isolated. The promise is retained + // so a reader that needs the settled revision can await it (see + // whenRevisionResolved) instead of observing a transient null. + this.enrichmentPromise = this.enrichRevision(this.imageRef, this.imageIdHex); const nets = info.NetworkSettings?.Networks ?? {}; for (const [name, net] of Object.entries(nets)) { @@ -118,6 +146,64 @@ class SelfIdentityService { } } + /** + * Canonical runtime build identity. All fields are captured fields or derived + * synchronously from them; this read never triggers a Docker call. `revision` + * is populated by the detached enrichment step fired during initialize() and + * reads null until that resolves (or if it fails). + */ + getBuildInfo(): BuildInfo { + return { + version: getSenchoVersion(), + channel: this.imageRef ? classifyBuildChannel(this.imageRef) : 'unknown', + imageRef: this.imageRef, + imageId: this.imageIdHex, + revision: this.revision, + }; + } + + /** + * Resolves once the detached revision enrichment has settled (success or + * failure), or immediately when none was started. Awaiting cannot hang or + * throw (enrichment is bounded and failure-isolated). A reader that needs + * the final `revision` awaits this before getBuildInfo() so a successful + * response never freezes a transient null. + */ + async whenRevisionResolved(): Promise { + if (this.enrichmentPromise) await this.enrichmentPromise; + } + + /** + * Resolve the immutable revision from the running image. For a dev-repo image + * carrying a pinned `dev-` tag, the tag itself is the revision. Otherwise + * the running image's `RepoDigests` are inspected for a digest matching the + * running reference. Any failure (inspect rejection, timeout, no matching + * digest) leaves `revision` null; enrichment never throws to the caller. + */ + private async enrichRevision(imageRef: string | null, imageIdHex: string | null): Promise { + try { + let revision: string | null = null; + if (imageRef && isSenchoDevRepository(imageRef)) { + const tag = parseImageRef(imageRef)?.tag; + if (tag && /^dev-[0-9a-f]{7,40}$/.test(tag)) revision = tag; + } + if (!revision && imageRef && imageIdHex) { + revision = await this.resolveDigestRevision(imageRef, imageIdHex); + } + this.revision = revision; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn('[SelfIdentity] build revision enrichment failed:', message); + } + } + + private async resolveDigestRevision(imageRef: string, imageIdHex: string): Promise { + const parsed = parseImageRef(imageRef); + if (!parsed) return null; + const inspected = await withTimeout(defaultInspectImage(imageIdHex), 2000, 'build revision inspect'); + return selectLocalRepoDigest(inspected.RepoDigests ?? [], parsed); + } + /** True when the given container ID or name matches the running Sencho container. Accepts short or full IDs. */ isOwnContainer(idOrName: string): boolean { if (!idOrName) return false; @@ -205,11 +291,14 @@ class SelfIdentityService { this.containerName = null; this.composeProjectName = null; this.imageIdHex = null; + this.imageRef = null; + this.revision = null; this.networkIds.clear(); this.networkNames.clear(); this.volumeNames.clear(); this.initialized = false; this.initializePromise = null; + this.enrichmentPromise = null; } private static stripSha(s: string): string { diff --git a/backend/src/services/selfDevBuildDetect.ts b/backend/src/services/selfDevBuildDetect.ts index 65cf4cfa..2ee64ef6 100644 --- a/backend/src/services/selfDevBuildDetect.ts +++ b/backend/src/services/selfDevBuildDetect.ts @@ -27,13 +27,17 @@ export type SelfDevBuildDetectResult = | { kind: 'inconclusive'; reason: string }; /** The subset of `docker image inspect` output the detector reads. */ -interface InspectedImage { +export interface InspectedImage { RepoDigests: string[]; Os: string; Architecture: string; } -async function defaultInspectImage(imageId: string): Promise { +/** Bounded read of `docker image inspect` (RepoDigests, OS, architecture) for a + * resolved image. Reused by `SelfIdentityService` for revision enrichment; + * callers wrap it so any rejection stays isolated from the fields already + * captured. */ +export async function defaultInspectImage(imageId: string): Promise { const inspect = await DockerController.getInstance().getDocker().getImage(`sha256:${imageId}`).inspect(); return { RepoDigests: inspect.RepoDigests ?? [], Os: inspect.Os, Architecture: inspect.Architecture }; } diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index fc0f82dc..67574fc3 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -108,7 +108,7 @@ The **Plan** card lists: - **License** (Community only): a link to view the AGPLv3 source on GitHub. - **Recovery Vault** (Admiral only): confirms the entitlement is included in your subscription. - **Hardened Build** (Admiral only): a **Switch to Hardened** button; see [Switching to Hardened Build](#switching-to-hardened-build) below. -- **Current image** and **Channel**: the image reference and channel (`Community` or `Hardened`) this control plane is currently running. +- **Current image** and **Channel**: the image reference and channel (`Community` or `Hardened`) this control plane is currently running. These rows reflect the running build, not the configured target, so a compose edit does not change them until the container is recreated. A hardened image is shown as `Restricted` to non-administrators. - **Customer**, **Product**, and **License key** (active licenses only): purchase metadata and your key masked to its last four characters (`****-****-****-XXXX`). The full key is never re-displayed after activation. ## Switching to Hardened Build diff --git a/docs/operations/verifying-images.mdx b/docs/operations/verifying-images.mdx index 07d25f12..27376e32 100644 --- a/docs/operations/verifying-images.mdx +++ b/docs/operations/verifying-images.mdx @@ -154,3 +154,17 @@ Maintainers publish these from open PRs for external validation. They are unsign |---|---|---| | `pr-` | `saelix/sencho:pr-1526` | Each re-run of the preview workflow for that PR | | `preview-` | `saelix/sencho:preview-abc1234` | Never (immutable per build) | + +## Reading your running build + +Sencho surfaces the build it is actually running in **Settings → About → Build** (and in the **Channel** and **Current image** rows of **Settings → Admiral Account**). These fields come from the running container's identity, not from the compose file, so a compose edit does not change them until the container is recreated. + +| Field | Meaning | +|---|---| +| **Version** | The packaged semantic version of the build. A dev build keeps the last stable version here, which is why the Channel row matters. | +| **Channel** | The build track of the running image: `Dev`, `Preview`, `Stable`, or `Unknown`. A `dev` or `dev-` image reads `Dev`; a `pr-` or `preview-` image reads `Preview`; a release image reads `Stable`. | +| **Current image** | The image reference the container was started with, for example `ghcr.io/studio-saelix/sencho-dev:dev-a1b2c3d`. | +| **Revision** | The immutable digest this build resolves to, or the pinned `dev-` tag when the running image is on the integration track. | +| **Image ID** | The first twelve characters of the running image's sha256 identifier; click to copy the full id. | + +These fields describe the **control plane** instance you are logged in to, not remote nodes. When identity metadata cannot be determined, the reference fields read `Unknown` rather than guessing. A hardened image viewed by a non-administrator shows `Restricted` for the reference fields instead of `Unknown`. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 44bc7625..49dc6c4e 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -167,8 +167,8 @@ Activate, view, or deactivate the license for this Sencho control plane, and see | **Sencho Admiral** | The active license on this control plane, with a tier badge. Community instances see an upgrade prompt here instead. | | **Recovery Vault** | Whether the current subscription includes Recovery Vault entitlement. | | **Hardened Build** | Switches this control plane between the Community image channel and the Admiral Hardened Build channel. Review entitlement and registry access before switching; see [Plans](/features/licensing#feature-breakdown) for what Hardened Build changes. | -| **Current image** | The image reference this control plane is currently running, so you can confirm which channel took effect after a switch. | -| **Channel** | The active image channel (Community or Hardened). | +| **Current image** | The image reference this control plane is currently running, so you can confirm which channel took effect after a switch. A hardened image is shown as `Restricted` to non-administrators. | +| **Channel** | The running image channel this control plane is on: Community, Hardened, or Unknown. Reflects the running build, not the configured target. | | **Customer** | The customer name on file with Lemon Squeezy (paid plans only). | | **Product** | The product (paid plans only). | | **License key** | The active key, masked to the last four characters. | @@ -751,9 +751,15 @@ Displays instance information at a glance. | Field | Description | |-------|-------------| -| **Version** | Current Sencho version. | +| **Version** | The semantic version of this control plane instance. | +| **Channel** | The build track this control plane is running: `Dev`, `Preview`, `Stable`, or `Unknown`. A dev build is identified here even when its packaged version still matches the previous stable release. | +| **Current image** | The image reference this control plane was started with. A compose edit changes the configured target until the container is recreated; this row always shows the running image, never the configured one. | +| **Revision** | The immutable digest or pinned `dev-` tag this build resolves to, or `Unknown` when it cannot be determined. | +| **Image ID** | The first twelve characters of the running image's sha256 identifier. Click to copy the full id. Hidden when no image identity is available. | | **Tier** | Community or Admiral badge. | | **Plan Status** | active, trial, expired, or community (Admiral entitlement state, not the AGPL software license). | | **Instance ID** | First eight characters of the unique identifier for this Sencho control plane (used by the license server to identify it). | +The Build rows describe the **control plane** instance (the Sencho you are logged in to), not remote nodes. When identity metadata is unavailable, the reference rows read `Unknown` rather than inferring a value. For a hardened image seen by a non-administrator, the reference fields read `Restricted` instead. See [Verifying images](/operations/verifying-images) for how the build tracks and immutable tags relate. + The **Links** section contains Source code, AGPLv3 License, Licensing documentation, and Changelog links. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 461b5779..41521525 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { AuthProvider, useAuth } from './context/AuthContext'; import { useReducedMotion } from './hooks/use-theme'; import { NodeProvider } from './context/NodeContext'; import { LicenseProvider } from './context/LicenseContext'; +import { BuildInfoProvider } from './context/BuildInfoProvider'; import { Login } from './components/Login'; import { Setup } from './components/Setup'; import EditorLayout from './components/EditorLayout'; @@ -67,11 +68,13 @@ function AppContent() { )} - - {/* Portal lives inside LicenseProvider so the editor surface and its - portalled overlays can read license state via useLicense(). - Outer DeployFeedbackProvider is still an ancestor through App. */} - + + + {/* Portal lives inside LicenseProvider so the editor surface and its + portalled overlays can read license state via useLicense(). + Outer DeployFeedbackProvider is still an ancestor through App. */} + + diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index bd982186..0284d3fb 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -22,6 +22,7 @@ import { useOverlayState } from './EditorLayout/hooks/useOverlayState'; import { useStackActions, NODE_SWITCH_PENDING_TOKEN } from './EditorLayout/hooks/useStackActions'; import { useSelectedStackLiveRefresh } from './EditorLayout/hooks/useSelectedStackLiveRefresh'; import { useTheme } from '@/hooks/use-theme'; +import { useBuildInfo } from '@/hooks/useBuildInfo'; import { ThemeQuickSwitch } from './theme/ThemeQuickSwitch'; import { useNotifications } from './EditorLayout/hooks/useNotifications'; import { useContainerStats } from './EditorLayout/hooks/useContainerStats'; @@ -453,6 +454,7 @@ export default function EditorLayout() { const stackMuteActions = useStackMuteActions(stackDisplayName, openMuteRulesWithPrefill); const { isDarkMode } = useTheme(); + const { buildInfo } = useBuildInfo(); // ---- Mobile shell (below md) --------------------------------------------- // Desktop renders the persistent sidebar + workspace untouched. On a phone we @@ -974,6 +976,7 @@ export default function EditorLayout() { const sidebarEl = ( openSettings('nodes')} diff --git a/frontend/src/components/MobileTabBar.test.tsx b/frontend/src/components/MobileTabBar.test.tsx index ad917026..162a9914 100644 --- a/frontend/src/components/MobileTabBar.test.tsx +++ b/frontend/src/components/MobileTabBar.test.tsx @@ -1,15 +1,37 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { Home, Radar, Clock } from 'lucide-react'; import { MobileTabBar } from './MobileTabBar'; +import { useBuildInfo } from '@/hooks/useBuildInfo'; +import type { BuildInfo } from '@/context/BuildInfoProvider'; import type { NavItem } from './EditorLayout/hooks/useViewNavigationState'; +vi.mock('@/hooks/useBuildInfo', () => ({ + useBuildInfo: vi.fn(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() })), +})); + +const mockUseBuildInfo = vi.mocked(useBuildInfo); + const allItems: NavItem[] = [ { value: 'dashboard', label: 'Home', icon: Home }, { value: 'fleet', label: 'Fleet', icon: Radar }, { value: 'scheduled-ops', label: 'Schedules', icon: Clock }, ]; +function buildInfo(channel: BuildInfo['channel']): BuildInfo { + return { + version: '0.97.1', + channel, + imageChannel: 'community', + imageRef: channel === 'dev' ? 'ghcr.io/studio-saelix/sencho-dev:dev' : 'ghcr.io/studio-saelix/sencho:0.97.1', + imageId: 'a'.repeat(64), + revision: null, + restricted: false, + }; +} + +const noPill = { buildInfo: null, status: 'ready' as const, retry: vi.fn() }; + function renderBar(over: Partial> = {}) { const props: React.ComponentProps = { navItems: allItems, @@ -76,3 +98,52 @@ describe('MobileTabBar', () => { expect(screen.getByRole('button', { name: 'Stacks' })).not.toHaveAttribute('aria-current'); }); }); + +describe('MobileTabBar build-identity pill', () => { + beforeEach(() => { + mockUseBuildInfo.mockReturnValue(noPill); + }); + + it('shows a text DEV pill for a dev build, not an interactive control', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() }); + renderBar(); + expect(screen.getByText('DEV')).toBeInTheDocument(); + // The pill is a plain span: it adds no competing tap target in the tab row. + expect(screen.queryByRole('button', { name: 'DEV' })).not.toBeInTheDocument(); + }); + + it('shows a text PREVIEW pill for a preview build', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('preview'), status: 'ready', retry: vi.fn() }); + renderBar(); + expect(screen.getByText('PREVIEW')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'PREVIEW' })).not.toBeInTheDocument(); + }); + + it('renders no pill for a stable build', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('stable'), status: 'ready', retry: vi.fn() }); + renderBar(); + expect(screen.queryByText('DEV')).not.toBeInTheDocument(); + expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument(); + }); + + it('keeps the tab touch targets present alongside the pill', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() }); + renderBar(); + expect(screen.getByRole('button', { name: 'Home' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Stacks' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + }); + + it('lays the pill in-flow as a non-overlapping sibling of the tabs', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() }); + renderBar(); + const pill = screen.getByText('DEV'); + // In-flow (flex sibling), not absolutely positioned over the Settings tab. + expect(pill).not.toHaveClass('absolute'); + expect(pill).toHaveClass('self-center', 'shrink-0'); + // Sibling of the tab buttons inside the nav, so flexbox reserves its own + // region rather than letting it overlap the rightmost tab. + const settingsTab = screen.getByRole('button', { name: 'Settings' }); + expect(pill.parentElement).toBe(settingsTab.parentElement); + }); +}); diff --git a/frontend/src/components/MobileTabBar.tsx b/frontend/src/components/MobileTabBar.tsx index 8edb096b..b9952a7a 100644 --- a/frontend/src/components/MobileTabBar.tsx +++ b/frontend/src/components/MobileTabBar.tsx @@ -1,6 +1,7 @@ import { Home, Layers, Radar, Clock, Settings as SettingsIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useBuildInfo } from '@/hooks/useBuildInfo'; import type { NavItem, ActiveView } from './EditorLayout/hooks/useViewNavigationState'; import type { MobileView } from './EditorLayout/mobile-surface'; @@ -45,7 +46,12 @@ export function MobileTabBar({ onNavigate, onSettings, }: MobileTabBarProps) { + const { buildInfo } = useBuildInfo(); + const has = (value: ActiveView) => navItems.some(i => i.value === value); + const channel = buildInfo?.channel; + const showPill = channel === 'dev' || channel === 'preview'; + const pillLabel = channel === 'dev' ? 'DEV' : 'PREVIEW'; const tabs: Tab[] = [ { id: 'home', label: 'Home', icon: Home }, @@ -79,7 +85,7 @@ export function MobileTabBar({ aria-label="Primary mobile" data-sn-glass="mobile-tabbar" className={cn( - 'md:hidden flex shrink-0 items-stretch', + 'md:hidden relative flex shrink-0 items-stretch', 'border-t border-hairline', 'bg-[color-mix(in_oklch,var(--card)_70%,transparent)] backdrop-blur-md backdrop-saturate-150', 'pb-[max(8px,env(safe-area-inset-bottom))]', @@ -108,6 +114,17 @@ export function MobileTabBar({ ); })} + {showPill ? ( + + {pillLabel} + + ) : null} ); } diff --git a/frontend/src/components/settings/AboutSection.tsx b/frontend/src/components/settings/AboutSection.tsx index 83c8016e..6a1d6bd0 100644 --- a/frontend/src/components/settings/AboutSection.tsx +++ b/frontend/src/components/settings/AboutSection.tsx @@ -1,5 +1,11 @@ +import { useState } from 'react'; import { useLicense } from '@/context/LicenseContext'; +import { useBuildInfo } from '@/hooks/useBuildInfo'; import { TierBadge } from '@/components/TierBadge'; +import { Badge } from '@/components/ui/badge'; +import { FlaskConical } from 'lucide-react'; +import { copyToClipboard } from '@/lib/clipboard'; +import { toast } from '@/components/ui/toast-store'; import { TogglePill } from '@/components/ui/toggle-pill'; import { useWhatsNewPreference } from '@/hooks/useWhatsNewPreference'; import { whatsNewEntries } from '@/whats-new/entries'; @@ -15,16 +21,110 @@ import { const linkClassName = 'font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-brand hover:text-brand/80 transition-colors'; +const mono = 'font-mono text-sm text-stat-value'; + +function BuildChannelChip({ label }: { label: string }) { + if (label === 'Dev') { + return ( + + Dev + + ); + } + if (label === 'Preview') { + return ( + + Preview + + ); + } + return {label}; +} + export function AboutSection() { const { license } = useLicense(); + const { buildInfo, status } = useBuildInfo(); const { enabled: whatsNewEnabled, setEnabled: setWhatsNewEnabled } = useWhatsNewPreference(); + const [copied, setCopied] = useState(false); + + // Loading surfaces a placeholder and error surfaces "Unknown" (truthful); + // the reference and revision fields below read "Restricted" for a redacted + // hardened image, before their null-check. + const channelLabel = (() => { + if (status === 'loading') return '…'; + if (status === 'error' || !buildInfo) return 'Unknown'; + switch (buildInfo.channel) { + case 'dev': return 'Dev'; + case 'preview': return 'Preview'; + case 'stable': return 'Stable'; + default: return 'Unknown'; + } + })(); + const resolveLabel = (value: string | null | undefined) => + buildInfo?.restricted + ? 'Restricted' + : status === 'loading' + ? '…' + : status === 'error' + ? 'Unknown' + : value ?? 'Unknown'; + + const imageRefLabel = resolveLabel(buildInfo?.imageRef); + const revisionLabel = resolveLabel(buildInfo?.revision); + const imageIdLabel = + status === 'loading' ? '…' + : status === 'error' || !buildInfo?.imageId ? 'Unknown' + : `sha256:${buildInfo.imageId.slice(0, 12)}`; + + const copyImageId = async () => { + if (!buildInfo?.imageId) return; + try { + await copyToClipboard(`sha256:${buildInfo.imageId}`); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + toast.error('Could not copy the image id.'); + } + }; return (
- v{__APP_VERSION__} + v{buildInfo?.version ?? __APP_VERSION__} + + + + + {imageRefLabel} + + + {revisionLabel} + + {imageIdLabel !== 'Unknown' && imageIdLabel !== '…' ? ( + + + + ) : null} diff --git a/frontend/src/components/settings/LicenseSection.tsx b/frontend/src/components/settings/LicenseSection.tsx index d1981dc8..7a2eb023 100644 --- a/frontend/src/components/settings/LicenseSection.tsx +++ b/frontend/src/components/settings/LicenseSection.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui/toast-store'; import { useLicense } from '@/context/LicenseContext'; +import { useBuildInfo } from '@/hooks/useBuildInfo'; import { TierBadge } from '@/components/TierBadge'; import { Crown, CheckCircle, XCircle, Clock, ExternalLink, @@ -47,8 +48,8 @@ function formatChannel(channel: ImageChannel): string { return 'Community'; case 'hardened': return 'Hardened'; - default: - return 'Custom'; + case 'unknown': + return 'Unknown'; } } @@ -64,6 +65,7 @@ function getTierMastheadValue(tier?: string): string { export function LicenseSection() { const { license, isPaid, activate, deactivate } = useLicense(); + const { buildInfo, status: buildInfoStatus } = useBuildInfo(); const [licenseKeyInput, setLicenseKeyInput] = useState(''); const [isActivating, setIsActivating] = useState(false); const [isDeactivating, setIsDeactivating] = useState(false); @@ -253,16 +255,28 @@ export function LicenseSection() { ) : null} - {channelStatus?.composeImageRef ?? formatChannel(channelStatus?.channel ?? 'unknown')} + {buildInfo?.restricted + ? 'Restricted' + : buildInfoStatus === 'loading' + ? '…' + : buildInfoStatus === 'error' + ? 'Unknown' + : buildInfo?.imageRef ?? 'Unknown'} - {formatChannel(channelStatus?.channel ?? 'unknown')} + + {buildInfoStatus === 'loading' + ? '…' + : buildInfoStatus === 'error' || !buildInfo + ? 'Unknown' + : formatChannel(buildInfo.imageChannel)} + {channelStatus?.operation?.state === 'failed' ? ( ({ useWhatsNewPreference: () => ({ enabled: true, setEnabled: mockSetEnabled, hasUnseen: false, markSeen: vi.fn() }), })); +vi.mock('@/hooks/useBuildInfo', () => ({ + useBuildInfo: vi.fn(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() })), +})); +import { useBuildInfo } from '@/hooks/useBuildInfo'; +import type { BuildInfo } from '@/context/BuildInfoProvider'; + +const { mockCopyToClipboard, mockToastError } = vi.hoisted(() => ({ + mockCopyToClipboard: vi.fn(), + mockToastError: vi.fn(), +})); +vi.mock('@/lib/clipboard', () => ({ copyToClipboard: mockCopyToClipboard })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: mockToastError, success: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, +})); + +const mockUseBuildInfo = vi.mocked(useBuildInfo); + +function buildInfo(over: Partial = {}): BuildInfo { + return { + version: '0.97.1', + channel: 'dev', + imageChannel: 'community', + imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev', + imageId: 'a'.repeat(64), + revision: 'dev-abc1234', + restricted: false, + ...over, + }; +} + // The shipped entries.json is empty, so populate it here; the empty state has its own file. vi.mock('@/whats-new/entries', () => ({ whatsNewEntries: [{ id: 'entry-a', title: 'A feature', blurb: 'Does a thing.' }], @@ -91,3 +121,49 @@ describe('AboutSection Preferences', () => { expect(mockSetEnabled).toHaveBeenCalledWith(false); }); }); + +describe('AboutSection Build identity', () => { + it('shows the runtime channel, current image, revision and version', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() }); + render(); + expect(screen.getByText('Dev')).toBeTruthy(); + expect(screen.getByText('ghcr.io/studio-saelix/sencho-dev:dev')).toBeTruthy(); + expect(screen.getByText('dev-abc1234')).toBeTruthy(); + expect(screen.getByText('v0.97.1')).toBeTruthy(); + }); + + it('labels redacted hardened reference fields Restricted, not Unknown', () => { + mockUseBuildInfo.mockReturnValue({ + buildInfo: buildInfo({ channel: 'stable', restricted: true, imageRef: null, revision: null }), + status: 'ready', + retry: vi.fn(), + }); + render(); + expect(screen.getByText('Stable')).toBeTruthy(); + expect(screen.getAllByText('Restricted').length).toBeGreaterThanOrEqual(2); + expect(screen.queryByText('Unknown')).toBeNull(); + }); + + it('shows Unknown for reference fields when build info is unavailable', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: null, status: 'error', retry: vi.fn() }); + render(); + expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0); + }); + + it('wraps long image and revision tokens so they do not overflow', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() }); + render(); + expect(screen.getByText('ghcr.io/studio-saelix/sencho-dev:dev')).toHaveClass('break-all'); + expect(screen.getByText('dev-abc1234')).toHaveClass('break-all'); + }); + + it('surfaces an error toast when copying the image id fails', async () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() }); + mockCopyToClipboard.mockRejectedValue(new Error('clipboard blocked')); + render(); + + await userEvent.click(screen.getByRole('button', { name: /sha256:/ })); + expect(mockCopyToClipboard).toHaveBeenCalledWith(`sha256:${'a'.repeat(64)}`); + expect(mockToastError).toHaveBeenCalledWith('Could not copy the image id.'); + }); +}); diff --git a/frontend/src/components/settings/__tests__/AboutSection.whatsNewEmpty.test.tsx b/frontend/src/components/settings/__tests__/AboutSection.whatsNewEmpty.test.tsx index 181a3c7d..8c0b92fa 100644 --- a/frontend/src/components/settings/__tests__/AboutSection.whatsNewEmpty.test.tsx +++ b/frontend/src/components/settings/__tests__/AboutSection.whatsNewEmpty.test.tsx @@ -43,6 +43,10 @@ vi.mock('@/hooks/useWhatsNewPreference', () => ({ useWhatsNewPreference: () => ({ enabled: true, setEnabled: vi.fn(), hasUnseen: false, markSeen: vi.fn() }), })); +vi.mock('@/hooks/useBuildInfo', () => ({ + useBuildInfo: () => ({ buildInfo: null, status: 'ready', retry: vi.fn() }), +})); + describe("AboutSection with no What's New entries authored", () => { it('hides the Preferences section entirely, so no toggle describes an absent icon', () => { render(); diff --git a/frontend/src/components/settings/__tests__/LicenseSection.test.tsx b/frontend/src/components/settings/__tests__/LicenseSection.test.tsx index 257bc0fe..d8d99605 100644 --- a/frontend/src/components/settings/__tests__/LicenseSection.test.tsx +++ b/frontend/src/components/settings/__tests__/LicenseSection.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import type { LicenseInfo } from '@/context/LicenseContext'; +import type { BuildInfoContextType } from '@/context/BuildInfoProvider'; const useLicenseMock = vi.fn(); @@ -12,6 +13,12 @@ vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {}, })); +const useBuildInfoMock = vi.fn<() => BuildInfoContextType>(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() })); + +vi.mock('@/hooks/useBuildInfo', () => ({ + useBuildInfo: () => useBuildInfoMock(), +})); + vi.mock('@/components/TierBadge', () => ({ TierBadge: () => tier, })); @@ -135,3 +142,72 @@ describe('LicenseSection pricing link', () => { expect(screen.getByText('See pricing')).toBeTruthy(); }); }); + +describe('LicenseSection build rows (running identity consistency)', () => { + beforeEach(() => { + useLicenseMock.mockReset(); + useBuildInfoMock.mockReset(); + useBuildInfoMock.mockReturnValue({ buildInfo: null, status: 'ready', retry: vi.fn() }); + mockLicense(baseLicense()); + }); + + it('renders the running Community channel and image ref, regardless of configured target', () => { + useBuildInfoMock.mockReturnValue({ + buildInfo: { + version: '0.97.1', + channel: 'stable', + imageChannel: 'community', + imageRef: 'ghcr.io/studio-saelix/sencho:0.97.1', + imageId: 'a'.repeat(64), + revision: null, + restricted: false, + }, + status: 'ready', + retry: vi.fn(), + }); + render(); + // The configured/compose target is hardened, but the running build is + // Community: the row must show the running image, never the target. + expect(screen.getByText('ghcr.io/studio-saelix/sencho:0.97.1')).toBeTruthy(); + expect(screen.queryByText('Hardened')).toBeNull(); + }); + + it('renders a hardened running image as Hardened channel and Restricted image, not Unknown', () => { + useBuildInfoMock.mockReturnValue({ + buildInfo: { + version: '0.97.1', + channel: 'stable', + imageChannel: 'hardened', + imageRef: null, + imageId: 'b'.repeat(64), + revision: null, + restricted: true, + }, + status: 'ready', + retry: vi.fn(), + }); + render(); + expect(screen.getByText('Hardened')).toBeTruthy(); + expect(screen.getByText('Restricted')).toBeTruthy(); + expect(screen.queryByText('Unknown')).toBeNull(); + }); + + it('labels an unclassifiable running image Channel Unknown, never Custom', () => { + useBuildInfoMock.mockReturnValue({ + buildInfo: { + version: '0.97.1', + channel: 'unknown', + imageChannel: 'unknown', + imageRef: null, + imageId: null, + revision: null, + restricted: false, + }, + status: 'ready', + retry: vi.fn(), + }); + render(); + expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0); + expect(screen.queryByText('Custom')).toBeNull(); + }); +}); diff --git a/frontend/src/components/sidebar/SidebarBrand.test.tsx b/frontend/src/components/sidebar/SidebarBrand.test.tsx new file mode 100644 index 00000000..19b6f59a --- /dev/null +++ b/frontend/src/components/sidebar/SidebarBrand.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SidebarBrand } from './SidebarBrand'; +import { chipDetail } from './chipDetail'; +import type { BuildInfo } from '@/context/BuildInfoProvider'; + +function info(channel: BuildInfo['channel']): BuildInfo { + return { + version: '0.97.1', + channel, + imageChannel: 'community', + imageRef: channel === 'dev' ? 'ghcr.io/studio-saelix/sencho-dev:dev' : 'ghcr.io/studio-saelix/sencho:0.97.1', + imageId: 'a'.repeat(64), + revision: null, + restricted: false, + }; +} + +describe('SidebarBrand build-identity chip', () => { + it('shows a DEV text chip for a dev build', () => { + render(); + const chip = screen.getByText('DEV'); + expect(chip).toBeInTheDocument(); + // Text is the cue, not color alone. + expect(chip.tagName).toBe('SPAN'); + expect(chip.textContent).toContain('DEV'); + }); + + it('shows a PREVIEW text chip for a preview build', () => { + render(); + expect(screen.getByText('PREVIEW')).toBeInTheDocument(); + }); + + it('renders no chip for a stable build', () => { + render(); + expect(screen.queryByText('DEV')).not.toBeInTheDocument(); + expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument(); + }); + + it('renders no chip when build info is unavailable', () => { + render(); + expect(screen.queryByText('DEV')).not.toBeInTheDocument(); + expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument(); + }); + + it('prefers the runtime version when available', () => { + render(); + expect(screen.getByText('v0.97.1')).toBeInTheDocument(); + }); +}); + +describe('chipDetail', () => { + it('reads Restricted for a redacted hardened reference', () => { + const b: BuildInfo = { ...info('stable'), restricted: true, imageRef: null, revision: null }; + expect(chipDetail(b)).toBe('Restricted'); + }); + + it('combines the reference and revision when both are present', () => { + const b: BuildInfo = { ...info('dev'), revision: 'dev-abc1234' }; + expect(chipDetail(b)).toBe('ghcr.io/studio-saelix/sencho-dev:dev · dev-abc1234'); + }); + + it('reads the reference alone when the revision is unknown', () => { + const b: BuildInfo = { ...info('dev'), revision: null }; + expect(chipDetail(b)).toBe('ghcr.io/studio-saelix/sencho-dev:dev'); + }); + + it('reads Unknown when the reference is absent and not restricted', () => { + const b: BuildInfo = { ...info('dev'), imageRef: null }; + expect(chipDetail(b)).toBe('Unknown'); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/sidebar/SidebarBrand.tsx b/frontend/src/components/sidebar/SidebarBrand.tsx index 8e5f0a78..08d867e5 100644 --- a/frontend/src/components/sidebar/SidebarBrand.tsx +++ b/frontend/src/components/sidebar/SidebarBrand.tsx @@ -1,8 +1,17 @@ +import { FlaskConical } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import type { BuildInfo } from '@/context/BuildInfoProvider'; +import { chipDetail } from './chipDetail'; + interface SidebarBrandProps { isDarkMode: boolean; + buildInfo?: BuildInfo | null; } -export function SidebarBrand({ isDarkMode }: SidebarBrandProps) { +export function SidebarBrand({ isDarkMode, buildInfo }: SidebarBrandProps) { + const channel = buildInfo?.channel; + const showChip = channel === 'dev' || channel === 'preview'; + return (
-
+
Sencho - v{__APP_VERSION__} + v{buildInfo?.version ?? __APP_VERSION__} + {showChip ? ( + + + + + {channel === 'dev' ? : null} + {channel === 'dev' ? 'DEV' : 'PREVIEW'} + + + + {chipDetail(buildInfo)} + + + + ) : null}
); diff --git a/frontend/src/components/sidebar/StackSidebar.tsx b/frontend/src/components/sidebar/StackSidebar.tsx index 1f1ecb25..4d78caf0 100644 --- a/frontend/src/components/sidebar/StackSidebar.tsx +++ b/frontend/src/components/sidebar/StackSidebar.tsx @@ -11,10 +11,12 @@ import { StackList, type StackListProps } from './StackList'; import type { FilterChip } from './sidebar-types'; import type { BulkAction } from '@/hooks/useBulkStackActions'; import type { SidebarActivitySummary } from './useSidebarActivitySummary'; +import type { BuildInfo } from '@/context/BuildInfoProvider'; import { isStacksListSettled } from './stacksLoadUi'; export interface StackSidebarProps { isDarkMode: boolean; + buildInfo?: BuildInfo | null; nodeSwitcherSlot: ReactNode; createStackSlot: ReactNode | null; onScan: () => void; @@ -43,7 +45,7 @@ export interface StackSidebarProps { export function StackSidebar(props: StackSidebarProps) { const { - isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate, + isDarkMode, buildInfo, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate, searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange, list, activitySummary, onActivityAction, bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction, @@ -76,7 +78,7 @@ export function StackSidebar(props: StackSidebarProps) { its kicker chip), so the in-sidebar brand and node rows are redundant there and hidden to save vertical space. */}
- +
{nodeSwitcherSlot}
{canCreate && createStackSlot !== null && ( diff --git a/frontend/src/components/sidebar/chipDetail.ts b/frontend/src/components/sidebar/chipDetail.ts new file mode 100644 index 00000000..e98ed8ae --- /dev/null +++ b/frontend/src/components/sidebar/chipDetail.ts @@ -0,0 +1,11 @@ +import type { BuildInfo } from '@/context/BuildInfoProvider'; + +/** Detail shown under the DEV/PREVIEW chip, or the truthful Unknown / Restricted + * states when the running reference is unavailable or redacted for this user. */ +export function chipDetail(buildInfo: BuildInfo | null | undefined): string { + if (buildInfo?.restricted) return 'Restricted'; + if (buildInfo?.imageRef) { + return buildInfo.revision ? `${buildInfo.imageRef} · ${buildInfo.revision}` : buildInfo.imageRef; + } + return 'Unknown'; +} \ No newline at end of file diff --git a/frontend/src/context/BuildInfoProvider.test.tsx b/frontend/src/context/BuildInfoProvider.test.tsx new file mode 100644 index 00000000..f9b498ee --- /dev/null +++ b/frontend/src/context/BuildInfoProvider.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { BuildInfoProvider } from './BuildInfoProvider'; +import { useBuildInfo } from '@/hooks/useBuildInfo'; + +const { currentUserRef } = vi.hoisted(() => ({ + currentUserRef: { value: null as { username: string; role: string } | null }, +})); + +vi.mock('./AuthContext', () => ({ + useAuth: () => ({ user: currentUserRef.value }), +})); + +vi.mock('@/lib/api', () => ({ + apiFetch: vi.fn(), +})); +import { apiFetch } from '@/lib/api'; + +const restrictedViewer = { + version: '0.97.1', + channel: 'stable', + imageChannel: 'hardened', + imageRef: null, + imageId: 'b'.repeat(64), + revision: null, + restricted: true, +}; + +const adminCommunity = { + version: '0.97.1', + channel: 'dev', + imageChannel: 'community', + imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev', + imageId: 'a'.repeat(64), + revision: null, + restricted: false, +}; + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { status }); +} + +function Harness() { + const { buildInfo, status } = useBuildInfo(); + return
{status}:{buildInfo?.imageRef ?? 'none'}
; +} + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('BuildInfoProvider', () => { + beforeEach(() => { + currentUserRef.value = null; + vi.mocked(apiFetch).mockReset(); + }); + + it('clears permission-filtered state and refetches when the auth identity changes', async () => { + currentUserRef.value = { username: 'admin', role: 'admin' }; + vi.mocked(apiFetch).mockResolvedValue(json(adminCommunity)); + const { rerender } = render(, { wrapper }); + await flush(); + expect(screen.getByTestId('s').textContent).toContain('ready'); + expect(screen.getByTestId('s').textContent).toContain('ghcr.io/studio-saelix/sencho-dev:dev'); + + // A viewer session must never see the admin-cached ref: the provider + // clears immediately and rejects the stale in-flight completion. + currentUserRef.value = { username: 'viewer', role: 'viewer' }; + vi.mocked(apiFetch).mockResolvedValue(json(restrictedViewer)); + rerender(); + // Cleared synchronously on the identity change, before the refetch lands. + expect(screen.getByTestId('s').textContent).toBe('loading:none'); + await flush(); + expect(screen.getByTestId('s').textContent).toBe('ready:none'); + }); + + it('rejects a stale in-flight completion from a prior identity', async () => { + currentUserRef.value = { username: 'admin', role: 'admin' }; + let resolveAdmin!: (r: Response) => void; + vi.mocked(apiFetch).mockReturnValue(new Promise((res) => { resolveAdmin = res; })); + const { rerender } = render(, { wrapper }); + + currentUserRef.value = { username: 'viewer', role: 'viewer' }; + vi.mocked(apiFetch).mockResolvedValue(json(restrictedViewer)); + rerender(); + await flush(); + expect(screen.getByTestId('s').textContent).toBe('ready:none'); + + // The old admin request resolving later must not surface its ref. + await act(async () => { resolveAdmin(json(adminCommunity)); }); + expect(screen.getByTestId('s').textContent).toBe('ready:none'); + }); + + it('shares one fetch across two consumers', async () => { + currentUserRef.value = { username: 'admin', role: 'admin' }; + vi.mocked(apiFetch).mockResolvedValue(json(adminCommunity)); + render( + + + + , + ); + await flush(); + expect(apiFetch).toHaveBeenCalledTimes(1); + expect(apiFetch).toHaveBeenCalledWith('/build-info', expect.objectContaining({ localOnly: true })); + expect(screen.getAllByTestId('s')).toHaveLength(2); + for (const el of screen.getAllByTestId('s')) { + expect(el.textContent).toContain('ghcr.io/studio-saelix/sencho-dev:dev'); + } + }); + + it('surfaces error as Unknown and retries on focus', async () => { + currentUserRef.value = { username: 'admin', role: 'admin' }; + vi.mocked(apiFetch).mockRejectedValue(new Error('down')); + render(, { wrapper }); + await flush(); + expect(screen.getByTestId('s').textContent).toBe('error:none'); + + vi.mocked(apiFetch).mockResolvedValue(json(adminCommunity)); + await act(async () => { + window.dispatchEvent(new Event('focus')); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId('s').textContent).toBe('ready:ghcr.io/studio-saelix/sencho-dev:dev'); + }); +}); \ No newline at end of file diff --git a/frontend/src/context/BuildInfoProvider.tsx b/frontend/src/context/BuildInfoProvider.tsx new file mode 100644 index 00000000..614a124b --- /dev/null +++ b/frontend/src/context/BuildInfoProvider.tsx @@ -0,0 +1,103 @@ +import { createContext, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { apiFetch } from '@/lib/api'; +import { useAuth } from './AuthContext'; + +export type BuildChannel = 'stable' | 'dev' | 'preview' | 'unknown'; +export type ImageChannel = 'community' | 'hardened' | 'unknown'; +export type BuildInfoStatus = 'loading' | 'ready' | 'error'; + +/** Canonical runtime build identity of the control instance. Mirrors the + * /api/build-info response: imageRef and revision are nulled for hardened + * images when the viewer is not an admin, with `restricted` marking that + * redaction (the UI shows "Restricted", never "Unknown", when set). + * `restricted === true` implies `imageRef === null && revision === null`. */ +export interface BuildInfo { + version: string | null; + channel: BuildChannel; + imageChannel: ImageChannel; + imageRef: string | null; + imageId: string | null; + revision: string | null; + restricted: boolean; +} + +export interface BuildInfoContextType { + buildInfo: BuildInfo | null; + status: BuildInfoStatus; + retry: () => void; +} + +// eslint-disable-next-line react-refresh/only-export-components +export const BuildInfoContext = createContext(undefined); + +/** Single owner of the control-instance build identity. Mounted inside the + * authenticated subtree. One shared fetch (localOnly, so it always targets the + * local hub); permission-filtered state is cleared the moment the auth identity + * or role changes, and stale in-flight completions from a prior session are + * rejected by a generation counter. Focus retries an error so it never sticks + * failed; consumers show a placeholder while loading and "Unknown" on error. */ +export function BuildInfoProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + + const [buildInfo, setBuildInfo] = useState(null); + const [status, setStatus] = useState('loading'); + + const generationRef = useRef(0); + const statusRef = useRef('loading'); + const identityRef = useRef(null); + + const load = useCallback(async () => { + const gen = ++generationRef.current; + setStatus('loading'); + try { + const res = await apiFetch('/build-info', { localOnly: true }); + if (gen !== generationRef.current) return; + if (res.ok) { + const data = (await res.json()) as BuildInfo; + if (gen !== generationRef.current) return; + setBuildInfo(data); + setStatus('ready'); + } else { + console.error(`[BuildInfo] fetch returned ${res.status}`); + setStatus('error'); + } + } catch (err) { + if (gen !== generationRef.current) return; + console.error('[BuildInfo] fetch failed', err); + setStatus('error'); + } + }, []); + + // Reload whenever the signed-in identity or role changes. Clearing state + // synchronously (before the replacement fetch resolves) guarantees a + // hardened admin's reference never surfaces in a viewer session. + useEffect(() => { + const identity = user ? `${user.username}:${user.role}` : null; + if (identity !== identityRef.current) { + identityRef.current = identity; + setBuildInfo(null); + setStatus('loading'); + void load(); + } + }, [user, load]); + + useEffect(() => { + statusRef.current = status; + }, [status]); + + // A transient failure (auth expiry, hub restart) should not stick: retry the + // moment the tab regains focus. + useEffect(() => { + const onFocus = () => { + if (statusRef.current === 'error') void load(); + }; + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + }, [load]); + + return ( + void load() }}> + {children} + + ); +} \ No newline at end of file diff --git a/frontend/src/hooks/useBuildInfo.ts b/frontend/src/hooks/useBuildInfo.ts new file mode 100644 index 00000000..7b13f06a --- /dev/null +++ b/frontend/src/hooks/useBuildInfo.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { BuildInfoContext, type BuildInfoContextType } from '@/context/BuildInfoProvider'; + +/** Consumer of the single shared control-instance build identity. The shell, + * About, and Admiral Account all read the same fetched BuildInfo by reference + * instead of issuing their own fetches. */ +export function useBuildInfo(): BuildInfoContextType { + const context = useContext(BuildInfoContext); + if (context === undefined) { + throw new Error('useBuildInfo must be used within a BuildInfoProvider'); + } + return context; +} \ No newline at end of file