Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .replworks/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();

Expand Down
3 changes: 3 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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({
Expand Down
94 changes: 94 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1 +1,95 @@
import './style.css';

const appRoot = document.querySelector<HTMLElement>('#app');

if (!appRoot) {
throw new Error('Operator console root element not found.');
}

appRoot.innerHTML = `
<section class="mx-auto max-w-3xl px-4 py-8 text-slate-900">
<header class="mb-6">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">
ImageForge
</p>
<h1 class="mt-2 text-3xl font-bold text-slate-900">운영자 콘솔</h1>
</header>

<form class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="mb-4">
<label for="service-select" class="mb-2 block text-sm font-medium text-slate-700">
서비스
</label>
<select
id="service-select"
class="w-full rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-base text-slate-900 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
aria-label="서비스 선택"
disabled
>
<option value="">서비스 목록을 불러오는 중...</option>
</select>
</div>

<div class="rounded-xl bg-slate-50 px-3 py-2 text-sm text-slate-600" id="catalog-status">
서비스 목록을 불러오고 있습니다.
</div>
</form>
</section>
`;

const serviceSelect = document.querySelector<HTMLSelectElement>('#service-select');
const catalogStatus = document.querySelector<HTMLElement>('#catalog-status');

async function loadCatalog(): Promise<void> {
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 = '<option value="">서비스 목록을 불러오지 못했습니다.</option>';
serviceSelect.disabled = true;
catalogStatus.textContent = '유효한 서비스 catalog를 불러오지 못했습니다.';
}
}

void loadCatalog();
7 changes: 6 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading