Compare commits
24
Commits
eb86708076
...
89a73c13c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a73c13c6 | ||
|
|
21f8425f1e | ||
|
|
7093d84ab5 | ||
|
|
d2c289c650 | ||
|
|
5cffe30200 | ||
|
|
197b2c7e72 | ||
|
|
c5e8735041 | ||
|
|
ab8c6c14db | ||
|
|
3754269118 | ||
|
|
760071156d | ||
|
|
03986da3d6 | ||
|
|
31dca00857 | ||
|
|
11c2713139 | ||
|
|
4b62bf3b1f | ||
|
|
6784eb1ce6 | ||
|
|
24c01aedf2 | ||
|
|
4566f2d7a8 | ||
|
|
c362ec6100 | ||
|
|
83409bef7a | ||
|
|
5e2b1a5586 | ||
|
|
f1498feee5 | ||
|
|
5fe355483e | ||
|
|
44caa477e3 | ||
|
|
fff5e6f59e |
+96
@@ -0,0 +1,96 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# The frontend deployment artifact. The repository had none — `dist/server.mjs`
|
||||
# is a preview server that applies neither the security headers nor the cache
|
||||
# policy `config/hosting/` declares — so a deployment had nothing to run.
|
||||
#
|
||||
# Two stages: the build produces `dist/` and, from the serving contract, the
|
||||
# nginx configuration that matches it; the runtime is nginx with both.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pinned by digest: the release-provenance gate requires an immutable runner
|
||||
# identity, and a floating tag cannot give one.
|
||||
FROM node@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# The profile is baked at build time (scripts/generate-runtime-config.ts), so it
|
||||
# has to be chosen here rather than at `docker run`. `dist/config.json` stays a
|
||||
# separate file in the image, which is what makes build-once/promote possible:
|
||||
# a deployment can replace just that file without rebuilding the bundle.
|
||||
ARG APP_PROFILE=production
|
||||
ENV APP_PROFILE=${APP_PROFILE}
|
||||
|
||||
# The bundle and the nginx locations must agree on the prefix the deployment
|
||||
# serves this under: "/" at a domain root, "/dev/" behind a path prefix.
|
||||
ARG VITE_ROUTER_BASE_PATH=/
|
||||
ENV VITE_ROUTER_BASE_PATH=${VITE_ROUTER_BASE_PATH}
|
||||
|
||||
# `CI=true` turns on the release-provenance gate (scripts/lib/build-environment.ts),
|
||||
# which refuses to build without an identity for the artifact. That is the point:
|
||||
# a deployed bundle that cannot say which commit it came from is not traceable,
|
||||
# and the checklist asks exactly that. Supplied as build args so the caller —
|
||||
# a pipeline or the deploy script — owns the values.
|
||||
ENV CI=true
|
||||
ARG VITE_BUILD_ID
|
||||
ARG VITE_COMMIT_SHA
|
||||
ARG RELEASE_ID
|
||||
ARG CI_RUNNER_IMAGE
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ENV VITE_BUILD_ID=${VITE_BUILD_ID}
|
||||
ENV VITE_COMMIT_SHA=${VITE_COMMIT_SHA}
|
||||
ENV RELEASE_ID=${RELEASE_ID}
|
||||
ENV CI_RUNNER_IMAGE=${CI_RUNNER_IMAGE}
|
||||
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
|
||||
|
||||
# The two values a deployment is allowed to supply (scripts/generate-runtime-
|
||||
# config.ts OVERRIDES); everything else is fixed by the profile. API_BASE_URL
|
||||
# has to be absolute — the runtime canonicalises it with `new URL(value)` — so
|
||||
# even a same-origin deployment names its own origin here. The committed
|
||||
# production profile ships a placeholder (https://api.example.com/), which is
|
||||
# what a deployment that forgets this would silently serve.
|
||||
ARG RUNTIME_API_BASE_URL
|
||||
ARG RUNTIME_TELEMETRY_ENDPOINT
|
||||
ENV RUNTIME_API_BASE_URL=${RUNTIME_API_BASE_URL}
|
||||
ENV RUNTIME_TELEMETRY_ENDPOINT=${RUNTIME_TELEMETRY_ENDPOINT}
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
# Dependencies first so a source-only change does not re-resolve them.
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN corepack pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
RUN corepack pnpm build \
|
||||
&& node scripts/generate-nginx-config.ts
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 AS runtime
|
||||
|
||||
# Replaces the packaged default server block; the generated file is the whole
|
||||
# server definition, including the BFF proxy locations.
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /src/dist/nginx.conf /etc/nginx/conf.d/tech-log.conf
|
||||
COPY --from=build /src/dist/ /usr/share/nginx/html/
|
||||
|
||||
# The generated config is served from /usr/share/nginx/html as root, so the two
|
||||
# copies above would also publish nginx.conf itself. It is not secret, but it is
|
||||
# not a page either.
|
||||
RUN rm -f /usr/share/nginx/html/nginx.conf /usr/share/nginx/html/server.mjs \
|
||||
&& rm -rf /usr/share/nginx/html/.vite \
|
||||
# The build writes config.json 0600, which nginx (running as `nginx`) cannot
|
||||
# read — the container came up healthy and answered 403 for the one file the
|
||||
# SPA needs before it can boot. Normalise what is served to world-readable.
|
||||
&& chmod -R a+rX /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# No `nginx -t` here: proxy_pass names are resolved when the config loads, and
|
||||
# `backend`/`keycloak` only exist on the compose network. The container's own
|
||||
# startup is the check, and it fails loudly.
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1${VITE_ROUTER_BASE_PATH:-/}config.json || exit 1
|
||||
@@ -0,0 +1,18 @@
|
||||
# TECH_LOG_STUDIO_RELEASES accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: TECH_LOG_STUDIO_RELEASES
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: pending
|
||||
M5 Error association: pending
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
|
||||
@@ -0,0 +1,18 @@
|
||||
# TECH_LOG_STUDIO_TAXONOMY accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: TECH_LOG_STUDIO_TAXONOMY
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: pending
|
||||
M5 Error association: pending
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
|
||||
@@ -1196,6 +1196,18 @@
|
||||
"schemaId": "markdown",
|
||||
"production": "source-controlled"
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_TAXONOMY.md",
|
||||
"schemaId": "markdown",
|
||||
"production": "source-controlled"
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
|
||||
"schemaId": "markdown",
|
||||
"production": "source-controlled"
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
|
||||
@@ -1949,6 +1961,8 @@
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATIONS-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
|
||||
"artifact-artifacts-tests-a11y-manual-report-json"
|
||||
|
||||
@@ -280,6 +280,86 @@
|
||||
"compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.",
|
||||
"rollback": "Remove the required codec field and runtime codec dispatch together.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_HOME:access:field-changed",
|
||||
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||
"owner": "tech-log-frontend"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENTS:access:field-changed",
|
||||
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||
"owner": "tech-log-frontend"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_NEW:access:field-changed",
|
||||
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||
"owner": "tech-log-frontend"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_EDIT:access:field-changed",
|
||||
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||
"owner": "tech-log-frontend"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_VALIDATION:access:field-changed",
|
||||
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||
"owner": "tech-log-frontend"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_PREVIEW:access:field-changed",
|
||||
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||
"owner": "tech-log-frontend"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_PUBLISH:access:field-changed",
|
||||
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||
"owner": "tech-log-frontend"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_PUBLICATIONS:access:field-changed",
|
||||
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||
"owner": "tech-log-frontend"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_PUBLICATION_PREVIEW:access:field-changed",
|
||||
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||
"owner": "tech-log-frontend"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_NOT_FOUND:access:field-changed",
|
||||
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||
"owner": "tech-log-frontend"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"TECH_LOG_STUDIO_SOURCE": "MOCK",
|
||||
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
||||
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"TECH_LOG_STUDIO_SOURCE": "MOCK",
|
||||
"TECH_LOG_PUBLIC_SOURCE": "MOCK",
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
||||
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
||||
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Keycloak realm
|
||||
|
||||
`tech-log-realm.json` is imported by the `keycloak` service at start
|
||||
(`--import-realm`). It exists because the realm was previously created by hand,
|
||||
which meant §27 of the release checklist — "Keycloak Realm 설정을 복원할 수
|
||||
있다" — had no answer: nothing in either repository described the realm.
|
||||
|
||||
What it declares, and why each part is load-bearing:
|
||||
|
||||
- **`studio-author` realm role.** `StudioAuthzEnvironmentPostProcessor` maps this
|
||||
name to `studio:read` and `studio:write`. The name is configurable through
|
||||
`APP_STUDIO_AUTHOR_ROLE`; if you change it here, change it there too.
|
||||
- **`tech-log-bff` confidential client.** The Authorization Code flow belongs to
|
||||
the backend, not the browser — the SPA never holds a token. `redirectUris` is
|
||||
relative so the same realm works on any origin the deployment is served from.
|
||||
- **`realm-roles` protocol mapper.** Without it the roles never reach the token,
|
||||
the registry resolves zero permissions, and every Studio call answers 403.
|
||||
|
||||
## Values that must be replaced
|
||||
|
||||
`CHANGE_ME_BFF_SECRET` and `CHANGE_ME_STUDIO_PASSWORD` are placeholders, and the
|
||||
deploy script substitutes them from the environment before import. They are left
|
||||
visible rather than pre-filled so a realm file committed with a real secret is an
|
||||
obvious mistake rather than a quiet one.
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"realm": "tech-log",
|
||||
"enabled": true,
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": false,
|
||||
"loginTheme": "keycloak",
|
||||
"accessTokenLifespan": 300,
|
||||
"ssoSessionIdleTimeout": 1800,
|
||||
"ssoSessionMaxLifespan": 36000,
|
||||
"roles": {
|
||||
"realm": [
|
||||
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
|
||||
]
|
||||
},
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "tech-log-bff",
|
||||
"name": "Tech Log BFF",
|
||||
"description": "백엔드가 소유하는 Authorization Code 클라이언트. SPA 는 토큰을 직접 들지 않는다.",
|
||||
"enabled": true,
|
||||
"publicClient": false,
|
||||
"secret": "CHANGE_ME_BFF_SECRET",
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"redirectUris": ["/login/oauth2/code/*"],
|
||||
"webOrigins": ["+"],
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "realm-roles",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||
"config": {
|
||||
"claim.name": "realm_access.roles",
|
||||
"jsonType.label": "String",
|
||||
"multivalued": "true",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "studio",
|
||||
"enabled": true,
|
||||
"emailVerified": true,
|
||||
"email": "studio@tech-log.local",
|
||||
"firstName": "Studio",
|
||||
"lastName": "Author",
|
||||
"credentials": [{ "type": "password", "value": "CHANGE_ME_STUDIO_PASSWORD", "temporary": false }],
|
||||
"realmRoles": ["default-roles-tech-log", "studio-author"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
# The Tech Log dev stack: one origin, five services.
|
||||
#
|
||||
# nginx is the only published port. Everything the browser touches — the SPA,
|
||||
# /api, the OIDC redirect chain, and Keycloak under /auth — arrives on the same
|
||||
# origin, which is what lets the session be a plain first-party httpOnly cookie
|
||||
# instead of a cross-site one needing SameSite=None.
|
||||
#
|
||||
# browser ──> frontend(nginx) ──┬─> / SPA bundle
|
||||
# ├─> /api backend
|
||||
# ├─> /oauth2 /login /logout backend (BFF)
|
||||
# └─> /auth keycloak
|
||||
#
|
||||
# Secrets here are development values and are meant to be replaced by the
|
||||
# deployment; they are named in .env so nothing is baked into an image.
|
||||
|
||||
name: tech-log
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-tech_log}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-tech_log}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||
TZ: UTC
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tech_log} -d ${POSTGRES_DB:-tech_log}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
networks: [tech-log]
|
||||
|
||||
redis:
|
||||
# Holds the Studio session. Losing it signs everyone out; it holds nothing
|
||||
# else, so it is not backed by a volume on purpose.
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks: [tech-log]
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.7.0
|
||||
command: ["start-dev", "--import-realm", "--http-relative-path=/auth"]
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin}
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD}
|
||||
KC_HTTP_ENABLED: "true"
|
||||
# Behind nginx: Keycloak must build its URLs from the forwarded host, or
|
||||
# the redirect back from the login page points at the container.
|
||||
KC_HOSTNAME: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}/auth
|
||||
KC_HOSTNAME_STRICT: "false"
|
||||
KC_PROXY_HEADERS: xforwarded
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
volumes:
|
||||
- ${KEYCLOAK_IMPORT_DIR:-./deploy/keycloak}:/opt/keycloak/data/import:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /auth/health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
networks: [tech-log]
|
||||
|
||||
backend:
|
||||
image: ${BACKEND_IMAGE:-tech-log-backend:local}
|
||||
volumes:
|
||||
- tls-public:/tls-public:ro
|
||||
# The image entrypoint is `java -jar /app/app.jar`; this wraps it so the
|
||||
# frontend's certificate lands in the JVM truststore first. Without it the
|
||||
# OIDC metadata fetch fails PKIX validation and the process crash-loops.
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
until [ -f /tls-public/server.crt ]; do sleep 1; done
|
||||
# The image runs as a non-root user, so the JVM's own cacerts is not
|
||||
# writable — importing there silently did nothing and the metadata fetch
|
||||
# kept failing PKIX. Copy it somewhere writable, add the edge
|
||||
# certificate, and point the JVM at that.
|
||||
cp "/opt/java/openjdk/lib/security/cacerts" /tmp/truststore.jks
|
||||
keytool -importcert -noprompt -trustcacerts -alias tech-log-edge \
|
||||
-file /tls-public/server.crt \
|
||||
-keystore /tmp/truststore.jks -storepass changeit
|
||||
exec java \
|
||||
-Djavax.net.ssl.trustStore=/tmp/truststore.jks \
|
||||
-Djavax.net.ssl.trustStorePassword=changeit \
|
||||
-jar /app/app.jar
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: local
|
||||
# Persistence
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tech_log}
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-tech_log}
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver
|
||||
SPRING_FLYWAY_ENABLED: "true"
|
||||
SPRING_JPA_HIBERNATE_DDL_AUTO: none
|
||||
CA_SKELETON_PERSISTENCE_VENDOR: postgresql
|
||||
# BFF session
|
||||
CA_SKELETON_SECURITY_AUTH_MODE: redis-session
|
||||
CA_SKELETON_SECURITY_SESSION_COOKIE_NAME: TECHLOG_SESSION
|
||||
APP_REDIS_ENABLED: "true"
|
||||
APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED: "true"
|
||||
SPRING_DATA_REDIS_HOST: redis
|
||||
SPRING_DATA_REDIS_PORT: "6379"
|
||||
# OIDC. The issuer is the browser-facing URL because the tokens carry it
|
||||
# and the browser is redirected there; the container reaches the same
|
||||
# Keycloak through nginx on the compose network.
|
||||
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID: ${OIDC_CLIENT_ID:-tech-log-bff}
|
||||
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
|
||||
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_SCOPE: openid,profile,email
|
||||
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_AUTHORIZATION_GRANT_TYPE: authorization_code
|
||||
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_REDIRECT_URI: "${PUBLIC_ORIGIN}/login/oauth2/code/keycloak"
|
||||
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI: ${PUBLIC_ORIGIN}/auth/realms/${KEYCLOAK_REALM:-tech-log}
|
||||
APP_STUDIO_AUTHOR_ROLE: ${STUDIO_AUTHOR_ROLE:-studio-author}
|
||||
APP_STUDIO_POST_LOGIN_REDIRECT: "${PUBLIC_ORIGIN}/studio"
|
||||
# Behind a proxy: trust the forwarded headers nginx sets, so redirect URLs
|
||||
# and client IPs are the browser's, not the container's.
|
||||
APP_SERVER_FORWARD_HEADERS_STRATEGY: framework
|
||||
TZ: UTC
|
||||
# The issuer in a token is the browser-facing URL, and the backend has to
|
||||
# both validate that exact string and fetch the realm's metadata from it.
|
||||
# Inside the container that host does not resolve, so discovery failed and
|
||||
# the process crash-looped. Mapping the public host to the docker gateway
|
||||
# makes one URL work from both sides — the browser reaches nginx directly,
|
||||
# the backend reaches the same nginx through the published port.
|
||||
extra_hosts:
|
||||
- "${PUBLIC_HOST:?set PUBLIC_HOST}:host-gateway"
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
keycloak: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/healthcheck"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 45s
|
||||
restart: unless-stopped
|
||||
networks: [tech-log]
|
||||
|
||||
frontend:
|
||||
image: ${FRONTEND_IMAGE:-tech-log-frontend:local}
|
||||
# The backend fetches the realm metadata from the same HTTPS origin the
|
||||
# browser uses, so it has to trust this certificate. Publishing it to a
|
||||
# shared volume keeps one certificate for both sides; a deployment that
|
||||
# mounts a CA-issued certificate over /etc/nginx/tls needs neither this nor
|
||||
# the backend's import step.
|
||||
volumes:
|
||||
- tls-public:/tls-public
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- "cp /etc/nginx/tls/server.crt /tls-public/server.crt && exec nginx -g 'daemon off;'"
|
||||
|
||||
ports:
|
||||
- "${PUBLIC_HTTP_PORT:-8088}:80"
|
||||
- "${PUBLIC_PORT:-8443}:443"
|
||||
depends_on:
|
||||
backend: { condition: service_started }
|
||||
keycloak: { condition: service_started }
|
||||
restart: unless-stopped
|
||||
networks: [tech-log]
|
||||
|
||||
networks:
|
||||
tech-log:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
tls-public:
|
||||
@@ -0,0 +1,394 @@
|
||||
# Tech Log 운영 출시 전 체크리스트 — 실측 검증 보고서
|
||||
|
||||
**검증일** 2026-08-19 · **방식** 두 저장소를 로컬에서 실제 기동해 엔드포인트·브라우저 단위로 실측
|
||||
|
||||
| 대상 | 위치 | 리비전 |
|
||||
|---|---|---|
|
||||
| Frontend | `tech-log-frontend` | `main` eb86708 → `fix/release-gate-frontend` fff5e6f |
|
||||
| Backend | `tech-log-backend` | `develop` ab0447a |
|
||||
| Keycloak | 로컬 컨테이너 `local-keycloak` | 26.7.0 (`:18080`) |
|
||||
| PostgreSQL | 로컬 컨테이너 `techlog-pg` | 16.15 (`:5433`) |
|
||||
|
||||
---
|
||||
|
||||
## 요약 판정: **출시 보류 (P0 미충족)**
|
||||
|
||||
체크리스트 §31의 P0 항목 중 **인증 우회 불가 · 인가 우회 불가 · Studio 주요 기능 정상 · Publish 정상**이
|
||||
현재 충족되지 않는다. 아래 근거는 전부 실행 결과다.
|
||||
|
||||
### 가장 중요한 구조적 사실
|
||||
|
||||
백엔드는 계약(`studio-v1.yaml`)이 선언한 **18개 오퍼레이션 중 2개**만 구현되어 있다.
|
||||
|
||||
| 상태 | 오퍼레이션 |
|
||||
|---|---|
|
||||
| 구현됨 (2) | `getStudioSession`, `listStudioCatalog` |
|
||||
| 미구현 (16) | `getStudioDashboard`, `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument`, `validateStudioDocument`, `getCurrentStudioPreview`, `createStudioPreview`, `publishStudioDocument`, `listStudioPublications`, `unpublishStudioPublication`, `getStudioPublicationSnapshot`, `listStudioAssets`, `uploadStudioAsset`, `getStudioAsset`, `updateStudioAsset`, `deleteStudioAsset` |
|
||||
|
||||
또한 **Public 읽기 엔드포인트는 계약에 아예 없다.** `studio-v1.yaml`은 Studio 전용이고,
|
||||
프론트엔드의 Public 화면(`/`, `/explore`, `/projects`, `/releases`, 문서 상세)은
|
||||
`src/features/tech-log/adapters/static/public-content.ts`의 **번들에 컴파일된 정적 콘텐츠**를 읽는다.
|
||||
|
||||
따라서 체크리스트의 다음 절은 검증 대상 자체가 존재하지 않는다:
|
||||
§2(탐색·검색·프로젝트·변경기록의 백엔드 연동), §3.1~3.3(문서 작성·관계·Publish),
|
||||
§12(Public/Private 데이터 경계), §17(파일/Object Storage), §29(E2E 시나리오).
|
||||
|
||||
---
|
||||
|
||||
## P0 — 출시 차단 결함
|
||||
|
||||
### P0-1. Studio 라우트가 인증을 검사하지 않았다 — **수정 완료**
|
||||
|
||||
`TECH_LOG_ROUTE_REGISTRY`가 모든 TechLog 라우트를 `access: "public"`으로 등록하고 있었다.
|
||||
라우터에 `decideRouteAccessForDefinition` 가드가 존재하지만 Studio에 대해 무력화된 상태였다.
|
||||
|
||||
운영 프로파일 빌드(`AUTH_MODE=external`)로 실측한 수정 전:
|
||||
|
||||
```
|
||||
/studio http=200 h1="작업 흐름" ← 비로그인 상태에서 Studio UI 렌더링
|
||||
/studio/documents http=200 h1="작업본"
|
||||
/studio/assets http=200 h1="Asset"
|
||||
```
|
||||
|
||||
`spec.layoutGroup === "STUDIO"`에서 `access`를 유도하도록 수정한 뒤:
|
||||
|
||||
```
|
||||
/studio http=200 h1="로그인 연동이 필요합니다."
|
||||
/studio/documents http=200 h1="로그인 연동이 필요합니다."
|
||||
/ , /explore 변화 없음
|
||||
```
|
||||
|
||||
로그인 후 원래 요청 화면으로 복귀하는 것도 확인했다(`/studio/documents` → 로그인 → `작업본`).
|
||||
|
||||
커밋 `fff5e6f`.
|
||||
|
||||
### P0-2. 백엔드 Studio API에 인가 검사가 없다 — **미해결**
|
||||
|
||||
`SecurityConfig`는 `anyRequest().authenticated()`로 끝나고, Studio 컨트롤러에
|
||||
`@RequiresPermission` 계열 애노테이션이 **하나도 없다**.
|
||||
|
||||
Keycloak에 Studio 권한이 없는 사용자(`plain`, realm role `plain-user`)를 만들어 확인:
|
||||
|
||||
```
|
||||
GET /api/v1/studio/catalog?type=TOPIC
|
||||
studio 사용자 (studio-author) → HTTP 200
|
||||
plain 사용자 (권한 없음) → HTTP 200 ← 인가 우회
|
||||
```
|
||||
|
||||
체크리스트 §11 "인증된 사용자라고 해서 무조건 Studio API를 호출할 수 있지 않다",
|
||||
§31 P0 "인가 우회 불가" 미충족.
|
||||
|
||||
### P0-3. 모든 Studio 경로가 `/api/api/v1/...`에 매핑된다 (Double Prefix) — **미해결**
|
||||
|
||||
`PresentationWebConfig`가 `configurer.addPathPrefix("/api", c -> true)`로 전 컨트롤러에
|
||||
`/api`를 붙이는데, Studio 컨트롤러는 `@GetMapping("/api/v1/studio/...")`로 이미 `/api`를 포함해 선언한다.
|
||||
|
||||
```
|
||||
GET /api/v1/studio/catalog → 404 ROUTE_NOT_FOUND
|
||||
GET /api/api/v1/studio/catalog → 200
|
||||
GET /api/v1/studio/session → 404 ROUTE_NOT_FOUND
|
||||
GET /api/api/v1/studio/session → 503
|
||||
```
|
||||
|
||||
프론트엔드는 계약대로 `/api/v1/studio/...`를 호출하므로 **현재 상태로는 단 한 건도 연결되지 않는다.**
|
||||
체크리스트 §25 "`/api` Prefix 처리에서 Double Prefix가 발생하지 않는다" 미충족.
|
||||
|
||||
### P0-4. `getStudioSession`이 항상 503을 반환한다 — **미해결**
|
||||
|
||||
`auth-mode: jwt`(저장소 기본값, `src/.env:115`)에서 `SecurityConfig`가 `csrf.disable()`로
|
||||
`CsrfFilter`를 제거하므로 `CsrfToken` 파라미터가 항상 `null`이고, 컨트롤러는 이를
|
||||
`STUDIO_UNAVAILABLE`(503)로 정직하게 보고한다.
|
||||
|
||||
```
|
||||
GET /api/api/v1/studio/session (유효한 studio 토큰)
|
||||
→ 503 {"code":"STUDIO_UNAVAILABLE","category":"TRANSIENT_DEPENDENCY","retryable":true}
|
||||
로그: "CSRF token unavailable: CSRF protection is disabled for the active auth-mode"
|
||||
```
|
||||
|
||||
프론트엔드 HTTP 모드는 `getStudioSession`으로 CSRF 토큰을 받아 부트스트랩하므로,
|
||||
**이 한 건 때문에 Studio HTTP 경로 전체가 시작조차 못 한다.**
|
||||
`auth-mode: redis-session`에 필요한 세션 빈이 저장소에 없다는 점은 백엔드 HANDOFF.md도 명시하고 있다.
|
||||
|
||||
### P0-5. `main` 브랜치의 dev 부팅이 깨져 있었다 — **수정 완료**
|
||||
|
||||
`eb86708`(계약 3.0.0 머지) 이후 `public/release-manifest.json`이 2.0.0으로 남아
|
||||
부팅 시 contract-set 검증이 fail-closed → **빈 화면**. 이전에 한 번 겪은 것과 같은 실패 양식이다.
|
||||
|
||||
```
|
||||
setDigest drift: manifest sha256:e0da7765…, build sha256:261ac630…
|
||||
package drift: manifest 2.0.0 / ce2e748 vs build 3.0.0 / b20d7a2
|
||||
```
|
||||
|
||||
`generate:dev-release-manifest`로 재생성하고, 같은 값을 하드코딩하던
|
||||
`tests/runtime-schema/release-manifest.test.ts`도 함께 갱신했다. 커밋 `fff5e6f`.
|
||||
|
||||
### P0-6. 커밋된 `.env`로는 prod 프로파일이 부팅하지 않는다 — **미해결**
|
||||
|
||||
`src/.env:140`이 `APP_DATASOURCE_DDL_AUTO=update`인데, `application-prod.yml`이 문서화한
|
||||
`JpaSchemaSafetyValidator`는 prod에서 `none|validate`만 허용하고 위반 시 exit 71로 종료한다.
|
||||
|
||||
### P0-7. `ddl-auto=validate`로는 PostgreSQL에서 부팅하지 않는다 — **미해결**
|
||||
|
||||
```
|
||||
SchemaManagementException: Schema-validation: missing table [fs_cleanup_item]
|
||||
```
|
||||
|
||||
`PostgreSqlPersistenceConfig`가 Flyway 위치를 `classpath:db/migration/postgresql`로 고정해
|
||||
`db/migration/jpa/fileserver` 트리가 **한 번도 적용되지 않는데**, 해당 JPA 엔티티는 스캔된다.
|
||||
`ddl-auto=update`가 이 사실을 가려 온 것이고, prod가 요구하는 `validate`로 바꾸는 순간 드러난다.
|
||||
(본 검증은 `ddl-auto=none`으로 우회해 진행했다.)
|
||||
|
||||
---
|
||||
|
||||
## P1 — 출시 전 해결 권장
|
||||
|
||||
| # | 항목 | 실측 근거 |
|
||||
|---|---|---|
|
||||
| P1-1 | Keycloak realm 구성이 두 저장소 어디에도 없다 | compose에 keycloak 서비스 없음, realm export 파일 없음. 검증을 위해 `ca-skeleton` realm·클라이언트·audience 매퍼·테스트 사용자를 수기로 생성해야 했다. §27 "Keycloak Realm 설정을 복원할 수 있다" 미충족 |
|
||||
| P1-2 | 프론트엔드에 로그인 구현이 없다 | OIDC/Keycloak 클라이언트 코드 0건. `AUTH_MODE=external`은 호스팅 페이지가 `window.__CA_FRONTEND_AUTH_OWNER__`를 주입하기를 기대하며, 없으면 `createUnavailableSessionAdapter`가 "로그인 연동이 필요합니다"를 띄운다. §1.4 인증 항목 전부 검증 불가 |
|
||||
| P1-3 | production 런타임 설정이 플레이스홀더 | `API_BASE_URL: https://api.example.com/`, `TELEMETRY_ENDPOINT: https://telemetry.example.com/v1/events` |
|
||||
| P1-4 | Rate Limit 비활성 | `APP_RATE_LIMIT_ENABLED=false`, `APP_RATE_LIMIT_PROVIDER=disabled`. 60회 연속 호출 전부 200 |
|
||||
| P1-5 | 보안 헤더를 적용하는 주체가 없다 | `config/hosting/security-headers.json`에 CSP·HSTS·X-Frame-Options 등이 정의돼 있으나 `dist/server.mjs`는 **하나도 적용하지 않는다**. `verify:hosting-headers`는 기본적으로 fixture 모드로 동작해 실 서버를 검사하지 않는다 |
|
||||
| P1-6 | 캐시 정책도 미적용 | `cache-policy.json`은 `/assets/*`에 `public, max-age=31536000, immutable`을 요구하나 실제 응답은 전부 `no-cache` |
|
||||
| P1-7 | 프론트엔드 배포 아티팩트 부재 | Dockerfile·nginx conf·compose 없음. `dist/server.mjs`는 프리뷰용이지 운영 파일 서버가 아니다 |
|
||||
| P1-8 | robots.txt / sitemap.xml 없음 | **Studio 경로가 검색 엔진에 차단되지 않는다.** §7 미충족 |
|
||||
| P1-9 | Open Graph·canonical 메타데이터 없음 | `dist/index.html`에 `og:*`·canonical 없음. `<title>`은 라우트별로 정상 동작하나 **런타임에 설정**되므로 JS를 실행하지 않는 공유 미리보기 크롤러에는 "Tech Log" 고정값만 노출된다 |
|
||||
| P1-10 | DB 타임아웃 30초 | `APP_DATASOURCE_CONNECTION_TIMEOUT=30000`. `application.yml`이 문서화한 D2 fail-fast 의도(기본 5s)와 어긋난다. 프론트엔드 `REQUEST_TIMEOUT_MS=10000`이므로 DB 장애 시 프론트가 항상 먼저 끊겨 `DB_UNAVAILABLE` 503을 보지 못한다 |
|
||||
| P1-11 | Tech Log Asset의 Object Storage 배선 없음 | objectstorage 어댑터는 템플릿 자산으로 존재하나 techlog 참조 0건, MinIO/S3 환경변수 0건, `uploadStudioAsset` 엔드포인트 미구현 |
|
||||
|
||||
---
|
||||
|
||||
## 검증되어 통과한 항목
|
||||
|
||||
### Frontend
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| Production Build | PASS (local·production 프로파일 모두) |
|
||||
| TypeScript compile | PASS (`check:types` 6개 프로젝트) |
|
||||
| ESLint | PASS (수정 후 0 error) |
|
||||
| 전체 테스트 | 1,818 passed / 16 skipped / **1 기존 flake** (`provider-guardian-transaction` — 단독 실행 2회 모두 PASS, 부하 의존) |
|
||||
| architecture / contract / dev-release-manifest / browser-security 게이트 | PASS |
|
||||
| Production 번들에 dev·localhost URL 없음 | PASS (`localhost`·`127.0.0.1` 0건, `.local` 매치는 전부 `locale`/`localeCompare`) |
|
||||
| Production 번들에 Mock API 미포함 | PASS (`createMockStudioGateway` 0건) |
|
||||
| Source Map 비공개 | PASS (`.map` 0개) |
|
||||
| Route 단위 Lazy Loading | PASS (30 청크, 총 954 KB / 최대 569 KB) |
|
||||
| SPA 라우팅·새로고침 | PASS (열거형 allowlist 방식. 존재하지 않는 문서 경로는 의도적으로 404) |
|
||||
| Route별 `<title>` | PASS (`탐색 · Tech Log`, `프로젝트 · Tech Log` …) |
|
||||
| 반응형 | PASS — 360/414/768/1440 × 6개 Public 라우트 **24개 조합 전부 가로 스크롤 없음** |
|
||||
| 접근성 | PASS — axe(wcag2a/2aa/21a/21aa) **serious+critical 0건** (Public 6 + Studio 4 라우트). h1 정확히 1개, heading 건너뜀 없음, alt 누락 0, 레이블 없는 icon button 0 |
|
||||
| 로그인 흐름 | PASS (게이트 → 로그인 → 원래 화면 복귀) |
|
||||
|
||||
### Backend
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| Production Profile Build | PASS — `:app-bootstrap:bootJar` 성공 |
|
||||
| 전체 테스트 | PASS — **3,530 tests / 0 failures / 7 skipped** (BUILD SUCCESSFUL 8m 9s). app-bootstrap 797 · application-core 568 · cache-redis 423 · fileserver 398 · inbound-web 341 · httpclient 283 · shared-contract 224 · objectstorage 140 · persistence-jpa 122 · 그 외 |
|
||||
| Docker Image Build | PASS — 623MB. `BUILD_VERSION`/`GIT_SHA`/`SOURCE_URL` build-arg를 강제하는 provenance 게이트가 있어 인자 없이는 의도적으로 실패한다 |
|
||||
| Production Image 실제 실행 | PASS — 컨테이너에서 14.5초 기동, `healthcheck` 200 · `readiness` 200 · `catalog` 200(실데이터 2건) |
|
||||
| Flyway 마이그레이션 (신규 DB, 처음부터) | PASS — 6개 적용, V7 techlog core 포함, 테이블 33개 생성 |
|
||||
| 응답 봉투 일관성 | PASS — `{success,data,error,meta}` 전 경로 동일 |
|
||||
| HTTP 상태 코드 | PASS — 401 / 404 / 405 / 422 / 500 / 503 모두 적절 |
|
||||
| 인증 오류 코드 분리 | PASS — `AUTH_TOKEN_MISSING` / `AUTH_TOKEN_MALFORMED` / `AUTH_TOKEN_INVALID_SIGNATURE` / `AUTH_TOKEN_EXPIRED` |
|
||||
| Validation | PASS — 잘못된 enum·필수 누락은 422 + `fieldErrors`, `limit` 상·하한 강제 |
|
||||
| SQL Injection | PASS — `' OR 1=1--` 파라미터 바인딩되어 빈 결과 |
|
||||
| Visibility 필터 | PASS — `ARCHIVED` 토픽이 catalog 결과에서 제외됨 |
|
||||
| CORS | PASS — 허용 origin 200 + `Allow-Credentials: true`, 미허용 origin 403, 와일드카드 없음 |
|
||||
| 보안 헤더 | PASS — `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Cache-Control: no-store` |
|
||||
| 오류 정보 노출 | PASS — Stack trace·SQL·내부 클래스명 모두 미노출 (`details: null`) |
|
||||
| 로그 위생 | PASS — 토큰·Authorization·쿠키·비밀번호 **0건**. `user=`는 가명화 해시 |
|
||||
| 추적성 | PASS — 모든 요청 로그에 `req=`·`trace=`, `http_request method= uri_template= status= duration_ms=` |
|
||||
| Metrics | PASS — Prometheus 127개 메트릭 패밀리 (`http_server_requests_seconds_bucket`, `jvm_gc_*`, `jvm_memory_*`, `hikaricp_connections_*`) |
|
||||
| Liveness / Readiness 분리 | PASS — DB 중단 시 readiness 503 DOWN, liveness 200 UP 유지 |
|
||||
| 의존성 장애 대응 | PASS(동작) — DB 중단 시 무한 대기 없이 **503 `DB_UNAVAILABLE` (retryable)** 반환, DB 복구 후 27ms/정상 데이터로 자동 회복. 단 응답까지 30초 소요(P1-10) |
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계 (권장 순서)
|
||||
|
||||
1. **Double Prefix 해소** (P0-3) — 컨트롤러 매핑에서 `/api`를 제거하거나 `addPathPrefix` 대상에서 제외. 이걸 고치기 전에는 프론트-백엔드가 한 건도 연결되지 않으므로 최우선.
|
||||
2. **세션 인프라** (P0-4) — `redis-session` 배선. 백엔드 HANDOFF.md도 Plan 02보다 앞선 선행 작업으로 지목하고 있다.
|
||||
3. **Studio 인가** (P0-2) — Studio 컨트롤러에 권한 검사 추가 + 권한 없는 사용자 403 회귀 테스트.
|
||||
4. **prod 부팅 설정** (P0-6, P0-7) — `.env`의 `ddl-auto`, fileserver 마이그레이션 위치.
|
||||
5. **나머지 16개 오퍼레이션** — 백엔드 HANDOFF.md가 지적한 생성 union 5종의 Jackson 파손 전략 결정이 선행.
|
||||
6. 배포 레이어 (P1-5·6·7) — nginx/CDN에 보안 헤더·캐시 정책 적용, 프론트엔드 이미지.
|
||||
7. robots.txt로 Studio 차단 (P1-8).
|
||||
|
||||
---
|
||||
|
||||
# 2차 검증 — 로컬에서 가능한 항목 완주 (2026-08-19)
|
||||
|
||||
1차에서 "물리적으로 불가능"이라 분류했던 항목 중 상당수가 실제로는 검증 가능했다.
|
||||
Studio는 mock 게이트웨이가 18개 오퍼레이션을 **전부** 구현하고 있고(낙관적 락·검증
|
||||
staleness·미리보기 만료·경고 승인·멱등성 포함), Public은 정적 콘텐츠지만 UI 동작
|
||||
항목은 그대로 검증된다. 아래는 그 재검증 결과다.
|
||||
|
||||
## 1차 판정 정정
|
||||
|
||||
### 정정 1 — P0-6 은 결함이 아니다
|
||||
|
||||
`prod` 프로파일이 커밋된 `src/.env`로 부팅하지 않는 것은 **의도된 설계**다.
|
||||
`application-prod.yml`이 문서화한 5개 startup validator가 개발용 값을 거부한다:
|
||||
|
||||
```
|
||||
JpaSchemaSafetyValidator ddl-auto must be none|validate (exit 71)
|
||||
FlywayProdSafetyValidator baseline-on-migrate / out-of-order / clean 비활성
|
||||
StartupSafetyValidator error-detail 노출 · body-capture 로깅 off
|
||||
PostgreSqlTransportSecurityValidator pgJDBC sslmode=verify-full
|
||||
PersistenceVendorProdSafetyValidator vendor·URL 모두 H2 금지
|
||||
```
|
||||
|
||||
실측으로 2개가 순서대로 발화하는 것을 확인했다:
|
||||
|
||||
```
|
||||
exit=71 error.code=PROFILE_MISMATCH
|
||||
"prod profile requires APP_DATASOURCE_DDL_AUTO ... to be none or validate,
|
||||
but was update; Flyway is the production schema writer"
|
||||
|
||||
ddl-auto=none 으로 넘긴 뒤:
|
||||
"prod PostgreSQL transport requires pgJDBC sslmode=verify-full"
|
||||
```
|
||||
|
||||
즉 **§9 "운영 환경에서 개발용 설정이 활성화되지 않는다"는 PASS**다.
|
||||
남는 진짜 갭은 별개다 — **운영 값 세트가 저장소에도 배포 시스템에도 아직 없다**(P1로 이동).
|
||||
|
||||
### 정정 2 — P0-7 의 심각도 하향
|
||||
|
||||
`ddl-auto=validate`가 `fs_cleanup_item` 누락으로 실패하는 것은 사실이나,
|
||||
prod는 `none|validate` **둘 다** 허용하므로 `none`으로 부팅할 수 있다(실제로 그렇게 기동해 검증했다).
|
||||
따라서 출시 차단은 아니고, **스키마 검증을 포기해야 한다는 제약**으로 남는다 → P1.
|
||||
|
||||
### 정정 3 — 1차의 오탐 2건
|
||||
|
||||
- **"Public UI에 Studio 노출"** — 오탐. 매칭된 "Studio"는 전부 게시된 릴리스 노트의 본문
|
||||
텍스트였다("TechLog Public·Studio 경계를 확정했습니다"). 실제 `a[href^="/studio"]`는
|
||||
모든 Public 화면에서 **0건**. §1.3 PASS.
|
||||
- **"코드 블록 미표시"** — 오탐. `code-block.tsx`가 `<figure class="code-block">` +
|
||||
`<pre role="region" tabindex=0>`을 렌더하고 CSS가 `overflow-x:auto`·`max-width:100%`를
|
||||
준다. 정적 공개 문서에 CODE_BLOCK이 0건이라 발견하지 못한 것이며, Studio 편집기에
|
||||
직접 넣어 확인하니 정상 렌더되고 페이지 가로 오버플로도 없었다. §4 PASS.
|
||||
|
||||
## 새로 발견한 결함
|
||||
|
||||
| # | 항목 | 근거 |
|
||||
|---|---|---|
|
||||
| **N-1** | **로그아웃할 방법이 없다** | `signOut` 포트와 `app-shell.tsx`의 세션 버튼(`action.signOut`="로그아웃")은 존재하지만, **TechLog는 자체 셸(`public-shell.tsx` + Studio 셸)을 쓰고 `AppShell`을 렌더하지 않는다.** 로그인 후 Public·Studio 어느 화면에서도 로그아웃 버튼이 없다. §1.4 "로그아웃", "로그아웃 후 보호된 데이터가 UI 상태에 남지 않는다" 미충족 |
|
||||
| **N-2** | **중복 관계 생성이 방지되지 않는다** | 같은 대상을 두 번 연결해 저장해도 경고가 없다. 계약에 `uniqueItems` 제약이 없고(`relations: maxItems 20`뿐), `validate-working-copy.ts`도 slug 중복만 검사한다(`SLUG_DUPLICATE`). **백엔드를 구현해도 계약이 허용하므로 같은 결과가 난다.** §3.2 미충족 |
|
||||
| **N-3** | **CLS 0.192 (기준 0.1)** | 원인 단일: `FOOTER.site-footer`가 t=538ms에 0.1922 이동. 나머지 shift는 0.0001. 세 라우트 모두 동일 값 → 앱 셸 마운트 시점의 footer 점프. §4 "주요 화면의 Layout Shift가 없다" 미충족 |
|
||||
| **N-4** | **`navigation_path`(slug 조회)에 인덱스가 없다** | 20,000행 기준 `Seq Scan`, `Rows Removed by Filter: 19999`, **236ms**. `enable_seqscan=off`로도 인덱스를 못 쓴다 → 존재하지 않는다. 체크리스트가 명시한 "Slug 조회" 쿼리 패턴 |
|
||||
| **N-5** | 검색 trgm 인덱스가 플래너에 선택되지 않음 | GIN trgm 인덱스는 존재하고 강제하면 3.96ms로 동작하나, 20k 규모에서 플래너가 Seq Scan(10.3ms)을 고른다. 운영 규모에서 재확인 필요 |
|
||||
| **N-6** | `/api/v3/api-docs`가 500 | `/swagger-ui`·`/v3/api-docs`는 404로 미배포(정상)인데, path prefix가 붙은 `/api/v3/api-docs`만 500 INTERNAL_ERROR |
|
||||
|
||||
## 검증 결과 — 절별
|
||||
|
||||
### §1.2 Routing · §1.3 경계 · §2 기능 — 27/27 PASS
|
||||
|
||||
```
|
||||
§1.2 존재하지 않는 Case / 잘못된 explore kind / 없는 프로젝트 / 없는 릴리스
|
||||
→ 전부 "페이지를 찾을 수 없습니다."
|
||||
§1.2 Not Found 화면, Back/Forward (/explore→/projects→back→forward) 정상
|
||||
§1.3 Public 6개 화면에 studio 링크 0건, Draft 표식 0건
|
||||
§2.1 탐색 목록 6건 · 중복 0 · 필터 적용 6→2건
|
||||
§2.2 검색창 열림 / Focus 이동 / Overlay 겹침 없음 / 입력 중 과요청 0
|
||||
결과 없음 UI / ESC 닫기 / 빈 검색어 정책 / 결과 클릭 → 상세 이동
|
||||
§2.3 프로젝트 목록 2건 · 상세("Backend Skeleton") · 포함 문서 6건
|
||||
§2.4 변경 기록 목록·상세, 연결 문서 7건, 시간순 정렬 일관
|
||||
```
|
||||
|
||||
### §3 Studio — 20/22 PASS (mock 기준)
|
||||
|
||||
```
|
||||
§3.1 새 문서(유형 4종) → 편집 진입 → 저장 → 상태 전달 PASS
|
||||
§3.1 저장 버튼 3연타 → 문서 수 8→9 (증가 1) PASS ← 멱등성 실동작
|
||||
§3.1 미저장 변경 이동 경고 [머무르기/변경 버리기/저장 후 이동] PASS
|
||||
§3.1 머무르기 후 입력값 보존 PASS
|
||||
§3.1 검증 화면("저장본 검증") / 게시 화면("게시 준비") PASS
|
||||
§3.2 관계 추가·순서 이동·삭제, 대상 카탈로그 4건 PASS
|
||||
§3.2 중복 관계 방지 FAIL (N-2)
|
||||
§3.3 즉시 미리보기 렌더 / Public Preview 화면 PASS
|
||||
§3.3 게시 기록 8건 · 게시 취소 버튼 3개 PASS
|
||||
§20 저장 충돌(409) 사용자 안내 PASS
|
||||
콘솔 오류 0건
|
||||
```
|
||||
|
||||
문서 **삭제**는 계약에 오퍼레이션 자체가 없다(`deleteStudioAsset`만 존재). §3.1의 "삭제"는 설계 범위 밖.
|
||||
|
||||
### §4 UX/UI · §5 접근성 · §6 성능 — 15/18 PASS
|
||||
|
||||
```
|
||||
§4 Layout Shift FAIL CLS=0.1924 (N-3)
|
||||
§4 Header가 콘텐츠를 가리지 않음 PASS
|
||||
§4 긴 제목(150자)/긴 본문/긴 URL PASS scrollWidth==clientWidth 1440
|
||||
§4 코드 블록 (pre overflow-x:auto) PASS
|
||||
§5 Modal Focus 이동 / role=dialog / Focus Trap / 닫은 뒤 복귀 PASS
|
||||
§5 키보드 순회 19개 요소 · Focus 표시 전부 존재 PASS
|
||||
§6 긴 문서 렌더링 296ms PASS
|
||||
§6 이미지 lazy loading · width/height 명시 PASS
|
||||
§6 동일 요청 중복 0 · 2초간 DOM 변경 0건(render loop 없음) PASS
|
||||
§6 검색 21자 입력+반영 847ms PASS
|
||||
```
|
||||
|
||||
### §14 데이터베이스
|
||||
|
||||
```
|
||||
Constraint PK 33 · FK 36 · UNIQUE 18 · CHECK 89 · NOT NULL 267 · PK 없는 테이블 0 PASS
|
||||
Index 실행계획 (20,000행 기준)
|
||||
Public 목록(최신순) Index Scan idx_public_latest 0.113ms PASS
|
||||
유형별 조회 Index Scan idx_public_type 0.129ms PASS
|
||||
Topic별 조회 Bitmap Index Scan idx_public_topic 0.229ms PASS
|
||||
검색(trgm) Seq Scan (인덱스 미선택) 10.3ms 주의 (N-5)
|
||||
slug 조회 Seq Scan (인덱스 부재) 236ms FAIL (N-4)
|
||||
```
|
||||
|
||||
`public_resource_projection`의 인덱스들이 `WHERE publication_state='ACTIVE' AND
|
||||
visibility='PUBLIC'` 부분 인덱스로 정의되어 있다 — Public/Private 경계를 인덱스 수준에서
|
||||
강제하는 좋은 설계다(§12를 구현할 때 그대로 활용 가능).
|
||||
|
||||
### §19 악용 방지 · §28 Swagger
|
||||
|
||||
```
|
||||
pagination 최대 크기 (limit=1000) 422 REQUEST_VALIDATION_FAILED PASS
|
||||
q 길이 제한 (500자) 422 REQUEST_VALIDATION_FAILED PASS
|
||||
Rate Limit APP_RATE_LIMIT_ENABLED=false 미적용
|
||||
대용량 Body 쓰기 엔드포인트 부재로 검증 불가
|
||||
/swagger-ui, /v3/api-docs 404 (미배포) PASS
|
||||
/api/v3/api-docs 500 주의 (N-6)
|
||||
```
|
||||
|
||||
### §26 의존성 장애
|
||||
|
||||
```
|
||||
PostgreSQL Down catalog 503 DB_UNAVAILABLE(retryable) 30s · readiness 503 DOWN
|
||||
liveness 200 UP 유지 · 복구 후 27ms 정상 PASS
|
||||
Keycloak Down JWKS 캐시로 기존 토큰 32ms/200 · 잘못된 서명 21ms/401
|
||||
readiness 200 UP 유지(외부 IdP를 readiness에 걸지 않음)
|
||||
복구 후 정상 PASS
|
||||
Backend 단절 Public 화면 정상 유지(정적 소스) PASS
|
||||
MinIO / Redis 해당 없음(미배선)
|
||||
```
|
||||
|
||||
### §0 · §9 설정
|
||||
|
||||
```
|
||||
src/.env 가 git에 커밋되어 있다 — 값은 local 프로파일용이지만 .gitignore에 .env가 없어
|
||||
구조적으로 막혀 있지 않다. Redis HMAC은 secret://environment/... 간접 참조를 쓴다(좋은 패턴).
|
||||
prod 5개 validator 실동작 확인 (정정 1)
|
||||
show-sql=false · 로그에 토큰/쿠키/비밀번호 0건 · user= 는 가명화 해시
|
||||
```
|
||||
|
||||
## 남은 것 — 로컬에서 불가능
|
||||
|
||||
| 절 | 이유 |
|
||||
|---|---|
|
||||
| §12 Public/Private 경계 | Public 엔드포인트·문서 엔드포인트 부재 |
|
||||
| §15 N+1 / JPA Query | Tech Log에 JPA 리포지토리 0건 (catalog는 raw JDBC 단일 쿼리) |
|
||||
| §16 Transaction | 쓰기 유스케이스 부재 |
|
||||
| §17 파일/Object Storage | 업로드 엔드포인트·스토리지 배선 부재 |
|
||||
| §18 HTTPS/HSTS/Redirect | TLS 종단 필요 |
|
||||
| §22 Grafana·Loki 대시보드 | 관측 스택 필요 (수집 측 127개 메트릭은 확인 완료) |
|
||||
| §24 Kubernetes | 매니페스트·오케스트레이터 부재 |
|
||||
| §25 Ingress 라우팅 · X-Forwarded-* | 리버스 프록시 필요 |
|
||||
| §27 Backup / Restore | 실제 볼륨·운영 DB 필요 |
|
||||
| §30 Production Smoke Test | 운영 환경 부재 |
|
||||
| §1.4 세션 만료 · 토큰 만료 후 프론트 동작 | demo 어댑터에 만료 개념이 없음 (외부 IdP 연동 필요) |
|
||||
@@ -5,6 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Tech Log frontend" />
|
||||
<title>Tech Log</title>
|
||||
<!-- public/favicon.svg 는 빌드가 dist 루트로 복사하고 nginx 도 서빙하지만,
|
||||
참조가 없어 브라우저는 /favicon.ico 를 찾다가 404 를 받고 기본 아이콘을
|
||||
띄우고 있었다. %BASE_URL% 은 Vite 가 base 로 치환한다 — 경로 프리픽스
|
||||
배포(/dev/)에서도 같은 파일을 가리키게 하려면 절대경로여선 안 된다. -->
|
||||
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -38,14 +38,28 @@
|
||||
},
|
||||
"contractSet": {
|
||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||
"setDigest": "sha256:e0da77655f51592ece583826d5fc6b092f57dd2bf63307e45e7e77283e6bf437",
|
||||
"setDigest": "sha256:38329cfe645e1d6cbfc7a9bb3e20e23b579a286ad0d1254982c70de5c6055ae8",
|
||||
"packages": [
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "2.0.0",
|
||||
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea",
|
||||
"packageId": "@tech-log/management-contract",
|
||||
"version": "1.0.0",
|
||||
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "ce2e748"
|
||||
"sourceRevision": "65a04fc"
|
||||
},
|
||||
{
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.0.0",
|
||||
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "65a04fc"
|
||||
},
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "3.0.0",
|
||||
"digest": "sha256:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "65a04fc"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -459,7 +459,16 @@ const CANONICAL_GATE_SHAPE_SHA256 =
|
||||
// Dev release manifest drift fix, item 2: recomputed again after FE-GATE-010
|
||||
// gained `check-dev-release-manifest`. Same method — 98d19911… was first
|
||||
// reproduced from the previous gates.json before this value was hashed.
|
||||
"b40962448e617883060e09eb7183837cfea6833519f435f11713efd083210dbe";
|
||||
// Taxonomy route: recomputed again after FE-GATE-009 gained the
|
||||
// TECH_LOG_STUDIO_TAXONOMY manual accessibility evidence artifact. The gate
|
||||
// lists one evidence artifact per installed route and refuses a set that does
|
||||
// not match the route scope exactly, so adding a route necessarily moves this
|
||||
// digest — that is the point of pinning it.
|
||||
// Release authoring: recomputed again after FE-GATE-009 gained the
|
||||
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
|
||||
// was first reproduced from the previous gates.json, so the computation that
|
||||
// produced this value is known to be the one the constant was pinned under.
|
||||
"187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865";
|
||||
|
||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||
const normalized = gates.map(
|
||||
@@ -512,8 +521,10 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
||||
// Template merge. 126 product artifacts plus the two the template added.
|
||||
// Task 11 added one more: the TECH_LOG_STUDIO_ASSETS manual a11y evidence file.
|
||||
// Alignment follow-up, item 2 added the TechLog junit report.
|
||||
if (contract.artifacts.length !== 130) {
|
||||
failures.push(`artifact authority baseline must contain exactly 130 artifacts; received ${contract.artifacts.length}`);
|
||||
// The taxonomy route added its own manual a11y evidence file — every installed
|
||||
// route carries one, and the gate checks that the two sets match exactly.
|
||||
if (contract.artifacts.length !== 132) {
|
||||
failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`);
|
||||
}
|
||||
if (contract.stages.length !== 5) {
|
||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Emits the nginx configuration the deployed frontend is served with.
|
||||
*
|
||||
* Generated rather than hand-written because three files already decide what it
|
||||
* must say, and a copy of them would drift: `dist/tech-log-serving-contract.json`
|
||||
* (which paths are SPA routes and what a miss answers with),
|
||||
* `config/hosting/security-headers.json`, and `config/hosting/cache-policy.json`.
|
||||
* The repository had no frontend deployment artifact at all — no Dockerfile, no
|
||||
* server config — so those two hosting files described a contract nothing
|
||||
* fulfilled: `dist/server.mjs` applies neither, answering `no-cache` for hashed
|
||||
* assets and sending no security headers.
|
||||
*
|
||||
* This serves static files only. TLS and the BFF paths belong to the edge: the
|
||||
* deployment's own nginx terminates HTTPS and sends `/api`, the OIDC redirect
|
||||
* chain and the identity provider to the backend directly (in Kubernetes,
|
||||
* Traefik does). A second proxy hop here would only add a place for the two
|
||||
* routing tables to disagree.
|
||||
*
|
||||
* The base path comes from `VITE_ROUTER_BASE_PATH`, the same value the bundle is
|
||||
* built with: served under a prefix, every route and asset lives under it too.
|
||||
*/
|
||||
|
||||
const DIST = "dist";
|
||||
const OUT = path.join(DIST, "nginx.conf");
|
||||
|
||||
type ServingContract = Readonly<{
|
||||
publicSpaPathPatterns: readonly string[];
|
||||
studioPathPrefix: string;
|
||||
studioSpaPathPatterns: readonly string[];
|
||||
notFound: Readonly<{ status: number; contentType: string; body: string }>;
|
||||
}>;
|
||||
|
||||
type HostingHeaders = Readonly<{ headers: Readonly<Record<string, string>> }>;
|
||||
|
||||
type CachePolicy = Readonly<{
|
||||
surfaces: Readonly<
|
||||
Record<
|
||||
string,
|
||||
Readonly<{
|
||||
path?: string;
|
||||
pathPattern?: string;
|
||||
cacheControl?: string;
|
||||
securityHeaders?: boolean;
|
||||
}>
|
||||
>
|
||||
>;
|
||||
}>;
|
||||
|
||||
async function readJson<T>(file: string): Promise<T> {
|
||||
return JSON.parse(await readFile(file, "utf8")) as T;
|
||||
}
|
||||
|
||||
/** nginx location matching is not regex-escaped for us; only `=` exact paths are literal. */
|
||||
function exactLocation(pathname: string): string {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* A JS regex from the contract translated for nginx. Both use PCRE-ish syntax
|
||||
* for what the contract uses (`^`, `$`, `[^/]+`, alternation), so the pattern
|
||||
* carries over unchanged — asserted rather than assumed, because a pattern that
|
||||
* silently failed to translate would open a Studio route to the 404 branch.
|
||||
*/
|
||||
function studioRegex(pattern: string): string {
|
||||
// nginx uses PCRE, so anchors, character classes, alternation and plain groups
|
||||
// carry over as written. Lookaround and backreferences do not translate the
|
||||
// same way and would silently change which paths match, so they are refused.
|
||||
if (/\(\?[=!<]|\\[1-9]/.test(pattern)) {
|
||||
throw new Error(
|
||||
`studio SPA pattern uses a construct this generator does not translate: ${pattern}`,
|
||||
);
|
||||
}
|
||||
return pattern;
|
||||
}
|
||||
|
||||
function headerDirectives(
|
||||
headers: Readonly<Record<string, string>>,
|
||||
indent: string,
|
||||
): string {
|
||||
return Object.entries(headers)
|
||||
.map(([name, value]) => `${indent}add_header ${name} "${value}" always;`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const contract = await readJson<ServingContract>(
|
||||
path.join(DIST, "tech-log-serving-contract.json"),
|
||||
);
|
||||
const security = await readJson<HostingHeaders>(
|
||||
"config/hosting/security-headers.json",
|
||||
);
|
||||
const cache = await readJson<CachePolicy>("config/hosting/cache-policy.json");
|
||||
|
||||
const surfaces = cache.surfaces;
|
||||
const indexCache = surfaces["index"]?.cacheControl ?? "no-cache";
|
||||
const configCache = surfaces["runtimeConfig"]?.cacheControl ?? "no-store";
|
||||
const manifestCache = surfaces["releaseManifest"]?.cacheControl ?? "no-store";
|
||||
const assetCache = surfaces["hashedAsset"]?.cacheControl ?? "no-cache";
|
||||
|
||||
const secure = headerDirectives(security.headers, " ");
|
||||
|
||||
// The bundle's own base path. `/` for a deployment at the domain root, `/dev/`
|
||||
// for one served under a prefix — the routes below have to carry it or nginx
|
||||
// matches paths the browser never asks for.
|
||||
const rawBase = process.env["VITE_ROUTER_BASE_PATH"] ?? "/";
|
||||
const basePath = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
|
||||
|
||||
const [notFoundType, notFoundCharsetParam] = contract.notFound.contentType
|
||||
.split(";")
|
||||
.map((part) => part.trim());
|
||||
const notFoundCharset = (notFoundCharsetParam ?? "charset=utf-8")
|
||||
.replace(/^charset=/i, "")
|
||||
.toLowerCase();
|
||||
|
||||
// Regex locations now, matching the Studio half: the contract declares which
|
||||
// paths the router serves, not which ones the fixture happened to contain, so
|
||||
// a record published after this build is served instead of 404ed at the edge.
|
||||
const publicLocations = contract.publicSpaPathPatterns
|
||||
.map(
|
||||
(pattern: string) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} {
|
||||
${secure}
|
||||
add_header Cache-Control "${indexCache}" always;
|
||||
try_files /index.html =404;
|
||||
}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
const studioLocations = contract.studioSpaPathPatterns
|
||||
.map(
|
||||
(pattern) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} {
|
||||
${secure}
|
||||
add_header Cache-Control "${indexCache}" always;
|
||||
try_files /index.html =404;
|
||||
}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
const conf = `# Generated by scripts/generate-nginx-config.ts — do not edit.
|
||||
# Sources: dist/tech-log-serving-contract.json, config/hosting/security-headers.json,
|
||||
# config/hosting/cache-policy.json
|
||||
#
|
||||
# Plain HTTP on purpose: the edge terminates TLS and this container is only ever
|
||||
# reached from inside the deployment network.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
||||
# The bundle is small and already compressed at rest by the build; gzip here
|
||||
# covers the JSON surfaces and index.html.
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript text/javascript application/json;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# Static surfaces, per config/hosting/cache-policy.json
|
||||
# ---------------------------------------------------------------------------
|
||||
location = ${basePath}/config.json {
|
||||
alias /usr/share/nginx/html/config.json;
|
||||
${secure}
|
||||
add_header Cache-Control "${configCache}" always;
|
||||
}
|
||||
|
||||
location = ${basePath}/release-manifest.json {
|
||||
alias /usr/share/nginx/html/release-manifest.json;
|
||||
${secure}
|
||||
add_header Cache-Control "${manifestCache}" always;
|
||||
}
|
||||
|
||||
# Content-hashed filenames, so the long TTL is safe and revalidation is waste.
|
||||
location ${basePath}/assets/ {
|
||||
# alias, not root + URI: under a base path the request is /dev/assets/x.js
|
||||
# while the file is dist/assets/x.js, so root would look for
|
||||
# dist/dev/assets/x.js and answer 404 for every script on the page.
|
||||
alias /usr/share/nginx/html/assets/;
|
||||
add_header Cache-Control "${assetCache}" always;
|
||||
}
|
||||
|
||||
# Source maps are not published (cache-policy sourceMap.public = false).
|
||||
location ~ \\.map$ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPA routes. Enumerated from the serving contract rather than a catch-all:
|
||||
# a path that is not a real route answers 404 instead of a 200 shell, which is
|
||||
# what tells a crawler the difference.
|
||||
# ---------------------------------------------------------------------------
|
||||
${publicLocations}
|
||||
|
||||
${studioLocations}
|
||||
|
||||
location = ${basePath}/favicon.svg {
|
||||
alias /usr/share/nginx/html/favicon.svg;
|
||||
add_header Cache-Control "${assetCache}" always;
|
||||
}
|
||||
|
||||
location = ${basePath}/media/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ${basePath}/media/ {
|
||||
alias /usr/share/nginx/html/media/;
|
||||
add_header Cache-Control "${assetCache}" always;
|
||||
}
|
||||
|
||||
# Anything else is not a route this deployment serves.
|
||||
location / {
|
||||
# The contract states the content type with its charset attached
|
||||
# (text/plain;charset=UTF-8), but nginx takes the two separately —
|
||||
# default_type rejects a parameter outright.
|
||||
default_type ${notFoundType};
|
||||
charset ${notFoundCharset};
|
||||
return ${contract.notFound.status} "${contract.notFound.body}";
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await writeFile(OUT, conf, "utf8");
|
||||
process.stdout.write(
|
||||
`nginx config: ${OUT} (${contract.publicSpaPathPatterns.length} public routes, ` +
|
||||
`${contract.studioSpaPathPatterns.length} studio patterns)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* canonical studio-v1.yaml을 vendor하고 타입을 생성한다.
|
||||
* canonical 계약(studio-v1, public-v1)을 vendor하고 타입을 생성한다.
|
||||
*
|
||||
* 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의
|
||||
* classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고
|
||||
@@ -12,15 +12,53 @@
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { argv, env, exit } from "node:process";
|
||||
|
||||
const CANONICAL_ROOT =
|
||||
env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package";
|
||||
const CANONICAL_YAML = `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`;
|
||||
const VENDOR_YAML = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
|
||||
const GENERATED = "src/features/tech-log/contracts/studio/generated.ts";
|
||||
const SOURCE_RECORD = "src/features/tech-log/contracts/studio/canonical-source.json";
|
||||
|
||||
/**
|
||||
* 계약은 둘이고 서로 독립이다. Studio는 인증된 작성 표면이고, Public은 인증
|
||||
* 없는 조회 표면이다. 각자 자기 canonical yaml에서 나오고 자기 digest를 들고
|
||||
* 다니므로, 한쪽이 갱신돼도 다른 쪽 drift 게이트는 조용하다.
|
||||
*/
|
||||
type ContractTarget = Readonly<{
|
||||
name: string;
|
||||
packageId: string;
|
||||
canonicalYaml: string;
|
||||
vendorYaml: string;
|
||||
generated: string;
|
||||
sourceRecord: string;
|
||||
}>;
|
||||
|
||||
const CONTRACTS: readonly ContractTarget[] = Object.freeze([
|
||||
Object.freeze({
|
||||
name: "studio",
|
||||
packageId: "@tech-log/studio-contract",
|
||||
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`,
|
||||
vendorYaml: "src/features/tech-log/contracts/studio/studio-api.openapi.yaml",
|
||||
generated: "src/features/tech-log/contracts/studio/generated.ts",
|
||||
sourceRecord: "src/features/tech-log/contracts/studio/canonical-source.json",
|
||||
}),
|
||||
Object.freeze({
|
||||
name: "public",
|
||||
packageId: "@tech-log/public-contract",
|
||||
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/public-v1.yaml`,
|
||||
vendorYaml: "src/features/tech-log/contracts/public/public-api.openapi.yaml",
|
||||
generated: "src/features/tech-log/contracts/public/generated.ts",
|
||||
sourceRecord: "src/features/tech-log/contracts/public/canonical-source.json",
|
||||
}),
|
||||
Object.freeze({
|
||||
name: "management",
|
||||
packageId: "@tech-log/management-contract",
|
||||
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/studio-management-v1.yaml`,
|
||||
vendorYaml: "src/features/tech-log/contracts/management/management-api.openapi.yaml",
|
||||
generated: "src/features/tech-log/contracts/management/generated.ts",
|
||||
sourceRecord: "src/features/tech-log/contracts/management/canonical-source.json",
|
||||
}),
|
||||
]);
|
||||
|
||||
const OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1";
|
||||
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
|
||||
@@ -56,71 +94,84 @@ function fail(problems: readonly string[]): never {
|
||||
}
|
||||
|
||||
if (check) {
|
||||
const vendored = readFileSync(VENDOR_YAML, "utf8");
|
||||
const generated = readFileSync(GENERATED, "utf8");
|
||||
const record = JSON.parse(readFileSync(SOURCE_RECORD, "utf8")) as CanonicalRecord;
|
||||
const problems: string[] = [];
|
||||
const summaries: string[] = [];
|
||||
|
||||
if (digestOf(readFileSync(VENDOR_YAML)) !== record.digest) {
|
||||
problems.push(`${VENDOR_YAML} does not hash to the recorded digest`);
|
||||
}
|
||||
const vendoredOperations = operationIdsOf(vendored);
|
||||
if (vendoredOperations.join(" ") !== [...record.operationIds].join(" ")) {
|
||||
problems.push(`${SOURCE_RECORD} operationIds differ from ${VENDOR_YAML}`);
|
||||
}
|
||||
if (specVersionOf(vendored) !== record.version) {
|
||||
problems.push(`${SOURCE_RECORD} version differs from ${VENDOR_YAML}`);
|
||||
}
|
||||
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
|
||||
for (const operationId of record.operationIds) {
|
||||
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
|
||||
problems.push(`${GENERATED} is missing operation ${operationId}`);
|
||||
for (const target of CONTRACTS) {
|
||||
const vendored = readFileSync(target.vendorYaml, "utf8");
|
||||
const generated = readFileSync(target.generated, "utf8");
|
||||
const record = JSON.parse(readFileSync(target.sourceRecord, "utf8")) as CanonicalRecord;
|
||||
|
||||
if (digestOf(readFileSync(target.vendorYaml)) !== record.digest) {
|
||||
problems.push(`${target.vendorYaml} does not hash to the recorded digest`);
|
||||
}
|
||||
if (operationIdsOf(vendored).join(" ") !== [...record.operationIds].join(" ")) {
|
||||
problems.push(`${target.sourceRecord} operationIds differ from ${target.vendorYaml}`);
|
||||
}
|
||||
if (specVersionOf(vendored) !== record.version) {
|
||||
problems.push(`${target.sourceRecord} version differs from ${target.vendorYaml}`);
|
||||
}
|
||||
if (record.packageId !== target.packageId) {
|
||||
problems.push(`${target.sourceRecord} packageId is not ${target.packageId}`);
|
||||
}
|
||||
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
|
||||
for (const operationId of record.operationIds) {
|
||||
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
|
||||
problems.push(`${target.generated} is missing operation ${operationId}`);
|
||||
}
|
||||
}
|
||||
summaries.push(
|
||||
`${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations`,
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length > 0) fail(problems);
|
||||
console.log(
|
||||
`tech-log contract is in sync: ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
||||
);
|
||||
console.log(`tech-log contracts are in sync:\n- ${summaries.join("\n- ")}`);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
const canonicalBytes = readFileSync(CANONICAL_YAML);
|
||||
const canonicalText = canonicalBytes.toString("utf8");
|
||||
const sourceRevision = execFileSync(
|
||||
"git",
|
||||
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
|
||||
const record: CanonicalRecord = {
|
||||
packageId: "@tech-log/studio-contract",
|
||||
version: specVersionOf(canonicalText),
|
||||
digest: digestOf(canonicalBytes),
|
||||
sourceRevision: execFileSync(
|
||||
"git",
|
||||
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
|
||||
{ encoding: "utf8" },
|
||||
).trim(),
|
||||
operationIds: operationIdsOf(canonicalText),
|
||||
};
|
||||
for (const target of CONTRACTS) {
|
||||
const canonicalBytes = readFileSync(target.canonicalYaml);
|
||||
const canonicalText = canonicalBytes.toString("utf8");
|
||||
|
||||
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
|
||||
const generated = execFileSync(
|
||||
"corepack",
|
||||
[
|
||||
"pnpm",
|
||||
"dlx",
|
||||
"--package",
|
||||
GENERATOR_TYPESCRIPT,
|
||||
"--package",
|
||||
OPENAPI_TYPESCRIPT,
|
||||
"openapi-typescript",
|
||||
CANONICAL_YAML,
|
||||
],
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
const record: CanonicalRecord = {
|
||||
packageId: target.packageId,
|
||||
version: specVersionOf(canonicalText),
|
||||
digest: digestOf(canonicalBytes),
|
||||
sourceRevision,
|
||||
operationIds: operationIdsOf(canonicalText),
|
||||
};
|
||||
|
||||
writeFileSync(VENDOR_YAML, canonicalText);
|
||||
writeFileSync(GENERATED, generated);
|
||||
writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`);
|
||||
console.log(
|
||||
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
||||
);
|
||||
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
|
||||
const generated = execFileSync(
|
||||
"corepack",
|
||||
[
|
||||
"pnpm",
|
||||
"dlx",
|
||||
"--package",
|
||||
GENERATOR_TYPESCRIPT,
|
||||
"--package",
|
||||
OPENAPI_TYPESCRIPT,
|
||||
"openapi-typescript",
|
||||
target.canonicalYaml,
|
||||
],
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
mkdirSync(dirname(target.vendorYaml), { recursive: true });
|
||||
writeFileSync(target.vendorYaml, canonicalText);
|
||||
writeFileSync(target.generated, generated);
|
||||
writeFileSync(target.sourceRecord, `${JSON.stringify(record, null, 2)}\n`);
|
||||
console.log(
|
||||
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 재생성은 매번 package digest를 바꾼다. `pnpm dev`가 그대로 서빙하는
|
||||
// `public/release-manifest.json`은 build가 컴파일한 contract set을 그대로
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import {
|
||||
projects,
|
||||
publicRecords,
|
||||
releases,
|
||||
} from "../src/features/tech-log/adapters/static/public-content.ts";
|
||||
import { TECH_LOG_ROUTE_REGISTRY } from "../src/features/tech-log/contracts/tech-log-route-contract.ts";
|
||||
import { writeTechLogServingArtifact } from "./lib/tech-log-serving-artifact.ts";
|
||||
import { createTechLogServingContract } from "./lib/tech-log-serving-contract.ts";
|
||||
|
||||
const contract = createTechLogServingContract({
|
||||
projects,
|
||||
publicRecords,
|
||||
releases,
|
||||
});
|
||||
// The router owns which public paths exist. Reading them from the catalog
|
||||
// instead — as this did — pinned the served set to whatever the bundled fixture
|
||||
// contained on the day of the build.
|
||||
const publicRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY)
|
||||
.filter((route) => route.layoutGroup === "PUBLIC")
|
||||
.map((route) => route.path);
|
||||
|
||||
const studioRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY)
|
||||
.filter((route) => route.layoutGroup === "STUDIO")
|
||||
.map((route) => route.path);
|
||||
|
||||
const contract = createTechLogServingContract({ publicRoutePaths, studioRoutePaths });
|
||||
|
||||
await writeTechLogServingArtifact({ distRoot: "dist", contract });
|
||||
|
||||
@@ -45,7 +45,9 @@ export function createTechLogProductionServer({
|
||||
contract,
|
||||
}) {
|
||||
const absoluteRoot = path.resolve(root);
|
||||
const publicSpaPaths = new Set(contract.publicSpaPaths);
|
||||
const publicSpaPathPatterns = contract.publicSpaPathPatterns.map(
|
||||
(pattern) => new RegExp(pattern),
|
||||
);
|
||||
const studioSpaPathPatterns = contract.studioSpaPathPatterns.map(
|
||||
(pattern) => new RegExp(pattern),
|
||||
);
|
||||
@@ -69,7 +71,7 @@ export function createTechLogProductionServer({
|
||||
}
|
||||
}
|
||||
if (
|
||||
publicSpaPaths.has(pathname) ||
|
||||
publicSpaPathPatterns.some((pattern) => pattern.test(pathname)) ||
|
||||
studioSpaPathPatterns.some((pattern) => pattern.test(pathname))
|
||||
) {
|
||||
await sendFile(path.join(absoluteRoot, "index.html"), request.method, response);
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
export type TechLogServingContract = Readonly<{
|
||||
schemaVersion: 1;
|
||||
publicSpaPaths: readonly string[];
|
||||
schemaVersion: 2;
|
||||
/**
|
||||
* Patterns, not an enumeration.
|
||||
*
|
||||
* This used to list every public path the bundled fixture happened to
|
||||
* contain, and the generated nginx served exactly those. A record published
|
||||
* after the build — which is the entire point of a backend — answered 404 at
|
||||
* the edge before the SPA was ever asked, and no amount of correct routing
|
||||
* inside the bundle could recover it.
|
||||
*
|
||||
* The route contract already declares which paths exist; the catalog only
|
||||
* decides which of them currently resolve, and that is the SPA's call, not
|
||||
* the web server's. Studio has been pattern-based all along — this brings the
|
||||
* public half to the same footing.
|
||||
*/
|
||||
publicSpaPathPatterns: readonly string[];
|
||||
studioPathPrefix: "/studio";
|
||||
studioSpaPathPatterns: readonly string[];
|
||||
notFound: Readonly<{
|
||||
@@ -11,68 +25,67 @@ export type TechLogServingContract = Readonly<{
|
||||
}>;
|
||||
|
||||
type ServingContractInput = Readonly<{
|
||||
publicRecords: readonly Readonly<{
|
||||
path: string;
|
||||
topicSlug: string;
|
||||
}>[];
|
||||
projects: readonly Readonly<{ slug: string }>[];
|
||||
releases: readonly Readonly<{ path: string }>[];
|
||||
/**
|
||||
* The public route templates the router registers, in route-contract form
|
||||
* (`/cases/:slug`). Passed in rather than imported so this module stays a
|
||||
* pure transform the tests can drive directly.
|
||||
*/
|
||||
publicRoutePaths: readonly string[];
|
||||
/** The Studio route templates, same form and same reason. */
|
||||
studioRoutePaths: readonly string[];
|
||||
}>;
|
||||
|
||||
const staticPublicPaths = Object.freeze([
|
||||
"/",
|
||||
"/explore",
|
||||
"/explore/cases",
|
||||
"/explore/questions",
|
||||
"/explore/references",
|
||||
"/profile",
|
||||
"/projects",
|
||||
"/releases",
|
||||
"/search",
|
||||
]);
|
||||
/**
|
||||
* `/cases/:slug` -> `^/cases/[^/]+$`. A parameter matches one segment and never
|
||||
* a slash, which is what keeps `/cases/a/b` a 404 instead of a case page.
|
||||
*/
|
||||
function patternOf(routePath: string): string {
|
||||
const escaped = routePath
|
||||
.split("/")
|
||||
.map((segment) =>
|
||||
segment.startsWith(":")
|
||||
? "[^/]+"
|
||||
: segment.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"),
|
||||
)
|
||||
.join("/");
|
||||
return `^${escaped === "" ? "/" : escaped}$`;
|
||||
}
|
||||
|
||||
|
||||
const studioSpaPathPatterns = Object.freeze([
|
||||
"^/studio$",
|
||||
// The Asset Library is a first-class Studio route (TECH_LOG_STUDIO_ASSETS in
|
||||
// the route contract) but was never listed here, so a hard navigation or a
|
||||
// reload of /studio/assets was served the in-shell Studio 404 -- the screen
|
||||
// was only reachable by client-side navigation from another Studio page.
|
||||
"^/studio/assets$",
|
||||
"^/studio/documents$",
|
||||
"^/studio/documents/new$",
|
||||
"^/studio/documents/[^/]+/(edit|validation|preview|publish)$",
|
||||
"^/studio/publications$",
|
||||
"^/studio/publications/[^/]+/preview$",
|
||||
]);
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export function createTechLogServingContract({
|
||||
publicRecords,
|
||||
projects,
|
||||
releases,
|
||||
}: ServingContractInput): TechLogServingContract {
|
||||
const publicSpaPaths = new Set(staticPublicPaths);
|
||||
for (const record of publicRecords) {
|
||||
publicSpaPaths.add(record.path);
|
||||
publicSpaPaths.add(`/topics/${record.topicSlug}`);
|
||||
/**
|
||||
* The catch-all is the SPA's own not-found screen; serving index.html for every
|
||||
* unmatched URL would turn the edge 404 into a soft 200 and hide broken links
|
||||
* from crawlers and from us.
|
||||
*/
|
||||
function patternsFor(routePaths: readonly string[]): readonly string[] {
|
||||
const patterns = new Set<string>();
|
||||
for (const routePath of routePaths) {
|
||||
if (routePath === "*" || routePath.includes("*")) continue;
|
||||
patterns.add(patternOf(routePath));
|
||||
}
|
||||
for (const project of projects) {
|
||||
const projectPath = `/projects/${project.slug}`;
|
||||
publicSpaPaths.add(projectPath);
|
||||
publicSpaPaths.add(`${projectPath}/activity`);
|
||||
publicSpaPaths.add(`${projectPath}/decisions`);
|
||||
publicSpaPaths.add(`${projectPath}/records`);
|
||||
}
|
||||
for (const release of releases) publicSpaPaths.add(release.path);
|
||||
return Object.freeze([...patterns].sort(asciiCompare));
|
||||
}
|
||||
|
||||
export function createTechLogServingContract({
|
||||
publicRoutePaths,
|
||||
studioRoutePaths,
|
||||
}: ServingContractInput): TechLogServingContract {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
publicSpaPaths: Object.freeze([...publicSpaPaths].sort(asciiCompare)),
|
||||
schemaVersion: 2,
|
||||
publicSpaPathPatterns: patternsFor(publicRoutePaths),
|
||||
studioPathPrefix: "/studio",
|
||||
studioSpaPathPatterns,
|
||||
// Derived, not listed. This was a hand-maintained array, and it went stale
|
||||
// exactly the way a hand-maintained array does: /studio/assets was missing
|
||||
// for its whole life, and /studio/releases repeated the mistake the moment
|
||||
// it was added — the route worked by client-side navigation and 404'd on
|
||||
// reload, because nginx had never heard of it. The route contract already
|
||||
// knows which Studio paths exist, so ask it.
|
||||
studioSpaPathPatterns: patternsFor(studioRoutePaths),
|
||||
notFound: Object.freeze({
|
||||
status: 404,
|
||||
contentType: "text/plain;charset=UTF-8",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 배포본 전수 확인.
|
||||
*
|
||||
* 이 파일은 절차 실패에서 나왔다 — 고친 화면만 확인하고 배포해서, 나머지가 깨진 것은 매번
|
||||
* 사용자가 먼저 발견했다. 운영 환경이므로 배포 전에 모든 화면을 한 번씩 열어 보는 것이 맞다.
|
||||
*
|
||||
* 각 화면에서 보는 것: main 이 그려졌는지, 콘솔 오류, 4xx/5xx API 응답, 그리고 화면에 뜬
|
||||
* 오류 문구. 하나라도 있으면 그 화면을 실패로 적고 끝까지 진행한다.
|
||||
*
|
||||
* SPW=<비밀번호> node scripts/smoke/production-sweep.mjs [origin]
|
||||
*/
|
||||
import process from "node:process";
|
||||
|
||||
import { chromium, type Page } from "@playwright/test";
|
||||
|
||||
const ORIGIN = process.argv[2] ?? "https://hyeonworks.com";
|
||||
const PW = process.env.SPW;
|
||||
const ERROR_TEXT =
|
||||
/요청을 처리하지 못했습니다|지원 정보 확인|표시할 수 없습니다|불러오지 못했습니다|화면을 찾을 수 없습니다|Not Found/;
|
||||
|
||||
const results: { label: string; path: string; problems: string[] }[] = [];
|
||||
|
||||
async function visit(
|
||||
page: Page,
|
||||
label: string,
|
||||
path: string,
|
||||
{ expectMain = true }: { expectMain?: boolean } = {},
|
||||
) {
|
||||
const problems: string[] = [];
|
||||
const onConsole = (m: { type(): string; text(): string }) => { if (m.type() === "error" && !/401/.test(m.text())) problems.push(`console: ${m.text().slice(0, 120)}`); };
|
||||
const onResponse = (r: {
|
||||
url(): string;
|
||||
status(): number;
|
||||
request(): { method(): string };
|
||||
}) => {
|
||||
const u = new URL(r.url()).pathname;
|
||||
if (r.status() >= 400 && u.startsWith("/api") && !u.includes("/studio/session")) {
|
||||
problems.push(`${r.status()} ${r.request().method()} ${u}`);
|
||||
}
|
||||
};
|
||||
page.on("console", onConsole);
|
||||
page.on("response", onResponse);
|
||||
try {
|
||||
await page.goto(ORIGIN + path, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||||
await page.waitForTimeout(3000);
|
||||
const main = await page.locator("main").count();
|
||||
const body = (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ");
|
||||
if (expectMain && main === 0) problems.push("main 없음");
|
||||
const shown = body.match(ERROR_TEXT);
|
||||
if (shown) problems.push(`화면 문구: ${shown[0]}`);
|
||||
} catch (error) {
|
||||
problems.push(`이동 실패: ${String(error).slice(0, 90)}`);
|
||||
} finally {
|
||||
page.off("console", onConsole);
|
||||
page.off("response", onResponse);
|
||||
}
|
||||
results.push({ label, path, problems });
|
||||
console.log(`${problems.length ? "✗" : "✓"} ${label.padEnd(22)} ${path}`);
|
||||
for (const p of problems) console.log(` ${p}`);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await (await browser.newContext()).newPage();
|
||||
|
||||
console.log("=== 공개 ===");
|
||||
for (const [label, path] of [
|
||||
["홈", "/"], ["탐색", "/explore"], ["Case 목록", "/explore/cases"],
|
||||
["프로젝트", "/projects"], ["변경 기록", "/releases"], ["릴리즈 상세", "/releases/0.1.0"],
|
||||
["검색", "/search"], ["프로필", "/profile"],
|
||||
]) await visit(page, label, path);
|
||||
|
||||
if (!PW) { console.log("\n(SPW 없음 — Studio 생략)"); await browser.close(); process.exit(0); }
|
||||
|
||||
console.log("\n=== 로그인 ===");
|
||||
await page.goto(ORIGIN + "/studio", { waitUntil: "domcontentloaded", timeout: 60000 });
|
||||
await page.waitForTimeout(2500);
|
||||
const start = page.getByRole("button", { name: /로그인 시작/ }).or(page.getByRole("link", { name: /로그인 시작/ }));
|
||||
if (await start.count()) { await start.first().click(); await page.waitForTimeout(5000); }
|
||||
await page.fill("#username", "hyeonworks");
|
||||
await page.fill("#password", PW);
|
||||
await page.click("#kc-login, input[type=submit], button[type=submit]");
|
||||
await page.waitForTimeout(6000);
|
||||
console.log(" 로그인 후:", page.url().replace(ORIGIN, "") || "/");
|
||||
|
||||
console.log("\n=== Studio ===");
|
||||
for (const [label, path] of [
|
||||
["대시보드", "/studio"], ["작업본", "/studio/documents"], ["새 문서", "/studio/documents/new"],
|
||||
["게시 기록", "/studio/publications"], ["Asset", "/studio/assets"],
|
||||
["주제·프로젝트", "/studio/taxonomy"], ["릴리즈", "/studio/releases"],
|
||||
]) await visit(page, label, path);
|
||||
|
||||
// 작업본 하나를 골라 편집·검증·미리보기까지 연다
|
||||
await page.goto(ORIGIN + "/studio/documents", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(3000);
|
||||
const href = await page.locator("a[href*='/studio/documents/'][href$='/edit']").first().getAttribute("href").catch(() => null);
|
||||
if (href) {
|
||||
const id = href.split("/")[3];
|
||||
console.log("\n=== 문서 흐름 ===", id);
|
||||
for (const [label, suffix] of [["편집", "/edit"], ["검증", "/validation"], ["미리보기", "/preview"], ["게시", "/publish"]])
|
||||
await visit(page, label, `/studio/documents/${id}${suffix}`);
|
||||
} else console.log("\n(편집 링크를 찾지 못해 문서 흐름 생략)");
|
||||
|
||||
await browser.close();
|
||||
const failed = results.filter((r) => r.problems.length);
|
||||
console.log(`\n=== 결과 === ${results.length - failed.length}/${results.length} 통과`);
|
||||
for (const r of failed) console.log(` ✗ ${r.label} (${r.path}): ${r.problems.join(" | ").slice(0, 160)}`);
|
||||
process.exit(failed.length ? 1 : 0);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveRuntimeCapabilities } from "../contracts/runtime-capabilities.ts";
|
||||
import { installBffSessionOwner } from "../features/tech-log/adapters/http/bff-session-owner.ts";
|
||||
import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
|
||||
import { createCompositionRoot } from "./composition-root.ts";
|
||||
import { loadReleaseManifest } from "./load-release-manifest.ts";
|
||||
@@ -27,13 +28,31 @@ export async function createRuntimeComposition(
|
||||
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
|
||||
loadRelease: (runtime) =>
|
||||
loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }),
|
||||
createAdapters: ({ config: runtime, release }) =>
|
||||
createRuntimeAdapters({
|
||||
createAdapters: ({ config: runtime, release }) => {
|
||||
// `AUTH_MODE: "external"` delegates the session to whoever hosts this
|
||||
// bundle. For Tech Log that host is its own backend — the session is an
|
||||
// httpOnly cookie the SPA cannot read — so the owner is installed here,
|
||||
// before the adapters resolve it. Only for the HTTP Studio: the MOCK
|
||||
// source has no backend to ask, and `demo` keeps its own adapter.
|
||||
const host =
|
||||
dependencies.host ?? (globalThis as unknown as Record<string, unknown>);
|
||||
if (
|
||||
runtime.config.AUTH_MODE === "external" &&
|
||||
runtime.config.TECH_LOG_STUDIO_SOURCE === "HTTP"
|
||||
) {
|
||||
installBffSessionOwner(
|
||||
host,
|
||||
runtime.config.API_BASE_URL,
|
||||
dependencies.fetcher ?? fetch,
|
||||
);
|
||||
}
|
||||
return createRuntimeAdapters({
|
||||
runtime,
|
||||
release,
|
||||
fetcher: dependencies.fetcher,
|
||||
host: dependencies.host,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const capabilities = resolveRuntimeCapabilities(
|
||||
|
||||
@@ -513,6 +513,18 @@ export async function createRuntimeAdapters(
|
||||
techLogCsrf,
|
||||
);
|
||||
if (studioOutcome) return studioOutcome;
|
||||
// An anonymous profile carries no credentials by definition — the
|
||||
// registry refuses to install one that allows any credential header. It
|
||||
// must therefore never consult the session: a signed-out visitor's state
|
||||
// is `unauthenticated`, and falling through below refused every public
|
||||
// read before it left the browser. The public site rendered its terminal
|
||||
// error surface on every screen with no request in the network log.
|
||||
//
|
||||
// Keyed on the profile's transport rather than a profile id, so any
|
||||
// anonymous operation is covered rather than one named surface.
|
||||
if (INSTALLED_REST_AUTH_PROFILES.get(operation.authProfileId)?.transport === "ANONYMOUS") {
|
||||
return Object.freeze({ kind: "READY" as const, headers: Object.freeze({}) });
|
||||
}
|
||||
const state = authSession.getState();
|
||||
if (state === "integration-failed") {
|
||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||
@@ -599,6 +611,7 @@ export async function createRuntimeAdapters(
|
||||
const featureInputs = createInstalledFeatureInputs({
|
||||
contractOperations,
|
||||
studioSource: config.TECH_LOG_STUDIO_SOURCE,
|
||||
publicSource: config.TECH_LOG_PUBLIC_SOURCE,
|
||||
apiBaseUrl: config.API_BASE_URL,
|
||||
requestTimeoutMs: config.REQUEST_TIMEOUT_MS,
|
||||
csrf: techLogCsrf,
|
||||
|
||||
@@ -56,6 +56,12 @@ export type RuntimeConfig = Readonly<{
|
||||
* MOCK.
|
||||
*/
|
||||
TECH_LOG_STUDIO_SOURCE: "MOCK" | "HTTP";
|
||||
/**
|
||||
* §3.5-adjacent runtime switch: which public-read adapter this build talks
|
||||
* to. Independent of the Studio switch — the two surfaces are separate
|
||||
* services. A V1 document predates the key and normalizes to MOCK.
|
||||
*/
|
||||
TECH_LOG_PUBLIC_SOURCE: "MOCK" | "HTTP";
|
||||
/** Present only while a V1 document is still accepted. */
|
||||
LEGACY_API_CONTRACT_VERSION?: string;
|
||||
}>;
|
||||
@@ -143,6 +149,10 @@ export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
|
||||
TECH_LOG_STUDIO_SOURCE: isV2
|
||||
? (parsed as RuntimeConfigV2).TECH_LOG_STUDIO_SOURCE
|
||||
: "MOCK",
|
||||
// Same treatment for the public-read switch.
|
||||
TECH_LOG_PUBLIC_SOURCE: isV2
|
||||
? (parsed as RuntimeConfigV2).TECH_LOG_PUBLIC_SOURCE
|
||||
: "MOCK",
|
||||
...(isV2
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -52,6 +52,11 @@ export const ENV_REGISTRY = Object.freeze({
|
||||
// TechLog Studio gateway adapter selection. Defaults to MOCK while the
|
||||
// backend does not exist yet.
|
||||
TECH_LOG_STUDIO_SOURCE: runtime("public", false, "MOCK"),
|
||||
// TechLog public-read adapter selection. Separate from the Studio switch on
|
||||
// purpose: the two surfaces are different services on different schedules,
|
||||
// and the combination that matters right now — an authoring backend that is
|
||||
// live while the public read API is not — is unreachable with one flag.
|
||||
TECH_LOG_PUBLIC_SOURCE: runtime("public", false, "MOCK"),
|
||||
// §3.5: build-time narrowing of the product manifest. A feature left out
|
||||
// here is not imported by any registry and never reaches the bundle.
|
||||
VITE_PRODUCT_FEATURES: build("compile-time", false, null),
|
||||
|
||||
@@ -159,6 +159,10 @@ export const runtimeConfigV2ArtifactSchema = z
|
||||
// TechLog Studio adapter selection. Backend is not live yet, so the
|
||||
// default is the in-memory mock; a document may opt a build into HTTP.
|
||||
TECH_LOG_STUDIO_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"),
|
||||
// TechLog public-read adapter selection, defaulted the same way and for
|
||||
// the same reason. Held apart from the Studio switch so one surface can
|
||||
// move to HTTP without dragging the other with it.
|
||||
TECH_LOG_PUBLIC_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(runtimeConfigArtifactInvariants);
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
|
||||
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||
import { TECH_LOG_MANAGEMENT_CONTRIBUTION } from "./tech-log/contracts/tech-log-management-contract-contribution.ts";
|
||||
import { TECH_LOG_PUBLIC_CONTRIBUTION } from "./tech-log/contracts/tech-log-public-contract-contribution.ts";
|
||||
import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-studio-contract-contribution.ts";
|
||||
|
||||
/**
|
||||
@@ -18,8 +20,17 @@ import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-stud
|
||||
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
||||
Object.freeze(
|
||||
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
|
||||
? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, TECH_LOG_STUDIO_CONTRIBUTION]
|
||||
: [TECH_LOG_STUDIO_CONTRIBUTION],
|
||||
? [
|
||||
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION,
|
||||
TECH_LOG_STUDIO_CONTRIBUTION,
|
||||
TECH_LOG_PUBLIC_CONTRIBUTION,
|
||||
TECH_LOG_MANAGEMENT_CONTRIBUTION,
|
||||
]
|
||||
: [
|
||||
TECH_LOG_STUDIO_CONTRIBUTION,
|
||||
TECH_LOG_PUBLIC_CONTRIBUTION,
|
||||
TECH_LOG_MANAGEMENT_CONTRIBUTION,
|
||||
],
|
||||
);
|
||||
|
||||
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
||||
|
||||
@@ -23,6 +23,7 @@ export function createInstalledFeatureInputs(
|
||||
context: Parameters<typeof createReferenceFeatureInstalledInput>[0] &
|
||||
Readonly<{
|
||||
studioSource: "MOCK" | "HTTP";
|
||||
publicSource: "MOCK" | "HTTP";
|
||||
apiBaseUrl: string;
|
||||
requestTimeoutMs: number;
|
||||
csrf: CsrfTokenProvider;
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
|
||||
import { createMockStudioAssetGateway } from "./mock/mock-studio-asset-gateway.ts";
|
||||
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
|
||||
import { createHttpManagementGateway } from "./http/http-management-gateway.ts";
|
||||
import { createHttpPublicContentGateway } from "./http/http-public-content-gateway.ts";
|
||||
import { publicContentQueries } from "./static/public-query.ts";
|
||||
|
||||
/**
|
||||
@@ -27,6 +29,7 @@ import { publicContentQueries } from "./static/public-query.ts";
|
||||
*/
|
||||
export type TechLogInstallContext = Readonly<{
|
||||
studioSource: "MOCK" | "HTTP";
|
||||
publicSource: "MOCK" | "HTTP";
|
||||
contractOperations: StudioOperationExecutor;
|
||||
apiBaseUrl: string;
|
||||
requestTimeoutMs: number;
|
||||
@@ -66,8 +69,20 @@ export function createTechLogFeatureInstalledInput(
|
||||
? createMockStudioGateway({ assets: mockAssets })
|
||||
: createHttpStudioGateway({ operations: context.contractOperations });
|
||||
|
||||
// The public read source switches independently of Studio: the two are
|
||||
// different services, and the combination that matters today is an authoring
|
||||
// backend that is live while the public read API is not.
|
||||
const publicContent =
|
||||
context.publicSource === "MOCK"
|
||||
? publicContentQueries
|
||||
: createHttpPublicContentGateway({ operations: context.contractOperations });
|
||||
|
||||
const createManagementGateway = () =>
|
||||
createHttpManagementGateway({ operations: context.contractOperations });
|
||||
|
||||
const input: TechLogFeatureInput = Object.freeze({
|
||||
publicContent: publicContentQueries,
|
||||
publicContent,
|
||||
createManagementGateway,
|
||||
createStudioGateway,
|
||||
createStudioAssetGateway,
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ export function createAssetUploadTransport(
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
let problemBody: unknown = null;
|
||||
let problemBody: unknown;
|
||||
try {
|
||||
problemBody = await response.json();
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { SessionState } from "../../../../application/ports/auth-session-port.ts";
|
||||
|
||||
/**
|
||||
* The host-installed session owner for the BFF deployment.
|
||||
*
|
||||
* `AUTH_MODE: "external"` means the page hosting this bundle owns the session
|
||||
* and publishes it on `window.__CA_FRONTEND_AUTH_OWNER__`; with no owner
|
||||
* present the runtime falls back to `createUnavailableSessionAdapter`, which is
|
||||
* why a signed-in browser still saw "로그인 연동이 필요합니다". Tech Log's host
|
||||
* *is* its backend: the session lives in an httpOnly `TECHLOG_SESSION` cookie
|
||||
* the SPA cannot read, so the only way to observe it is to ask the backend.
|
||||
*
|
||||
* That is what this owner does. It is not a second authentication mechanism —
|
||||
* `getStudioSession` is already the contract's bootstrap operation (the one
|
||||
* that issues the CSRF token), so reading session state from it adds no
|
||||
* round-trip the Studio would not make anyway.
|
||||
*/
|
||||
|
||||
const SESSION_PATH = "api/v1/studio/session";
|
||||
const SIGN_IN_PATH = "oauth2/authorization/keycloak";
|
||||
const SIGN_OUT_PATH = "logout";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
function endpoint(apiBaseUrl: string, path: string): string {
|
||||
return new URL(path, apiBaseUrl).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts at `recovery-pending` rather than `unauthenticated`. The state cannot
|
||||
* be known synchronously and the router already models exactly this: a
|
||||
* session-required route in that state renders the recovering surface and calls
|
||||
* `recoverSession()`, which is where the probe belongs. Starting at
|
||||
* `unauthenticated` would flash a sign-in prompt at a signed-in user on every
|
||||
* reload.
|
||||
*/
|
||||
export function createBffSessionOwner(
|
||||
apiBaseUrl: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
) {
|
||||
let state: SessionState = "recovery-pending";
|
||||
// The session response carries the CSRF token; `/logout` is a mutation and the
|
||||
// backend rejects it without one. Kept here so sign-out does not need a second
|
||||
// round-trip on the happy path.
|
||||
let csrf: Readonly<{ token: string; header: string }> | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const publish = (next: SessionState) => {
|
||||
if (state === next) return;
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
async function probe(): Promise<"restored" | "no-session"> {
|
||||
try {
|
||||
const response = await fetcher(endpoint(apiBaseUrl, SESSION_PATH), {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (response.ok) {
|
||||
csrf = await readCsrf(response);
|
||||
publish("authenticated");
|
||||
return "restored";
|
||||
}
|
||||
csrf = null;
|
||||
// 401/403 are answers, not faults: the caller simply has no session.
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
publish("unauthenticated");
|
||||
return "no-session";
|
||||
}
|
||||
// 5xx means the backend could not say. Claiming "signed out" would send
|
||||
// the user through a login they do not need, so report the integration
|
||||
// as unavailable and let the shell surface that instead.
|
||||
publish("integration-failed");
|
||||
return "no-session";
|
||||
} catch {
|
||||
publish("integration-failed");
|
||||
return "no-session";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Total: a session that parses is still a session. A body we cannot read only
|
||||
* costs sign-out its cached token, and `signOut` re-probes for one.
|
||||
*/
|
||||
async function readCsrf(
|
||||
response: Response,
|
||||
): Promise<Readonly<{ token: string; header: string }> | null> {
|
||||
try {
|
||||
const body = (await response.clone().json()) as {
|
||||
data?: { csrfToken?: unknown; csrfHeaderName?: unknown };
|
||||
};
|
||||
const token = body?.data?.csrfToken;
|
||||
const header = body?.data?.csrfHeaderName;
|
||||
return typeof token === "string" && typeof header === "string"
|
||||
? Object.freeze({ token, header })
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Probe immediately instead of waiting for the router's recovery button. The
|
||||
// session is knowable without asking the user to do anything, and the button
|
||||
// exists for owners that genuinely need a user gesture (a popup-based flow,
|
||||
// say). Subscribers are notified when this settles, so a route that mounted
|
||||
// during `recovery-pending` re-renders on its own. `recoverSession` remains
|
||||
// wired for the manual path and for a retry after `integration-failed`.
|
||||
void probe();
|
||||
|
||||
return Object.freeze({
|
||||
readState: () => state,
|
||||
subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
/**
|
||||
* A full-page navigation, not a fetch: the authorization-code flow is a
|
||||
* browser redirect chain through the identity provider, and an XHR cannot
|
||||
* follow it. The backend sends the browser back to the SPA once the session
|
||||
* cookie is set.
|
||||
*/
|
||||
async beginSignIn(): Promise<void> {
|
||||
globalThis.location.assign(endpoint(apiBaseUrl, SIGN_IN_PATH));
|
||||
},
|
||||
/**
|
||||
* Only reports signed-out when the backend actually ended the session.
|
||||
*
|
||||
* The first version published `unauthenticated` in a `finally`, which read
|
||||
* as defensive but was the opposite: `/logout` is a mutation and answered
|
||||
* 403 without the CSRF header, so the cookie survived while the UI claimed
|
||||
* the user was out — the exact failure someone on a shared machine would
|
||||
* never think to check. A sign-out that did not happen has to look like a
|
||||
* sign-out that did not happen.
|
||||
*/
|
||||
async signOut(): Promise<void> {
|
||||
if (csrf === null) {
|
||||
// No cached token (never probed, or the probe body was unreadable).
|
||||
// Ask again rather than sending a request that is certain to 403.
|
||||
await probe();
|
||||
}
|
||||
const headers: Record<string, string> = csrf
|
||||
? { [csrf.header]: csrf.token }
|
||||
: {};
|
||||
const response = await fetcher(endpoint(apiBaseUrl, SIGN_OUT_PATH), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`sign-out failed with status ${response.status}; the session is still active`,
|
||||
);
|
||||
}
|
||||
csrf = null;
|
||||
publish("unauthenticated");
|
||||
},
|
||||
/** Cookies travel on their own; the CSRF header comes from its own collaborator. */
|
||||
async attachCredential() {
|
||||
return Object.freeze({ headers: Object.freeze({}) });
|
||||
},
|
||||
recoverSession: probe,
|
||||
notifyUnauthenticated() {
|
||||
publish("unauthenticated");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the owner on the host global the runtime reads. Called before
|
||||
* `createRuntimeAdapters`, which resolves the owner once and keeps it.
|
||||
*/
|
||||
export function installBffSessionOwner(
|
||||
host: Record<string, unknown>,
|
||||
apiBaseUrl: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): void {
|
||||
host["__CA_FRONTEND_AUTH_OWNER__"] = createBffSessionOwner(apiBaseUrl, fetcher);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
CreateDraftResponse,
|
||||
ProjectEditResponse,
|
||||
ProjectIndexPage,
|
||||
ProjectUpdateRequest,
|
||||
PublishResponse,
|
||||
ReleaseEditResponse,
|
||||
ReleaseIndexPage,
|
||||
ReleaseUpdateRequest,
|
||||
TopicEdit,
|
||||
} from "../../contracts/management/contract.ts";
|
||||
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
|
||||
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
||||
|
||||
export type { ManagementGateway };
|
||||
|
||||
const ROUTE_ID = "TECH_LOG_STUDIO";
|
||||
|
||||
/**
|
||||
* 주제·프로젝트 관리 게이트웨이.
|
||||
*
|
||||
* <p>Studio 게이트웨이와 같은 실패 규약을 쓴다 — 실패는 던지고, 화면은 `usePublicContent` 가 아니라
|
||||
* Studio 쪽 상태 처리를 그대로 쓴다. 여기서 Result 로 감싸면 이 표면만 다른 규약이 된다.
|
||||
*/
|
||||
|
||||
export class ManagementGatewayError extends Error {
|
||||
readonly operationId: string;
|
||||
readonly code: string;
|
||||
|
||||
constructor(operationId: string, code: string) {
|
||||
super(`${operationId}: ${code}`);
|
||||
this.name = "ManagementGatewayError";
|
||||
this.operationId = operationId;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function createHttpManagementGateway(
|
||||
deps: Readonly<{ operations: StudioOperationExecutor }>,
|
||||
): ManagementGateway {
|
||||
async function run<T>(operationId: string, input: unknown): Promise<T> {
|
||||
const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID });
|
||||
if (outcome.kind === "SUCCESS") return outcome.value as T;
|
||||
if (outcome.kind === "PROBLEM") {
|
||||
// The management surface answers with the ADR-006 envelope, which nests
|
||||
// the code under `error` — reading `problem.code` found nothing and every
|
||||
// failure surfaced as the literal "PROBLEM", matching no i18n key.
|
||||
const body = outcome.problem as
|
||||
| Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }>
|
||||
| null;
|
||||
const code =
|
||||
typeof body?.code === "string"
|
||||
? body.code
|
||||
: typeof body?.error?.code === "string"
|
||||
? body.error.code
|
||||
: "PROBLEM";
|
||||
throw new ManagementGatewayError(operationId, code);
|
||||
}
|
||||
throw new ManagementGatewayError(operationId, outcome.kind);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
listTopics: () => run<TopicEdit[]>("listStudioTopics", {}),
|
||||
createTopic: (input: TopicEdit) => run<TopicEdit>("createTopic", input),
|
||||
updateTopic: (id: string, body: TopicEdit) => run<TopicEdit>("updateTopic", { id, body }),
|
||||
deleteTopic: async (id: string, expectedVersion: number) => {
|
||||
await run<void>("deleteTopic", { id, expectedVersion });
|
||||
},
|
||||
listProjects: (page?: number, size?: number) =>
|
||||
run<ProjectIndexPage>("listStudioProjects", { ...(page !== undefined ? { page } : {}), ...(size !== undefined ? { size } : {}) }),
|
||||
getProject: (id: string) => run<ProjectEditResponse>("getProjectForEdit", { id }),
|
||||
createProject: (title: string) => run<CreateDraftResponse>("createProject", { title }),
|
||||
updateProject: (id: string, body: ProjectUpdateRequest) =>
|
||||
run<ProjectEditResponse>("updateProject", { id, body }),
|
||||
listReleases: (page?: number, size?: number) =>
|
||||
run<ReleaseIndexPage>("listStudioReleases", { ...(page !== undefined ? { page } : {}), ...(size !== undefined ? { size } : {}) }),
|
||||
getRelease: (id: string) => run<ReleaseEditResponse>("getReleaseForEdit", { id }),
|
||||
createRelease: (title: string) => run<CreateDraftResponse>("createRelease", { title }),
|
||||
updateRelease: (id: string, body: ReleaseUpdateRequest) =>
|
||||
run<ReleaseEditResponse>("updateRelease", { id, body }),
|
||||
deleteRelease: async (id: string, expectedVersion: number) => {
|
||||
await run<void>("deleteRelease", { id, expectedVersion });
|
||||
},
|
||||
publishRelease: (id: string, expectedVersion: number) =>
|
||||
run<PublishResponse>("publishRelease", { id, expectedVersion }),
|
||||
archiveRelease: (id: string, expectedVersion: number) =>
|
||||
run<ReleaseEditResponse>("archiveRelease", { id, expectedVersion }),
|
||||
deleteDocument: async (
|
||||
kind: "CASE" | "REFERENCE" | "QUESTION",
|
||||
id: string,
|
||||
expectedVersion: number,
|
||||
) => {
|
||||
const operationId =
|
||||
kind === "CASE"
|
||||
? "deleteCaseDraft"
|
||||
: kind === "REFERENCE"
|
||||
? "deleteReferenceDraft"
|
||||
: "deleteQuestion";
|
||||
await run<void>(operationId, { id, expectedVersion });
|
||||
},
|
||||
deleteDecision: async (projectId: string, decisionId: string, expectedVersion: number) => {
|
||||
await run<void>("deleteProjectDecision", { id: projectId, decisionId, expectedVersion });
|
||||
},
|
||||
deleteProject: async (id: string, expectedVersion: number) => {
|
||||
await run<void>("deleteProject", { id, expectedVersion });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import type {
|
||||
HomeFocusItem,
|
||||
ProjectActivity,
|
||||
ProjectDecision,
|
||||
Project,
|
||||
PublicContentQueries,
|
||||
PublicRecord,
|
||||
PublicTopic,
|
||||
QuestionRecord,
|
||||
RecordFilters,
|
||||
RecordKind,
|
||||
Release,
|
||||
SearchablePublicEntity,
|
||||
} from "../../application/ports/public-content-queries.ts";
|
||||
import {
|
||||
activityItemToActivity,
|
||||
baseOf,
|
||||
dateLabel,
|
||||
decisionItemToDecision,
|
||||
flattenRelations,
|
||||
knowledgeListItemToRecord,
|
||||
markdownLines,
|
||||
markdownSections,
|
||||
questionListItemToRecord,
|
||||
releaseDetailToRelease,
|
||||
searchItemToEntity,
|
||||
} from "./public-content-mapping.ts";
|
||||
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
||||
|
||||
const ROUTE_ID = "TECH_LOG_PUBLIC";
|
||||
|
||||
/**
|
||||
* A missing slug is an answer, not a failure.
|
||||
*
|
||||
* The port returns `undefined` for a record that is not published, and the
|
||||
* screens turn that into their not-found route. So a 404 is unwrapped here
|
||||
* rather than thrown — throwing would put the terminal-error surface on a page
|
||||
* whose real state is "this does not exist".
|
||||
*/
|
||||
const NOT_FOUND = Symbol("not-found");
|
||||
|
||||
export type PublicContentGatewayError = Error & { readonly failure?: unknown };
|
||||
|
||||
function gatewayError(operationId: string, detail: string): PublicContentGatewayError {
|
||||
const error = new Error(`${operationId}: ${detail}`) as PublicContentGatewayError;
|
||||
error.name = "PublicContentGatewayError";
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the backend's error code out of either response shape — RFC7807 puts it
|
||||
* at the top level, the ADR-006 envelope nests it under `error`. Without this
|
||||
* every failure was reported as the literal "PROBLEM", which told a reader
|
||||
* nothing and matched no i18n key.
|
||||
*/
|
||||
function problemCode(problem: unknown): string {
|
||||
if (!problem || typeof problem !== "object") return "PROBLEM";
|
||||
const body = problem as Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }>;
|
||||
if (typeof body.code === "string") return body.code;
|
||||
if (typeof body.error?.code === "string") return body.error.code;
|
||||
return "PROBLEM";
|
||||
}
|
||||
|
||||
export function createHttpPublicContentGateway(
|
||||
deps: Readonly<{ operations: StudioOperationExecutor }>,
|
||||
): PublicContentQueries {
|
||||
async function read<T>(operationId: string, input: unknown): Promise<T | typeof NOT_FOUND> {
|
||||
const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID });
|
||||
if (outcome.kind === "SUCCESS") return outcome.value as T;
|
||||
if (outcome.kind === "PROBLEM") {
|
||||
// The HTTP status is the authoritative signal, and the only one that
|
||||
// holds across both shapes this surface answers with. RFC7807 carries
|
||||
// `status` in the body; the ADR-006 envelope does not — it puts the
|
||||
// reason in `error.category` and a backend-specific string in
|
||||
// `error.code` (PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The old
|
||||
// body-only check matched neither, so every 404 raised the terminal
|
||||
// error surface on a page whose real state was "this does not exist".
|
||||
if (outcome.metadata.status === 404) return NOT_FOUND;
|
||||
throw gatewayError(operationId, problemCode(outcome.problem));
|
||||
}
|
||||
throw gatewayError(operationId, outcome.kind);
|
||||
}
|
||||
|
||||
async function readOrThrow<T>(operationId: string, input: unknown): Promise<T> {
|
||||
const value = await read<T>(operationId, input);
|
||||
if (value === NOT_FOUND) throw gatewayError(operationId, "NOT_FOUND");
|
||||
return value;
|
||||
}
|
||||
|
||||
type Page = Readonly<{ items?: readonly Readonly<Record<string, unknown>>[] }>;
|
||||
|
||||
/**
|
||||
* `listRecords` is one port method over two endpoints: the contract splits
|
||||
* knowledge (Case, Reference) from questions because they page and filter
|
||||
* differently. A caller that asks for one kind must not pay for the other, so
|
||||
* the unfiltered call is the only one that fans out.
|
||||
*/
|
||||
async function listRecords(filters: RecordFilters = {}): Promise<PublicRecord[]> {
|
||||
const wantsQuestions = !filters.kind || filters.kind === "QUESTION";
|
||||
const wantsKnowledge = !filters.kind || filters.kind !== "QUESTION";
|
||||
const query = {
|
||||
...(filters.topic ? { topic: filters.topic } : {}),
|
||||
...(filters.project ? { project: filters.project } : {}),
|
||||
};
|
||||
const [knowledge, questions] = await Promise.all([
|
||||
wantsKnowledge
|
||||
? readOrThrow<Page>("exploreKnowledge", {
|
||||
...query,
|
||||
...(filters.kind && filters.kind !== "QUESTION" ? { type: filters.kind } : {}),
|
||||
})
|
||||
: Promise.resolve({ items: [] } as Page),
|
||||
wantsQuestions
|
||||
? readOrThrow<Page>("exploreQuestions", {
|
||||
...query,
|
||||
...(filters.openQuestionsOnly ? { status: "OPEN" } : {}),
|
||||
})
|
||||
: Promise.resolve({ items: [] } as Page),
|
||||
]);
|
||||
const records = [
|
||||
...(knowledge.items ?? []).map(knowledgeListItemToRecord).filter((r): r is PublicRecord => r !== null),
|
||||
...(questions.items ?? []).map(questionListItemToRecord),
|
||||
];
|
||||
return records.sort((left, right) => right.publishedAt.localeCompare(left.publishedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* The cast at each return is not laziness. `kind` is a generic parameter, so
|
||||
* narrowing it inside the body does not narrow `Extract<PublicRecord, {kind: K}>`
|
||||
* with it — the compiler cannot know the branch it took corresponds to the K it
|
||||
* was given. The discriminant on each object is a literal, so the shape is
|
||||
* checked; only the tie back to K is asserted.
|
||||
*/
|
||||
async function getRecord<K extends RecordKind>(
|
||||
kind: K,
|
||||
slug: string,
|
||||
): Promise<Extract<PublicRecord, { kind: K }> | undefined> {
|
||||
const operationId =
|
||||
kind === "CASE" ? "getPublicCase" : kind === "REFERENCE" ? "getPublicReference" : "getPublicQuestion";
|
||||
const detail = await read<Readonly<Record<string, unknown>>>(operationId, { slug });
|
||||
if (detail === NOT_FOUND) return undefined;
|
||||
|
||||
const canonicalPath = String(detail.canonicalPath ?? "");
|
||||
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
|
||||
|
||||
if (kind === "CASE") {
|
||||
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
|
||||
return Object.freeze({
|
||||
...baseOf("CASE", slug, {
|
||||
title: body.title as string,
|
||||
summary: body.problemSummary as string,
|
||||
path: canonicalPath,
|
||||
primaryTopic: body.primaryTopic as never,
|
||||
primaryProject: body.primaryProject as never,
|
||||
publishedAt: body.publishedAt as string,
|
||||
relations: flattenRelations(groups, {
|
||||
originQuestion: "이 기록이 시작된 질문",
|
||||
projectDecisions: "이 기록이 뒷받침하는 결정",
|
||||
derivedReferences: "이 기록에서 정리된 기준",
|
||||
relatedCases: "관련 기록",
|
||||
}),
|
||||
}),
|
||||
kind: "CASE",
|
||||
problem: (body.problemSummary as string) ?? "",
|
||||
conclusion: (body.conclusionSummary as string) ?? "",
|
||||
environment: ((body.environmentSummary as readonly string[]) ?? []).join(", "),
|
||||
// The Case document renders a verification line. The contract has no
|
||||
// field for it — verification lives in the body — so it stays empty
|
||||
// rather than being guessed from a heading.
|
||||
verification: "",
|
||||
lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string),
|
||||
sections: markdownSections(body.content as string),
|
||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||
}
|
||||
|
||||
if (kind === "REFERENCE") {
|
||||
const body = (detail.reference as Readonly<Record<string, unknown>>) ?? {};
|
||||
return Object.freeze({
|
||||
...baseOf("REFERENCE", slug, {
|
||||
title: body.title as string,
|
||||
summary: body.purposeSummary as string,
|
||||
path: canonicalPath,
|
||||
primaryTopic: body.primaryTopic as never,
|
||||
primaryProject: body.primaryProject as never,
|
||||
publishedAt: body.publishedAt as string,
|
||||
relations: flattenRelations(groups, {
|
||||
originCases: "이 기준이 나온 기록",
|
||||
projectDecisions: "이 기준을 따르는 결정",
|
||||
relatedReferences: "관련 기준",
|
||||
}),
|
||||
}),
|
||||
kind: "REFERENCE",
|
||||
purpose: (body.purposeSummary as string) ?? "",
|
||||
rules: Object.freeze(
|
||||
markdownSections(body.content as string).map((section) => ({
|
||||
title: section.title,
|
||||
body: section.paragraphs.join("\n"),
|
||||
})),
|
||||
),
|
||||
applyWhen: Object.freeze(markdownLines(body.applyWhenMarkdown as string)),
|
||||
exceptions: Object.freeze(markdownLines(body.exceptionsMarkdown as string)),
|
||||
examples: Object.freeze(markdownLines(body.examplesMarkdown as string)),
|
||||
verifiedAt: dateLabel(body.lastVerifiedAt as string),
|
||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||
}
|
||||
|
||||
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
|
||||
const points = (body.points as readonly Readonly<Record<string, unknown>>[] | undefined) ?? [];
|
||||
const pointsOf = (group: string) =>
|
||||
Object.freeze(
|
||||
points
|
||||
.filter((point) => point.group === group)
|
||||
.flatMap((point) => (point.items as readonly string[] | undefined) ?? []),
|
||||
);
|
||||
return Object.freeze({
|
||||
...baseOf("QUESTION", slug, {
|
||||
title: body.question as string,
|
||||
summary: body.summary as string,
|
||||
path: canonicalPath,
|
||||
primaryTopic: body.primaryTopic as never,
|
||||
primaryProject: body.primaryProject as never,
|
||||
publishedAt: body.updatedAt as string,
|
||||
relations: flattenRelations(groups, {
|
||||
derivedCases: "이 질문에서 나온 기록",
|
||||
projectDecisions: "이 질문이 이끈 결정",
|
||||
relatedQuestions: "관련 질문",
|
||||
}),
|
||||
}),
|
||||
kind: "QUESTION",
|
||||
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
||||
facts: pointsOf("KNOWN_FACT"),
|
||||
assumptions: pointsOf("ASSUMPTION"),
|
||||
unknowns: pointsOf("UNRESOLVED"),
|
||||
constraints: pointsOf("CONSTRAINT"),
|
||||
options: Object.freeze([]),
|
||||
nextValidation: (body.nextVerification as string) ?? "",
|
||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||
}
|
||||
|
||||
async function getProject(slug: string): Promise<Project | undefined> {
|
||||
const detail = await read<Readonly<Record<string, unknown>>>("getPublicProject", { slug });
|
||||
if (detail === NOT_FOUND) return undefined;
|
||||
const body = (detail.project as Readonly<Record<string, unknown>>) ?? {};
|
||||
const [decisions, activity] = await Promise.all([
|
||||
getProjectDecisions(slug),
|
||||
getProjectActivity(slug),
|
||||
]);
|
||||
return Object.freeze({
|
||||
slug,
|
||||
title: String(body.name ?? ""),
|
||||
summary: String(body.oneLinePurpose ?? ""),
|
||||
thesis: String(body.purpose ?? body.oneLinePurpose ?? ""),
|
||||
stage: body.phase === "VALIDATION" ? "VALIDATION" : "DESIGN",
|
||||
currentGoal: String(body.currentObjective ?? ""),
|
||||
nextStep: String(body.nextStep ?? ""),
|
||||
topics: Object.freeze(
|
||||
((body.topics as readonly Readonly<{ name?: string }>[] | undefined) ?? [])
|
||||
.map((topic) => topic.name ?? "")
|
||||
.filter((name) => name.length > 0),
|
||||
),
|
||||
decisions: Object.freeze(decisions),
|
||||
activity: Object.freeze(activity),
|
||||
});
|
||||
}
|
||||
|
||||
async function getProjectDecisions(projectSlug: string): Promise<ProjectDecision[]> {
|
||||
const page = await read<Page>("listPublicProjectDecisions", { slug: projectSlug });
|
||||
if (page === NOT_FOUND) return [];
|
||||
return (page.items ?? []).map(decisionItemToDecision);
|
||||
}
|
||||
|
||||
async function getProjectActivity(projectSlug: string): Promise<ProjectActivity[]> {
|
||||
const page = await read<Page>("listPublicProjectActivities", { slug: projectSlug });
|
||||
if (page === NOT_FOUND) return [];
|
||||
return (page.items ?? []).map(activityItemToActivity);
|
||||
}
|
||||
|
||||
async function getProjectRecords(projectSlug: string): Promise<PublicRecord[]> {
|
||||
const page = await read<Page>("listPublicProjectRecords", { slug: projectSlug });
|
||||
if (page === NOT_FOUND) return [];
|
||||
return (page.items ?? [])
|
||||
.map(knowledgeListItemToRecord)
|
||||
.filter((record): record is PublicRecord => record !== null);
|
||||
}
|
||||
|
||||
async function getRelease(version: string): Promise<Release | undefined> {
|
||||
const detail = await read<Readonly<Record<string, unknown>>>("getPublicRelease", { version });
|
||||
if (detail === NOT_FOUND) return undefined;
|
||||
return releaseDetailToRelease(detail, version);
|
||||
}
|
||||
|
||||
/**
|
||||
* The home screen shows up to three focus cards. The contract returns them as
|
||||
* one object with a named slot per kind rather than a list, because each slot
|
||||
* has its own shape; the order below is the order the screen renders them in.
|
||||
*/
|
||||
async function listTopics(): Promise<PublicTopic[]> {
|
||||
const page = await read<Page>("listPublicTopics", {});
|
||||
if (page === NOT_FOUND) return [];
|
||||
return (page.items ?? []).map((item) =>
|
||||
Object.freeze({
|
||||
name: String(item.name ?? ""),
|
||||
slug: String(item.slug ?? ""),
|
||||
recordCount: Number(item.recordCount ?? 0),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function getHomeFocusItems(): Promise<HomeFocusItem[]> {
|
||||
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
|
||||
"getPublicHome",
|
||||
{},
|
||||
);
|
||||
if (home === NOT_FOUND) return [];
|
||||
const focus = (home.focus ?? {}) as Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
||||
const items: HomeFocusItem[] = [];
|
||||
const work = focus.currentWork;
|
||||
if (work) {
|
||||
items.push(
|
||||
Object.freeze({
|
||||
key: "current",
|
||||
label: "지금 하는 일",
|
||||
title: String(work.projectName ?? ""),
|
||||
summary: String(work.purpose ?? ""),
|
||||
details: Object.freeze([
|
||||
{ label: "단계", value: String(work.phase ?? "") },
|
||||
{ label: "현재 목표", value: String(work.currentObjective ?? "") },
|
||||
{ label: "다음 작업", value: String(work.nextStep ?? "") },
|
||||
]),
|
||||
targetPath: String(work.projectPath ?? "/projects"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const question = focus.openQuestion;
|
||||
if (question) {
|
||||
items.push(
|
||||
Object.freeze({
|
||||
key: "question",
|
||||
label: "열린 질문",
|
||||
title: String(question.question ?? ""),
|
||||
summary: String(question.summary ?? ""),
|
||||
details: Object.freeze([
|
||||
{ label: "확인한 사실", value: ((question.knownFacts as readonly string[]) ?? []).join(" · ") },
|
||||
{ label: "미해결", value: ((question.unresolvedPoints as readonly string[]) ?? []).join(" · ") },
|
||||
{ label: "다음 검증", value: String(question.nextVerification ?? "") },
|
||||
]),
|
||||
targetPath: String(question.questionPath ?? "/explore/questions"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const decision = focus.recentDecision;
|
||||
if (decision) {
|
||||
items.push(
|
||||
Object.freeze({
|
||||
key: "decision",
|
||||
label: "최근 결정",
|
||||
title: String(decision.statement ?? ""),
|
||||
summary: String(decision.rationale ?? ""),
|
||||
details: Object.freeze([
|
||||
{ label: "결정일", value: dateLabel(decision.decidedAt as string) },
|
||||
{ label: "영향", value: ((decision.consequences as readonly string[]) ?? []).join(" · ") },
|
||||
]),
|
||||
targetPath: String(decision.decisionPath ?? "/projects"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 검색어는 검색이 아니라 "카탈로그 전부"라는 뜻이다.
|
||||
*
|
||||
* 픽스처가 그렇게 동작했고 화면들이 그 의미에 기대어 쓰고 있다 — 홈 타임라인,
|
||||
* 프로젝트 목록, 릴리즈 목록, 탐색 필터가 전부 `searchPublicContent("")` 로 카탈로그를
|
||||
* 받아 간다. 계약에는 그런 의미가 없고 `q` 는 필수라, 그대로 보내면 400
|
||||
* (`PUBLIC_REQUEST_INVALID`) 이 오고 홈을 포함한 네 화면이 통째로 오류 화면이 된다.
|
||||
*
|
||||
* 그래서 빈 검색어는 검색 엔드포인트로 보내지 않고, 계약이 이미 가진 목록
|
||||
* 엔드포인트에서 조립한다. 검색어가 있으면 그때는 서버 검색을 쓴다 — 클라이언트에서
|
||||
* 거르면 페이지 밖의 결과를 영영 못 찾는다.
|
||||
*/
|
||||
async function searchPublicContent(query: string): Promise<SearchablePublicEntity[]> {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length > 0) {
|
||||
const page = await read<Page>("searchPublicResources", { q: trimmed });
|
||||
if (page === NOT_FOUND) return [];
|
||||
return (page.items ?? []).map(searchItemToEntity);
|
||||
}
|
||||
|
||||
const [knowledge, questions, projects, releases] = await Promise.all([
|
||||
read<Page>("exploreKnowledge", {}),
|
||||
read<Page>("exploreQuestions", {}),
|
||||
read<Page>("listPublicProjects", {}),
|
||||
read<Page>("listPublicReleases", {}),
|
||||
]);
|
||||
const items = (page: Page | typeof NOT_FOUND) =>
|
||||
page === NOT_FOUND ? [] : (page.items ?? []);
|
||||
|
||||
const entities: SearchablePublicEntity[] = [];
|
||||
for (const item of items(knowledge)) {
|
||||
const record = knowledgeListItemToRecord(item);
|
||||
if (record) entities.push(recordToEntity(record));
|
||||
}
|
||||
for (const item of items(questions)) {
|
||||
entities.push(recordToEntity(questionListItemToRecord(item)));
|
||||
}
|
||||
for (const item of items(projects)) {
|
||||
entities.push(
|
||||
Object.freeze({
|
||||
contentType: "PROJECT",
|
||||
title: String(item.name ?? ""),
|
||||
summary: String(item.oneLinePurpose ?? ""),
|
||||
path: String(item.path ?? `/projects/${String(item.slug ?? "")}`),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const item of items(releases)) {
|
||||
entities.push(
|
||||
Object.freeze({
|
||||
contentType: "RELEASE",
|
||||
title: String(item.title ?? ""),
|
||||
summary: String(item.summary ?? ""),
|
||||
path: String(item.path ?? `/releases/${String(item.version ?? "")}`),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
function recordToEntity(record: PublicRecord): SearchablePublicEntity {
|
||||
return Object.freeze({
|
||||
contentType: record.kind,
|
||||
title: record.title,
|
||||
summary: record.summary,
|
||||
path: record.path,
|
||||
...(record.topic ? { topic: record.topic } : {}),
|
||||
...(record.projectTitle ? { project: record.projectTitle } : {}),
|
||||
...(record.publishedAt ? { publishedAt: record.publishedAt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
listRecords,
|
||||
getRecord,
|
||||
getProject,
|
||||
getRelease,
|
||||
listTopics,
|
||||
getProjectRecords,
|
||||
getProjectDecisions,
|
||||
getProjectActivity,
|
||||
getHomeFocusItems,
|
||||
searchPublicContent,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import type {
|
||||
CaseRecord,
|
||||
ProjectActivity,
|
||||
ProjectDecision,
|
||||
PublicRecord,
|
||||
QuestionRecord,
|
||||
RecordSection,
|
||||
ReferenceRecord,
|
||||
Release,
|
||||
SearchablePublicEntity,
|
||||
} from "../../application/ports/public-content-queries.ts";
|
||||
|
||||
/**
|
||||
* The contract and the screens disagree about shape, on purpose.
|
||||
*
|
||||
* The contract speaks in what the server stores — timestamps, one markdown body,
|
||||
* relations grouped by their kind. The screens were built against a catalog that
|
||||
* spoke in what a page renders — formatted labels, sections, one flat relation
|
||||
* list. Neither is wrong, and translating here rather than at either end is what
|
||||
* keeps the presentation components untouched by this migration.
|
||||
*
|
||||
* Where the contract has no counterpart the value is empty rather than invented,
|
||||
* and the gap is named at the call site.
|
||||
*/
|
||||
|
||||
const DATE_LABEL = new Intl.DateTimeFormat("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
/** `2026. 08. 20.` → `2026.08.20`, the form the fixture used. */
|
||||
export function dateLabel(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "";
|
||||
return DATE_LABEL.format(parsed).replaceAll(" ", "").replace(/\.$/u, "");
|
||||
}
|
||||
|
||||
export function isoDate(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The contract carries one markdown body; the document components render an
|
||||
* ordered list of titled sections. Splitting on `##` reproduces that structure
|
||||
* without a full renderer: everything before the first heading is the lead, and
|
||||
* each heading opens a section whose bullets are its `-`/`*` lines.
|
||||
*
|
||||
* This is deliberately not the Studio parser (`parseCaseContent`). That one
|
||||
* produces the canonical render-block union the editor needs — inline marks,
|
||||
* evidence directives, tables — which is a richer tree than `RecordSection` can
|
||||
* hold. Reusing it would mean flattening its output back down to this shape, and
|
||||
* flattening loses exactly the blocks that made it worth using.
|
||||
*/
|
||||
export function markdownSections(body: string | null | undefined): RecordSection[] {
|
||||
if (!body) return [];
|
||||
const sections: RecordSection[] = [];
|
||||
let current: { id: string; title: string; paragraphs: string[]; bullets: string[] } | null = null;
|
||||
const flush = () => {
|
||||
if (!current) return;
|
||||
sections.push(
|
||||
Object.freeze({
|
||||
id: current.id,
|
||||
title: current.title,
|
||||
paragraphs: Object.freeze([...current.paragraphs]),
|
||||
...(current.bullets.length > 0 ? { bullets: Object.freeze([...current.bullets]) } : {}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
for (const rawLine of body.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
const heading = /^#{2,3}\s+(.*)$/u.exec(line);
|
||||
if (heading) {
|
||||
flush();
|
||||
const title = heading[1]!.trim();
|
||||
current = { id: slugOf(title, sections.length), title, paragraphs: [], bullets: [] };
|
||||
continue;
|
||||
}
|
||||
if (!current) {
|
||||
if (line.length === 0) continue;
|
||||
current = { id: "lead", title: "", paragraphs: [], bullets: [] };
|
||||
}
|
||||
if (line.length === 0) continue;
|
||||
const bullet = /^[-*]\s+(.*)$/u.exec(line);
|
||||
if (bullet) current.bullets.push(bullet[1]!.trim());
|
||||
else current.paragraphs.push(line);
|
||||
}
|
||||
flush();
|
||||
return sections;
|
||||
}
|
||||
|
||||
/** Markdown that is really a list — the release document's four bodies are. */
|
||||
export function markdownLines(body: string | null | undefined): string[] {
|
||||
if (!body) return [];
|
||||
return body
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => line.replace(/^[-*]\s+/u, ""));
|
||||
}
|
||||
|
||||
function slugOf(title: string, index: number): string {
|
||||
const normalized = title
|
||||
.toLocaleLowerCase("ko-KR")
|
||||
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
|
||||
.replace(/^-+|-+$/gu, "");
|
||||
return normalized.length > 0 ? normalized : `section-${index + 1}`;
|
||||
}
|
||||
|
||||
type Related = Readonly<{ type?: string; title?: string; summary?: string; path?: string }>;
|
||||
|
||||
/**
|
||||
* The contract groups relations by why they relate (origin question, derived
|
||||
* references, related cases); the renderer takes one list where the reason is a
|
||||
* label. Flattening keeps the group name as that label.
|
||||
*/
|
||||
export function flattenRelations(
|
||||
groups: Readonly<Record<string, Related | readonly Related[] | undefined>>,
|
||||
labels: Readonly<Record<string, string>>,
|
||||
): ReadonlyArray<{ reason: string; title: string; path: string }> {
|
||||
const flat: { reason: string; title: string; path: string }[] = [];
|
||||
for (const [group, value] of Object.entries(groups)) {
|
||||
if (!value) continue;
|
||||
const reason = labels[group] ?? group;
|
||||
for (const entry of Array.isArray(value) ? value : [value as Related]) {
|
||||
if (!entry?.path || !entry.title) continue;
|
||||
flat.push({ reason, title: entry.title, path: entry.path });
|
||||
}
|
||||
}
|
||||
return Object.freeze(flat);
|
||||
}
|
||||
|
||||
type Summary = Readonly<{ name?: string; slug?: string; path?: string }> | undefined;
|
||||
|
||||
export function baseOf(
|
||||
kind: PublicRecord["kind"],
|
||||
slug: string,
|
||||
fields: Readonly<{
|
||||
title?: string;
|
||||
summary?: string;
|
||||
path?: string;
|
||||
primaryTopic?: Summary;
|
||||
primaryProject?: Summary;
|
||||
publishedAt?: string | null;
|
||||
relations?: ReadonlyArray<{ reason: string; title: string; path: string }>;
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
kind,
|
||||
slug,
|
||||
title: fields.title ?? "",
|
||||
summary: fields.summary ?? "",
|
||||
path: fields.path ?? "",
|
||||
topic: fields.primaryTopic?.name ?? "",
|
||||
topicSlug: fields.primaryTopic?.slug ?? "",
|
||||
projectSlug: fields.primaryProject?.slug ?? "",
|
||||
projectTitle: fields.primaryProject?.name ?? "",
|
||||
publishedAt: isoDate(fields.publishedAt),
|
||||
publishedLabel: dateLabel(fields.publishedAt),
|
||||
visibility: "PUBLIC" as const,
|
||||
relations: fields.relations ?? Object.freeze([]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A list endpoint answers with what a list row needs, not with a whole document.
|
||||
* The port's type is the full record, so the detail fields are filled empty here
|
||||
* and the detail screens fetch by slug. That is the same trip the fixture made
|
||||
* for free; it is a real extra request now, and the alternative — widening the
|
||||
* list response — would send every body to render a title.
|
||||
*/
|
||||
export function knowledgeListItemToRecord(item: Readonly<Record<string, unknown>>): PublicRecord | null {
|
||||
const type = String(item.type ?? "");
|
||||
const path = String(item.path ?? "");
|
||||
const slug = path.split("/").filter(Boolean).pop() ?? "";
|
||||
const base = baseOf(type === "REFERENCE" ? "REFERENCE" : "CASE", slug, {
|
||||
title: item.title as string,
|
||||
summary: (item.primarySummary as string) ?? "",
|
||||
path,
|
||||
primaryTopic: item.primaryTopic as Summary,
|
||||
primaryProject: item.primaryProject as Summary,
|
||||
publishedAt: item.publishedAt as string,
|
||||
});
|
||||
if (type === "CASE") {
|
||||
return Object.freeze({
|
||||
...base,
|
||||
kind: "CASE",
|
||||
problem: (item.primarySummary as string) ?? "",
|
||||
conclusion: (item.secondarySummary as string) ?? "",
|
||||
environment: "",
|
||||
verification: "",
|
||||
lastVerifiedLabel: dateLabel(item.lastVerifiedAt as string),
|
||||
sections: Object.freeze([]),
|
||||
}) as CaseRecord;
|
||||
}
|
||||
if (type === "REFERENCE") {
|
||||
return Object.freeze({
|
||||
...base,
|
||||
kind: "REFERENCE",
|
||||
purpose: (item.primarySummary as string) ?? "",
|
||||
rules: Object.freeze([]),
|
||||
applyWhen: Object.freeze([]),
|
||||
exceptions: Object.freeze([]),
|
||||
examples: Object.freeze([]),
|
||||
verifiedAt: dateLabel(item.lastVerifiedAt as string),
|
||||
}) as ReferenceRecord;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function questionListItemToRecord(item: Readonly<Record<string, unknown>>): QuestionRecord {
|
||||
const path = String(item.path ?? "");
|
||||
const slug = path.split("/").filter(Boolean).pop() ?? "";
|
||||
return Object.freeze({
|
||||
...baseOf("QUESTION", slug, {
|
||||
title: item.question as string,
|
||||
summary: (item.summary as string) ?? "",
|
||||
path,
|
||||
primaryProject: item.primaryProject as Summary,
|
||||
publishedAt: item.updatedAt as string,
|
||||
}),
|
||||
kind: "QUESTION",
|
||||
questionStatus: (item.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
||||
facts: Object.freeze([]),
|
||||
assumptions: Object.freeze([]),
|
||||
unknowns: Object.freeze([]),
|
||||
constraints: Object.freeze([]),
|
||||
options: Object.freeze([]),
|
||||
nextValidation: (item.nextVerification as string) ?? "",
|
||||
}) as QuestionRecord;
|
||||
}
|
||||
|
||||
export function decisionItemToDecision(item: Readonly<Record<string, unknown>>): ProjectDecision {
|
||||
const sources = [item.sourceQuestion, item.sourceCase]
|
||||
.filter((entry): entry is Related => Boolean(entry))
|
||||
.map((entry) => ({ title: entry.title ?? "", path: entry.path ?? "" }));
|
||||
return Object.freeze({
|
||||
id: String(item.id ?? ""),
|
||||
status: (item.status as ProjectDecision["status"]) ?? "PROPOSED",
|
||||
date: dateLabel(item.decidedAt as string),
|
||||
title: String(item.statement ?? ""),
|
||||
statement: String(item.statement ?? ""),
|
||||
rationale: String(item.rationaleSummary ?? ""),
|
||||
// The list response carries a rationale summary, not the consequence list the
|
||||
// decision screen renders; the contract has no field for it here.
|
||||
consequences: Object.freeze([]),
|
||||
evidence: Object.freeze(sources),
|
||||
});
|
||||
}
|
||||
|
||||
export function activityItemToActivity(
|
||||
item: Readonly<Record<string, unknown>>,
|
||||
index: number,
|
||||
): ProjectActivity {
|
||||
const occurredAt = item.occurredAt as string;
|
||||
const relatedPath = (item.relatedPath as string) ?? "";
|
||||
return Object.freeze({
|
||||
id: `activity-${index + 1}`,
|
||||
date: dateLabel(occurredAt),
|
||||
dateTime: isoDate(occurredAt),
|
||||
type: (item.type as ProjectActivity["type"]) ?? "PROJECT UPDATE",
|
||||
title: String(item.title ?? ""),
|
||||
summary: String(item.summary ?? ""),
|
||||
path: relatedPath,
|
||||
...(relatedPath ? { recordPath: relatedPath } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function releaseDetailToRelease(
|
||||
detail: Readonly<Record<string, unknown>>,
|
||||
version: string,
|
||||
): Release {
|
||||
const related = (detail.relatedRecords as readonly Related[] | undefined) ?? [];
|
||||
return Object.freeze({
|
||||
version: String(detail.version ?? version),
|
||||
path: `/releases/${String(detail.version ?? version)}`,
|
||||
title: String(detail.title ?? ""),
|
||||
summary: String(detail.summary ?? ""),
|
||||
publishedAt: isoDate(detail.releasedOn as string),
|
||||
publishedLabel: dateLabel(detail.releasedOn as string),
|
||||
changes: Object.freeze(markdownLines(detail.changesMarkdown as string)),
|
||||
reasons: Object.freeze(markdownLines(detail.reasonMarkdown as string)),
|
||||
impacts: Object.freeze([
|
||||
...markdownLines(detail.userImpactMarkdown as string),
|
||||
...markdownLines(detail.implementationImpactMarkdown as string),
|
||||
]),
|
||||
related: Object.freeze(
|
||||
related
|
||||
.filter((entry) => entry.path && entry.title)
|
||||
.map((entry) => ({ title: entry.title!, path: entry.path! })),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function searchItemToEntity(
|
||||
item: Readonly<Record<string, unknown>>,
|
||||
): SearchablePublicEntity {
|
||||
const topic = (item.primaryTopic as Summary)?.name;
|
||||
const project = (item.primaryProject as Summary)?.name;
|
||||
return Object.freeze({
|
||||
contentType: (item.contentType as SearchablePublicEntity["contentType"]) ?? "CASE",
|
||||
title: String(item.title ?? ""),
|
||||
summary: String(item.snippet ?? ""),
|
||||
path: String(item.path ?? ""),
|
||||
...(topic ? { topic } : {}),
|
||||
...(project ? { project } : {}),
|
||||
...(item.publishedAt ? { publishedAt: isoDate(item.publishedAt as string) } : {}),
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
type Release,
|
||||
type HomeFocusItem,
|
||||
} from "./public-content.ts";
|
||||
import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts";
|
||||
import type {
|
||||
PublicContentQueries,
|
||||
PublicTopic,
|
||||
} from "../../application/ports/public-content-queries.ts";
|
||||
|
||||
export type RecordFilters = {
|
||||
kind?: RecordKind;
|
||||
@@ -98,6 +101,24 @@ export function getProjectActivity(projectSlug: string): ProjectActivity[] {
|
||||
return [...(getProject(projectSlug)?.activity ?? [])];
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 카탈로그에는 주제 테이블이 없다 — 기록마다 붙은 주제 이름이 있을 뿐이다. 그것을 모아
|
||||
* 세면 백엔드의 `listPublicTopics` 와 같은 모양이 되고, 이 어댑터의 목적(백엔드 없이 화면을
|
||||
* 그린다)에도 맞는다.
|
||||
*/
|
||||
export function listTopics(): PublicTopic[] {
|
||||
const counts = new Map<string, { name: string; slug: string; recordCount: number }>();
|
||||
for (const record of listRecords()) {
|
||||
if (!record.topic) continue;
|
||||
const existing = counts.get(record.topicSlug);
|
||||
if (existing) existing.recordCount += 1;
|
||||
else counts.set(record.topicSlug, { name: record.topic, slug: record.topicSlug, recordCount: 1 });
|
||||
}
|
||||
return [...counts.values()]
|
||||
.sort((left, right) => right.recordCount - left.recordCount || (left.name < right.name ? -1 : 1))
|
||||
.map((entry) => Object.freeze(entry));
|
||||
}
|
||||
|
||||
export function getHomeFocusItems(): HomeFocusItem[] {
|
||||
const project = getProject("backend-skeleton");
|
||||
const question = getRecord("QUESTION", "validate-edge-token-again");
|
||||
@@ -208,14 +229,45 @@ export function searchPublicContent(query: string): SearchablePublicEntity[] {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The MOCK source. The functions above stay synchronous — they filter arrays
|
||||
* that are already in the bundle, and making them async would only add a
|
||||
* microtask to every fixture test — so the port's async shape is applied here,
|
||||
* at the adapter boundary, rather than pushed into the query implementations.
|
||||
*
|
||||
* `async` rather than `Promise.resolve(...)` so a throw from one of these
|
||||
* becomes a rejected promise like the HTTP adapter's would, instead of
|
||||
* escaping synchronously past the caller's await.
|
||||
*/
|
||||
export const publicContentQueries = Object.freeze({
|
||||
listRecords,
|
||||
getRecord,
|
||||
getProject,
|
||||
getRelease,
|
||||
getProjectRecords,
|
||||
getProjectDecisions,
|
||||
getProjectActivity,
|
||||
getHomeFocusItems,
|
||||
searchPublicContent,
|
||||
async listRecords(filters?: RecordFilters) {
|
||||
return listRecords(filters);
|
||||
},
|
||||
async getRecord<K extends RecordKind>(kind: K, slug: string) {
|
||||
return getRecord(kind, slug);
|
||||
},
|
||||
async getProject(slug: string) {
|
||||
return getProject(slug);
|
||||
},
|
||||
async getRelease(version: string) {
|
||||
return getRelease(version);
|
||||
},
|
||||
async getProjectRecords(projectSlug: string) {
|
||||
return getProjectRecords(projectSlug);
|
||||
},
|
||||
async getProjectDecisions(projectSlug: string) {
|
||||
return getProjectDecisions(projectSlug);
|
||||
},
|
||||
async getProjectActivity(projectSlug: string) {
|
||||
return getProjectActivity(projectSlug);
|
||||
},
|
||||
async listTopics() {
|
||||
return listTopics();
|
||||
},
|
||||
async getHomeFocusItems() {
|
||||
return getHomeFocusItems();
|
||||
},
|
||||
async searchPublicContent(query: string) {
|
||||
return searchPublicContent(query);
|
||||
},
|
||||
}) satisfies PublicContentQueries;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
CreateDraftResponse,
|
||||
ProjectEditResponse,
|
||||
ProjectIndexPage,
|
||||
ProjectUpdateRequest,
|
||||
PublishResponse,
|
||||
ReleaseEditResponse,
|
||||
ReleaseIndexPage,
|
||||
ReleaseUpdateRequest,
|
||||
TopicEdit,
|
||||
} from "../../contracts/management/contract.ts";
|
||||
|
||||
/**
|
||||
* 주제·프로젝트·릴리즈·작업본 삭제 관리 표면.
|
||||
*
|
||||
* <p>Studio 게이트웨이와 같은 실패 규약이다 — 실패는 던지고, 화면이 잡는다. MOCK 대응물을 두지
|
||||
* 않는 것도 의도다: 이 표면은 백엔드가 없으면 존재할 이유가 없고, 픽스처를 만들면 실제로는 만들
|
||||
* 수 없는 주제를 화면이 보여주게 된다.
|
||||
*/
|
||||
export type ManagementGateway = Readonly<{
|
||||
listTopics(): Promise<TopicEdit[]>;
|
||||
createTopic(input: TopicEdit): Promise<TopicEdit>;
|
||||
updateTopic(id: string, body: TopicEdit): Promise<TopicEdit>;
|
||||
deleteTopic(id: string, expectedVersion: number): Promise<void>;
|
||||
listProjects(page?: number, size?: number): Promise<ProjectIndexPage>;
|
||||
getProject(id: string): Promise<ProjectEditResponse>;
|
||||
createProject(title: string): Promise<CreateDraftResponse>;
|
||||
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
|
||||
deleteProject(id: string, expectedVersion: number): Promise<void>;
|
||||
listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>;
|
||||
getRelease(id: string): Promise<ReleaseEditResponse>;
|
||||
createRelease(title: string): Promise<CreateDraftResponse>;
|
||||
updateRelease(id: string, body: ReleaseUpdateRequest): Promise<ReleaseEditResponse>;
|
||||
deleteRelease(id: string, expectedVersion: number): Promise<void>;
|
||||
publishRelease(id: string, expectedVersion: number): Promise<PublishResponse>;
|
||||
archiveRelease(id: string, expectedVersion: number): Promise<ReleaseEditResponse>;
|
||||
/**
|
||||
* 작업본 삭제. 종류마다 다른 endpoint 인 것은 계약의 모양이자 저장 구조다 — Case 와 Reference
|
||||
* 는 한 테이블을 나눠 쓰고 Question 은 다른 테이블이다. Decision 은 계약에 삭제가 없다:
|
||||
* 그쪽 수명주기는 수락·기각·대체이고, 그건 지우는 것이 아니라 무슨 일이 있었는지 남기는 것이다.
|
||||
*/
|
||||
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION", id: string, expectedVersion: number): Promise<void>;
|
||||
/**
|
||||
* Decision 은 프로젝트에 속하므로 경로가 둘을 요구한다. 다른 종류처럼 한 번에 묶지 않는 이유는
|
||||
* 계약이 그렇게 선언했고, 실제로도 프로젝트 밖의 Decision 은 존재하지 않기 때문이다.
|
||||
*/
|
||||
deleteDecision(projectId: string, decisionId: string, expectedVersion: number): Promise<void>;
|
||||
}>;
|
||||
@@ -132,6 +132,13 @@ export type Release = {
|
||||
related: ReadonlyArray<{ title: string; path: string }>;
|
||||
};
|
||||
|
||||
/** 계약 `TopicSummary`. 목록에 필요한 만큼만 옮긴다. */
|
||||
export type PublicTopic = {
|
||||
name: string;
|
||||
slug: string;
|
||||
recordCount: number;
|
||||
};
|
||||
|
||||
export type FocusKey = "current" | "question" | "decision";
|
||||
|
||||
export type HomeFocusItem = {
|
||||
@@ -165,17 +172,36 @@ export type SearchablePublicEntity = {
|
||||
* The application-facing boundary for the immutable source Public catalog.
|
||||
* Method signatures intentionally retain the source query argument and return shapes.
|
||||
*/
|
||||
/**
|
||||
* The public read surface.
|
||||
*
|
||||
* Every method is async because one of the two adapters behind this port is a
|
||||
* network client. The other reads a bundled fixture and could answer
|
||||
* synchronously, but a port has one shape: if the fixture adapter kept the
|
||||
* synchronous signature, the HTTP adapter could not implement the same port
|
||||
* and callers written against the fixture would not compile against the
|
||||
* network.
|
||||
*
|
||||
* Failures throw rather than resolving to a Result. That matches the Studio
|
||||
* gateways, and it lets `useApplicationQuery` classify a rejection once at the
|
||||
* boundary instead of every caller unwrapping.
|
||||
*/
|
||||
export type PublicContentQueries = Readonly<{
|
||||
listRecords(filters?: RecordFilters): PublicRecord[];
|
||||
listRecords(filters?: RecordFilters): Promise<PublicRecord[]>;
|
||||
getRecord<K extends RecordKind>(
|
||||
kind: K,
|
||||
slug: string,
|
||||
): Extract<PublicRecord, { kind: K }> | undefined;
|
||||
getProject(slug: string): Project | undefined;
|
||||
getRelease(version: string): Release | undefined;
|
||||
getProjectRecords(projectSlug: string): PublicRecord[];
|
||||
getProjectDecisions(projectSlug: string): ProjectDecision[];
|
||||
getProjectActivity(projectSlug: string): ProjectActivity[];
|
||||
getHomeFocusItems(): HomeFocusItem[];
|
||||
searchPublicContent(query: string): SearchablePublicEntity[];
|
||||
): Promise<Extract<PublicRecord, { kind: K }> | undefined>;
|
||||
getProject(slug: string): Promise<Project | undefined>;
|
||||
getRelease(version: string): Promise<Release | undefined>;
|
||||
getProjectRecords(projectSlug: string): Promise<PublicRecord[]>;
|
||||
getProjectDecisions(projectSlug: string): Promise<ProjectDecision[]>;
|
||||
getProjectActivity(projectSlug: string): Promise<ProjectActivity[]>;
|
||||
/**
|
||||
* 공개된 주제 목록. 프로필의 "주요 관심 주제"가 이 값을 그린다 — 그 목록은 코드에 박혀
|
||||
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
|
||||
*/
|
||||
listTopics(): Promise<PublicTopic[]>;
|
||||
getHomeFocusItems(): Promise<HomeFocusItem[]>;
|
||||
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
|
||||
}>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
|
||||
import type { StudioAssetGateway } from "./ports/studio-asset-gateway.ts";
|
||||
import type { ManagementGateway } from "./ports/management-gateway.ts";
|
||||
import type { StudioGateway } from "./ports/studio-gateway.ts";
|
||||
|
||||
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
|
||||
@@ -8,6 +9,9 @@ export type TechLogFeatureInput = Readonly<{
|
||||
publicContent: PublicContentQueries;
|
||||
createStudioGateway(): StudioGateway;
|
||||
createStudioAssetGateway(): StudioAssetGateway;
|
||||
// 주제·프로젝트 관리. MOCK 대응물이 없다 — 이 표면은 백엔드가 없으면 존재할 이유가 없고,
|
||||
// 픽스처를 만들면 실제로는 못 만드는 주제를 화면이 보여주게 된다.
|
||||
createManagementGateway(): ManagementGateway;
|
||||
}>;
|
||||
|
||||
declare module "../../../application/ports/in/application-api.ts" {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"packageId": "@tech-log/management-contract",
|
||||
"version": "1.0.0",
|
||||
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
|
||||
"sourceRevision": "65a04fc",
|
||||
"operationIds": [
|
||||
"createCaseDraft",
|
||||
"getCaseForEdit",
|
||||
"updateCaseDraft",
|
||||
"deleteCaseDraft",
|
||||
"createReferenceDraft",
|
||||
"getReferenceForEdit",
|
||||
"updateReferenceDraft",
|
||||
"deleteReferenceDraft",
|
||||
"validateCase",
|
||||
"submitReviewCase",
|
||||
"returnToDraftCase",
|
||||
"unpublishCase",
|
||||
"archiveCase",
|
||||
"restoreCase",
|
||||
"publishCase",
|
||||
"validateReference",
|
||||
"submitReviewReference",
|
||||
"returnToDraftReference",
|
||||
"unpublishReference",
|
||||
"archiveReference",
|
||||
"restoreReference",
|
||||
"publishReference",
|
||||
"createQuestion",
|
||||
"listStudioQuestions",
|
||||
"getQuestionForEdit",
|
||||
"updateQuestion",
|
||||
"deleteQuestion",
|
||||
"addQuestionUpdate",
|
||||
"updateQuestionUpdate",
|
||||
"deleteQuestionUpdate",
|
||||
"resolveQuestion",
|
||||
"startQuestionInvestigation",
|
||||
"pauseQuestion",
|
||||
"resumeQuestion",
|
||||
"reopenQuestion",
|
||||
"archiveQuestion",
|
||||
"publishQuestion",
|
||||
"unpublishQuestion",
|
||||
"createProject",
|
||||
"listStudioProjects",
|
||||
"getProjectForEdit",
|
||||
"updateProject",
|
||||
"deleteProject",
|
||||
"changeProjectPhase",
|
||||
"publishProject",
|
||||
"unpublishProject",
|
||||
"createProjectDecision",
|
||||
"listStudioProjectDecisions",
|
||||
"getProjectDecision",
|
||||
"updateProjectDecision",
|
||||
"deleteProjectDecision",
|
||||
"acceptProjectDecision",
|
||||
"rejectProjectDecision",
|
||||
"supersedeProjectDecision",
|
||||
"createRelease",
|
||||
"listStudioReleases",
|
||||
"getReleaseForEdit",
|
||||
"updateRelease",
|
||||
"deleteRelease",
|
||||
"publishRelease",
|
||||
"archiveRelease",
|
||||
"listStudioTopics",
|
||||
"createTopic",
|
||||
"updateTopic",
|
||||
"deleteTopic",
|
||||
"listStudioTags",
|
||||
"createTag",
|
||||
"updateTag",
|
||||
"deleteTag",
|
||||
"getStudioSite",
|
||||
"updateStudioSite",
|
||||
"getStudioProfile",
|
||||
"updateStudioProfile",
|
||||
"publishProfile",
|
||||
"unpublishProfile",
|
||||
"getHomeFocus",
|
||||
"updateHomeFocus",
|
||||
"listStudioProjectActivities",
|
||||
"createProjectActivity",
|
||||
"updateProjectActivity"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { components } from "./generated.ts";
|
||||
|
||||
type Schemas = components["schemas"];
|
||||
|
||||
export type TopicEdit = Schemas["TopicEdit"];
|
||||
export type ProjectEditResponse = Schemas["ProjectEditResponse"];
|
||||
export type ProjectIndexItem = Schemas["ProjectIndexItem"];
|
||||
export type ProjectIndexPage = Schemas["ProjectIndexPage"];
|
||||
export type ProjectUpdateRequest = Schemas["ProjectUpdateRequest"];
|
||||
export type CreateDraftRequest = Schemas["CreateDraftRequest"];
|
||||
export type CreateDraftResponse = Schemas["CreateDraftResponse"];
|
||||
export type ExpectedVersionRequest = Schemas["ExpectedVersionRequest"];
|
||||
export type ReleaseEditResponse = Schemas["ReleaseEditResponse"];
|
||||
export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
|
||||
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
|
||||
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
|
||||
export type PublishResponse = Schemas["PublishResponse"];
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,5 +4,8 @@ export const publicSiteConfig = Object.freeze({
|
||||
operator: "동현",
|
||||
contactLabel: "프로필",
|
||||
contactPath: "/profile",
|
||||
latestRelease: "/releases/0.1.0",
|
||||
// 변경 기록 목록. 이전에는 특정 버전(`/releases/0.1.0`)을 박아 두었는데, 그 릴리즈가 아직
|
||||
// 없어서 푸터 링크가 404 였고, 있었더라도 다음 버전이 나오면 다시 낡는 자리였다. 목록은 어떤
|
||||
// 버전이 최신인지 아는 유일한 곳이고 릴리즈가 하나도 없어도 성립한다.
|
||||
releasesPath: "/releases",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.0.0",
|
||||
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
|
||||
"sourceRevision": "65a04fc",
|
||||
"operationIds": [
|
||||
"getPublicSite",
|
||||
"getPublicHome",
|
||||
"exploreKnowledge",
|
||||
"exploreQuestions",
|
||||
"listPublicTopics",
|
||||
"getPublicTopic",
|
||||
"getPublicCase",
|
||||
"getPublicReference",
|
||||
"getPublicQuestion",
|
||||
"listPublicProjects",
|
||||
"getPublicProject",
|
||||
"listPublicProjectDecisions",
|
||||
"listPublicProjectRecords",
|
||||
"listPublicProjectActivities",
|
||||
"listPublicReleases",
|
||||
"getPublicRelease",
|
||||
"getPublicProfile",
|
||||
"searchPublicResources",
|
||||
"getPublicMedia"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "3.0.0",
|
||||
"digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4",
|
||||
"sourceRevision": "b20d7a2",
|
||||
"digest": "sha256:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4",
|
||||
"sourceRevision": "65a04fc",
|
||||
"operationIds": [
|
||||
"getStudioSession",
|
||||
"getStudioDashboard",
|
||||
|
||||
@@ -645,7 +645,7 @@ export interface components {
|
||||
constraints: components["schemas"]["OrderedText"][];
|
||||
options: components["schemas"]["QuestionOption"][];
|
||||
nextValidation: string;
|
||||
resolution: components["schemas"]["QuestionResolution"] | null;
|
||||
resolution?: components["schemas"]["QuestionResolution"] | null;
|
||||
} & {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
|
||||
@@ -959,7 +959,12 @@ components:
|
||||
allOf:
|
||||
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
|
||||
- type: object
|
||||
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
|
||||
# `resolution` 은 여기 없다. 미해결 질문에는 해결 내용이 없고, 그것을 required 로 두면
|
||||
# Java 생성기가 nullable 여부와 무관하게 @NotNull 을 찍는다 — oneOf 로 적은 null 을 그
|
||||
# 생성기는 읽지 못한다. 실제로 그래서 Question 작업본을 만들 수 없었다: 프론트가 계약대로
|
||||
# resolution: null 을 보냈고 백엔드가 422 로 거절했다. 같은 목록의 `questionStatus` 가
|
||||
# 통과하는 것은 그쪽이 nullability 를 `type: [string, "null"]` 로 적었기 때문이다.
|
||||
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation]
|
||||
properties:
|
||||
kind: { type: string, enum: [QUESTION] }
|
||||
questionStatus:
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import type {
|
||||
CommandEffectDescriptor,
|
||||
InstalledContractContribution,
|
||||
InstalledHttpContract,
|
||||
} from "../../../contracts/external-contract-runtime.ts";
|
||||
import type { ProblemDetails } from "./studio/contract.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
|
||||
import canonicalSource from "./management/canonical-source.json" with { type: "json" };
|
||||
import {
|
||||
envelopeData,
|
||||
envelopeError,
|
||||
passthroughInput,
|
||||
} from "./tech-log-studio-contract-contribution.ts";
|
||||
import { TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID } from "../adapters/http/studio-session-credentials.ts";
|
||||
|
||||
type PathValues = Readonly<Record<string, string>>;
|
||||
type QueryEntries = readonly (readonly [string, string])[];
|
||||
|
||||
const NO_PATH: PathValues = Object.freeze({});
|
||||
const NO_QUERY = Object.freeze([]) as QueryEntries;
|
||||
/** studio-management-v1.yaml `ApiError.code` enum과 1:1이다. */
|
||||
const MANAGEMENT_ERROR_CODES = Object.freeze([
|
||||
"AUTHENTICATION_REQUIRED",
|
||||
"STUDIO_ACCESS_DENIED",
|
||||
"REQUEST_VALIDATION_FAILED",
|
||||
"VERSION_CONFLICT",
|
||||
"TOPIC_NOT_FOUND",
|
||||
"TOPIC_NAME_TAKEN",
|
||||
"TOPIC_SLUG_TAKEN",
|
||||
"TOPIC_IN_USE",
|
||||
"PROJECT_NOT_FOUND",
|
||||
"PROJECT_SLUG_TAKEN",
|
||||
"PROJECT_IN_USE",
|
||||
"RELEASE_NOT_FOUND",
|
||||
"RELEASE_VERSION_TAKEN",
|
||||
"RELEASE_NOT_PUBLISHABLE",
|
||||
"DOCUMENT_NOT_FOUND",
|
||||
"DOCUMENT_PUBLISHED",
|
||||
"DOCUMENT_IN_USE",
|
||||
"QUESTION_NOT_FOUND",
|
||||
"QUESTION_IN_USE",
|
||||
"DECISION_NOT_FOUND",
|
||||
"DECISION_IN_USE",
|
||||
"INTERNAL_ERROR",
|
||||
]);
|
||||
|
||||
const PROBLEM = envelopeError(MANAGEMENT_ERROR_CODES, "ManagementErrorEnvelope");
|
||||
|
||||
/** Studio 쪽과 같은 판정이다: 4xx 도메인 거절은 적용되지 않았음이 확정, 5xx·네트워크는 불확정. */
|
||||
const COMMAND_EFFECT: CommandEffectDescriptor<ProblemDetails> = Object.freeze({
|
||||
successEffect: "APPLIED_CONFIRMED" as const,
|
||||
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
|
||||
return status >= 400 && status < 500 ? "NOT_APPLIED" : "MAYBE_APPLIED";
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 관리 표면은 Studio 와 같은 세션·CSRF 를 쓴다. 계약이 `sessionCookie` 보안과 `CsrfToken`
|
||||
* 파라미터를 선언하고 있고, 실제로 같은 백엔드의 같은 필터 체인을 지난다 — 그래서 인증 프로필도
|
||||
* 공유한다. 부트스트랩 프로필은 쓰지 않는다: CSRF 토큰을 발급하는 것은 `getStudioSession`
|
||||
* 하나뿐이고, 이 표면은 그 뒤에만 호출된다.
|
||||
*/
|
||||
function readOperation(
|
||||
operationId: string,
|
||||
pathTemplate: string,
|
||||
responseByteLimit: number,
|
||||
project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }> = () =>
|
||||
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
|
||||
): InstalledHttpContract<unknown, unknown, unknown> {
|
||||
return Object.freeze({
|
||||
contract: Object.freeze({
|
||||
operationId,
|
||||
method: "GET" as const,
|
||||
pathTemplate,
|
||||
inputValidator: passthroughInput(`${operationId}Input`),
|
||||
outputValidator: envelopeData(`${operationId}Output`),
|
||||
problemValidator: PROBLEM,
|
||||
acceptedStatuses: Object.freeze([200]),
|
||||
emptyBodyStatuses: Object.freeze([]),
|
||||
retrySemantics: "SAFE" as const,
|
||||
requestBody: "NONE" as const,
|
||||
responseBody: "REQUIRED_JSON" as const,
|
||||
commandRecovery: null,
|
||||
commandEffect: null,
|
||||
projectRequest(input: never) {
|
||||
return Object.freeze({ ...project(input), body: null });
|
||||
},
|
||||
}),
|
||||
frontend: Object.freeze({
|
||||
policyId: `${operationId}_V1`,
|
||||
requestByteLimit: 0,
|
||||
responseByteLimit,
|
||||
totalDeadlineMs: 10_000,
|
||||
retryBudget: 2 as const,
|
||||
authProfileId: TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID,
|
||||
diagnosticsOperation: `techLog.management.${operationId}`,
|
||||
}),
|
||||
}) as InstalledHttpContract<unknown, unknown, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰기는 `Idempotency-Key` 를 쓰지 않는다 — 계약이 요구하지 않고, 재생 보호는 `expectedVersion`
|
||||
* 이 맡는다. 그래서 `NOT_IDEMPOTENT` 가 아니라 재시도 예산 0 으로 둔다: 응답을 못 본 재시도가
|
||||
* 두 번째 생성을 만들 수 있는 표면이다.
|
||||
*/
|
||||
function writeOperation(
|
||||
operationId: string,
|
||||
method: "POST" | "PUT" | "DELETE",
|
||||
pathTemplate: string,
|
||||
options: Readonly<{
|
||||
acceptedStatuses: readonly number[];
|
||||
emptyBodyStatuses?: readonly number[];
|
||||
requestByteLimit: number;
|
||||
responseByteLimit: number;
|
||||
}>,
|
||||
project: (input: never) => Readonly<{
|
||||
pathValues: PathValues;
|
||||
queryEntries: QueryEntries;
|
||||
body: unknown;
|
||||
}>,
|
||||
): InstalledHttpContract<unknown, unknown, unknown> {
|
||||
return Object.freeze({
|
||||
contract: Object.freeze({
|
||||
operationId,
|
||||
method,
|
||||
pathTemplate,
|
||||
inputValidator: passthroughInput(`${operationId}Input`),
|
||||
outputValidator: envelopeData(`${operationId}Output`),
|
||||
problemValidator: PROBLEM,
|
||||
acceptedStatuses: Object.freeze([...options.acceptedStatuses]),
|
||||
emptyBodyStatuses: Object.freeze([...(options.emptyBodyStatuses ?? [])]),
|
||||
// 생성은 재생 보호가 없으므로 NEVER 다. 수정·삭제는 expectedVersion 이 두 번째
|
||||
// 적용을 409 로 막으므로 IDEMPOTENT 로 둘 수 있지만, 세 경우를 한 헬퍼가 만들고
|
||||
// 있어 가장 보수적인 값으로 통일한다 — 재시도 예산도 0 이라 실제 차이는 없다.
|
||||
retrySemantics: "NEVER" as const,
|
||||
requestBody: "JSON" as const,
|
||||
responseBody:
|
||||
(options.emptyBodyStatuses ?? []).length > 0
|
||||
? ("OPTIONAL_JSON" as const)
|
||||
: ("REQUIRED_JSON" as const),
|
||||
commandRecovery: null,
|
||||
commandEffect: COMMAND_EFFECT,
|
||||
projectRequest(input: never) {
|
||||
return Object.freeze(project(input));
|
||||
},
|
||||
}),
|
||||
frontend: Object.freeze({
|
||||
policyId: `${operationId}_V1`,
|
||||
requestByteLimit: options.requestByteLimit,
|
||||
responseByteLimit: options.responseByteLimit,
|
||||
totalDeadlineMs: 15_000,
|
||||
retryBudget: 0 as const,
|
||||
authProfileId: TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID,
|
||||
diagnosticsOperation: `techLog.management.${operationId}`,
|
||||
}),
|
||||
// read 쪽과 달리 여기서만 unknown 을 거친다: `emptyBodyStatuses` 유무로 responseBody 가
|
||||
// 갈리는 삼항이 union 타입을 만들어, 컴파일러가 리터럴을 대상 타입과 겹친다고 보지 않는다.
|
||||
}) as unknown as InstalledHttpContract<unknown, unknown, unknown>;
|
||||
}
|
||||
|
||||
const byId = (input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string }>;
|
||||
return Object.freeze({ pathValues: Object.freeze({ id: value.id }), queryEntries: NO_QUERY });
|
||||
};
|
||||
|
||||
const T = "/api/v1/studio/topics";
|
||||
const P = "/api/v1/studio/projects";
|
||||
const R = "/api/v1/studio/releases";
|
||||
const D = "/api/v1/studio";
|
||||
|
||||
const HTTP_CONTRACTS = Object.freeze([
|
||||
readOperation("listStudioTopics", T, 131_072),
|
||||
writeOperation(
|
||||
"createTopic",
|
||||
"POST",
|
||||
T,
|
||||
{ acceptedStatuses: [201], requestByteLimit: 16_384, responseByteLimit: 16_384 },
|
||||
(input: never) =>
|
||||
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
|
||||
),
|
||||
writeOperation(
|
||||
"updateTopic",
|
||||
"PUT",
|
||||
`${T}/{id}`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: value.body,
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"deleteTopic",
|
||||
"DELETE",
|
||||
`${T}/{id}`,
|
||||
{
|
||||
acceptedStatuses: [204],
|
||||
emptyBodyStatuses: [204],
|
||||
requestByteLimit: 1_024,
|
||||
responseByteLimit: 1_024,
|
||||
},
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
readOperation("listStudioProjects", P, 262_144, (input: never) => {
|
||||
const value = input as unknown as Readonly<{ page?: number; size?: number }> | undefined;
|
||||
const entries: (readonly [string, string])[] = [];
|
||||
if (value?.page !== undefined) entries.push(["page", String(value.page)]);
|
||||
if (value?.size !== undefined) entries.push(["size", String(value.size)]);
|
||||
return Object.freeze({ pathValues: NO_PATH, queryEntries: Object.freeze(entries) });
|
||||
}),
|
||||
readOperation("getProjectForEdit", `${P}/{id}`, 262_144, byId),
|
||||
writeOperation(
|
||||
"createProject",
|
||||
"POST",
|
||||
P,
|
||||
{ acceptedStatuses: [201], requestByteLimit: 4_096, responseByteLimit: 8_192 },
|
||||
(input: never) =>
|
||||
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
|
||||
),
|
||||
writeOperation(
|
||||
"updateProject",
|
||||
"PUT",
|
||||
`${P}/{id}`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 131_072, responseByteLimit: 262_144 },
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: value.body,
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"deleteProject",
|
||||
"DELETE",
|
||||
`${P}/{id}`,
|
||||
{
|
||||
acceptedStatuses: [204],
|
||||
emptyBodyStatuses: [204],
|
||||
requestByteLimit: 1_024,
|
||||
responseByteLimit: 1_024,
|
||||
},
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
readOperation("listStudioReleases", R, 262_144, (input: never) => {
|
||||
const value = input as unknown as Readonly<{ page?: number; size?: number }> | undefined;
|
||||
const entries: (readonly [string, string])[] = [];
|
||||
if (value?.page !== undefined) entries.push(["page", String(value.page)]);
|
||||
if (value?.size !== undefined) entries.push(["size", String(value.size)]);
|
||||
return Object.freeze({ pathValues: NO_PATH, queryEntries: Object.freeze(entries) });
|
||||
}),
|
||||
readOperation("getReleaseForEdit", `${R}/{id}`, 262_144, byId),
|
||||
writeOperation(
|
||||
"createRelease",
|
||||
"POST",
|
||||
R,
|
||||
{ acceptedStatuses: [201], requestByteLimit: 4_096, responseByteLimit: 8_192 },
|
||||
(input: never) =>
|
||||
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
|
||||
),
|
||||
writeOperation(
|
||||
"updateRelease",
|
||||
"PUT",
|
||||
`${R}/{id}`,
|
||||
// 릴리즈 본문은 마크다운 여섯 구획이라 문서 다음으로 큰 요청이다.
|
||||
{ acceptedStatuses: [200], requestByteLimit: 262_144, responseByteLimit: 262_144 },
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: value.body,
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"deleteRelease",
|
||||
"DELETE",
|
||||
`${R}/{id}`,
|
||||
{
|
||||
acceptedStatuses: [204],
|
||||
emptyBodyStatuses: [204],
|
||||
requestByteLimit: 1_024,
|
||||
responseByteLimit: 1_024,
|
||||
},
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"publishRelease",
|
||||
"POST",
|
||||
`${R}/{id}/publish`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 },
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"archiveRelease",
|
||||
"POST",
|
||||
`${R}/{id}/archive`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 262_144 },
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"deleteCaseDraft",
|
||||
"DELETE",
|
||||
`${D}/cases/{id}`,
|
||||
{
|
||||
acceptedStatuses: [204],
|
||||
emptyBodyStatuses: [204],
|
||||
requestByteLimit: 1_024,
|
||||
responseByteLimit: 1_024,
|
||||
},
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"deleteReferenceDraft",
|
||||
"DELETE",
|
||||
`${D}/references/{id}`,
|
||||
{
|
||||
acceptedStatuses: [204],
|
||||
emptyBodyStatuses: [204],
|
||||
requestByteLimit: 1_024,
|
||||
responseByteLimit: 1_024,
|
||||
},
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"deleteQuestion",
|
||||
"DELETE",
|
||||
`${D}/questions/{id}`,
|
||||
{
|
||||
acceptedStatuses: [204],
|
||||
emptyBodyStatuses: [204],
|
||||
requestByteLimit: 1_024,
|
||||
responseByteLimit: 1_024,
|
||||
},
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"deleteProjectDecision",
|
||||
"DELETE",
|
||||
`${P}/{id}/decisions/{decisionId}`,
|
||||
{
|
||||
acceptedStatuses: [204],
|
||||
emptyBodyStatuses: [204],
|
||||
requestByteLimit: 1_024,
|
||||
responseByteLimit: 1_024,
|
||||
},
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{
|
||||
id: string;
|
||||
decisionId: string;
|
||||
expectedVersion: number;
|
||||
}>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id, decisionId: value.decisionId }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
export const TECH_LOG_MANAGEMENT_OPERATION_IDS = Object.freeze(
|
||||
HTTP_CONTRACTS.map((entry) => entry.contract.operationId),
|
||||
);
|
||||
|
||||
export const TECH_LOG_MANAGEMENT_CONTRIBUTION: InstalledContractContribution = Object.freeze({
|
||||
contributionId: "tech-log-management-http-v1",
|
||||
featureId: TECH_LOG_FEATURE_ID,
|
||||
source: Object.freeze({
|
||||
kind: "EXTERNAL_PACKAGE" as const,
|
||||
package: Object.freeze({
|
||||
packageId: canonicalSource.packageId,
|
||||
version: canonicalSource.version,
|
||||
digest: canonicalSource.digest as `sha256:${string}`,
|
||||
runtimeProtocolVersion: 1 as const,
|
||||
sourceRevision: canonicalSource.sourceRevision,
|
||||
}),
|
||||
}),
|
||||
http: HTTP_CONTRACTS,
|
||||
events: Object.freeze([]),
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import type {
|
||||
InstalledContractContribution,
|
||||
InstalledHttpContract,
|
||||
} from "../../../contracts/external-contract-runtime.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
|
||||
import canonicalSource from "./public/canonical-source.json" with { type: "json" };
|
||||
import { envelopeData, envelopeError, passthroughInput } from "./tech-log-studio-contract-contribution.ts";
|
||||
|
||||
type PathValues = Readonly<Record<string, string>>;
|
||||
type QueryEntries = readonly (readonly [string, string])[];
|
||||
|
||||
const NO_PATH: PathValues = Object.freeze({});
|
||||
const NO_QUERY = Object.freeze([]) as QueryEntries;
|
||||
/**
|
||||
* public-v1.yaml `ApiError.code` enum과 1:1이다. `INTERNAL_ERROR` 는 이 기능이
|
||||
* 아니라 스켈레톤 공통 처리기가 내는 코드이고, 계약이 그것까지 열거하므로 여기도
|
||||
* 열거한다 — 빠지면 500 응답이 계약 위반으로 분류된다.
|
||||
*/
|
||||
const PUBLIC_ERROR_CODES = Object.freeze([
|
||||
"PUBLIC_REQUEST_INVALID",
|
||||
"PUBLIC_RESOURCE_NOT_FOUND",
|
||||
"INTERNAL_ERROR",
|
||||
]);
|
||||
|
||||
const PROBLEM = envelopeError(PUBLIC_ERROR_CODES, "PublicErrorEnvelope");
|
||||
|
||||
function queryOf(input: Readonly<Record<string, unknown>>): QueryEntries {
|
||||
const entries: (readonly [string, string])[] = [];
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
entries.push([key, String(value)]);
|
||||
}
|
||||
return Object.freeze(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every public operation has the same shape, which is the point of holding this
|
||||
* surface apart from Studio: all 18 are GET, none carries a body, none needs a
|
||||
* session, and none needs a CSRF token. The `ANONYMOUS` auth profile is what
|
||||
* states that — it forbids credentials outright, so a future change that starts
|
||||
* sending the session cookie on a public read fails the profile check rather
|
||||
* than silently making the cache-friendly surface user-specific.
|
||||
*/
|
||||
function publicRead(
|
||||
operationId: string,
|
||||
pathTemplate: string,
|
||||
responseByteLimit: number,
|
||||
project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }> = () =>
|
||||
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
|
||||
): InstalledHttpContract<unknown, unknown, unknown> {
|
||||
return Object.freeze({
|
||||
contract: Object.freeze({
|
||||
operationId,
|
||||
method: "GET" as const,
|
||||
pathTemplate,
|
||||
inputValidator: passthroughInput(`${operationId}Input`),
|
||||
outputValidator: envelopeData(`${operationId}Output`),
|
||||
problemValidator: PROBLEM,
|
||||
// 404 is a normal answer here — a slug that is not published — so it is
|
||||
// mapped by the gateway rather than treated as a transport failure.
|
||||
acceptedStatuses: Object.freeze([200]),
|
||||
emptyBodyStatuses: Object.freeze([]),
|
||||
retrySemantics: "SAFE" as const,
|
||||
requestBody: "NONE" as const,
|
||||
responseBody: "REQUIRED_JSON" as const,
|
||||
commandRecovery: null,
|
||||
commandEffect: null,
|
||||
projectRequest(input: never) {
|
||||
return Object.freeze({ ...project(input), body: null });
|
||||
},
|
||||
}),
|
||||
frontend: Object.freeze({
|
||||
policyId: `${operationId}_V1`,
|
||||
requestByteLimit: 0,
|
||||
responseByteLimit,
|
||||
totalDeadlineMs: 10_000,
|
||||
retryBudget: 2 as const,
|
||||
authProfileId: "ANONYMOUS",
|
||||
diagnosticsOperation: `techLog.public.${operationId}`,
|
||||
}),
|
||||
}) as InstalledHttpContract<unknown, unknown, unknown>;
|
||||
}
|
||||
|
||||
const bySlug = (input: never) => {
|
||||
const value = input as unknown as Readonly<{ slug: string }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ slug: value.slug }),
|
||||
queryEntries: NO_QUERY,
|
||||
});
|
||||
};
|
||||
|
||||
const P = "/api/v1/public";
|
||||
|
||||
const HTTP_CONTRACTS = Object.freeze([
|
||||
publicRead("getPublicSite", `${P}/site`, 32_768),
|
||||
publicRead("getPublicHome", `${P}/home`, 262_144),
|
||||
publicRead("exploreKnowledge", `${P}/explore/knowledge`, 262_144, (input: never) => {
|
||||
const value = input as unknown as Readonly<{
|
||||
type?: string;
|
||||
topic?: string;
|
||||
project?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}>;
|
||||
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
|
||||
}),
|
||||
publicRead("exploreQuestions", `${P}/explore/questions`, 262_144, (input: never) => {
|
||||
const value = input as unknown as Readonly<{
|
||||
status?: string;
|
||||
topic?: string;
|
||||
project?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}>;
|
||||
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
|
||||
}),
|
||||
publicRead("listPublicTopics", `${P}/topics`, 65_536),
|
||||
publicRead("getPublicTopic", `${P}/topics/{topicSlug}`, 262_144, (input: never) => {
|
||||
const value = input as unknown as Readonly<{ topicSlug: string }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ topicSlug: value.topicSlug }),
|
||||
queryEntries: NO_QUERY,
|
||||
});
|
||||
}),
|
||||
publicRead("getPublicCase", `${P}/cases/{slug}`, 524_288, bySlug),
|
||||
publicRead("getPublicReference", `${P}/references/{slug}`, 524_288, bySlug),
|
||||
publicRead("getPublicQuestion", `${P}/questions/{slug}`, 524_288, bySlug),
|
||||
publicRead("listPublicProjects", `${P}/projects`, 262_144),
|
||||
publicRead("getPublicProject", `${P}/projects/{slug}`, 262_144, bySlug),
|
||||
publicRead("listPublicProjectDecisions", `${P}/projects/{slug}/decisions`, 262_144, bySlug),
|
||||
publicRead("listPublicProjectRecords", `${P}/projects/{slug}/records`, 262_144, bySlug),
|
||||
publicRead("listPublicProjectActivities", `${P}/projects/{slug}/activities`, 262_144, bySlug),
|
||||
publicRead("listPublicReleases", `${P}/releases`, 262_144),
|
||||
publicRead("getPublicRelease", `${P}/releases/{version}`, 262_144, (input: never) => {
|
||||
const value = input as unknown as Readonly<{ version: string }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ version: value.version }),
|
||||
queryEntries: NO_QUERY,
|
||||
});
|
||||
}),
|
||||
publicRead("getPublicProfile", `${P}/profile`, 65_536),
|
||||
publicRead("searchPublicResources", `${P}/search`, 262_144, (input: never) => {
|
||||
const value = input as unknown as Readonly<{ q?: string; page?: number; size?: number }>;
|
||||
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
|
||||
}),
|
||||
]);
|
||||
|
||||
export const TECH_LOG_PUBLIC_OPERATION_IDS = Object.freeze(
|
||||
HTTP_CONTRACTS.map((entry) => entry.contract.operationId),
|
||||
);
|
||||
|
||||
export type TechLogPublicOperationId =
|
||||
(typeof TECH_LOG_PUBLIC_OPERATION_IDS)[number];
|
||||
|
||||
export const TECH_LOG_PUBLIC_CONTRIBUTION: InstalledContractContribution =
|
||||
Object.freeze({
|
||||
contributionId: "tech-log-public-http-v1",
|
||||
featureId: TECH_LOG_FEATURE_ID,
|
||||
source: Object.freeze({
|
||||
kind: "EXTERNAL_PACKAGE" as const,
|
||||
package: Object.freeze({
|
||||
packageId: canonicalSource.packageId,
|
||||
version: canonicalSource.version,
|
||||
digest: canonicalSource.digest as `sha256:${string}`,
|
||||
runtimeProtocolVersion: 1 as const,
|
||||
sourceRevision: canonicalSource.sourceRevision,
|
||||
}),
|
||||
}),
|
||||
http: HTTP_CONTRACTS,
|
||||
events: Object.freeze([]),
|
||||
});
|
||||
@@ -48,6 +48,8 @@ const TECH_LOG_ROUTE_SPECS = [
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATIONS", path: "/studio/publications", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "게시 기록", navigationLabel: "게시 기록", navigationOrder: 20 }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
|
||||
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
|
||||
] as const;
|
||||
@@ -139,7 +141,13 @@ export const TECH_LOG_ROUTE_REGISTRY = Object.freeze(
|
||||
spec.routeId,
|
||||
Object.freeze({
|
||||
...spec,
|
||||
access: "public",
|
||||
// Studio routes are the authenticated surface. Deriving this from the
|
||||
// spec's own `layoutGroup` -- rather than restating it per route --
|
||||
// keeps a newly added Studio route gated by construction. Registering
|
||||
// every TechLog route as "public" made `decideRouteAccessForDefinition`
|
||||
// a no-op for Studio: a signed-out visitor who typed /studio got the
|
||||
// Studio shell rendered instead of the sign-in surface.
|
||||
access: spec.layoutGroup === "STUDIO" ? "session-required" : "public",
|
||||
loadingSurface: spec.path.endsWith("*") ? "none" : "app-shell",
|
||||
errorSurface: spec.path.endsWith("*")
|
||||
? "not-found"
|
||||
|
||||
@@ -52,6 +52,12 @@ function zodValidator<T>(schemaId: string, schema: z.ZodType<T>): RuntimeValidat
|
||||
const passthrough = <T>(schemaId: string) =>
|
||||
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>);
|
||||
|
||||
/**
|
||||
* Shared with the public contribution: both surfaces project their request
|
||||
* inputs in code, so neither re-validates them at the transport boundary.
|
||||
*/
|
||||
export const passthroughInput = passthrough;
|
||||
|
||||
/**
|
||||
* wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는
|
||||
* 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신
|
||||
@@ -71,14 +77,15 @@ export const envelopeData = <T>(schemaId: string): RuntimeValidator<T> =>
|
||||
.transform((envelope) => envelope.data as T) as unknown as z.ZodType<T>,
|
||||
);
|
||||
|
||||
const apiErrorSchema = z
|
||||
.object({
|
||||
code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]),
|
||||
category: z.string().min(1),
|
||||
message: z.string().min(1).max(5000),
|
||||
retryable: z.boolean(),
|
||||
})
|
||||
.loose();
|
||||
const apiErrorSchema = (codes: readonly string[]) =>
|
||||
z
|
||||
.object({
|
||||
code: z.enum(codes as unknown as [string, ...string[]]),
|
||||
category: z.string().min(1),
|
||||
message: z.string().min(1).max(5000),
|
||||
retryable: z.boolean(),
|
||||
})
|
||||
.loose();
|
||||
|
||||
/**
|
||||
* 봉투 오류를 기존 ProblemDetails 형태로 옮긴다. 앱 계층(`StudioGatewayError`)은
|
||||
@@ -94,11 +101,27 @@ const apiErrorSchema = z
|
||||
* 않는다. 지금 그 필드들을 평평하게 읽는 소비자가 없고, 분해는 실제 소비자가
|
||||
* 생겼을 때 추가할 투기적 작업이다.
|
||||
*/
|
||||
export const envelopeError = (): RuntimeValidator<ProblemDetails> =>
|
||||
/**
|
||||
* The code enum is per-surface, and getting that wrong took the public site
|
||||
* down. Public, Studio and Management each declare their own `ApiError.code`
|
||||
* enum in their own contract; this validator was pinned to the Studio list and
|
||||
* shared by all three, so every public error — `PUBLIC_RESOURCE_NOT_FOUND`
|
||||
* first among them — failed the enum, became a CONTRACT_VIOLATION rather than a
|
||||
* PROBLEM, and reached the screens as an unclassifiable failure. A visitor
|
||||
* following a link to a project that no longer exists got the terminal error
|
||||
* surface instead of a not-found page, and no gate noticed, because a strict
|
||||
* enum checked against the wrong surface's contract still looks strict.
|
||||
*
|
||||
* Each caller now passes the enum from its own contract.
|
||||
*/
|
||||
export const envelopeError = (
|
||||
codes: readonly string[] = STUDIO_ERROR_CODES as readonly string[],
|
||||
schemaId = "StudioErrorEnvelope",
|
||||
): RuntimeValidator<ProblemDetails> =>
|
||||
zodValidator<ProblemDetails>(
|
||||
"StudioErrorEnvelope",
|
||||
schemaId,
|
||||
z
|
||||
.object({ success: z.literal(false), error: apiErrorSchema, meta: metaSchema })
|
||||
.object({ success: z.literal(false), error: apiErrorSchema(codes), meta: metaSchema })
|
||||
.loose()
|
||||
.transform((envelope) => ({
|
||||
type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
export function ExploreFilterForm({
|
||||
action,
|
||||
@@ -18,20 +17,34 @@ export function ExploreFilterForm({
|
||||
showType?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const publicRecords = publicContent.listRecords();
|
||||
const topics = [...new Set(publicRecords.map((record) => record.topic))].sort();
|
||||
const projectPrefix = "/projects/";
|
||||
const projects = publicContent
|
||||
.searchPublicContent("")
|
||||
.filter((entity) => entity.contentType === "PROJECT")
|
||||
.flatMap((entity) => {
|
||||
if (!entity.path.startsWith(projectPrefix)) return [];
|
||||
const item = publicContent.getProject(
|
||||
decodeURIComponent(entity.path.slice(projectPrefix.length)),
|
||||
// This form sits inside a page that renders its own loading state, so it does
|
||||
// not hand back a fallback of its own — that would put a second skeleton
|
||||
// inside a screen already showing one, and move the layout under it. It
|
||||
// renders its real structure immediately with empty option lists and fills
|
||||
// them in when the catalog arrives.
|
||||
const view = usePublicContent(["tech-log", "explore-filters"], async (queries) => {
|
||||
const projectPrefix = "/projects/";
|
||||
const [records, entities] = await Promise.all([
|
||||
queries.listRecords(),
|
||||
queries.searchPublicContent(""),
|
||||
]);
|
||||
const projectSlugs = entities
|
||||
.filter((entity) => entity.contentType === "PROJECT")
|
||||
.flatMap((entity) =>
|
||||
entity.path.startsWith(projectPrefix)
|
||||
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
|
||||
: [],
|
||||
);
|
||||
return item ? [{ slug: item.slug, title: item.title }] : [];
|
||||
});
|
||||
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
|
||||
return {
|
||||
topics: [...new Set(records.map((record) => record.topic))].sort(),
|
||||
projects: resolved
|
||||
.filter((item) => item !== undefined)
|
||||
.map((item) => ({ slug: item.slug, title: item.title })),
|
||||
};
|
||||
});
|
||||
const topics = view.data?.topics ?? [];
|
||||
const projects = view.data?.projects ?? [];
|
||||
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
|
||||
const selectedTopic = topics.find(
|
||||
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
||||
@@ -43,7 +56,11 @@ export function ExploreFilterForm({
|
||||
item.title.toLocaleLowerCase("ko-KR") === normalizedProject,
|
||||
)?.slug;
|
||||
const hasActiveFilter = Boolean((showType && kind) || topic || project);
|
||||
const formKey = [kind, topic, project, showType].join(":");
|
||||
// 선택지는 나중에 도착하는데 아래 select 들은 `defaultValue` 를 쓰는 비제어 요소다 —
|
||||
// 첫 렌더에는 맞출 option 이 없어 값이 비어버린다. 도착 여부를 키에 넣어 그때 폼을
|
||||
// 다시 마운트시키면 defaultValue 가 적용된다. 제어 요소로 바꾸지 않는 이유는 이 폼이
|
||||
// submit 으로 URL 을 만드는 구조라 값의 주인이 URL 이기 때문이다.
|
||||
const formKey = [kind, topic, project, showType, view.ready].join(":");
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
type SearchDialogProps = {
|
||||
className?: string;
|
||||
@@ -19,8 +18,21 @@ export function SearchDialog({
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR");
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const results = publicContent.searchPublicContent(normalizedQuery);
|
||||
// Keyed on the empty query, then filtered here, rather than one request per
|
||||
// keystroke. This is a type-ahead: re-querying per character would replace the
|
||||
// result list with a loading skeleton on every key, which is a worse dialog
|
||||
// than a stale-free local filter. The predicate is the same one the catalog
|
||||
// applies for a non-empty query, so the visible result set is unchanged.
|
||||
const view = usePublicContent(["tech-log", "search", "dialog"], async (queries) => ({
|
||||
entities: await queries.searchPublicContent(""),
|
||||
}));
|
||||
const results = (view.data?.entities ?? []).filter((entity) =>
|
||||
normalizedQuery
|
||||
? [entity.title, entity.summary, entity.topic, entity.project, ...(entity.topics ?? [])]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.some((value) => value.toLocaleLowerCase("ko-KR").includes(normalizedQuery))
|
||||
: true,
|
||||
);
|
||||
|
||||
function open() {
|
||||
onBeforeOpen?.();
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { CaseDocumentPage } from "../components/case-document-page.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
@@ -14,9 +13,12 @@ export function CasePage() {
|
||||
const { params, search } = useRouteInput<"TECH_LOG_CASE">();
|
||||
const slug = optionalString(params.slug);
|
||||
const requestedState = optionalString(search.state);
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const record = slug ? publicContent.getRecord("CASE", slug) : undefined;
|
||||
const view = usePublicContent(["tech-log", "case", slug], async (queries) => ({
|
||||
record: slug ? await queries.getRecord("CASE", slug) : undefined,
|
||||
}));
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { record } = view.data;
|
||||
if (!record) return <RegisteredNotFoundRoute />;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
const kinds = {
|
||||
cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." },
|
||||
@@ -28,18 +27,29 @@ function getKindConfig(value: string | undefined) {
|
||||
|
||||
export function ExploreKindPage() {
|
||||
const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">();
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const kind = optionalString(params.kind);
|
||||
const config = getKindConfig(kind);
|
||||
if (!config) return <RegisteredNotFoundRoute />;
|
||||
const topic = optionalString(search.topic);
|
||||
const project = optionalString(search.project);
|
||||
const records = publicContent.listRecords({
|
||||
kind: config.kind,
|
||||
...(topic ? { topic } : {}),
|
||||
...(project ? { project } : {}),
|
||||
});
|
||||
// The unknown-kind check reads as an early return, but it cannot come before
|
||||
// the query: hooks run unconditionally or React loses the call order. The
|
||||
// loader short-circuits instead, and the not-found route is chosen below.
|
||||
const view = usePublicContent(
|
||||
["tech-log", "explore-kind", config?.kind, topic, project],
|
||||
async (queries) => ({
|
||||
records: config
|
||||
? await queries.listRecords({
|
||||
kind: config.kind,
|
||||
...(topic ? { topic } : {}),
|
||||
...(project ? { project } : {}),
|
||||
})
|
||||
: [],
|
||||
}),
|
||||
);
|
||||
if (!config) return <RegisteredNotFoundRoute />;
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { records } = view.data;
|
||||
return <main id="main-content" className="shell public-index-page">
|
||||
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>{config.title}</h1><p>{config.description}</p></header>
|
||||
<ExploreFilterForm action={`/explore/${kind}`} topic={topic} project={project} showType={false} />
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
@@ -17,13 +16,19 @@ export function ExplorePage() {
|
||||
const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find(
|
||||
(item) => item === requestedKind,
|
||||
) satisfies RecordKind | undefined;
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const records = publicContent.listRecords({
|
||||
...(kind ? { kind } : {}),
|
||||
...(topic ? { topic } : {}),
|
||||
...(project ? { project } : {}),
|
||||
});
|
||||
const view = usePublicContent(
|
||||
["tech-log", "explore", kind, topic, project],
|
||||
async (queries) => ({
|
||||
records: await queries.listRecords({
|
||||
...(kind ? { kind } : {}),
|
||||
...(topic ? { topic } : {}),
|
||||
...(project ? { project } : {}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { records } = view.data;
|
||||
return <main id="main-content" className="shell public-index-page">
|
||||
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>탐색</h1><p>유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다.</p></header>
|
||||
<ExploreFilterForm action="/explore" kind={kind} topic={topic} project={project} />
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
|
||||
import { normalizeFocus } from "../../../domain/public/focus-state.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
import { FatalErrorState } from "../components/fatal-error-state.tsx";
|
||||
import { HomeFocus } from "../components/home-focus.tsx";
|
||||
import {
|
||||
@@ -40,12 +39,14 @@ function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
|
||||
const publicRecords = publicContent.listRecords();
|
||||
async function getLatestEntries(
|
||||
publicContent: PublicContentQueries,
|
||||
): Promise<LatestEntry[]> {
|
||||
const publicRecords = await publicContent.listRecords();
|
||||
const publicRecordByPath = new Map(
|
||||
publicRecords.map((record) => [record.path, record]),
|
||||
);
|
||||
const searchableEntities = publicContent.searchPublicContent("");
|
||||
const searchableEntities = await publicContent.searchPublicContent("");
|
||||
const projectPrefix = "/projects/";
|
||||
const projectSlugs = searchableEntities
|
||||
.filter((entity) => entity.contentType === "PROJECT")
|
||||
@@ -54,10 +55,16 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
|
||||
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
|
||||
: [],
|
||||
);
|
||||
const projectTimeline = projectSlugs.flatMap((projectSlug) => {
|
||||
const project = publicContent.getProject(projectSlug);
|
||||
if (!project) return [];
|
||||
return publicContent.getProjectActivity(projectSlug).map((activity) => {
|
||||
// One project at a time would serialise a request per project; issuing them
|
||||
// together keeps the timeline's cost at its slowest project rather than their
|
||||
// sum. The flatten below restores the original single-list shape.
|
||||
const projectTimeline = (
|
||||
await Promise.all(
|
||||
projectSlugs.map(async (projectSlug) => {
|
||||
const project = await publicContent.getProject(projectSlug);
|
||||
if (!project) return [];
|
||||
const activities = await publicContent.getProjectActivity(projectSlug);
|
||||
return activities.map((activity) => {
|
||||
const record = publicRecordByPath.get(
|
||||
activity.recordPath ?? activity.path,
|
||||
);
|
||||
@@ -77,20 +84,24 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
|
||||
topic: record?.topic ?? project.topics[0] ?? "",
|
||||
project: project.title,
|
||||
path: activity.path,
|
||||
};
|
||||
});
|
||||
});
|
||||
const releaseTimeline = searchableEntities
|
||||
.filter((entity) => entity.contentType === "RELEASE")
|
||||
.flatMap((entity) => {
|
||||
const prefix = "/releases/";
|
||||
if (!entity.path.startsWith(prefix)) return [];
|
||||
const release = publicContent.getRelease(
|
||||
decodeURIComponent(entity.path.slice(prefix.length)),
|
||||
);
|
||||
if (!release) return [];
|
||||
return [
|
||||
{
|
||||
};
|
||||
});
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
const releaseTimeline = (
|
||||
await Promise.all(
|
||||
searchableEntities
|
||||
.filter((entity) => entity.contentType === "RELEASE")
|
||||
.map(async (entity) => {
|
||||
const prefix = "/releases/";
|
||||
if (!entity.path.startsWith(prefix)) return [];
|
||||
const release = await publicContent.getRelease(
|
||||
decodeURIComponent(entity.path.slice(prefix.length)),
|
||||
);
|
||||
if (!release) return [];
|
||||
return [
|
||||
{
|
||||
id: `release-${release.version}`,
|
||||
typeLabel: "RELEASE",
|
||||
title: release.title,
|
||||
@@ -99,10 +110,12 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
|
||||
dateTime: release.publishedAt,
|
||||
topic: "TechLog",
|
||||
project: "TechLog",
|
||||
path: release.path,
|
||||
},
|
||||
];
|
||||
});
|
||||
path: release.path,
|
||||
},
|
||||
];
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
|
||||
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
|
||||
right.dateTime.localeCompare(left.dateTime),
|
||||
@@ -113,8 +126,16 @@ export function HomePage() {
|
||||
const { search } = useRouteInput<"TECH_LOG_HOME">();
|
||||
const requestedKey = optionalString(search.focus);
|
||||
const requestedState = optionalString(search.state);
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const focusItems = publicContent.getHomeFocusItems();
|
||||
const view = usePublicContent(["tech-log", "home"], async (queries) => {
|
||||
const [focusItems, latestEntries] = await Promise.all([
|
||||
queries.getHomeFocusItems(),
|
||||
getLatestEntries(queries),
|
||||
]);
|
||||
return { focusItems, latestEntries };
|
||||
});
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { focusItems, latestEntries } = view.data;
|
||||
const availableFocusItems = requestedState === "focus-empty" ? [] : focusItems;
|
||||
const normalizedKey = normalizeFocus(
|
||||
requestedKey,
|
||||
@@ -131,8 +152,6 @@ export function HomePage() {
|
||||
return <FatalErrorState traceId="PREVIEW-HOME-500" />;
|
||||
}
|
||||
|
||||
const latestEntries = getLatestEntries(publicContent);
|
||||
|
||||
return (
|
||||
<main id="main-content">
|
||||
<section className="shell home-identity" aria-labelledby="home-title">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
|
||||
const principles = [
|
||||
@@ -22,16 +23,31 @@ const principles = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const currentProjectSlugs = ["backend-skeleton", "auth-lab"] as const;
|
||||
const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const;
|
||||
|
||||
export function ProfilePage() {
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const currentProjects = currentProjectSlugs.flatMap((slug) => {
|
||||
const project = publicContent.getProject(slug);
|
||||
return project ? [project] : [];
|
||||
// The two project slugs this named were the static fixture's, and they exist
|
||||
// in no real deployment — the page asked the backend for them, took two 404s,
|
||||
// and rendered nothing but an error. "Current projects" means the published
|
||||
// ones, so read them from the catalogue the projects index already reads.
|
||||
const view = usePublicContent(["tech-log", "profile"], async (queries) => {
|
||||
const entries = (await queries.searchPublicContent("")).filter(
|
||||
(item) => item.contentType === "PROJECT",
|
||||
);
|
||||
const [resolved, topics] = await Promise.all([
|
||||
Promise.all(entries.map((item) => queries.getProject(item.path.replace("/projects/", "")))),
|
||||
queries.listTopics(),
|
||||
]);
|
||||
return {
|
||||
currentProjects: resolved.filter((project) => project !== undefined),
|
||||
topics,
|
||||
};
|
||||
});
|
||||
|
||||
// Only the project list comes from the network. Returning the page-wide
|
||||
// fallback here — as every public screen did — held the operator's name, the
|
||||
// principles, and the topics behind a request that has nothing to do with
|
||||
// them, so a visitor saw a skeleton, then possibly an error, where the page
|
||||
// could have been readable the whole time. The markup below is unchanged;
|
||||
// the fallback now sits in the one section that is actually waiting.
|
||||
return (
|
||||
<main id="main-content" className="shell profile-page">
|
||||
<header className="profile-header">
|
||||
@@ -61,29 +77,45 @@ export function ProfilePage() {
|
||||
<p className="section-kicker">Current</p>
|
||||
<h2 id="profile-projects-title">현재 프로젝트</h2>
|
||||
</div>
|
||||
<ul>
|
||||
{currentProjects.map((project) => (
|
||||
<li key={project.slug}>
|
||||
<Link to={`/projects/${project.slug}`}>
|
||||
<div>
|
||||
<strong>{project.title}</strong>
|
||||
<span>{project.stage}</span>
|
||||
</div>
|
||||
<p>{project.currentGoal}</p>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!view.ready ? (
|
||||
view.fallback
|
||||
) : view.data.currentProjects.length === 0 ? (
|
||||
<p className="public-empty-note">아직 공개된 프로젝트가 없습니다.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{view.data.currentProjects.map((project) => (
|
||||
<li key={project.slug}>
|
||||
<Link to={`/projects/${project.slug}`}>
|
||||
<div>
|
||||
<strong>{project.title}</strong>
|
||||
<span>{project.stage}</span>
|
||||
</div>
|
||||
<p>{project.currentGoal}</p>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
{/*
|
||||
이 목록은 코드에 네 개가 박혀 있었다 — Studio 에서 주제를 만들거나 지워도 프로필은
|
||||
그대로였고, 고치려면 배포를 다시 해야 했다. 이제 공개 주제 목록을 그대로 그린다.
|
||||
*/}
|
||||
<section className="profile-topics" aria-labelledby="profile-topics-title">
|
||||
<p className="section-kicker">Topics</p>
|
||||
<h2 id="profile-topics-title">주요 관심 주제</h2>
|
||||
<ul>
|
||||
{topics.map((topic) => (
|
||||
<li key={topic}>{topic}</li>
|
||||
))}
|
||||
</ul>
|
||||
{!view.ready ? (
|
||||
view.fallback
|
||||
) : view.data.topics.length === 0 ? (
|
||||
<p className="public-empty-note">아직 등록한 주제가 없습니다.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{view.data.topics.map((topic) => (
|
||||
<li key={topic.slug}>{topic.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
export function ProjectActivityPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">();
|
||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const project = publicContent.getProject(slug);
|
||||
const view = usePublicContent(["tech-log", "project", slug, "activity"], async (queries) => {
|
||||
const project = await queries.getProject(slug);
|
||||
return project
|
||||
? { project, activity: await queries.getProjectActivity(slug) }
|
||||
: { project: undefined, activity: [] };
|
||||
});
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { project, activity } = view.data;
|
||||
if (!project) return <RegisteredNotFoundRoute />;
|
||||
|
||||
const activity = publicContent.getProjectActivity(slug);
|
||||
return (
|
||||
<main id="main-content" className="shell project-page">
|
||||
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
export function ProjectDecisionsPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">();
|
||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const project = publicContent.getProject(slug);
|
||||
const view = usePublicContent(["tech-log", "project", slug, "decisions"], async (queries) => {
|
||||
const project = await queries.getProject(slug);
|
||||
return project
|
||||
? { project, decisions: await queries.getProjectDecisions(slug) }
|
||||
: { project: undefined, decisions: [] };
|
||||
});
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { project, decisions } = view.data;
|
||||
if (!project) return <RegisteredNotFoundRoute />;
|
||||
|
||||
const decisions = publicContent.getProjectDecisions(slug);
|
||||
return (
|
||||
<main id="main-content" className="shell project-page">
|
||||
<ProjectPageHeader project={project} title={`${project.title} 결정`} />
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
export function ProjectOverviewPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_PROJECT">();
|
||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const project = publicContent.getProject(slug);
|
||||
const view = usePublicContent(["tech-log", "project", slug, "overview"], async (queries) => {
|
||||
const project = await queries.getProject(slug);
|
||||
if (!project) {
|
||||
return { project: undefined, records: [], decisions: [], activity: [] };
|
||||
}
|
||||
// Three independent reads for one screen: issued together rather than in
|
||||
// sequence, so the page waits for the slowest instead of their sum.
|
||||
const [records, decisions, activity] = await Promise.all([
|
||||
queries.getProjectRecords(slug),
|
||||
queries.getProjectDecisions(slug),
|
||||
queries.getProjectActivity(slug),
|
||||
]);
|
||||
return { project, records, decisions, activity };
|
||||
});
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { project, records, decisions, activity } = view.data;
|
||||
if (!project) return <RegisteredNotFoundRoute />;
|
||||
|
||||
const records = publicContent.getProjectRecords(slug);
|
||||
const decisions = publicContent.getProjectDecisions(slug);
|
||||
const activity = publicContent.getProjectActivity(slug);
|
||||
return (
|
||||
<main id="main-content" className="shell project-page">
|
||||
<ProjectPageHeader project={project} title={project.title} />
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
export function ProjectRecordsPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">();
|
||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const project = publicContent.getProject(slug);
|
||||
const view = usePublicContent(["tech-log", "project", slug, "records"], async (queries) => {
|
||||
const project = await queries.getProject(slug);
|
||||
return project
|
||||
? { project, records: await queries.getProjectRecords(slug) }
|
||||
: { project: undefined, records: [] };
|
||||
});
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { project, records } = view.data;
|
||||
if (!project) return <RegisteredNotFoundRoute />;
|
||||
|
||||
const records = publicContent.getProjectRecords(slug);
|
||||
return (
|
||||
<main id="main-content" className="shell project-page">
|
||||
<ProjectPageHeader project={project} title={`${project.title} 기록`} />
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
export function ProjectsPage() {
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const projects = publicContent
|
||||
.searchPublicContent("")
|
||||
.filter((item) => item.contentType === "PROJECT")
|
||||
.flatMap((item) => {
|
||||
const project = publicContent.getProject(item.path.replace("/projects/", ""));
|
||||
return project ? [project] : [];
|
||||
});
|
||||
const view = usePublicContent(["tech-log", "projects"], async (queries) => {
|
||||
const entries = (await queries.searchPublicContent("")).filter(
|
||||
(item) => item.contentType === "PROJECT",
|
||||
);
|
||||
const resolved = await Promise.all(
|
||||
entries.map((item) => queries.getProject(item.path.replace("/projects/", ""))),
|
||||
);
|
||||
return { projects: resolved.filter((project) => project !== undefined) };
|
||||
});
|
||||
|
||||
// Header first: it is fixed copy and owes the network nothing.
|
||||
return (
|
||||
<main
|
||||
id="main-content"
|
||||
@@ -26,8 +27,13 @@ export function ProjectsPage() {
|
||||
봅니다.
|
||||
</p>
|
||||
</header>
|
||||
{!view.ready ? (
|
||||
view.fallback
|
||||
) : view.data.projects.length === 0 ? (
|
||||
<p className="public-empty-note">아직 공개된 프로젝트가 없습니다.</p>
|
||||
) : (
|
||||
<ol className="project-index-list">
|
||||
{projects.map((project, index) => (
|
||||
{view.data.projects.map((project, index) => (
|
||||
<li key={project.slug}>
|
||||
<Link to={`/projects/${project.slug}`}>
|
||||
<span>{String(index + 1).padStart(2, "0")}</span>
|
||||
@@ -53,6 +59,7 @@ export function ProjectsPage() {
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { QuestionDocumentPage } from "../components/question-document-page.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
|
||||
export function QuestionPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_QUESTION">();
|
||||
const slug = optionalString(params.slug);
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const record = slug ? publicContent.getRecord("QUESTION", slug) : undefined;
|
||||
const view = usePublicContent(
|
||||
["tech-log", "question", slug],
|
||||
async (queries) => ({
|
||||
record: slug ? await queries.getRecord("QUESTION", slug) : undefined,
|
||||
}),
|
||||
);
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { record } = view.data;
|
||||
return record ? (
|
||||
<QuestionDocumentPage record={record} />
|
||||
) : (
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { ReferenceDocumentPage } from "../components/reference-document-page.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
|
||||
export function ReferencePage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_REFERENCE">();
|
||||
const slug = optionalString(params.slug);
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const record = slug ? publicContent.getRecord("REFERENCE", slug) : undefined;
|
||||
const view = usePublicContent(
|
||||
["tech-log", "reference", slug],
|
||||
async (queries) => ({
|
||||
record: slug ? await queries.getRecord("REFERENCE", slug) : undefined,
|
||||
}),
|
||||
);
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { record } = view.data;
|
||||
return record ? (
|
||||
<ReferenceDocumentPage record={record} />
|
||||
) : (
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
export function ReleasePage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_RELEASE">();
|
||||
const version = typeof params.version === "string" ? params.version : "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const release = publicContent.getRelease(version);
|
||||
const view = usePublicContent(["tech-log", "release", version], async (queries) => ({
|
||||
release: await queries.getRelease(version),
|
||||
}));
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { release } = view.data;
|
||||
if (!release) return <RegisteredNotFoundRoute />;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
export function ReleasesPage() {
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const releases = publicContent
|
||||
.searchPublicContent("")
|
||||
.filter((item) => item.contentType === "RELEASE")
|
||||
.flatMap((item) => {
|
||||
const release = publicContent.getRelease(item.path.replace("/releases/", ""));
|
||||
return release ? [release] : [];
|
||||
});
|
||||
const view = usePublicContent(["tech-log", "releases"], async (queries) => {
|
||||
const entries = (await queries.searchPublicContent("")).filter(
|
||||
(item) => item.contentType === "RELEASE",
|
||||
);
|
||||
const resolved = await Promise.all(
|
||||
entries.map((item) => queries.getRelease(item.path.replace("/releases/", ""))),
|
||||
);
|
||||
return { releases: resolved.filter((release) => release !== undefined) };
|
||||
});
|
||||
|
||||
// The header is fixed copy; only the list is a request. Returning the
|
||||
// page-wide fallback here left a visitor with a skeleton — or an error —
|
||||
// where the page's own explanation of itself could already be on screen.
|
||||
return (
|
||||
<main
|
||||
id="main-content"
|
||||
@@ -26,8 +29,13 @@ export function ReleasesPage() {
|
||||
생겼는지 남깁니다.
|
||||
</p>
|
||||
</header>
|
||||
{!view.ready ? (
|
||||
view.fallback
|
||||
) : view.data.releases.length === 0 ? (
|
||||
<p className="public-empty-note">아직 공개된 릴리즈가 없습니다.</p>
|
||||
) : (
|
||||
<ol className="release-index-list">
|
||||
{releases.map((release) => (
|
||||
{view.data.releases.map((release) => (
|
||||
<li key={release.version}>
|
||||
<Link to={release.path}>
|
||||
<div>
|
||||
@@ -45,6 +53,7 @@ export function ReleasesPage() {
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
const labels = { CASE: "Case", REFERENCE: "Reference", QUESTION: "Open Question", PROJECT: "Project", RELEASE: "Release" } as const;
|
||||
|
||||
@@ -14,8 +13,13 @@ export function SearchPage() {
|
||||
const navigate = useNavigate();
|
||||
const { search } = useRouteInput<"TECH_LOG_SEARCH">();
|
||||
const query = optionalString(search.q)?.trim() ?? "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const results = publicContent.searchPublicContent(query);
|
||||
// 키의 마지막 조각이 화면을 구분한다. 헤더의 검색 다이얼로그도 같은 카탈로그를 읽고
|
||||
// 빈 검색어일 때 앞 세 조각이 완전히 겹치는데, 두 화면이 담아 오는 모양이 다르다
|
||||
// (여기는 `results`, 다이얼로그는 `entities`). 키가 같으면 react-query 가 한쪽 캐시를
|
||||
// 다른 쪽에 돌려주고, 받는 쪽은 없는 필드를 읽다 렌더에서 죽는다.
|
||||
const view = usePublicContent(["tech-log", "search", "page", query], async (queries) => ({
|
||||
results: await queries.searchPublicContent(query),
|
||||
}));
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -25,6 +29,9 @@ export function SearchPage() {
|
||||
void navigate(`/search?q=${encodeURIComponent(nextQuery)}`);
|
||||
}
|
||||
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const { results } = view.data;
|
||||
return <main id="main-content" className="shell search-page">
|
||||
<header className="public-page-header"><p className="section-kicker">Search</p><h1>검색</h1><p>제목과 요약, 주제, 프로젝트를 함께 검색합니다.</p></header>
|
||||
<form key={query} className="search-page-form" action="/search" method="get" onSubmit={submit}><label><span className="visually-hidden">검색어</span><input type="search" name="q" defaultValue={query} placeholder="검색어를 입력하세요" /></label><button type="submit">검색</button></form>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||
import { usePublicContent } from "../use-public-content.tsx";
|
||||
|
||||
const topics = {
|
||||
jpa: {
|
||||
@@ -37,11 +36,17 @@ function topicConfig(value: unknown) {
|
||||
export function TopicPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_TOPIC">();
|
||||
const topic = topicConfig(params.slug);
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
|
||||
// Hooks run unconditionally, so the unknown-topic case is handled by the
|
||||
// loader and the not-found route is chosen after it.
|
||||
const view = usePublicContent(
|
||||
["tech-log", "topic", topic?.title],
|
||||
async (queries) =>
|
||||
topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] },
|
||||
);
|
||||
if (!topic) return <RegisteredNotFoundRoute />;
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
const records = publicContent.listRecords({ topic: topic.title });
|
||||
const { records } = view.data;
|
||||
return (
|
||||
<main id="main-content" className="shell public-index-page">
|
||||
<header className="public-page-header">
|
||||
|
||||
@@ -29,7 +29,7 @@ export function PublicShell({ children, currentPath }: PublicShellProps) {
|
||||
<Link to={publicSiteConfig.contactPath}>
|
||||
{publicSiteConfig.contactLabel}
|
||||
</Link>
|
||||
<Link to={publicSiteConfig.latestRelease}>최신 Release</Link>
|
||||
<Link to={publicSiteConfig.releasesPath}>변경 기록</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
|
||||
import { createFailure } from "../../../../contracts/errors.ts";
|
||||
import {
|
||||
LoadingSurface,
|
||||
TerminalErrorSurface,
|
||||
} from "../../../../presentation/components/async-surface.tsx";
|
||||
import { useApplicationQuery } from "../../../../presentation/adapters/query/index.ts";
|
||||
import { useApplication } from "../../../../presentation/providers/application-provider.tsx";
|
||||
import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts";
|
||||
|
||||
/**
|
||||
* One query per screen, not one per call.
|
||||
*
|
||||
* The public pages were written against a synchronous fixture, so they read
|
||||
* whatever they needed inline — and several read in a loop: the home timeline
|
||||
* walks every project for its activity, the explore filter walks search results
|
||||
* to resolve project titles. Turning each of those into its own hook would mean
|
||||
* a variable number of hooks per render, which React forbids outright.
|
||||
*
|
||||
* So a screen loads everything in one `execute`, where a loop is just a loop and
|
||||
* `Promise.all` is available. The cost is that a screen waits for its slowest
|
||||
* read; the benefit is that the page bodies keep computing from plain values and
|
||||
* the markup is unchanged.
|
||||
*
|
||||
* The return is a discriminated union so a page can hand back `view.fallback`
|
||||
* and have `view.data` narrow to present on the line after — without that, every
|
||||
* page would need its own non-null assertion.
|
||||
*/
|
||||
export type PublicContentView<Value> =
|
||||
| Readonly<{ ready: false; fallback: ReactNode; data?: undefined }>
|
||||
| Readonly<{ ready: true; fallback: null; data: Value }>;
|
||||
|
||||
/**
|
||||
* `Value extends object` is load-bearing, not decoration. `undefined` is how the
|
||||
* query layer says "no result yet", so a loader that returned the record itself
|
||||
* would make a genuinely missing slug — `getRecord` resolving to `undefined` —
|
||||
* indistinguishable from a request still in flight, and the page would sit on a
|
||||
* loading skeleton instead of rendering its not-found route. Wrapping the
|
||||
* screen's reads in an object keeps the two apart.
|
||||
*/
|
||||
export function usePublicContent<Value extends object>(
|
||||
queryKey: readonly unknown[],
|
||||
load: (queries: PublicContentQueries) => Promise<Value>,
|
||||
): PublicContentView<Value> {
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
|
||||
// `load` is a new closure every render, so depending on it would re-run the
|
||||
// query forever. The key is the declared identity of the request — the same
|
||||
// rule the rest of the query layer follows — so the key is what this closes
|
||||
// over.
|
||||
const execute = useCallback(
|
||||
async () => {
|
||||
try {
|
||||
return { ok: true as const, value: await load(publicContent) };
|
||||
} catch (cause) {
|
||||
return { ok: false as const, error: failureFor(cause) };
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by queryKey, see above
|
||||
[publicContent, ...queryKey],
|
||||
);
|
||||
|
||||
const query = useApplicationQuery<Value>(
|
||||
useMemo(() => ({ queryKey, execute }), [execute, queryKey]),
|
||||
);
|
||||
|
||||
if (query.data !== undefined) {
|
||||
return Object.freeze({ ready: true as const, fallback: null, data: query.data });
|
||||
}
|
||||
const failure = query.state.failure;
|
||||
return Object.freeze({
|
||||
ready: false as const,
|
||||
fallback: failure ? (
|
||||
<TerminalErrorSurface
|
||||
userMessageKey={failure.userMessageKey}
|
||||
action={failure.action}
|
||||
onAction={() => void query.retry()}
|
||||
/>
|
||||
) : (
|
||||
<LoadingSurface />
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapters throw. One that knows what went wrong attaches the classified
|
||||
* failure to the error; anything else arriving here is a defect in this layer
|
||||
* rather than a server condition, and is not reported as one.
|
||||
*/
|
||||
function failureFor(cause: unknown) {
|
||||
const attached = (cause as { failure?: unknown } | null)?.failure;
|
||||
if (isAppFailure(attached)) return attached;
|
||||
return createFailure("UNKNOWN_CLIENT_FAILURE", "TECH_LOG_PUBLIC_CONTENT", 0, {
|
||||
code: "PUBLIC_CONTENT_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
function isAppFailure(
|
||||
value: unknown,
|
||||
): value is ReturnType<typeof createFailure> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as { kind?: unknown }).kind === "string" &&
|
||||
typeof (value as { code?: unknown }).code === "string"
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,44 @@ function assertNever(value: never): never {
|
||||
throw new Error(`Unsupported inline value: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문단 안의 줄바꿈을 그대로 보여준다.
|
||||
*
|
||||
* <p>Markdown 은 한 번의 줄바꿈을 문단을 잇는 공백으로 읽는다. 파서는 그 줄바꿈을 텍스트에
|
||||
* 남겨 두는데, 여기서 그대로 내보내면 HTML 이 다시 공백으로 접는다 — 작성자가 엔터로 나눠 쓴
|
||||
* 글이 한 줄로 이어져 보였다. 미리보기만의 현상이 아니었다: 공개 화면도 같은 렌더러를 쓴다.
|
||||
*
|
||||
* <p>빈 줄로 나눈 문단은 파서가 이미 문단 둘로 만들어 두므로 여기 오지 않는다. 이 함수가 보는
|
||||
* 것은 한 문단 안의 줄바꿈뿐이고, 작성자가 의도한 것도 그것이다.
|
||||
*/
|
||||
function renderText(text: string, key: number) {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length === 1) return <Fragment key={key}>{text}</Fragment>;
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
{lines.map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? <br /> : null}
|
||||
{line}
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성자가 직접 쓴 평문. 요약·문제·결론·환경처럼 Markdown 을 거치지 않고 그대로 그려지는 칸들이
|
||||
* 여기 온다 — 그 칸들은 텍스트 노드에 줄바꿈이 그대로 들어가고, HTML 이 그것을 공백으로 접어
|
||||
* 작성자가 나눠 쓴 줄이 이어져 보였다. 본문(Markdown)만 고쳤을 때 이 칸들이 남아 있던 이유다.
|
||||
*/
|
||||
export function PlainText({ text }: { text: string }) {
|
||||
return renderText(text, 0);
|
||||
}
|
||||
|
||||
function renderInline(inline: Inline, key: number) {
|
||||
switch (inline.type) {
|
||||
case "TEXT":
|
||||
return <Fragment key={key}>{inline.text}</Fragment>;
|
||||
return renderText(inline.text, key);
|
||||
case "INLINE_CODE":
|
||||
return <code key={key}>{inline.code}</code>;
|
||||
case "EMPHASIS":
|
||||
|
||||
+15
-14
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
||||
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { inlinePlainText } from "../../../domain/content-format/inline-plain-text.ts";
|
||||
import { PlainText } from "./inline-renderer.tsx";
|
||||
import type {
|
||||
ResolveEvidenceAsset,
|
||||
ResolvePublishedLabel,
|
||||
@@ -92,7 +93,7 @@ function ModelDocumentHeader({
|
||||
) : null}
|
||||
</nav>
|
||||
<h1>{model.title}</h1>
|
||||
<p>{model.summary}</p>
|
||||
<p><PlainText text={model.summary} /></p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>유형</dt>
|
||||
@@ -149,21 +150,21 @@ function GenericCase({
|
||||
<section className="document-snapshot" aria-label="문제와 결론">
|
||||
<div>
|
||||
<p className="snapshot-label">문제</p>
|
||||
<p>{model.problem}</p>
|
||||
<p><PlainText text={model.problem} /></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="snapshot-label snapshot-label--answer">결론</p>
|
||||
<p>{model.conclusion}</p>
|
||||
<p><PlainText text={model.conclusion} /></p>
|
||||
</div>
|
||||
</section>
|
||||
<dl className="document-facts">
|
||||
<div>
|
||||
<dt>검증 환경</dt>
|
||||
<dd>{model.environment}</dd>
|
||||
<dd><PlainText text={model.environment} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>검증 데이터</dt>
|
||||
<dd>{model.reproduction}</dd>
|
||||
<dd><PlainText text={model.reproduction} /></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<article className="public-document-body">
|
||||
@@ -213,27 +214,27 @@ function FetchJoinCase({
|
||||
</nav>
|
||||
|
||||
<h1>{model.title}</h1>
|
||||
<p className="case-summary">{model.summary}</p>
|
||||
<p className="case-summary"><PlainText text={model.summary} /></p>
|
||||
|
||||
<section className="case-snapshot" aria-label="문제와 결론">
|
||||
<div>
|
||||
<p className="snapshot-label">문제</p>
|
||||
<p>{model.problem}</p>
|
||||
<p><PlainText text={model.problem} /></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="snapshot-label snapshot-label--answer">결론</p>
|
||||
<p>{model.conclusion}</p>
|
||||
<p><PlainText text={model.conclusion} /></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<dl className="case-meta">
|
||||
<div>
|
||||
<dt>검증 환경</dt>
|
||||
<dd>{model.environment}</dd>
|
||||
<dd><PlainText text={model.environment} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>데이터셋</dt>
|
||||
<dd>{model.reproduction.replace(/^Dataset:\s*/, "")}</dd>
|
||||
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기록</dt>
|
||||
@@ -287,7 +288,7 @@ function ReferenceDocument({
|
||||
<section className="reference-purpose" aria-labelledby="purpose-title">
|
||||
<p className="section-kicker">Purpose</p>
|
||||
<h2 id="purpose-title">이 기준을 쓰는 이유</h2>
|
||||
<p>{model.purpose}</p>
|
||||
<p><PlainText text={model.purpose} /></p>
|
||||
</section>
|
||||
<article className="public-document-body reference-body">
|
||||
<section aria-labelledby="rules-title">
|
||||
@@ -434,7 +435,7 @@ function QuestionDocument({
|
||||
>
|
||||
<p className="section-kicker">Next</p>
|
||||
<h2 id="next-validation-title">다음 검증</h2>
|
||||
<p>{model.nextValidation}</p>
|
||||
<p><PlainText text={model.nextValidation} /></p>
|
||||
</section>
|
||||
</article>
|
||||
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
||||
@@ -476,11 +477,11 @@ function ProjectDecisionDocument({
|
||||
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
|
||||
</div>
|
||||
<h2>{model.title}</h2>
|
||||
<p>{model.statement}</p>
|
||||
<p><PlainText text={model.statement} /></p>
|
||||
</header>
|
||||
<section>
|
||||
<h3>판단 이유</h3>
|
||||
<p>{model.rationale}</p>
|
||||
<p><PlainText text={model.rationale} /></p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>영향</h3>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useId, useState, type FormEvent } from "react";
|
||||
|
||||
import type { Asset } from "../../../contracts/studio/contract.ts";
|
||||
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
|
||||
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
|
||||
/** One screenful of candidates; searching, not scrolling, reaches the rest. */
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -57,6 +59,14 @@ export function AssetPicker({
|
||||
// than fewer, and no trailing request after the author stops), and it is the
|
||||
// pair `document-list.tsx` already uses for the same job.
|
||||
const [searchDraft, setSearchDraft] = useState("");
|
||||
/**
|
||||
* 삽입할 때 확대를 허용할지. 예전에는 {@code kind === "DIAGRAM"} 일 때만 켰는데, 작성자가
|
||||
* 스크린샷을 ATTACHMENT 나 IMAGE 로 올리면 확대가 꺼진 채로 들어갔고 켜는 방법도 없었다 —
|
||||
* "줌이 왜 꺼져 있는지 모르겠다" 가 그것이다. 그림이면 켜 두고, 끄고 싶으면 여기서 끈다.
|
||||
*/
|
||||
const [allowZoom, setAllowZoom] = useState(true);
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const searchId = useId();
|
||||
|
||||
@@ -99,6 +109,27 @@ export function AssetPicker({
|
||||
setQ(searchDraft.trim());
|
||||
};
|
||||
|
||||
const removeAsset = async (asset: Asset) => {
|
||||
if (removingId !== null) return;
|
||||
setRemovingId(asset.id);
|
||||
setNotice("");
|
||||
try {
|
||||
await gateway.deleteAsset(asset.id, {
|
||||
idempotencyKey: createLocalId(`studio-asset-picker-delete-${asset.id}`),
|
||||
});
|
||||
setAssets((current) => current.filter((entry) => entry.id !== asset.id));
|
||||
setNotice(`${asset.assetKey} 을(를) 삭제했습니다.`);
|
||||
} catch (error) {
|
||||
setNotice(
|
||||
isStudioGatewayError(error)
|
||||
? error.problem.detail
|
||||
: "삭제하지 못했습니다. 문서에서 쓰이고 있을 수 있습니다.",
|
||||
);
|
||||
} finally {
|
||||
setRemovingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const listMessage = status === "LOADING"
|
||||
? "Asset 목록을 불러오는 중입니다."
|
||||
: selectable.length > 0
|
||||
@@ -124,6 +155,15 @@ export function AssetPicker({
|
||||
<button type="submit">검색</button>
|
||||
</div>
|
||||
</form>
|
||||
<label className="asset-picker-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowZoom}
|
||||
onChange={(event) => setAllowZoom(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>삽입할 때 크게 보기 허용</span>
|
||||
</label>
|
||||
{notice ? <p className="studio-asset-picker-empty" role="status">{notice}</p> : null}
|
||||
{status === "ERROR"
|
||||
? <p className="studio-error" role="alert">Asset 목록을 불러오지 못했습니다.</p>
|
||||
: <p className="studio-asset-picker-empty" role="status">{listMessage}</p>}
|
||||
@@ -135,11 +175,23 @@ export function AssetPicker({
|
||||
assetKey: asset.assetKey,
|
||||
alt: asset.decorative ? "" : (asset.altText ?? ""),
|
||||
caption: "",
|
||||
zoom: asset.kind === "DIAGRAM",
|
||||
zoom: allowZoom && asset.mediaType.startsWith("image/"),
|
||||
}))}
|
||||
>
|
||||
{asset.assetKey}
|
||||
</button>
|
||||
{/*
|
||||
문서를 쓰다가 잘못 올린 Asset 을 여기서 바로 지운다. 예전에는 Asset 화면으로 나가야
|
||||
했고, 그러면 편집 중인 작업본을 떠나야 했다. 쓰이고 있는 Asset 은 서버가 거절한다.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
className="studio-secondary-button"
|
||||
disabled={removingId !== null}
|
||||
onClick={() => { void removeAsset(asset); }}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</li>)}
|
||||
</ul> : null}
|
||||
</div>;
|
||||
|
||||
@@ -22,7 +22,7 @@ export function CommonDocumentFields({
|
||||
<div className="studio-editor-section-heading"><p className="studio-eyebrow">DOCUMENT</p><h2 id="studio-common-fields-title">기본 정보</h2></div>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field studio-field--wide"><span>제목</span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /></label>
|
||||
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} placeholder="비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)" onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /></label>
|
||||
<label className="studio-field studio-field--wide"><span>요약</span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value="">선택하지 않음</option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value="">미지정</option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||
|
||||
@@ -8,6 +8,12 @@ export type DocumentEditorController = {
|
||||
saved: WorkingCopy;
|
||||
draft: WorkingCopyInput;
|
||||
status: StudioEditorStatus;
|
||||
/**
|
||||
* 저장이 실패한 이유. 이전에는 `setRequestAnnouncement` 로만 알렸는데 그것은 aria-live 라
|
||||
* 눈에는 아무것도 보이지 않았다 — 한글 slug 로 저장이 422 로 거절돼도 값은 화면에 그대로
|
||||
* 남아 있어, 작성자는 저장된 줄 알고 검증에서 "slug 이 없다" 를 만났다.
|
||||
*/
|
||||
saveError: string;
|
||||
update(patch: Partial<WorkingCopyInput>): void;
|
||||
replace(draft: WorkingCopyInput): void;
|
||||
save(): Promise<void>;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||
import { DocumentEditor } from "./document-editor.tsx";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { slugFromName } from "./slug-from-name.ts";
|
||||
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
@@ -25,6 +26,13 @@ function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||
return input as WorkingCopyInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* 백엔드가 문서 slug 에 요구하는 모양 (`WorkingCopyInputValidator.SLUG`). 저장 전에 여기서 먼저
|
||||
* 보는 이유는, 어긋난 값을 보내면 서버가 details 없는 422 로 거절하고 편집기는 그것을 이유 없는
|
||||
* 실패로만 보여 주기 때문이다 — 작성자에게는 어느 칸이 문제인지 알 방법이 없었다.
|
||||
*/
|
||||
const SLUG_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||
|
||||
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
const studio = useStudio();
|
||||
const session = useStudioEditorSession();
|
||||
@@ -48,6 +56,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
// which can only grow the catalog -- the one legitimate reset is the effect
|
||||
// below, when the screen switches to a different document.
|
||||
const [assets, setAssets] = useState<readonly Asset[]>([]);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const observeAssets = useCallback((observed: readonly Asset[]) => {
|
||||
setAssets((current) => mergeAssetCatalog(current, observed));
|
||||
}, []);
|
||||
@@ -97,13 +106,26 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
current.status === "SAVING" ||
|
||||
current.status === "CONFLICT"
|
||||
) return;
|
||||
// 비워 두면 제목에서 만든다. 한글 제목도 로마자로 옮겨 유효한 slug 가 되므로, 작성자가
|
||||
// slug 규칙을 몰라도 저장이 막히지 않는다.
|
||||
const slug = current.draft.slug.trim() || slugFromName(current.draft.title);
|
||||
if (slug && !SLUG_SHAPE.test(slug)) {
|
||||
setSaveError(
|
||||
"slug 은 영문 소문자·숫자·하이픈만 쓸 수 있습니다. 비워 두면 제목에서 만들어 드립니다.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const draft = slug === current.draft.slug
|
||||
? current.draft
|
||||
: ({ ...current.draft, slug } as typeof current.draft);
|
||||
setSaveError("");
|
||||
setStatus("SAVING");
|
||||
try {
|
||||
const detail = await studio.gateway.saveDocument(
|
||||
current.documentId,
|
||||
{
|
||||
expectedVersion: current.saved.version,
|
||||
document: current.draft,
|
||||
document: draft,
|
||||
},
|
||||
{ idempotencyKey: createLocalId("studio-editor-save") },
|
||||
);
|
||||
@@ -115,11 +137,11 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
const conflict = isStudioGatewayError(error) &&
|
||||
error.code === "VERSION_CONFLICT";
|
||||
setStatus(conflict ? "CONFLICT" : "DIRTY");
|
||||
studio.setRequestAnnouncement(
|
||||
isStudioGatewayError(error)
|
||||
? error.problem.detail
|
||||
: "저장하지 못했습니다.",
|
||||
);
|
||||
const detail = isStudioGatewayError(error)
|
||||
? error.problem.detail
|
||||
: "저장하지 못했습니다.";
|
||||
setSaveError(detail);
|
||||
studio.setRequestAnnouncement(detail);
|
||||
}
|
||||
}, [begin, editor, setStatus, studio]);
|
||||
|
||||
@@ -129,6 +151,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
saved: editor.saved,
|
||||
draft: editor.draft,
|
||||
status: editor.status,
|
||||
saveError,
|
||||
update(patch) {
|
||||
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
|
||||
},
|
||||
@@ -137,7 +160,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
},
|
||||
save,
|
||||
};
|
||||
}, [documentId, editor, save, updateDraft]);
|
||||
}, [documentId, editor, save, saveError, updateDraft]);
|
||||
|
||||
const currentResult = result?.key === requestKey ? result : null;
|
||||
const problem = currentResult?.problem ?? null;
|
||||
|
||||
@@ -30,7 +30,9 @@ function isAbortError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
export function DocumentList() {
|
||||
const { gateway } = useStudio();
|
||||
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [searchDraft, setSearchDraft] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [kind, setKind] = useState<ListDocumentsQuery["kind"]>();
|
||||
@@ -42,6 +44,54 @@ export function DocumentList() {
|
||||
const [error, setError] = useState("");
|
||||
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||
|
||||
/**
|
||||
* 목록 행에는 version 이 없다 (계약의 `DocumentSummary`). 삭제는 expectedVersion 을 요구하므로
|
||||
* 지우기 직전에 작업본을 한 번 읽어 그 시점의 version 을 쓴다 — 목록을 띄워 둔 채 다른 곳에서
|
||||
* 수정된 경우 여기서 409 로 걸리는 편이, 목록이 기억하던 낡은 version 으로 지우는 것보다 낫다.
|
||||
*/
|
||||
const removeDocument = async (item: DocumentPage["items"][number]) => {
|
||||
if (deletingId !== null) return;
|
||||
setDeletingId(item.id);
|
||||
setDeleteError("");
|
||||
try {
|
||||
const detail = await gateway.getDocument(item.id);
|
||||
if (item.kind === "PROJECT_DECISION") {
|
||||
// Decision 은 프로젝트에 속하고 경로가 둘을 요구한다. 목록 행이 프로젝트를 들고 있지
|
||||
// 않으면 지울 주소를 만들 수 없다 — 그때는 프로젝트를 먼저 지정해야 한다.
|
||||
if (!item.project) {
|
||||
setDeleteError("프로젝트에 속하지 않은 결정은 여기서 지울 수 없습니다. 먼저 프로젝트를 지정해 주세요.");
|
||||
return;
|
||||
}
|
||||
await managementGateway.deleteDecision(
|
||||
item.project.id,
|
||||
item.id,
|
||||
detail.document.version,
|
||||
);
|
||||
} else {
|
||||
await managementGateway.deleteDocument(
|
||||
item.kind as "CASE" | "REFERENCE" | "QUESTION",
|
||||
item.id,
|
||||
detail.document.version,
|
||||
);
|
||||
}
|
||||
setRequestAnnouncement(`작업본 ${item.title || "제목 없음"} 을(를) 삭제했습니다.`);
|
||||
// 다시 불러오지 않고 이 행만 지운다. 목록은 커서 페이지네이션이라 재조회하면 다음
|
||||
// 항목이 빈 자리를 즉시 채우고, 개수도 20 그대로다 — 작성자에게는 삭제가 아무 일도
|
||||
// 하지 않은 것처럼 보인다. 삭제가 성공한 뒤의 화면은 그 행이 없는 화면이 맞다.
|
||||
setPage((current) =>
|
||||
current
|
||||
? { ...current, items: current.items.filter((row) => row.id !== item.id) }
|
||||
: current,
|
||||
);
|
||||
} catch {
|
||||
setDeleteError(
|
||||
"삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.",
|
||||
);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
@@ -168,7 +218,19 @@ export function DocumentList() {
|
||||
) : null}
|
||||
{page && !loading && !error ? (
|
||||
<>
|
||||
<p className="studio-result-count">{page.items.length}개의 작업본</p>
|
||||
{/*
|
||||
이 숫자는 전체가 아니라 이 페이지에 실린 수다. 예전 문구는 그것을 전체처럼 읽히게
|
||||
해서, 28건 중 20건이 보이는 동안 무엇을 지워도 "20개" 가 그대로였다.
|
||||
*/}
|
||||
<p className="studio-result-count">
|
||||
<span>{page.items.length}개 표시 중</span>
|
||||
{page.nextCursor ? <span> · 더 있습니다</span> : null}
|
||||
</p>
|
||||
{deleteError ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{deleteError}
|
||||
</p>
|
||||
) : null}
|
||||
{page.items.length ? (
|
||||
<div className="studio-document-list">
|
||||
{page.items.map((item) => (
|
||||
@@ -202,6 +264,14 @@ export function DocumentList() {
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={deletingId !== null}
|
||||
onClick={() => void removeDocument(item)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,18 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC
|
||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||
<GuardedStudioLink className="studio-editor-next-link" href={`/studio/documents/${controller.saved.id}/validation`}>저장본 검증</GuardedStudioLink>
|
||||
{controller.status === "CONFLICT" ? <p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p> : <p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>}
|
||||
{/*
|
||||
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
||||
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
||||
두 번 말하게 된다.
|
||||
*/}
|
||||
{controller.status === "CONFLICT" ? (
|
||||
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
||||
) : controller.saveError ? (
|
||||
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
||||
) : (
|
||||
<p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type {
|
||||
ReleaseEditResponse,
|
||||
ReleaseIndexItem,
|
||||
ReleaseUpdateRequest,
|
||||
} from "../../../contracts/management/contract.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
/**
|
||||
* 릴리즈 작성.
|
||||
*
|
||||
* <p>공개 사이트에 변경 기록 화면과 푸터 링크가 있는데 둘 다 비어 있었다 — 읽기는 처음부터
|
||||
* 있었고 쓰는 곳이 없었다. 그래서 이 화면의 범위는 목록·작성·발행이다.
|
||||
*
|
||||
* <p>본문이 마크다운 여섯 칸으로 나뉜 것은 계약의 모양이자 릴리즈 노트의 성격이다. 한 칸짜리
|
||||
* 자유 서술이었다면 "검증을 안 썼다"를 아무도 알아채지 못한다.
|
||||
*
|
||||
* <p>새 CSS 를 만들지 않는다 — 문서 편집기가 쓰는 field 클래스와 작업본 목록의 행 클래스를 그대로
|
||||
* 재사용하므로 Studio 의 나머지와 같은 간격·타이포·색을 따른다.
|
||||
*/
|
||||
|
||||
/** 백엔드 {@code UpdateReleaseUseCase.CHANGE_TYPES} 와 같은 집합이다. */
|
||||
const CHANGE_TYPES = [
|
||||
{ value: "FEATURE", label: "기능" },
|
||||
{ value: "FIX", label: "수정" },
|
||||
{ value: "REFACTOR", label: "구조" },
|
||||
{ value: "DOCS", label: "문서" },
|
||||
{ value: "INFRA", label: "인프라" },
|
||||
{ value: "BREAKING", label: "호환 깨짐" },
|
||||
] as const;
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: "reasonMarkdown", label: "왜 바꿨나" },
|
||||
{ key: "changesMarkdown", label: "무엇을 바꿨나" },
|
||||
{ key: "userImpactMarkdown", label: "사용자에게 달라지는 것" },
|
||||
{ key: "implementationImpactMarkdown", label: "구현에 남는 것" },
|
||||
{ key: "verificationMarkdown", label: "어떻게 검증했나" },
|
||||
{ key: "knownLimitationsMarkdown", label: "아직 못 한 것" },
|
||||
] as const;
|
||||
|
||||
type Draft = Readonly<{
|
||||
expectedVersion: number;
|
||||
versionLabel: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
releasedOn: string;
|
||||
changeTypes: readonly string[];
|
||||
reasonMarkdown: string;
|
||||
changesMarkdown: string;
|
||||
userImpactMarkdown: string;
|
||||
implementationImpactMarkdown: string;
|
||||
verificationMarkdown: string;
|
||||
knownLimitationsMarkdown: string;
|
||||
}>;
|
||||
|
||||
function toDraft(release: ReleaseEditResponse): Draft {
|
||||
return {
|
||||
expectedVersion: release.version,
|
||||
versionLabel: release.versionLabel,
|
||||
title: release.title,
|
||||
summary: release.summary ?? "",
|
||||
releasedOn: release.releasedOn ?? "",
|
||||
changeTypes: release.changeTypes ?? [],
|
||||
reasonMarkdown: release.reasonMarkdown ?? "",
|
||||
changesMarkdown: release.changesMarkdown ?? "",
|
||||
userImpactMarkdown: release.userImpactMarkdown ?? "",
|
||||
implementationImpactMarkdown: release.implementationImpactMarkdown ?? "",
|
||||
verificationMarkdown: release.verificationMarkdown ?? "",
|
||||
knownLimitationsMarkdown: release.knownLimitationsMarkdown ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||
DRAFT: "작성 중",
|
||||
PUBLISHED: "공개",
|
||||
ARCHIVED: "보관",
|
||||
};
|
||||
|
||||
export function ReleaseManager() {
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
const [releases, setReleases] = useState<ReleaseIndexItem[] | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void managementGateway.listReleases(0, 50).then(
|
||||
(page) => {
|
||||
if (cancelled) return;
|
||||
setReleases(page.items ?? []);
|
||||
setLoading(false);
|
||||
},
|
||||
() => {
|
||||
if (cancelled) return;
|
||||
setError("릴리즈를 불러오지 못했습니다.");
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [managementGateway, generation]);
|
||||
|
||||
// 선택된 릴리즈의 본문은 목록에 없다 — 목록 행은 마크다운을 싣지 않으므로 따로 읽는다.
|
||||
useEffect(() => {
|
||||
if (selectedId === null) {
|
||||
setDraft(null);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
void managementGateway.getRelease(selectedId).then(
|
||||
(release) => {
|
||||
if (!cancelled) setDraft(toDraft(release));
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [managementGateway, selectedId, generation]);
|
||||
|
||||
const update = (patch: Partial<Draft>) =>
|
||||
setDraft((current) => (current === null ? current : { ...current, ...patch }));
|
||||
|
||||
const submitNew = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const title = newTitle.trim();
|
||||
if (!title) {
|
||||
setError("릴리즈 제목을 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await managementGateway.createRelease(title);
|
||||
setNewTitle("");
|
||||
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
|
||||
setSelectedId(created.id);
|
||||
reload();
|
||||
} catch {
|
||||
setError("릴리즈를 만들지 못했습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (pending || draft === null || selectedId === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const body: ReleaseUpdateRequest = {
|
||||
expectedVersion: draft.expectedVersion,
|
||||
versionLabel: draft.versionLabel.trim(),
|
||||
title: draft.title.trim(),
|
||||
summary: draft.summary,
|
||||
changeTypes: [...draft.changeTypes],
|
||||
changesMarkdown: draft.changesMarkdown,
|
||||
verificationMarkdown: draft.verificationMarkdown,
|
||||
...(draft.releasedOn ? { releasedOn: draft.releasedOn } : {}),
|
||||
reasonMarkdown: draft.reasonMarkdown,
|
||||
userImpactMarkdown: draft.userImpactMarkdown,
|
||||
implementationImpactMarkdown: draft.implementationImpactMarkdown,
|
||||
knownLimitationsMarkdown: draft.knownLimitationsMarkdown,
|
||||
} as ReleaseUpdateRequest;
|
||||
const saved = await managementGateway.updateRelease(selectedId, body);
|
||||
setDraft(toDraft(saved));
|
||||
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("저장하지 못했습니다. 같은 버전이 이미 있거나 다른 곳에서 먼저 수정되었을 수 있습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
if (pending || draft === null || selectedId === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion);
|
||||
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
|
||||
reload();
|
||||
} catch {
|
||||
// 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로
|
||||
// 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다.
|
||||
setError(
|
||||
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const archive = async () => {
|
||||
if (pending || draft === null || selectedId === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.archiveRelease(selectedId, draft.expectedVersion);
|
||||
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
|
||||
reload();
|
||||
} catch {
|
||||
setError("공개에서 내리지 못했습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (release: ReleaseIndexItem) => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.deleteRelease(release.id, release.version);
|
||||
if (selectedId === release.id) setSelectedId(null);
|
||||
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("삭제하지 못했습니다. 공개된 릴리즈는 삭제 대신 공개에서 내려야 합니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleChangeType = (value: string) => {
|
||||
if (draft === null) return;
|
||||
const next = draft.changeTypes.includes(value)
|
||||
? draft.changeTypes.filter((entry) => entry !== value)
|
||||
: [...draft.changeTypes, value];
|
||||
update({ changeTypes: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-documents-page">
|
||||
<header className="studio-page-top">
|
||||
<div className="studio-page-heading">
|
||||
<p className="studio-eyebrow">RELEASES</p>
|
||||
<h1>릴리즈</h1>
|
||||
<p>무엇을 왜 바꿨는지 버전으로 묶어 공개 변경 기록에 남깁니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="studio-document-tools" aria-label="릴리즈 만들기">
|
||||
<form onSubmit={submitNew}>
|
||||
<label htmlFor="release-new-title">새 릴리즈</label>
|
||||
<div>
|
||||
<input
|
||||
id="release-new-title"
|
||||
type="text"
|
||||
value={newTitle}
|
||||
placeholder="릴리즈 제목"
|
||||
onChange={(event) => setNewTitle(event.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={pending}>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="studio-empty-inline">불러오는 중입니다.</p>
|
||||
) : (releases?.length ?? 0) === 0 ? (
|
||||
<p className="studio-empty-inline">아직 릴리즈가 없습니다.</p>
|
||||
) : (
|
||||
<section aria-label="릴리즈 목록">
|
||||
<p className="studio-result-count">{releases?.length}개의 릴리즈</p>
|
||||
<div className="studio-document-list">
|
||||
{(releases ?? []).map((release) => (
|
||||
<article
|
||||
key={release.id}
|
||||
className="studio-document-row"
|
||||
aria-current={selectedId === release.id ? "true" : undefined}
|
||||
>
|
||||
<div className="studio-document-title">
|
||||
<h2>{release.title}</h2>
|
||||
<p>
|
||||
{release.versionLabel}
|
||||
{release.releasedOn ? ` · ${release.releasedOn}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>상태</dt>
|
||||
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
|
||||
>
|
||||
{selectedId === release.id ? "닫기" : "편집"}
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void remove(release)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{draft === null ? null : (
|
||||
<section aria-label="릴리즈 편집">
|
||||
<h2 className="studio-section-title">{draft.title || "릴리즈"} 편집</h2>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field">
|
||||
<span>버전</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.versionLabel}
|
||||
placeholder="0.1.0"
|
||||
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>제목</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(event) => update({ title: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>공개일</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draft.releasedOn}
|
||||
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>한 줄 요약</span>
|
||||
<textarea
|
||||
value={draft.summary}
|
||||
onChange={(event) => update({ summary: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="studio-field-grid">
|
||||
<legend className="studio-field">
|
||||
<span>변경 유형</span>
|
||||
</legend>
|
||||
{CHANGE_TYPES.map((changeType) => (
|
||||
<label key={changeType.value} className="studio-field studio-field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.changeTypes.includes(changeType.value)}
|
||||
onChange={() => toggleChangeType(changeType.value)}
|
||||
/>
|
||||
<span>{changeType.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<div className="studio-field-grid">
|
||||
{SECTIONS.map((section) => (
|
||||
<label key={section.key} className="studio-field studio-field--wide">
|
||||
<span>{section.label}</span>
|
||||
<textarea
|
||||
value={draft[section.key]}
|
||||
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="studio-create-footer">
|
||||
<button
|
||||
className="studio-primary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
공개
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void archive()}
|
||||
>
|
||||
공개에서 내리기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 이름에서 slug 를 만든다.
|
||||
*
|
||||
* <p>원래는 `[^a-z0-9]` 를 전부 하이픈으로 바꿨다. 한글은 전부 거기 걸리므로 이름에 섞인 영문과
|
||||
* 숫자만 남았고, 결과는 두 가지로 나빴다 — `인증` 은 빈 문자열이 되어 저장 자체가 막혔고,
|
||||
* `Redis 캐시` 와 `Redis 클러스터` 는 둘 다 `redis` 가 되어 두 번째 주제가 "같은 slug 가 이미
|
||||
* 있습니다" 로 거절됐다. 작성자에게는 "가끔 안 되다가 이름을 바꾸면 되는" 현상으로 보인다.
|
||||
*
|
||||
* <p>그래서 한글을 버리지 않고 로마자로 옮긴다. 한글 음절은 (초성, 중성, 종성) 로 산술 분해되므로
|
||||
* 표가 필요 없고 결과가 결정적이다. 표기법은 국어의 로마자 표기법의 자모 대응만 쓴다 — 음운 변동
|
||||
* (자음동화 같은 것) 은 반영하지 않는다. slug 는 읽히기 위한 것이지 발음을 옮기기 위한 것이 아니고,
|
||||
* 변동 규칙을 넣으면 같은 이름이 문맥에 따라 다른 slug 가 될 수 있다.
|
||||
*
|
||||
* <p>결과는 문서 slug 와 같은 모양이다 (`^[a-z0-9]+(?:-[a-z0-9]+)*$`) — 한 저장소가 두 가지 slug
|
||||
* 규칙을 갖지 않도록.
|
||||
*/
|
||||
|
||||
const SYLLABLE_BASE = 0xac00;
|
||||
const SYLLABLE_LAST = 0xd7a3;
|
||||
const MEDIAL_COUNT = 21;
|
||||
const FINAL_COUNT = 28;
|
||||
|
||||
const INITIALS = [
|
||||
"g", "kk", "n", "d", "tt", "r", "m", "b", "pp", "s",
|
||||
"ss", "", "j", "jj", "ch", "k", "t", "p", "h",
|
||||
] as const;
|
||||
|
||||
const MEDIALS = [
|
||||
"a", "ae", "ya", "yae", "eo", "e", "yeo", "ye", "o", "wa",
|
||||
"wae", "oe", "yo", "u", "wo", "we", "wi", "yu", "eu", "ui", "i",
|
||||
] as const;
|
||||
|
||||
const FINALS = [
|
||||
"", "k", "k", "ks", "n", "nj", "nh", "t", "l", "lg",
|
||||
"lm", "lb", "ls", "lt", "lp", "lh", "m", "b", "bs", "s",
|
||||
"ss", "ng", "j", "ch", "k", "t", "p", "h",
|
||||
] as const;
|
||||
|
||||
/** 한글 자모가 아닌 문자는 그대로 돌려준다 — 뒤의 필터가 처리한다. */
|
||||
function romanizeSyllable(codePoint: number): string {
|
||||
if (codePoint < SYLLABLE_BASE || codePoint > SYLLABLE_LAST) {
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
const offset = codePoint - SYLLABLE_BASE;
|
||||
const initial = Math.floor(offset / (MEDIAL_COUNT * FINAL_COUNT));
|
||||
const medial = Math.floor((offset % (MEDIAL_COUNT * FINAL_COUNT)) / FINAL_COUNT);
|
||||
const final = offset % FINAL_COUNT;
|
||||
return `${INITIALS[initial]}${MEDIALS[medial]}${FINALS[final]}`;
|
||||
}
|
||||
|
||||
export function slugFromName(value: string): string {
|
||||
const romanized = [...value.trim()]
|
||||
.map((character) => romanizeSyllable(character.codePointAt(0) ?? 0))
|
||||
.join("");
|
||||
return romanized
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/gu, "-")
|
||||
.replace(/^-+|-+$/gu, "");
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useId, useState } from "react";
|
||||
|
||||
import { useLocale } from "../../../../../presentation/i18n/index.ts";
|
||||
import { useSession } from "../../../../../presentation/providers/session-provider.tsx";
|
||||
|
||||
import { techLogNavigation } from "../../tech-log-navigation.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
|
||||
@@ -40,10 +43,38 @@ function StudioNavigation({
|
||||
</GuardedStudioLink>
|
||||
))}
|
||||
<a href="/">공개 사이트 보기</a>
|
||||
<StudioSessionAction />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 자리. 세션 조작은 템플릿의 {@code AppShell} 에만 있었는데 TechLog 는 자체 셸을 쓰므로
|
||||
* 어느 화면에도 렌더되지 않았다 — 로그인은 되는데 로그아웃할 방법이 없었다.
|
||||
*
|
||||
* 인증된 상태에서만 그린다. 미인증이면 라우터의 인증 게이트 화면이 이미 로그인 조작을 들고 있어서
|
||||
* 여기 같은 버튼을 또 두면 두 개가 생긴다.
|
||||
*/
|
||||
function StudioSessionAction() {
|
||||
const { sessionState, signOut } = useSession();
|
||||
const { message } = useLocale();
|
||||
const [pending, setPending] = useState(false);
|
||||
if (sessionState !== "authenticated") return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-session-action"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setPending(true);
|
||||
void signOut().finally(() => setPending(false));
|
||||
}}
|
||||
>
|
||||
{message("action.signOut")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const mobileId = useId();
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
|
||||
import { slugFromName } from "./slug-from-name.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
/**
|
||||
* 주제와 프로젝트 관리.
|
||||
*
|
||||
* <p>이 화면이 존재하는 이유는 문서 발행이 주제를 요구하는데 주제를 만들 곳이 없었기 때문이다.
|
||||
* 그래서 범위를 목록·생성·삭제로 끊었다 — 편집(이름 변경, 단계 전환, 공개 범위)은 계약에 있고
|
||||
* 백엔드도 구현돼 있으나, 그 화면은 별도 설계가 필요하다.
|
||||
*
|
||||
* <p>새 CSS 를 만들지 않는다. 작업본 목록이 쓰는 클래스만 재사용하므로 이 화면은 Studio 의
|
||||
* 나머지와 같은 간격·타이포·색을 그대로 따른다.
|
||||
*/
|
||||
export function TaxonomyManager() {
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
const [topics, setTopics] = useState<TopicEdit[] | null>(null);
|
||||
const [projects, setProjects] = useState<ProjectIndexItem[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const [topicName, setTopicName] = useState("");
|
||||
const [topicSlug, setTopicSlug] = useState("");
|
||||
const [projectTitle, setProjectTitle] = useState("");
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void Promise.all([managementGateway.listTopics(), managementGateway.listProjects(0, 50)]).then(
|
||||
([topicList, projectPage]) => {
|
||||
if (cancelled) return;
|
||||
setTopics(topicList);
|
||||
setProjects(projectPage.items ?? []);
|
||||
setLoading(false);
|
||||
},
|
||||
() => {
|
||||
if (cancelled) return;
|
||||
setError("주제와 프로젝트를 불러오지 못했습니다.");
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [managementGateway, generation]);
|
||||
|
||||
/**
|
||||
* slug 를 비워 두면 이름에서 만든다. 한글 이름이 흔한데 이전 규칙은 한글을 전부 버려서, 이름에
|
||||
* 섞인 영문·숫자만 남았다 — `인증` 은 빈 slug 가 되고 `Redis 캐시` 와 `Redis 클러스터` 는 둘 다
|
||||
* `redis` 가 됐다. 지금은 로마자로 옮긴다 ({@link slugFromName}).
|
||||
*/
|
||||
const slugify = slugFromName;
|
||||
|
||||
const submitTopic = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const name = topicName.trim();
|
||||
const slug = slugify(topicSlug || topicName);
|
||||
if (!name || !slug) {
|
||||
setError("주제 이름과 slug 를 입력해 주세요. slug 는 영문·숫자·하이픈만 가능합니다.");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.createTopic({ name, slug } as TopicEdit);
|
||||
setTopicName("");
|
||||
setTopicSlug("");
|
||||
setRequestAnnouncement(`주제 ${name} 을(를) 만들었습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("주제를 만들지 못했습니다. 같은 이름이나 slug 가 이미 있을 수 있습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitProject = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const title = projectTitle.trim();
|
||||
if (!title) {
|
||||
setError("프로젝트 이름을 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.createProject(title);
|
||||
setProjectTitle("");
|
||||
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("프로젝트를 만들지 못했습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTopic = async (topic: TopicEdit) => {
|
||||
if (pending || topic.id === undefined || topic.version === undefined) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.deleteTopic(topic.id, topic.version);
|
||||
setRequestAnnouncement(`주제 ${topic.name} 을(를) 삭제했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("주제를 삭제하지 못했습니다. 이 주제를 쓰는 기록이 있을 수 있습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeProject = async (project: ProjectIndexItem) => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.deleteProject(project.id, project.version);
|
||||
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 삭제했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("프로젝트를 삭제하지 못했습니다. 연결된 기록이 있을 수 있습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-documents-page">
|
||||
<header className="studio-page-top">
|
||||
<div className="studio-page-heading">
|
||||
<p className="studio-eyebrow">TAXONOMY</p>
|
||||
<h1>주제와 프로젝트</h1>
|
||||
<p>문서를 게시하려면 주제가 필요합니다. 여기서 만들고 정리합니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="studio-document-tools" aria-label="주제 만들기">
|
||||
<form onSubmit={submitTopic}>
|
||||
<label htmlFor="taxonomy-topic-name">새 주제</label>
|
||||
<div>
|
||||
<input
|
||||
id="taxonomy-topic-name"
|
||||
type="text"
|
||||
value={topicName}
|
||||
placeholder="주제 이름"
|
||||
onChange={(event) => setTopicName(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={topicSlug}
|
||||
placeholder="slug (비우면 이름에서 생성)"
|
||||
aria-label="주제 slug"
|
||||
onChange={(event) => setTopicSlug(event.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={pending}>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form onSubmit={submitProject}>
|
||||
<label htmlFor="taxonomy-project-title">새 프로젝트</label>
|
||||
<div>
|
||||
<input
|
||||
id="taxonomy-project-title"
|
||||
type="text"
|
||||
value={projectTitle}
|
||||
placeholder="프로젝트 이름"
|
||||
onChange={(event) => setProjectTitle(event.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={pending}>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<p className="studio-loading" role="status">
|
||||
주제와 프로젝트를 불러오는 중입니다.
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!loading && topics ? (
|
||||
<>
|
||||
<p className="studio-result-count">{topics.length}개의 주제</p>
|
||||
{topics.length ? (
|
||||
<div className="studio-document-list">
|
||||
{topics.map((topic) => (
|
||||
<article className="studio-document-row" key={topic.id ?? topic.slug}>
|
||||
<p className="studio-row-label">TOPIC</p>
|
||||
<div className="studio-document-title">
|
||||
<h2>{topic.name}</h2>
|
||||
<p>{topic.slug}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>상태</dt>
|
||||
<dd>{topic.status === "ARCHIVED" ? "보관" : "사용 중"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeTopic(topic)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="studio-empty">아직 주제가 없습니다. 위에서 하나 만들어 주세요.</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!loading && projects ? (
|
||||
<>
|
||||
<p className="studio-result-count">{projects.length}개의 프로젝트</p>
|
||||
{projects.length ? (
|
||||
<div className="studio-document-list">
|
||||
{projects.map((project) => (
|
||||
<article className="studio-document-row" key={project.id}>
|
||||
<p className="studio-row-label">PROJECT</p>
|
||||
<div className="studio-document-title">
|
||||
<h2>{project.name}</h2>
|
||||
<p>{project.currentObjective ?? "목표 미지정"}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>단계</dt>
|
||||
<dd>{project.phase}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>공개</dt>
|
||||
<dd>{project.targetVisibility}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeProject(project)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="studio-empty">아직 프로젝트가 없습니다.</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReleaseManager } from "../components/release-manager.tsx";
|
||||
|
||||
export function StudioReleasesPage() {
|
||||
return <ReleaseManager />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TaxonomyManager } from "../components/taxonomy-manager.tsx";
|
||||
|
||||
export function TaxonomyPage() {
|
||||
return <TaxonomyManager />;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
|
||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
|
||||
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
||||
import type {
|
||||
WorkingCopy,
|
||||
@@ -29,6 +30,7 @@ type StudioProviderProps = Readonly<{
|
||||
// Optional so test harnesses that only exercise the document gateway keep
|
||||
// working unchanged. `StudioShell` always supplies one in the running app.
|
||||
createAssetGateway?: () => StudioAssetGateway;
|
||||
createManagementGateway: () => ManagementGateway;
|
||||
resolvePublishedLabel?: ResolvePublishedLabel;
|
||||
now?: () => Date;
|
||||
navigate?: (href: string) => void;
|
||||
@@ -53,6 +55,7 @@ export function StudioProvider({
|
||||
children,
|
||||
createGateway,
|
||||
createAssetGateway,
|
||||
createManagementGateway,
|
||||
resolvePublishedLabel = missingPublishedLabel,
|
||||
now = () => new Date("2026-08-14T01:00:00.000Z"),
|
||||
navigate = defaultNavigate,
|
||||
@@ -64,6 +67,7 @@ export function StudioProvider({
|
||||
const [assetGateway] = useState<StudioAssetGateway | null>(
|
||||
() => createAssetGateway?.() ?? null,
|
||||
);
|
||||
const [managementGateway] = useState<ManagementGateway>(() => createManagementGateway());
|
||||
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
||||
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
||||
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
||||
@@ -159,6 +163,7 @@ export function StudioProvider({
|
||||
const value = useMemo<StudioContextValue>(
|
||||
() => ({
|
||||
gateway,
|
||||
managementGateway,
|
||||
assetGateway,
|
||||
resolvePublishedLabel,
|
||||
now,
|
||||
@@ -177,6 +182,7 @@ export function StudioProvider({
|
||||
clearEditor,
|
||||
editor,
|
||||
gateway,
|
||||
managementGateway,
|
||||
navigateInternal,
|
||||
now,
|
||||
requestAnnouncement,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { usePublicContent } from "../public/use-public-content.tsx";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useApplication } from "../../../../presentation/providers/application-provider.tsx";
|
||||
@@ -6,6 +8,7 @@ import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts
|
||||
import { StudioHeader } from "./components/studio-header.tsx";
|
||||
import { StudioProvider } from "./studio-provider.tsx";
|
||||
import { StudioRuntimeBoundary } from "./studio-runtime-boundary.tsx";
|
||||
import { useSession } from "../../../../presentation/providers/session-provider.tsx";
|
||||
import { useStudio } from "./use-studio.ts";
|
||||
|
||||
type StudioShellProps = Readonly<{ children: ReactNode }>;
|
||||
@@ -13,12 +16,20 @@ type StudioShellProps = Readonly<{ children: ReactNode }>;
|
||||
function StudioFrame({ children }: StudioShellProps) {
|
||||
const location = useLocation();
|
||||
const { requestAnnouncement } = useStudio();
|
||||
const { sessionState } = useSession();
|
||||
// The router gates the page, not the chrome, so a signed-out visitor who
|
||||
// typed /studio still got the Studio navigation — every workspace link, by
|
||||
// name. No data leaks through an href, but the checklist item is that a
|
||||
// signed-out visitor does not see the Studio screen, and the navigation is
|
||||
// the Studio screen. `children` here is the router's sign-in surface, which
|
||||
// is the whole of what such a visitor should get.
|
||||
const signedIn = sessionState === "authenticated";
|
||||
return (
|
||||
<>
|
||||
<a className="studio-skip-link" href="#main-content">
|
||||
본문으로 건너뛰기
|
||||
</a>
|
||||
<StudioHeader currentPath={location.pathname} />
|
||||
{signedIn ? <StudioHeader currentPath={location.pathname} /> : null}
|
||||
<main id="main-content" className="studio-main" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
@@ -47,17 +58,28 @@ export function StudioShell({ children }: StudioShellProps) {
|
||||
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(),
|
||||
[application],
|
||||
);
|
||||
const createManagementGateway = useCallback(
|
||||
() => application.features.get(TECH_LOG_FEATURE_ID).createManagementGateway(),
|
||||
[application],
|
||||
);
|
||||
const createAssetGateway = useCallback(
|
||||
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(),
|
||||
[application],
|
||||
);
|
||||
// `ResolvePublishedLabel` is called from inside the public renderer, which is
|
||||
// synchronous by design — making it async would push awaits through the whole
|
||||
// render tree. So the catalog is loaded once here and the callback stays a
|
||||
// lookup over what has already arrived. Before it arrives the renderer falls
|
||||
// back to its own "게시 전" label, which is what it showed for an unknown path
|
||||
// anyway.
|
||||
const publishedLabels = usePublicContent(
|
||||
["tech-log", "studio", "published-labels"],
|
||||
async (queries) => ({ records: await queries.listRecords() }),
|
||||
);
|
||||
const records = publishedLabels.data?.records;
|
||||
const resolvePublishedLabel = useCallback(
|
||||
(path: string) =>
|
||||
application.features
|
||||
.get(TECH_LOG_FEATURE_ID)
|
||||
.publicContent.listRecords()
|
||||
.find((record) => record.path === path)?.publishedLabel,
|
||||
[application],
|
||||
(path: string) => records?.find((record) => record.path === path)?.publishedLabel,
|
||||
[records],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -74,6 +96,7 @@ export function StudioShell({ children }: StudioShellProps) {
|
||||
key={generation}
|
||||
createGateway={createGateway}
|
||||
createAssetGateway={createAssetGateway}
|
||||
createManagementGateway={createManagementGateway}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
navigate={navigateInternal}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
|
||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
|
||||
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
||||
import type {
|
||||
WorkingCopy,
|
||||
@@ -23,6 +24,9 @@ export type StudioContextValue = Readonly<{
|
||||
// `createAssetGateway` prop. `StudioShell` — the real app path — always
|
||||
// supplies one, so production code sees this populated.
|
||||
assetGateway: StudioAssetGateway | null;
|
||||
// 주제·프로젝트 관리. `assetGateway` 와 같은 이유로 nullable 이 아니다 — 이 표면은
|
||||
// MOCK 소스가 없어 항상 HTTP 이고, 없는 경우가 존재하지 않는다.
|
||||
managementGateway: ManagementGateway;
|
||||
resolvePublishedLabel: ResolvePublishedLabel;
|
||||
now(): Date;
|
||||
editor: StudioEditorState | null;
|
||||
|
||||
@@ -79,7 +79,39 @@ a {
|
||||
}
|
||||
|
||||
.site-frame {
|
||||
min-height: 100vh;
|
||||
/* Flex column with the footer pushed down by `margin-top: auto` instead of
|
||||
sitting wherever the flow leaves it. The route chunk loads after first
|
||||
paint, so in flow the footer rendered just under the header and then jumped
|
||||
when the content arrived — a single 0.192 layout shift, which is most of
|
||||
what the page scored. Pinned to the bottom of the frame it starts where it
|
||||
ends up. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* The route chunk loads after first paint, and while it does the shell renders
|
||||
the Suspense fallback — a `section.ui-page`, not a `main`. Both slots need the
|
||||
same reserved height: with only `main` covered the footer still sat at the
|
||||
viewport bottom during loading and then dropped out of view when the content
|
||||
arrived, which is the whole 0.192 the page scored. */
|
||||
.site-frame > main,
|
||||
.site-frame > .ui-page {
|
||||
/* Takes the slack so the footer stays put whether the route rendered a long
|
||||
document or nothing yet.
|
||||
|
||||
`min-height` is what actually removes the shift. The route chunk loads
|
||||
after first paint; with only `flex` the footer sat at the bottom edge of
|
||||
the viewport — visible — and then dropped to y≈2500 when the content
|
||||
arrived, which is a visible element moving and so counts in full. Holding
|
||||
the content area to a viewport tall puts the footer below the fold from the
|
||||
first frame, and later growth only pushes it further out of sight. */
|
||||
flex: 1 0 auto;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.shell {
|
||||
@@ -1926,6 +1958,11 @@ dialog::backdrop {
|
||||
}
|
||||
}
|
||||
|
||||
/* Shown where a public list would be, when the site has nothing published yet.
|
||||
Takes the same top border and muted tone the lists it replaces already use,
|
||||
so an empty page reads as a page rather than as a failure. */
|
||||
.public-empty-note { margin: 70px 0 0; padding: 44px 0; border-top: 1px solid var(--line-strong); color: var(--muted); font-size: 14px; }
|
||||
|
||||
.release-index-list { margin: 70px 0 0; padding: 0; border-top: 1px solid var(--line-strong); list-style: none; }
|
||||
.release-index-list > li { border-bottom: 1px solid var(--line); }
|
||||
.release-index-list a { display: grid; min-height: 150px; grid-template-columns: 150px minmax(0, 1fr) 24px; gap: 28px; padding: 30px 0 33px; }
|
||||
|
||||
@@ -357,8 +357,13 @@
|
||||
}
|
||||
|
||||
.dialog {
|
||||
/* studio.css 의 두 다이얼로그와 같은 이유로 명시한다 — UA 기본 margin:auto 에
|
||||
맡기면 좌상단에 붙는다. */
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: min(580px, calc(100% - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
|
||||
@@ -86,6 +86,10 @@
|
||||
a flex container both default to an automatic minimum of their min-content
|
||||
size, so at 360px the search row sized itself to 366px inside a 328px column
|
||||
and pushed the submit button off-screen (document scrollWidth 382). */
|
||||
/* Picker 자체의 옵션이지 문서의 입력 칸이 아니다 — `studio-field` 를 쓰면 편집기의 칸 목록에
|
||||
섞여 들어간다. 모양은 그 칸들과 같게 두고 이름만 분리한다. */
|
||||
.studio-app .asset-picker-option { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||
.studio-app .asset-picker-option input { width: 18px; min-width: 18px; min-height: 18px; padding: 0; }
|
||||
.studio-app .asset-picker-search { display: grid; grid-template-columns: minmax(0, 1fr); max-width: 420px; gap: 8px; margin-bottom: 14px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||
.studio-app .asset-picker-search div { display: flex; min-width: 0; gap: 8px; }
|
||||
.studio-app .asset-picker-search input { min-width: 0; flex: 1; min-height: 44px; padding-inline: 12px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-size: 14px; }
|
||||
|
||||
@@ -30,7 +30,11 @@
|
||||
.studio-app .studio-route-state button { min-height: 44px; margin-top: 18px; padding-inline: 16px; border: 1px solid var(--signal); border-radius: 5px; background: var(--signal); color: #fff; }
|
||||
.studio-app .studio-loading { min-height: 180px; }
|
||||
.studio-app .studio-visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.studio-app .studio-unsaved-dialog { width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); }
|
||||
/* 모달 dialog 를 화면 가운데에 둔다. UA 기본값(margin:auto)에 맡기면 이 빌드에서는
|
||||
좌상단에 붙는다 — 공개 화면의 .search-dialog 가 같은 이유로 position/inset/margin 을
|
||||
이미 명시하고 있고, 여기도 같은 방식을 쓴다. max-height/overflow 는 내용이 길어졌을 때
|
||||
화면 밖으로 나가지 않게 하는 짝이다. */
|
||||
.studio-app .studio-unsaved-dialog { position: fixed; inset: 0; margin: auto; max-height: calc(100dvh - 32px); overflow: auto; width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); }
|
||||
.studio-app .studio-unsaved-dialog::backdrop { background: rgba(23, 24, 27, 0.48); }
|
||||
.studio-app .studio-dialog-body { padding: 30px; }
|
||||
.studio-app .studio-dialog-body h2 { margin: 0 0 12px; font-size: 26px; }
|
||||
@@ -38,7 +42,7 @@
|
||||
.studio-app .studio-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 28px; }
|
||||
.studio-app .studio-dialog-actions button { min-height: 44px; padding-inline: 14px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); }
|
||||
.studio-app .studio-dialog-actions button:last-child { border-color: var(--signal); background: var(--signal); color: #fff; }
|
||||
.studio-app .studio-asset-upload-dialog { width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); }
|
||||
.studio-app .studio-asset-upload-dialog { position: fixed; inset: 0; margin: auto; max-height: calc(100dvh - 32px); overflow: auto; width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); }
|
||||
.studio-app .studio-asset-upload-dialog::backdrop { background: rgba(23, 24, 27, 0.48); }
|
||||
.studio-app .studio-asset-upload-dialog .studio-dialog-status { min-height: 20px; margin: 16px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.studio-app .studio-asset-upload-dialog .studio-field + .studio-field { margin-top: 18px; }
|
||||
|
||||
@@ -209,6 +209,20 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
||||
"AssetsPage",
|
||||
),
|
||||
),
|
||||
TECH_LOG_STUDIO_TAXONOMY: runtime(
|
||||
"TECH_LOG_STUDIO_TAXONOMY",
|
||||
routeModule(
|
||||
() => import("./studio/pages/taxonomy-page.tsx"),
|
||||
"TaxonomyPage",
|
||||
),
|
||||
),
|
||||
TECH_LOG_STUDIO_RELEASES: runtime(
|
||||
"TECH_LOG_STUDIO_RELEASES",
|
||||
routeModule(
|
||||
() => import("./studio/pages/releases-page.tsx"),
|
||||
"StudioReleasesPage",
|
||||
),
|
||||
),
|
||||
TECH_LOG_STUDIO_NOT_FOUND: runtime(
|
||||
"TECH_LOG_STUDIO_NOT_FOUND",
|
||||
routeModule(
|
||||
|
||||
@@ -143,6 +143,32 @@ const PLATFORM_KO_MESSAGES = {
|
||||
"error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.",
|
||||
"error.render_failure": "화면을 표시하지 못했습니다.",
|
||||
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
|
||||
// 아래 22개는 문구가 비어 있어 화면에 "요청한 문구를 표시할 수 없습니다."(common.unavailable)
|
||||
// 가 대신 나가고 있었다 — 실패 종류 38개 중 절반 이상이 그 상태였다. 사용자에게는
|
||||
// 원인 분류가 아니라 무엇이 안 됐고 무엇을 하면 되는지가 필요하므로, 기술 용어를
|
||||
// 그대로 옮기지 않는다. 구분이 필요한 진단 정보는 로그와 error.code 가 들고 있다.
|
||||
"error.request_aborted": "요청이 취소되었습니다.",
|
||||
"error.content_type_mismatch": "서버 응답 형식이 예상과 달라 표시하지 못했습니다.",
|
||||
"error.malformed_json": "서버 응답을 읽지 못했습니다.",
|
||||
"error.response_body_limit": "응답이 너무 커서 표시하지 못했습니다.",
|
||||
"error.envelope_mismatch": "서버 응답 형식이 예상과 달라 표시하지 못했습니다.",
|
||||
"error.schema_mismatch": "서버 응답 형식이 예상과 달라 표시하지 못했습니다.",
|
||||
"error.mapping_contract_violation": "서버 응답을 화면에 옮기지 못했습니다.",
|
||||
"error.result_limit_exceeded": "결과가 너무 많습니다. 조건을 좁혀 주세요.",
|
||||
"error.scope_generation_changed": "화면이 바뀌어 이전 요청을 버렸습니다. 다시 시도해 주세요.",
|
||||
"error.identity_intern_limit_exceeded": "한 번에 처리할 수 있는 항목 수를 넘었습니다.",
|
||||
"error.duplicate_in_flight": "같은 요청이 이미 처리 중입니다.",
|
||||
"error.pagination_contract_violation": "다음 페이지를 불러오지 못했습니다.",
|
||||
"error.conflict": "다른 곳에서 먼저 바뀌었습니다. 새로 불러온 뒤 다시 시도해 주세요.",
|
||||
"error.validation_rejected": "입력 값이 올바르지 않습니다.",
|
||||
"error.unknown_client_failure": "요청을 처리하지 못했습니다.",
|
||||
"error.boot_config_failure": "설정을 불러오지 못했습니다.",
|
||||
"error.release_manifest_failure": "릴리스 정보를 불러오지 못했습니다.",
|
||||
"error.deploy_mismatch": "배포 버전이 현재 화면과 일치하지 않습니다.",
|
||||
"error.storage_unavailable": "브라우저 저장소를 사용할 수 없습니다.",
|
||||
"error.storage_quota_exceeded": "브라우저 저장 공간이 부족합니다.",
|
||||
"error.telemetry_failure": "사용 기록을 전송하지 못했습니다.",
|
||||
"error.query_cache_failure": "화면 데이터를 갱신하지 못했습니다.",
|
||||
"boot.failure.title": "애플리케이션을 시작할 수 없습니다.",
|
||||
"boot.field.error": "오류",
|
||||
"boot.field.code": "코드",
|
||||
@@ -301,6 +327,30 @@ const PLATFORM_EN_MESSAGES = {
|
||||
"error.asset_mismatch": "The page assets do not match this release.",
|
||||
"error.render_failure": "The page could not be displayed.",
|
||||
"error.unknown_failure": "An unexpected problem occurred.",
|
||||
// Mirrors the ko-KR additions: every failure kind needs copy, or the surface
|
||||
// falls back to `common.unavailable` and tells the reader nothing.
|
||||
"error.request_aborted": "The request was cancelled.",
|
||||
"error.content_type_mismatch": "The server replied in an unexpected format.",
|
||||
"error.malformed_json": "The server reply could not be read.",
|
||||
"error.response_body_limit": "The reply was too large to display.",
|
||||
"error.envelope_mismatch": "The server replied in an unexpected format.",
|
||||
"error.schema_mismatch": "The server replied in an unexpected format.",
|
||||
"error.mapping_contract_violation": "The reply could not be shown on this screen.",
|
||||
"error.result_limit_exceeded": "Too many results. Narrow the filters.",
|
||||
"error.scope_generation_changed": "The screen changed, so the earlier request was dropped. Try again.",
|
||||
"error.identity_intern_limit_exceeded": "More items than this screen can handle at once.",
|
||||
"error.duplicate_in_flight": "The same request is already in progress.",
|
||||
"error.pagination_contract_violation": "The next page could not be loaded.",
|
||||
"error.conflict": "It changed elsewhere first. Reload and try again.",
|
||||
"error.validation_rejected": "Some values are not valid.",
|
||||
"error.unknown_client_failure": "The request could not be completed.",
|
||||
"error.boot_config_failure": "Configuration could not be loaded.",
|
||||
"error.release_manifest_failure": "Release information could not be loaded.",
|
||||
"error.deploy_mismatch": "The deployed version does not match this screen.",
|
||||
"error.storage_unavailable": "Browser storage is unavailable.",
|
||||
"error.storage_quota_exceeded": "Browser storage is full.",
|
||||
"error.telemetry_failure": "Usage data could not be sent.",
|
||||
"error.query_cache_failure": "Screen data could not be refreshed.",
|
||||
"boot.failure.title": "The application could not start.",
|
||||
"boot.field.error": "Error",
|
||||
"boot.field.code": "Code",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
InlineRenderer,
|
||||
PlainText,
|
||||
} from "../../src/features/tech-log/presentation/shared/public-render/inline-renderer.tsx";
|
||||
|
||||
/**
|
||||
* 작성자가 엔터로 나눠 쓴 글이 한 줄로 이어져 보였다. Markdown 이 한 번의 줄바꿈을 문단 내 공백으로
|
||||
* 읽고, 파서가 그 줄바꿈을 텍스트에 남긴 뒤, HTML 이 다시 공백으로 접기 때문이다. 미리보기와 공개
|
||||
* 화면이 같은 렌더러를 쓰므로 두 곳 모두에서 그랬다.
|
||||
*/
|
||||
describe("문단 안의 줄바꿈", () => {
|
||||
it("줄바꿈을 <br> 로 그린다", () => {
|
||||
const { container } = render(
|
||||
<InlineRenderer content={[{ type: "TEXT", text: "첫째 줄\n둘째 줄" }]} />,
|
||||
);
|
||||
expect(container.querySelectorAll("br")).toHaveLength(1);
|
||||
expect(container.textContent).toBe("첫째 줄둘째 줄");
|
||||
});
|
||||
|
||||
it("줄바꿈이 없으면 <br> 를 넣지 않는다", () => {
|
||||
const { container } = render(
|
||||
<InlineRenderer content={[{ type: "TEXT", text: "한 줄" }]} />,
|
||||
);
|
||||
expect(container.querySelectorAll("br")).toHaveLength(0);
|
||||
expect(container.textContent).toBe("한 줄");
|
||||
});
|
||||
|
||||
it("여러 번 엔터를 친 만큼 내려간다", () => {
|
||||
const { container } = render(
|
||||
<InlineRenderer content={[{ type: "TEXT", text: "가\n나\n다" }]} />,
|
||||
);
|
||||
expect(container.querySelectorAll("br")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 요약·문제·결론·환경은 Markdown 을 거치지 않고 그대로 그려진다. 본문만 고쳤을 때 이 칸들이
|
||||
* 여전히 이어져 보인 이유이고, 작성자가 "아직 안 고쳐졌다" 고 본 것도 이쪽이다.
|
||||
*/
|
||||
describe("Markdown 을 거치지 않는 평문 칸", () => {
|
||||
it("작성자가 나눠 쓴 줄을 지킨다", () => {
|
||||
const { container } = render(<PlainText text={"문제 첫 줄\n문제 둘째 줄"} />);
|
||||
expect(container.querySelectorAll("br")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("한 줄이면 <br> 를 넣지 않는다", () => {
|
||||
const { container } = render(<PlainText text="한 줄" />);
|
||||
expect(container.querySelectorAll("br")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -49,10 +49,12 @@ const { AppRouter } = await import("../../src/presentation/routes/app-router.tsx
|
||||
const { ApplicationProvider } = await import(
|
||||
"../../src/presentation/providers/application-provider.tsx"
|
||||
);
|
||||
const { renderWithQueryProviders } = await import("../helpers/query-providers.tsx");
|
||||
|
||||
function renderAt(path: string, disabled: boolean) {
|
||||
window.history.pushState({}, "", path);
|
||||
return render(
|
||||
renderWithQueryProviders(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session: createAnonymousSessionAdapter(),
|
||||
@@ -68,6 +70,7 @@ function renderAt(path: string, disabled: boolean) {
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
RouterProvider,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../helpers/studio-install-context.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "../../src/features/tech-log/application/tech-log-feature-input.ts";
|
||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
||||
import { renderWithQueryProviders } from "../helpers/query-providers.tsx";
|
||||
import {
|
||||
AppRouter,
|
||||
createGroupedRouteObjects,
|
||||
@@ -102,17 +104,36 @@ function compileTimeGroupedRouteContract() {
|
||||
}
|
||||
void compileTimeGroupedRouteContract;
|
||||
|
||||
function renderRouter() {
|
||||
/**
|
||||
* Studio routes are `session-required`, so a Studio assertion needs a session
|
||||
* that says so — with the anonymous adapter the router correctly renders the
|
||||
* sign-in surface instead of the page, which is what the gate is for.
|
||||
*/
|
||||
function createSignedInSessionAdapter() {
|
||||
return createExternalAuthSessionAdapter({
|
||||
readState: () => "authenticated" as const,
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
attachCredential: async () => ({ headers: {} }),
|
||||
recoverSession: async () => "restored" as const,
|
||||
notifyUnauthenticated: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
function renderRouter(session = createAnonymousSessionAdapter()) {
|
||||
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT);
|
||||
return render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session: createAnonymousSessionAdapter(),
|
||||
featureInputs: { [TECH_LOG_FEATURE_ID]: techLog.input },
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>,
|
||||
renderWithQueryProviders(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session,
|
||||
featureInputs: { [TECH_LOG_FEATURE_ID]: techLog.input },
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,15 +157,17 @@ describe("generic application router", () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<ApplicationProvider application={createTestApplication()}>
|
||||
<LocaleProvider>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
renderWithQueryProviders(
|
||||
<ApplicationProvider application={createTestApplication()}>
|
||||
<LocaleProvider>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -260,15 +283,17 @@ describe("generic application router", () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<ApplicationProvider application={createTestApplication()}>
|
||||
<LocaleProvider>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
renderWithQueryProviders(
|
||||
<ApplicationProvider application={createTestApplication()}>
|
||||
<LocaleProvider>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("studio-layout")).toBeVisible();
|
||||
@@ -307,15 +332,17 @@ describe("generic application router", () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<ApplicationProvider application={createTestApplication()}>
|
||||
<LocaleProvider>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
renderWithQueryProviders(
|
||||
<ApplicationProvider application={createTestApplication()}>
|
||||
<LocaleProvider>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -355,10 +382,27 @@ describe("generic application router", () => {
|
||||
expect(screen.queryByText("Not Found", { exact: true })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
||||
it("keeps a signed-out visitor out of the Studio layout entirely", async () => {
|
||||
window.history.pushState({}, "", "/studio");
|
||||
renderRouter();
|
||||
|
||||
// Not merely "no data": the Studio surface itself must not mount. Every
|
||||
// TechLog route used to register as `access: "public"`, so a signed-out
|
||||
// visitor who typed /studio got the shell, the navigation, and the page —
|
||||
// and the page then issued Studio API calls.
|
||||
await screen.findByRole("heading", { name: /세션|로그인/ });
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "작업 흐름" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Studio 주 탐색" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
||||
window.history.pushState({}, "", "/studio");
|
||||
renderRouter(createSignedInSessionAdapter());
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "작업 흐름" }),
|
||||
).toBeVisible();
|
||||
@@ -371,7 +415,7 @@ describe("generic application router", () => {
|
||||
|
||||
it("gives the Studio wildcard precedence over the Public not-found route", async () => {
|
||||
window.history.pushState({}, "", "/studio/missing");
|
||||
renderRouter();
|
||||
renderRouter(createSignedInSessionAdapter());
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user