diff --git a/.replworks/TASKS.md b/.replworks/TASKS.md index e2bd56a..24185f3 100644 --- a/.replworks/TASKS.md +++ b/.replworks/TASKS.md @@ -38,7 +38,7 @@ - No user, password, or session storage is used and no logout screen is available, as required by PRODUCT_SPEC.md §6 (S2, S5) and ARCHITECTURE.md §8 (AR6). - Unit tests for identity verification pass where unit tests apply. -- [ ] T-005 Show the predefined service catalog in the operator console +- [X] T-005 Show the predefined service catalog in the operator console - Satisfies: PRODUCT_SPEC.md §§4.2, 4.3, 4.4 - External boundary: yes - Acceptance criteria: diff --git a/src/app.test.ts b/src/app.test.ts index ee0471e..8db451e 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -12,6 +12,7 @@ const purgeDocument = await readFile( "utf8", ); const denyIdentity: IdentityVerifier = async () => false; +const allowIdentity: IdentityVerifier = async () => true; function createTestApp() { return createApp(landingDocument, purgeDocument, denyIdentity); @@ -80,6 +81,19 @@ describe("public landing page", () => { expect(unknown.status).toBe(404); }); + it("V1 serves the predefined catalog as a read-only service list", async () => { + const app = createApp( + landingDocument, + purgeDocument, + allowIdentity, + ["images", "avatars"], + ); + const response = await app.request("/purge/catalog"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ services: ["images", "avatars"] }); + }); + it("SA1 and SA2 deny all protected paths and methods without a valid identity", async () => { const app = createTestApp(); diff --git a/src/app.ts b/src/app.ts index de889d0..5d1e0ce 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,8 +7,10 @@ export function createApp( landingDocument: string, purgeDocument: string, verifyIdentity: IdentityVerifier, + catalog: readonly string[] = [], ): Hono { const app = new Hono(); + const services = catalog.filter((service) => service.trim().length > 0); app.get('/', (context) => context.html(landingDocument, 200, { @@ -41,6 +43,7 @@ export function createApp( app.get('/purge', (context) => context.redirect('/purge/')); app.get('/purge/', (context) => context.html(purgeDocument)); + app.get('/purge/catalog', (context) => context.json({ services })); app.use( '/purge/assets/*', serveStatic({ diff --git a/src/main.ts b/src/main.ts index aa3357b..4c41303 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1 +1,95 @@ import './style.css'; + +const appRoot = document.querySelector('#app'); + +if (!appRoot) { + throw new Error('Operator console root element not found.'); +} + +appRoot.innerHTML = ` +
+
+

+ ImageForge +

+

운영자 콘솔

+
+ +
+
+ + +
+ +
+ 서비스 목록을 불러오고 있습니다. +
+
+
+`; + +const serviceSelect = document.querySelector('#service-select'); +const catalogStatus = document.querySelector('#catalog-status'); + +async function loadCatalog(): Promise { + if (!serviceSelect || !catalogStatus) { + return; + } + + try { + const response = await fetch('/purge/catalog', { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Catalog request failed: ${response.status}`); + } + + const payload = (await response.json()) as { services?: string[] }; + const services = Array.isArray(payload.services) + ? payload.services.filter((service): service is string => typeof service === 'string' && service.trim().length > 0) + : []; + + serviceSelect.innerHTML = ''; + + if (services.length === 0) { + const fallback = document.createElement('option'); + fallback.value = ''; + fallback.textContent = '사용 가능한 서비스가 없습니다.'; + serviceSelect.append(fallback); + serviceSelect.disabled = true; + catalogStatus.textContent = '서비스 목록이 비어 있습니다.'; + return; + } + + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = '서비스를 선택하세요'; + serviceSelect.append(placeholder); + + for (const service of services) { + const option = document.createElement('option'); + option.value = service; + option.textContent = service; + serviceSelect.append(option); + } + + serviceSelect.disabled = false; + catalogStatus.textContent = `${services.length}개의 서비스가 로드되었습니다.`; + } catch { + serviceSelect.innerHTML = ''; + serviceSelect.disabled = true; + catalogStatus.textContent = '유효한 서비스 catalog를 불러오지 못했습니다.'; + } +} + +void loadCatalog(); diff --git a/src/server.ts b/src/server.ts index 176b799..f3b6e10 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,7 +17,12 @@ const verifyIdentity = createIdentityVerifier( config.cfAccessTeamDomain, config.cfAccessAud, ); -const app = createApp(landingDocument, purgeDocument, verifyIdentity); +const app = createApp( + landingDocument, + purgeDocument, + verifyIdentity, + config.services, +); serve({ fetch: app.fetch,