Compare commits
58
Commits
68c5dbdaa3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2420fcee9f | ||
|
|
2632850322 | ||
|
|
ad8f322b43 | ||
|
|
ab4d822956 | ||
|
|
344a163d84 | ||
|
|
60c8c82097 | ||
|
|
b3119952d5 | ||
|
|
7211dd1a92 | ||
|
|
3036b8d788 | ||
|
|
fd73bc88a1 | ||
|
|
e5770dfbc8 | ||
|
|
a7fe069a6d | ||
|
|
ab67eb912c | ||
|
|
e9b866184a | ||
|
|
84d72c4f60 | ||
|
|
6b9dc3f0a2 | ||
|
|
f9d20e8946 | ||
|
|
014f21b9e1 | ||
|
|
16e5b9f4ec | ||
|
|
0eb3c86839 | ||
|
|
c87e0a2338 | ||
|
|
b3aa304975 | ||
|
|
c03b0c77b8 | ||
|
|
1801414592 | ||
|
|
7345500ef3 | ||
|
|
fb478f951b | ||
|
|
7289ce97bb | ||
|
|
348420618d | ||
|
|
89a73c13c6 | ||
|
|
21f8425f1e | ||
|
|
7093d84ab5 | ||
|
|
d2c289c650 | ||
|
|
5cffe30200 | ||
|
|
197b2c7e72 | ||
|
|
c5e8735041 | ||
|
|
ab8c6c14db | ||
|
|
3754269118 | ||
|
|
760071156d | ||
|
|
03986da3d6 | ||
|
|
31dca00857 | ||
|
|
11c2713139 | ||
|
|
4b62bf3b1f | ||
|
|
6784eb1ce6 | ||
|
|
24c01aedf2 | ||
|
|
4566f2d7a8 | ||
|
|
c362ec6100 | ||
|
|
83409bef7a | ||
|
|
5e2b1a5586 | ||
|
|
f1498feee5 | ||
|
|
5fe355483e | ||
|
|
44caa477e3 | ||
|
|
fff5e6f59e | ||
|
|
eb86708076 | ||
|
|
3e2406349a | ||
|
|
aaaf0ac343 | ||
|
|
9244f5c15d | ||
|
|
d23a18f659 | ||
|
|
25a6b63d27 |
@@ -29,3 +29,4 @@ artifacts/tests/visual/
|
|||||||
# Git worktrees created inside the repository. A worktree is a checkout, not
|
# Git worktrees created inside the repository. A worktree is a checkout, not
|
||||||
# source: committing one would nest a second working copy inside this one.
|
# source: committing one would nest a second working copy inside this one.
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
+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_PROJECT_EDIT accessibility review
|
||||||
|
|
||||||
|
Status: pending-manual-review
|
||||||
|
Route ID: TECH_LOG_STUDIO_PROJECT_EDIT
|
||||||
|
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_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_RELEASE_EDIT accessibility review
|
||||||
|
|
||||||
|
Status: pending-manual-review
|
||||||
|
Route ID: TECH_LOG_STUDIO_RELEASE_EDIT
|
||||||
|
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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -1196,6 +1196,30 @@
|
|||||||
"schemaId": "markdown",
|
"schemaId": "markdown",
|
||||||
"production": "source-controlled"
|
"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-PROJECT-EDIT-md",
|
||||||
|
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.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-RELEASE-EDIT-md",
|
||||||
|
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md",
|
||||||
|
"schemaId": "markdown",
|
||||||
|
"production": "source-controlled"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
|
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
|
||||||
@@ -1949,6 +1973,10 @@
|
|||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATIONS-md",
|
"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-PUBLICATION-PREVIEW-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-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-PROJECT-EDIT-md",
|
||||||
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||||
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-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-NOT-FOUND-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-report-json"
|
"artifact-artifacts-tests-a11y-manual-report-json"
|
||||||
|
|||||||
@@ -280,6 +280,86 @@
|
|||||||
"compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.",
|
"compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.",
|
||||||
"rollback": "Remove the required codec field and runtime codec dispatch together.",
|
"rollback": "Remove the required codec field and runtime codec dispatch together.",
|
||||||
"owner": "frontend-platform"
|
"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",
|
"SERVICE_WORKER": "DEFAULT",
|
||||||
"OFFLINE_COMMANDS": "DEFAULT"
|
"OFFLINE_COMMANDS": "DEFAULT"
|
||||||
},
|
},
|
||||||
"TECH_LOG_STUDIO_SOURCE": "MOCK",
|
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
||||||
|
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
|
||||||
"FEATURE_OVERRIDES": {
|
"FEATURE_OVERRIDES": {
|
||||||
"reference-feature": "DEFAULT"
|
"reference-feature": "DEFAULT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"OFFLINE_COMMANDS": "DEFAULT"
|
"OFFLINE_COMMANDS": "DEFAULT"
|
||||||
},
|
},
|
||||||
"TECH_LOG_STUDIO_SOURCE": "MOCK",
|
"TECH_LOG_STUDIO_SOURCE": "MOCK",
|
||||||
|
"TECH_LOG_PUBLIC_SOURCE": "MOCK",
|
||||||
"FEATURE_OVERRIDES": {
|
"FEATURE_OVERRIDES": {
|
||||||
"reference-feature": "DEFAULT"
|
"reference-feature": "DEFAULT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"OFFLINE_COMMANDS": "DEFAULT"
|
"OFFLINE_COMMANDS": "DEFAULT"
|
||||||
},
|
},
|
||||||
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
||||||
|
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
|
||||||
"FEATURE_OVERRIDES": {
|
"FEATURE_OVERRIDES": {
|
||||||
"reference-feature": "DEFAULT"
|
"reference-feature": "DEFAULT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"OFFLINE_COMMANDS": "DEFAULT"
|
"OFFLINE_COMMANDS": "DEFAULT"
|
||||||
},
|
},
|
||||||
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
||||||
|
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
|
||||||
"FEATURE_OVERRIDES": {
|
"FEATURE_OVERRIDES": {
|
||||||
"reference-feature": "DEFAULT"
|
"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": 28800,
|
||||||
|
"ssoSessionMaxLifespan": 86400,
|
||||||
|
"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 연동 필요) |
|
||||||
@@ -594,6 +594,20 @@ const commonSecurityRules = {
|
|||||||
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
|
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
|
||||||
message: "Runtime script construction is prohibited by FE-OC-019.",
|
message: "Runtime script construction is prohibited by FE-OC-019.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
setState 업데이터는 핸들러가 끝난 뒤, 다음 렌더에 실행된다. 그때 React 는 이미
|
||||||
|
`event.currentTarget` 을 null 로 되돌려 놓았으므로 업데이터 안에서 그것을 읽으면
|
||||||
|
"Cannot read properties of null" 로 화면이 통째로 죽는다.
|
||||||
|
|
||||||
|
타입 검사도 lint 도 잡지 못했고, 첫 입력에서야 드러났다 — 값은 핸들러가 도는 동안
|
||||||
|
지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다.
|
||||||
|
*/
|
||||||
|
selector:
|
||||||
|
"CallExpression[callee.name=/^set[A-Z]/] > ArrowFunctionExpression MemberExpression[property.name='currentTarget']",
|
||||||
|
message:
|
||||||
|
"Read event.currentTarget before the setState updater runs — it is null by the time the updater is called.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Tech Log frontend" />
|
<meta name="description" content="Tech Log frontend" />
|
||||||
<title>Tech Log</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -38,14 +38,28 @@
|
|||||||
},
|
},
|
||||||
"contractSet": {
|
"contractSet": {
|
||||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||||
"setDigest": "sha256:e0da77655f51592ece583826d5fc6b092f57dd2bf63307e45e7e77283e6bf437",
|
"setDigest": "sha256:cdcfb628a502d71596f1162726eb395aad0f5f92cf05fd77d304f8e51c81b2fc",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/studio-contract",
|
"packageId": "@tech-log/management-contract",
|
||||||
"version": "2.0.0",
|
"version": "1.0.0",
|
||||||
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea",
|
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "ce2e748"
|
"sourceRevision": "ef49d3a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"packageId": "@tech-log/public-contract",
|
||||||
|
"version": "2.1.0",
|
||||||
|
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
|
||||||
|
"runtimeProtocolVersion": 1,
|
||||||
|
"sourceRevision": "ef49d3a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"packageId": "@tech-log/studio-contract",
|
||||||
|
"version": "3.1.0",
|
||||||
|
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
|
||||||
|
"runtimeProtocolVersion": 1,
|
||||||
|
"sourceRevision": "ef49d3a"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,7 +459,21 @@ const CANONICAL_GATE_SHAPE_SHA256 =
|
|||||||
// Dev release manifest drift fix, item 2: recomputed again after FE-GATE-010
|
// Dev release manifest drift fix, item 2: recomputed again after FE-GATE-010
|
||||||
// gained `check-dev-release-manifest`. Same method — 98d19911… was first
|
// gained `check-dev-release-manifest`. Same method — 98d19911… was first
|
||||||
// reproduced from the previous gates.json before this value was hashed.
|
// 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.
|
||||||
|
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
|
||||||
|
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
|
||||||
|
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
|
||||||
|
// 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
|
||||||
|
// 재현했다.
|
||||||
|
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
|
||||||
|
|
||||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||||
const normalized = gates.map(
|
const normalized = gates.map(
|
||||||
@@ -512,8 +526,15 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
|||||||
// Template merge. 126 product artifacts plus the two the template added.
|
// 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.
|
// Task 11 added one more: the TECH_LOG_STUDIO_ASSETS manual a11y evidence file.
|
||||||
// Alignment follow-up, item 2 added the TechLog junit report.
|
// Alignment follow-up, item 2 added the TechLog junit report.
|
||||||
if (contract.artifacts.length !== 130) {
|
// The taxonomy route added its own manual a11y evidence file — every installed
|
||||||
failures.push(`artifact authority baseline must contain exactly 130 artifacts; received ${contract.artifacts.length}`);
|
// route carries one, and the gate checks that the two sets match exactly.
|
||||||
|
// The project edit route did the same: it is what finally lets a project carry
|
||||||
|
// a purpose, a current objective, and a next step, so the public screens that
|
||||||
|
// read those fields stop rendering blanks.
|
||||||
|
// The release edit route followed: the editor used to open below the release
|
||||||
|
// list, so editing meant scrolling past every release to reach it.
|
||||||
|
if (contract.artifacts.length !== 134) {
|
||||||
|
failures.push(`artifact authority baseline must contain exactly 134 artifacts; received ${contract.artifacts.length}`);
|
||||||
}
|
}
|
||||||
if (contract.stages.length !== 5) {
|
if (contract.stages.length !== 5) {
|
||||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
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의
|
* 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의
|
||||||
* classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고
|
* classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고
|
||||||
@@ -12,15 +12,53 @@
|
|||||||
*/
|
*/
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { execFileSync } from "node:child_process";
|
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";
|
import { argv, env, exit } from "node:process";
|
||||||
|
|
||||||
const CANONICAL_ROOT =
|
const CANONICAL_ROOT =
|
||||||
env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package";
|
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";
|
* 계약은 둘이고 서로 독립이다. Studio는 인증된 작성 표면이고, Public은 인증
|
||||||
const SOURCE_RECORD = "src/features/tech-log/contracts/studio/canonical-source.json";
|
* 없는 조회 표면이다. 각자 자기 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 OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1";
|
||||||
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
|
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
|
||||||
@@ -56,71 +94,84 @@ function fail(problems: readonly string[]): never {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (check) {
|
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 problems: string[] = [];
|
||||||
|
const summaries: string[] = [];
|
||||||
|
|
||||||
if (digestOf(readFileSync(VENDOR_YAML)) !== record.digest) {
|
for (const target of CONTRACTS) {
|
||||||
problems.push(`${VENDOR_YAML} does not hash to the recorded digest`);
|
const vendored = readFileSync(target.vendorYaml, "utf8");
|
||||||
}
|
const generated = readFileSync(target.generated, "utf8");
|
||||||
const vendoredOperations = operationIdsOf(vendored);
|
const record = JSON.parse(readFileSync(target.sourceRecord, "utf8")) as CanonicalRecord;
|
||||||
if (vendoredOperations.join(" ") !== [...record.operationIds].join(" ")) {
|
|
||||||
problems.push(`${SOURCE_RECORD} operationIds differ from ${VENDOR_YAML}`);
|
if (digestOf(readFileSync(target.vendorYaml)) !== record.digest) {
|
||||||
}
|
problems.push(`${target.vendorYaml} does not hash to the recorded digest`);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
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);
|
if (problems.length > 0) fail(problems);
|
||||||
console.log(
|
console.log(`tech-log contracts are in sync:\n- ${summaries.join("\n- ")}`);
|
||||||
`tech-log contract is in sync: ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
|
||||||
);
|
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canonicalBytes = readFileSync(CANONICAL_YAML);
|
const sourceRevision = execFileSync(
|
||||||
const canonicalText = canonicalBytes.toString("utf8");
|
"git",
|
||||||
|
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
).trim();
|
||||||
|
|
||||||
const record: CanonicalRecord = {
|
for (const target of CONTRACTS) {
|
||||||
packageId: "@tech-log/studio-contract",
|
const canonicalBytes = readFileSync(target.canonicalYaml);
|
||||||
version: specVersionOf(canonicalText),
|
const canonicalText = canonicalBytes.toString("utf8");
|
||||||
digest: digestOf(canonicalBytes),
|
|
||||||
sourceRevision: execFileSync(
|
|
||||||
"git",
|
|
||||||
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
|
|
||||||
{ encoding: "utf8" },
|
|
||||||
).trim(),
|
|
||||||
operationIds: operationIdsOf(canonicalText),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
|
const record: CanonicalRecord = {
|
||||||
const generated = execFileSync(
|
packageId: target.packageId,
|
||||||
"corepack",
|
version: specVersionOf(canonicalText),
|
||||||
[
|
digest: digestOf(canonicalBytes),
|
||||||
"pnpm",
|
sourceRevision,
|
||||||
"dlx",
|
operationIds: operationIdsOf(canonicalText),
|
||||||
"--package",
|
};
|
||||||
GENERATOR_TYPESCRIPT,
|
|
||||||
"--package",
|
|
||||||
OPENAPI_TYPESCRIPT,
|
|
||||||
"openapi-typescript",
|
|
||||||
CANONICAL_YAML,
|
|
||||||
],
|
|
||||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
|
||||||
);
|
|
||||||
|
|
||||||
writeFileSync(VENDOR_YAML, canonicalText);
|
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
|
||||||
writeFileSync(GENERATED, generated);
|
const generated = execFileSync(
|
||||||
writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`);
|
"corepack",
|
||||||
console.log(
|
[
|
||||||
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
"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`가 그대로 서빙하는
|
// 재생성은 매번 package digest를 바꾼다. `pnpm dev`가 그대로 서빙하는
|
||||||
// `public/release-manifest.json`은 build가 컴파일한 contract set을 그대로
|
// `public/release-manifest.json`은 build가 컴파일한 contract set을 그대로
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import {
|
import { TECH_LOG_ROUTE_REGISTRY } from "../src/features/tech-log/contracts/tech-log-route-contract.ts";
|
||||||
projects,
|
|
||||||
publicRecords,
|
|
||||||
releases,
|
|
||||||
} from "../src/features/tech-log/adapters/static/public-content.ts";
|
|
||||||
import { writeTechLogServingArtifact } from "./lib/tech-log-serving-artifact.ts";
|
import { writeTechLogServingArtifact } from "./lib/tech-log-serving-artifact.ts";
|
||||||
import { createTechLogServingContract } from "./lib/tech-log-serving-contract.ts";
|
import { createTechLogServingContract } from "./lib/tech-log-serving-contract.ts";
|
||||||
|
|
||||||
const contract = createTechLogServingContract({
|
// The router owns which public paths exist. Reading them from the catalog
|
||||||
projects,
|
// instead — as this did — pinned the served set to whatever the bundled fixture
|
||||||
publicRecords,
|
// contained on the day of the build.
|
||||||
releases,
|
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 });
|
await writeTechLogServingArtifact({ distRoot: "dist", contract });
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ export function createTechLogProductionServer({
|
|||||||
contract,
|
contract,
|
||||||
}) {
|
}) {
|
||||||
const absoluteRoot = path.resolve(root);
|
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(
|
const studioSpaPathPatterns = contract.studioSpaPathPatterns.map(
|
||||||
(pattern) => new RegExp(pattern),
|
(pattern) => new RegExp(pattern),
|
||||||
);
|
);
|
||||||
@@ -69,7 +71,7 @@ export function createTechLogProductionServer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
publicSpaPaths.has(pathname) ||
|
publicSpaPathPatterns.some((pattern) => pattern.test(pathname)) ||
|
||||||
studioSpaPathPatterns.some((pattern) => pattern.test(pathname))
|
studioSpaPathPatterns.some((pattern) => pattern.test(pathname))
|
||||||
) {
|
) {
|
||||||
await sendFile(path.join(absoluteRoot, "index.html"), request.method, response);
|
await sendFile(path.join(absoluteRoot, "index.html"), request.method, response);
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
export type TechLogServingContract = Readonly<{
|
export type TechLogServingContract = Readonly<{
|
||||||
schemaVersion: 1;
|
schemaVersion: 2;
|
||||||
publicSpaPaths: readonly string[];
|
/**
|
||||||
|
* 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";
|
studioPathPrefix: "/studio";
|
||||||
studioSpaPathPatterns: readonly string[];
|
studioSpaPathPatterns: readonly string[];
|
||||||
notFound: Readonly<{
|
notFound: Readonly<{
|
||||||
@@ -11,68 +25,67 @@ export type TechLogServingContract = Readonly<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
type ServingContractInput = Readonly<{
|
type ServingContractInput = Readonly<{
|
||||||
publicRecords: readonly Readonly<{
|
/**
|
||||||
path: string;
|
* The public route templates the router registers, in route-contract form
|
||||||
topicSlug: string;
|
* (`/cases/:slug`). Passed in rather than imported so this module stays a
|
||||||
}>[];
|
* pure transform the tests can drive directly.
|
||||||
projects: readonly Readonly<{ slug: string }>[];
|
*/
|
||||||
releases: readonly Readonly<{ path: string }>[];
|
publicRoutePaths: readonly string[];
|
||||||
|
/** The Studio route templates, same form and same reason. */
|
||||||
|
studioRoutePaths: readonly string[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const staticPublicPaths = Object.freeze([
|
/**
|
||||||
"/",
|
* `/cases/:slug` -> `^/cases/[^/]+$`. A parameter matches one segment and never
|
||||||
"/explore",
|
* a slash, which is what keeps `/cases/a/b` a 404 instead of a case page.
|
||||||
"/explore/cases",
|
*/
|
||||||
"/explore/questions",
|
function patternOf(routePath: string): string {
|
||||||
"/explore/references",
|
const escaped = routePath
|
||||||
"/profile",
|
.split("/")
|
||||||
"/projects",
|
.map((segment) =>
|
||||||
"/releases",
|
segment.startsWith(":")
|
||||||
"/search",
|
? "[^/]+"
|
||||||
]);
|
: 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 {
|
function asciiCompare(left: string, right: string): number {
|
||||||
return left < right ? -1 : left > right ? 1 : 0;
|
return left < right ? -1 : left > right ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTechLogServingContract({
|
/**
|
||||||
publicRecords,
|
* The catch-all is the SPA's own not-found screen; serving index.html for every
|
||||||
projects,
|
* unmatched URL would turn the edge 404 into a soft 200 and hide broken links
|
||||||
releases,
|
* from crawlers and from us.
|
||||||
}: ServingContractInput): TechLogServingContract {
|
*/
|
||||||
const publicSpaPaths = new Set(staticPublicPaths);
|
function patternsFor(routePaths: readonly string[]): readonly string[] {
|
||||||
for (const record of publicRecords) {
|
const patterns = new Set<string>();
|
||||||
publicSpaPaths.add(record.path);
|
for (const routePath of routePaths) {
|
||||||
publicSpaPaths.add(`/topics/${record.topicSlug}`);
|
if (routePath === "*" || routePath.includes("*")) continue;
|
||||||
|
patterns.add(patternOf(routePath));
|
||||||
}
|
}
|
||||||
for (const project of projects) {
|
return Object.freeze([...patterns].sort(asciiCompare));
|
||||||
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);
|
|
||||||
|
|
||||||
|
export function createTechLogServingContract({
|
||||||
|
publicRoutePaths,
|
||||||
|
studioRoutePaths,
|
||||||
|
}: ServingContractInput): TechLogServingContract {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
publicSpaPaths: Object.freeze([...publicSpaPaths].sort(asciiCompare)),
|
publicSpaPathPatterns: patternsFor(publicRoutePaths),
|
||||||
studioPathPrefix: "/studio",
|
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({
|
notFound: Object.freeze({
|
||||||
status: 404,
|
status: 404,
|
||||||
contentType: "text/plain;charset=UTF-8",
|
contentType: "text/plain;charset=UTF-8",
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* 배포본 전수 확인.
|
||||||
|
*
|
||||||
|
* 이 파일은 절차 실패에서 나왔다 — 고친 화면만 확인하고 배포해서, 나머지가 깨진 것은 매번
|
||||||
|
* 사용자가 먼저 발견했다. 운영 환경이므로 배포 전에 모든 화면을 한 번씩 열어 보는 것이 맞다.
|
||||||
|
*
|
||||||
|
* 각 화면에서 보는 것: 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")) return;
|
||||||
|
// 두 가지는 화면이 다루는 정상 상태다: 로그인 전 세션 탐침의 401, 그리고 아직 미리보기를
|
||||||
|
// 만들지 않은 문서의 404. 이것들을 실패로 세면 매번 같은 줄이 뜨고, 진짜 실패가 그 사이에
|
||||||
|
// 묻힌다 — 늑대가 왔다고 매번 외치는 점검은 아무도 읽지 않는다.
|
||||||
|
if (u.includes("/studio/session")) return;
|
||||||
|
if (r.status() === 404 && r.request().method() === "GET" && u.endsWith("/preview")) return;
|
||||||
|
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 { 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 { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
|
||||||
import { createCompositionRoot } from "./composition-root.ts";
|
import { createCompositionRoot } from "./composition-root.ts";
|
||||||
import { loadReleaseManifest } from "./load-release-manifest.ts";
|
import { loadReleaseManifest } from "./load-release-manifest.ts";
|
||||||
@@ -27,13 +28,31 @@ export async function createRuntimeComposition(
|
|||||||
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
|
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
|
||||||
loadRelease: (runtime) =>
|
loadRelease: (runtime) =>
|
||||||
loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }),
|
loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }),
|
||||||
createAdapters: ({ config: runtime, release }) =>
|
createAdapters: ({ config: runtime, release }) => {
|
||||||
createRuntimeAdapters({
|
// `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,
|
runtime,
|
||||||
release,
|
release,
|
||||||
fetcher: dependencies.fetcher,
|
fetcher: dependencies.fetcher,
|
||||||
host: dependencies.host,
|
host: dependencies.host,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const capabilities = resolveRuntimeCapabilities(
|
const capabilities = resolveRuntimeCapabilities(
|
||||||
|
|||||||
@@ -513,6 +513,18 @@ export async function createRuntimeAdapters(
|
|||||||
techLogCsrf,
|
techLogCsrf,
|
||||||
);
|
);
|
||||||
if (studioOutcome) return studioOutcome;
|
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();
|
const state = authSession.getState();
|
||||||
if (state === "integration-failed") {
|
if (state === "integration-failed") {
|
||||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||||
@@ -599,6 +611,7 @@ export async function createRuntimeAdapters(
|
|||||||
const featureInputs = createInstalledFeatureInputs({
|
const featureInputs = createInstalledFeatureInputs({
|
||||||
contractOperations,
|
contractOperations,
|
||||||
studioSource: config.TECH_LOG_STUDIO_SOURCE,
|
studioSource: config.TECH_LOG_STUDIO_SOURCE,
|
||||||
|
publicSource: config.TECH_LOG_PUBLIC_SOURCE,
|
||||||
apiBaseUrl: config.API_BASE_URL,
|
apiBaseUrl: config.API_BASE_URL,
|
||||||
requestTimeoutMs: config.REQUEST_TIMEOUT_MS,
|
requestTimeoutMs: config.REQUEST_TIMEOUT_MS,
|
||||||
csrf: techLogCsrf,
|
csrf: techLogCsrf,
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ export type RuntimeConfig = Readonly<{
|
|||||||
* MOCK.
|
* MOCK.
|
||||||
*/
|
*/
|
||||||
TECH_LOG_STUDIO_SOURCE: "MOCK" | "HTTP";
|
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. */
|
/** Present only while a V1 document is still accepted. */
|
||||||
LEGACY_API_CONTRACT_VERSION?: string;
|
LEGACY_API_CONTRACT_VERSION?: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -143,6 +149,10 @@ export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
|
|||||||
TECH_LOG_STUDIO_SOURCE: isV2
|
TECH_LOG_STUDIO_SOURCE: isV2
|
||||||
? (parsed as RuntimeConfigV2).TECH_LOG_STUDIO_SOURCE
|
? (parsed as RuntimeConfigV2).TECH_LOG_STUDIO_SOURCE
|
||||||
: "MOCK",
|
: "MOCK",
|
||||||
|
// Same treatment for the public-read switch.
|
||||||
|
TECH_LOG_PUBLIC_SOURCE: isV2
|
||||||
|
? (parsed as RuntimeConfigV2).TECH_LOG_PUBLIC_SOURCE
|
||||||
|
: "MOCK",
|
||||||
...(isV2
|
...(isV2
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export const ENV_REGISTRY = Object.freeze({
|
|||||||
// TechLog Studio gateway adapter selection. Defaults to MOCK while the
|
// TechLog Studio gateway adapter selection. Defaults to MOCK while the
|
||||||
// backend does not exist yet.
|
// backend does not exist yet.
|
||||||
TECH_LOG_STUDIO_SOURCE: runtime("public", false, "MOCK"),
|
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
|
// §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.
|
// here is not imported by any registry and never reaches the bundle.
|
||||||
VITE_PRODUCT_FEATURES: build("compile-time", false, null),
|
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
|
// 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.
|
// default is the in-memory mock; a document may opt a build into HTTP.
|
||||||
TECH_LOG_STUDIO_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"),
|
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()
|
.strict()
|
||||||
.superRefine(runtimeConfigArtifactInvariants);
|
.superRefine(runtimeConfigArtifactInvariants);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
|
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 { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
||||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.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";
|
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[] =
|
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
||||||
Object.freeze(
|
Object.freeze(
|
||||||
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
|
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(
|
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function createInstalledFeatureInputs(
|
|||||||
context: Parameters<typeof createReferenceFeatureInstalledInput>[0] &
|
context: Parameters<typeof createReferenceFeatureInstalledInput>[0] &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
studioSource: "MOCK" | "HTTP";
|
studioSource: "MOCK" | "HTTP";
|
||||||
|
publicSource: "MOCK" | "HTTP";
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
csrf: CsrfTokenProvider;
|
csrf: CsrfTokenProvider;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
|
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
|
||||||
import { createMockStudioAssetGateway } from "./mock/mock-studio-asset-gateway.ts";
|
import { createMockStudioAssetGateway } from "./mock/mock-studio-asset-gateway.ts";
|
||||||
import { createMockStudioGateway } from "./mock/mock-studio-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";
|
import { publicContentQueries } from "./static/public-query.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +29,7 @@ import { publicContentQueries } from "./static/public-query.ts";
|
|||||||
*/
|
*/
|
||||||
export type TechLogInstallContext = Readonly<{
|
export type TechLogInstallContext = Readonly<{
|
||||||
studioSource: "MOCK" | "HTTP";
|
studioSource: "MOCK" | "HTTP";
|
||||||
|
publicSource: "MOCK" | "HTTP";
|
||||||
contractOperations: StudioOperationExecutor;
|
contractOperations: StudioOperationExecutor;
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
@@ -66,8 +69,20 @@ export function createTechLogFeatureInstalledInput(
|
|||||||
? createMockStudioGateway({ assets: mockAssets })
|
? createMockStudioGateway({ assets: mockAssets })
|
||||||
: createHttpStudioGateway({ operations: context.contractOperations });
|
: 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({
|
const input: TechLogFeatureInput = Object.freeze({
|
||||||
publicContent: publicContentQueries,
|
publicContent,
|
||||||
|
createManagementGateway,
|
||||||
createStudioGateway,
|
createStudioGateway,
|
||||||
createStudioAssetGateway,
|
createStudioAssetGateway,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||||
import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts";
|
import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts";
|
||||||
import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts";
|
import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts";
|
||||||
|
import { envelopeData, envelopeError } from "../../contracts/tech-log-studio-contract-contribution.ts";
|
||||||
import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts";
|
import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts";
|
||||||
import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts";
|
import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts";
|
||||||
|
|
||||||
const CODES = new Set<string>(STUDIO_ERROR_CODES);
|
const CODES = new Set<string>(STUDIO_ERROR_CODES);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* canonical `uploadStudioAsset`도 다른 18개 operation과 같은 봉투(ADR-006)를
|
||||||
|
* 쓴다 — 이 seam만 일반 계약 런타임을 안 거칠 뿐이지 wire format은 같다.
|
||||||
|
* 그래서 `tech-log-studio-contract-contribution.ts`의 언랩 validator를 그대로
|
||||||
|
* 재사용한다: 봉투 뼈대 검증 로직이 두 곳에서 따로 드리프트하는 것을 막는다.
|
||||||
|
*/
|
||||||
|
const UPLOAD_DATA = envelopeData<Asset>("uploadStudioAssetOutput");
|
||||||
|
const UPLOAD_PROBLEM = envelopeError();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The contract runtime can only express `requestBody: "NONE" | "JSON"` and the
|
* The contract runtime can only express `requestBody: "NONE" | "JSON"` and the
|
||||||
* low-level client always serializes the body as JSON (see
|
* low-level client always serializes the body as JSON (see
|
||||||
@@ -81,8 +91,9 @@ export function createAssetUploadTransport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 201 || response.status === 200) {
|
if (response.status === 201 || response.status === 200) {
|
||||||
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
return (await response.json()) as Asset;
|
body = await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
// M1 (fix round 1). This port's contract is `StudioGatewayError`;
|
// M1 (fix round 1). This port's contract is `StudioGatewayError`;
|
||||||
// a malformed success body must not throw a raw `SyntaxError` out
|
// a malformed success body must not throw a raw `SyntaxError` out
|
||||||
@@ -91,16 +102,33 @@ export function createAssetUploadTransport(
|
|||||||
`Upload returned status ${response.status} with a body that could not be parsed as JSON.`,
|
`Upload returned status ${response.status} with a body that could not be parsed as JSON.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const parsed = UPLOAD_DATA.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
// 봉투 뼈대(`{success:true, data, meta}`)가 아니다 — payload는
|
||||||
|
// 통과시키되 봉투 자체는 반드시 검증한다(다른 18개 operation과 동일
|
||||||
|
// 원칙, ADR-006).
|
||||||
|
throw unavailable(
|
||||||
|
`Upload returned status ${response.status} with a body that did not match the response envelope.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
let problem: ProblemDetails | null;
|
let problemBody: unknown;
|
||||||
try {
|
try {
|
||||||
problem = (await response.json()) as ProblemDetails;
|
problemBody = await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
problem = null;
|
problemBody = null;
|
||||||
}
|
}
|
||||||
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
|
const parsedProblem = problemBody === null ? null : UPLOAD_PROBLEM.safeParse(problemBody);
|
||||||
throw new StudioGatewayError(problem);
|
if (parsedProblem && parsedProblem.success && CODES.has(parsedProblem.data.code)) {
|
||||||
|
// `studio-error-mapping.ts`의 PROBLEM 분기와 같은 캐스트: 봉투는 이미
|
||||||
|
// `code`를 `apiErrorSchema`의 enum으로 검증했으므로(`CODES.has` 확인도
|
||||||
|
// 통과) `ProblemDetails["code"]`로 좁혀도 안전하다. envelope에는 HTTP
|
||||||
|
// status가 없다 (`envelopeError`가 0으로 둔다) — 이 seam은 실제 status를
|
||||||
|
// 이미 들고 있으므로 바로 덮는다.
|
||||||
|
const problem = parsedProblem.data as unknown as ProblemDetails;
|
||||||
|
throw new StudioGatewayError({ ...problem, status: response.status });
|
||||||
}
|
}
|
||||||
// Fix round 2, item 2. The real status is passed through (not the
|
// Fix round 2, item 2. The real status is passed through (not the
|
||||||
// hardcoded 503 default) so `http-studio-asset-gateway.ts`'s
|
// hardcoded 503 default) so `http-studio-asset-gateway.ts`'s
|
||||||
|
|||||||
@@ -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,139 @@
|
|||||||
|
import type {
|
||||||
|
CreateDraftResponse,
|
||||||
|
HomeFocusRequest,
|
||||||
|
HomeFocusResponse,
|
||||||
|
ProjectActivityRequest,
|
||||||
|
ProjectActivityResponse,
|
||||||
|
UpdateProjectActivityRequest,
|
||||||
|
ProjectEditResponse,
|
||||||
|
ProjectIndexPage,
|
||||||
|
ProjectUpdateRequest,
|
||||||
|
PublishResponse,
|
||||||
|
ReleaseEditResponse,
|
||||||
|
ReleaseIndexPage,
|
||||||
|
ReleaseUpdateRequest,
|
||||||
|
TopicEdit,
|
||||||
|
} from "../../contracts/management/contract.ts";
|
||||||
|
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
|
||||||
|
import { ManagementGatewayError } from "../../application/ports/management-gateway-error.ts";
|
||||||
|
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
||||||
|
|
||||||
|
export type { ManagementGateway };
|
||||||
|
|
||||||
|
const ROUTE_ID = "TECH_LOG_STUDIO";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주제·프로젝트 관리 게이트웨이.
|
||||||
|
*
|
||||||
|
* <p>Studio 게이트웨이와 같은 실패 규약을 쓴다 — 실패는 던지고, 화면은 `usePublicContent` 가 아니라
|
||||||
|
* Studio 쪽 상태 처리를 그대로 쓴다. 여기서 Result 로 감싸면 이 표면만 다른 규약이 된다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
ManagementGatewayError,
|
||||||
|
managementFailureMessage,
|
||||||
|
} from "../../application/ports/management-gateway-error.ts";
|
||||||
|
|
||||||
|
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;
|
||||||
|
detail?: unknown;
|
||||||
|
error?: Readonly<{ code?: unknown; message?: unknown }>;
|
||||||
|
}>
|
||||||
|
| null;
|
||||||
|
const code =
|
||||||
|
typeof body?.code === "string"
|
||||||
|
? body.code
|
||||||
|
: typeof body?.error?.code === "string"
|
||||||
|
? body.error.code
|
||||||
|
: "PROBLEM";
|
||||||
|
const detail =
|
||||||
|
typeof body?.error?.message === "string"
|
||||||
|
? body.error.message
|
||||||
|
: typeof body?.detail === "string"
|
||||||
|
? body.detail
|
||||||
|
: "";
|
||||||
|
throw new ManagementGatewayError(operationId, code, detail);
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
listProjectActivities: (id: string) =>
|
||||||
|
run<ProjectActivityResponse[]>("listStudioProjectActivities", { id }),
|
||||||
|
createProjectActivity: (id: string, body: ProjectActivityRequest) =>
|
||||||
|
run<ProjectActivityResponse>("createProjectActivity", { id, body }),
|
||||||
|
updateProjectActivity: (
|
||||||
|
id: string,
|
||||||
|
activityId: string,
|
||||||
|
body: UpdateProjectActivityRequest,
|
||||||
|
) => run<ProjectActivityResponse>("updateProjectActivity", { id, activityId, body }),
|
||||||
|
deleteProjectActivity: async (id: string, activityId: string, expectedVersion: number) => {
|
||||||
|
await run<void>("deleteProjectActivity", { id, activityId, expectedVersion });
|
||||||
|
},
|
||||||
|
publishProject: (
|
||||||
|
id: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
visibility: "PUBLIC" | "UNLISTED" = "PUBLIC",
|
||||||
|
) => run<PublishResponse>("publishProject", { id, expectedVersion, visibility }),
|
||||||
|
unpublishProject: (id: string, expectedVersion: number) =>
|
||||||
|
run<ProjectEditResponse>("unpublishProject", { id, expectedVersion }),
|
||||||
|
getHomeFocus: () => run<HomeFocusResponse>("getHomeFocus", {}),
|
||||||
|
updateHomeFocus: (body: HomeFocusRequest) =>
|
||||||
|
run<HomeFocusResponse>("updateHomeFocus", body),
|
||||||
|
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,545 @@
|
|||||||
|
import type {
|
||||||
|
HomeFocusItem,
|
||||||
|
LatestRecordEntry,
|
||||||
|
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,
|
||||||
|
markdownSections,
|
||||||
|
questionListItemToRecord,
|
||||||
|
releaseDetailToRelease,
|
||||||
|
searchItemToEntity,
|
||||||
|
} from "./public-content-mapping.ts";
|
||||||
|
import type { components } from "../../contracts/public/generated.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>>) ?? {};
|
||||||
|
const projectOf = (entry: Readonly<{ title?: string; path?: string }> | undefined) =>
|
||||||
|
entry?.path
|
||||||
|
? { name: entry.title ?? "", slug: entry.path.split("/").filter(Boolean).pop() ?? "", path: entry.path }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
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.summary 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) ?? "",
|
||||||
|
// `environmentSummary` 는 검증 환경과 재현 조건을 그 순서로 담는다 — 서버가 비어 있지
|
||||||
|
// 않은 것만 순서대로 넣는다. 예전에는 둘을 쉼표로 이어 붙여 한 칸에 넣고 재현 조건 칸은
|
||||||
|
// "계약에 없다"며 비워 두었는데, 계약에는 있었고 채우는 쪽이 없었을 뿐이다.
|
||||||
|
environment: ((body.environmentSummary as readonly string[]) ?? [])[0] ?? "",
|
||||||
|
verification: ((body.environmentSummary as readonly string[]) ?? [])[1] ?? "",
|
||||||
|
lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string),
|
||||||
|
content: (body.content as string) ?? "",
|
||||||
|
bodyAssets: Object.freeze(
|
||||||
|
((body.bodyAssets as readonly Readonly<Record<string, unknown>>[]) ?? []).map((asset) =>
|
||||||
|
Object.freeze({
|
||||||
|
assetKey: asset.assetKey as string,
|
||||||
|
assetId: asset.assetId as string,
|
||||||
|
url: asset.url as string,
|
||||||
|
contentType: asset.contentType as string,
|
||||||
|
altText: (asset.altText as string) ?? "",
|
||||||
|
width: (asset.width as number) ?? null,
|
||||||
|
height: (asset.height as number) ?? null,
|
||||||
|
decorative: Boolean(asset.decorative),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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.summary 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",
|
||||||
|
/*
|
||||||
|
여기서 읽는 이름은 계약이 실제로 주는 이름이어야 한다. 한때 `purposeSummary`,
|
||||||
|
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown` 을 읽었는데 계약에는 그런
|
||||||
|
칸이 없다 — 전부 undefined 로 떨어져 공개 Reference 화면이 통째로 비었다. Studio 에서는
|
||||||
|
같은 글이 다 보이므로 "공개 쪽만 안 나온다" 로 드러났다.
|
||||||
|
|
||||||
|
규칙과 예시는 `content` 마크다운을 잘라 만드는 것이 아니라 계약이 구조로 준다. Studio 의
|
||||||
|
편집기가 제목과 본문을 따로 받기 때문이다.
|
||||||
|
*/
|
||||||
|
purpose: (body.scopeSummary as string) ?? "",
|
||||||
|
rules: Object.freeze(
|
||||||
|
((body.rules as readonly Readonly<Record<string, unknown>>[] | undefined) ?? []).map(
|
||||||
|
(rule) => ({
|
||||||
|
title: String(rule.title ?? ""),
|
||||||
|
body: String(rule.body ?? ""),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
applyWhen: Object.freeze(((body.appliesTo as readonly string[] | undefined) ?? []).map(String)),
|
||||||
|
exceptions: Object.freeze(
|
||||||
|
((body.excludedScope as readonly string[] | undefined) ?? []).map(String),
|
||||||
|
),
|
||||||
|
examples: Object.freeze(((body.examples as readonly string[] | undefined) ?? []).map(String)),
|
||||||
|
verifiedAt: dateLabel(body.lastVerifiedAt as string),
|
||||||
|
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
|
||||||
|
/*
|
||||||
|
`points` 는 그룹 이름을 키로 갖는 객체다 — 계약의 `QuestionPointGroup`. 여기서는
|
||||||
|
`{group, items}` 배열로 읽으면서 `.filter` 를 불렀고, 객체에는 그런 것이 없으니 상세
|
||||||
|
화면이 통째로 「요청을 처리하지 못했습니다」가 됐다. 목록은 이 칸을 비워 두고 만들기
|
||||||
|
때문에 탐색에서는 멀쩡히 보였고, 그래서 "게시했는데 안 뜬다" 로만 드러났다.
|
||||||
|
|
||||||
|
`as` 캐스트가 그 어긋남을 타입 검사에서 가렸다. 계약의 타입을 그대로 쓰면 다음에 모양이
|
||||||
|
바뀔 때 컴파일이 먼저 막는다.
|
||||||
|
*/
|
||||||
|
type QuestionPoints = components["schemas"]["QuestionPointGroup"];
|
||||||
|
const points = body.points as QuestionPoints | undefined;
|
||||||
|
const pointsOf = (group: keyof QuestionPoints) =>
|
||||||
|
Object.freeze([...(points?.[group] ?? [])].map(String));
|
||||||
|
return Object.freeze({
|
||||||
|
...baseOf("QUESTION", slug, {
|
||||||
|
title: body.question as string,
|
||||||
|
summary: body.summary as string,
|
||||||
|
path: canonicalPath,
|
||||||
|
primaryTopic: body.primaryTopic as never,
|
||||||
|
/*
|
||||||
|
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
|
||||||
|
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트
|
||||||
|
칸이 늘 비어 있었다.
|
||||||
|
|
||||||
|
그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
|
||||||
|
`name`/`slug` 이므로 여기서 옮겨 준다 — slug 는 경로의 마지막 마디다.
|
||||||
|
*/
|
||||||
|
primaryProject: projectOf(groups.primaryProject) as never,
|
||||||
|
publishedAt: body.updatedAt as string,
|
||||||
|
/*
|
||||||
|
계약이 주는 이름은 `resultCase` / `producedDecision` / `derivedReferences` 다.
|
||||||
|
여기서는 `derivedCases` / `projectDecisions` / `relatedQuestions` 를 찾고 있었고,
|
||||||
|
하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다.
|
||||||
|
|
||||||
|
`primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다 — 머리말이 이미
|
||||||
|
보여 주므로 관계 목록에 넣지 않는다.
|
||||||
|
*/
|
||||||
|
relations: flattenRelations(
|
||||||
|
{
|
||||||
|
resultCase: groups.resultCase,
|
||||||
|
producedDecision: groups.producedDecision,
|
||||||
|
derivedReferences: groups.derivedReferences,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resultCase: "이 질문에서 나온 기록",
|
||||||
|
producedDecision: "이 질문이 이끈 결정",
|
||||||
|
derivedReferences: "이 질문에서 정리된 기준",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
kind: "QUESTION",
|
||||||
|
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
||||||
|
facts: pointsOf("facts"),
|
||||||
|
assumptions: pointsOf("assumptions"),
|
||||||
|
unknowns: pointsOf("unknowns"),
|
||||||
|
constraints: pointsOf("constraints"),
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 투영이 고른 최근 기록. `entryType` 은 계약이 네 값만 허용하므로 그대로 믿고 쓴다 —
|
||||||
|
* 서버가 이미 걸러 보낸다.
|
||||||
|
*/
|
||||||
|
async function getLatestEntries(): Promise<LatestRecordEntry[]> {
|
||||||
|
const home = await read<Readonly<{ latestEntries?: readonly Readonly<Record<string, unknown>>[] }>>(
|
||||||
|
"getPublicHome",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
if (home === NOT_FOUND) return [];
|
||||||
|
return (home.latestEntries ?? []).map((entry) => {
|
||||||
|
const topic = entry.primaryTopic as Readonly<Record<string, unknown>> | null | undefined;
|
||||||
|
const project = entry.primaryProject as Readonly<Record<string, unknown>> | null | undefined;
|
||||||
|
const path = String(entry.path ?? "");
|
||||||
|
return Object.freeze({
|
||||||
|
id: `${String(entry.entryType ?? "")}:${path}`,
|
||||||
|
entryType: String(entry.entryType ?? "CASE") as LatestRecordEntry["entryType"],
|
||||||
|
title: String(entry.title ?? ""),
|
||||||
|
summary: String(entry.summary ?? ""),
|
||||||
|
path,
|
||||||
|
publishedAt: String(entry.publishedAt ?? ""),
|
||||||
|
topic: topic ? String(topic.name ?? "") : "",
|
||||||
|
project: project ? String(project.name ?? "") : "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
getLatestEntries,
|
||||||
|
searchPublicContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
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),
|
||||||
|
// 목록 항목은 본문을 담지 않는다 — 본문은 상세 조회에서만 온다.
|
||||||
|
content: "",
|
||||||
|
bodyAssets: Object.freeze([]),
|
||||||
|
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) } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export const STUDIO_ERROR_CODES = Object.freeze([
|
|||||||
"DOCUMENT_NOT_FOUND",
|
"DOCUMENT_NOT_FOUND",
|
||||||
"VERSION_CONFLICT",
|
"VERSION_CONFLICT",
|
||||||
"REQUEST_VALIDATION_FAILED",
|
"REQUEST_VALIDATION_FAILED",
|
||||||
"VALIDATION_FAILED",
|
"DOCUMENT_VALIDATION_FAILED",
|
||||||
"VALIDATION_STALE",
|
"VALIDATION_STALE",
|
||||||
"PREVIEW_NOT_FOUND",
|
"PREVIEW_NOT_FOUND",
|
||||||
"PREVIEW_STALE",
|
"PREVIEW_STALE",
|
||||||
@@ -57,13 +57,20 @@ export function toStudioGatewayError(
|
|||||||
): StudioGatewayError {
|
): StudioGatewayError {
|
||||||
switch (outcome.kind) {
|
switch (outcome.kind) {
|
||||||
case "PROBLEM": {
|
case "PROBLEM": {
|
||||||
const problem = outcome.problem as ProblemDetails;
|
// 봉투 오류는 wire에 HTTP status를 싣지 않는다 (`envelopeError`가 `status`를
|
||||||
|
// 0으로 둔다) — 실제 status는 전송 계층이 `outcome.metadata.status`로
|
||||||
|
// 이미 들고 있으므로 여기서 덮는다. `SafeResponseMetadata.status`는
|
||||||
|
// `PROBLEM` outcome에서 필수 필드다 (`http-execution-v3.ts`).
|
||||||
|
// `problem`이 falsy이거나 status가 0(봉투의 sentinel)이면 metadata로
|
||||||
|
// 덮는다 — 원래 코드처럼 `problem`을 안전하지 않게 역참조하지 않는다.
|
||||||
|
const problem = outcome.problem as ProblemDetails | undefined;
|
||||||
|
const status = problem?.status || outcome.metadata.status;
|
||||||
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
|
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
|
||||||
return new StudioGatewayError(problem);
|
return new StudioGatewayError({ ...problem, status });
|
||||||
}
|
}
|
||||||
return synthetic(
|
return synthetic(
|
||||||
"STUDIO_UNAVAILABLE",
|
"STUDIO_UNAVAILABLE",
|
||||||
outcome.metadata.status,
|
status,
|
||||||
`${operationId} returned an uncontracted problem code.`,
|
`${operationId} returned an uncontracted problem code.`,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ function invalid(detail: string) {
|
|||||||
const problem: ProblemDetails = {
|
const problem: ProblemDetails = {
|
||||||
type: "https://techlog.local/problems/request-validation-failed", title: "Request validation failed",
|
type: "https://techlog.local/problems/request-validation-failed", title: "Request validation failed",
|
||||||
status: 422, detail, code: "REQUEST_VALIDATION_FAILED", retryable: false,
|
status: 422, detail, code: "REQUEST_VALIDATION_FAILED", retryable: false,
|
||||||
fieldErrors: [{ path: "/cursor", message: detail }],
|
// wire와 같은 자리: `details`가 `ValidationErrorDetails` 모양이다 (Task 3
|
||||||
|
// fix round 1 — 예전엔 `fieldErrors`가 최상위 필드였다).
|
||||||
|
details: { fieldErrors: [{ path: "/cursor", message: detail }] },
|
||||||
};
|
};
|
||||||
return new StudioGatewayError(problem);
|
return new StudioGatewayError(problem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ function gatewayProblem(status: number, code: ProblemDetails["code"], detail: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requestError(fieldErrors: components["schemas"]["FieldError"][]) {
|
function requestError(fieldErrors: components["schemas"]["FieldError"][]) {
|
||||||
return gatewayProblem(422, "REQUEST_VALIDATION_FAILED", "Request fields are invalid.", { fieldErrors });
|
// wire와 같은 자리: `details`가 `ValidationErrorDetails` 모양이다 (Task 3
|
||||||
|
// fix round 1 — 예전엔 `fieldErrors`가 최상위 필드였다).
|
||||||
|
return gatewayProblem(422, "REQUEST_VALIDATION_FAILED", "Request fields are invalid.", {
|
||||||
|
details: { fieldErrors },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function inputOf(document: WorkingCopy): WorkingCopyInput {
|
function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||||
@@ -169,7 +173,7 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
|||||||
const nextAction = deriveDocumentState({ document: base.document, validation: base.currentValidation, preview: base.latestPreview, publication: base.currentPublication, dependencyRevision: base.dependencyRevision, now: dependencies.clock.now() }).nextAction;
|
const nextAction = deriveDocumentState({ document: base.document, validation: base.currentValidation, preview: base.latestPreview, publication: base.currentPublication, dependencyRevision: base.dependencyRevision, now: dependencies.clock.now() }).nextAction;
|
||||||
return { ...base, nextAction };
|
return { ...base, nextAction };
|
||||||
};
|
};
|
||||||
const version = (value: WorkingCopy, expected: number) => { if (value.version !== expected) throw gatewayProblem(409, "VERSION_CONFLICT", `Expected ${expected}; current ${value.version}.`, { latestDocument: clone(detail(value.id)), conflictingFields: [] }); };
|
const version = (value: WorkingCopy, expected: number) => { if (value.version !== expected) throw gatewayProblem(409, "VERSION_CONFLICT", `Expected ${expected}; current ${value.version}.`, { details: { latestDocument: clone(detail(value.id)), conflictingFields: [] } }); };
|
||||||
const structure = (input: WorkingCopyInput) => { const errors = validateWorkingCopyInputStructure(input); if (errors.length) throw requestError(errors); };
|
const structure = (input: WorkingCopyInput) => { const errors = validateWorkingCopyInputStructure(input); if (errors.length) throw requestError(errors); };
|
||||||
const materialize = (id: string, value: number, input: WorkingCopyInput): WorkingCopy => ({ ...clone(input), id, version: value, updatedAt: dependencies.clock.now().toISOString(), relations: input.relations.map((relation) => ({ ...relation, id: relation.id ?? dependencies.idGenerator.next(), targetId: relation.targetId! })) }) as WorkingCopy;
|
const materialize = (id: string, value: number, input: WorkingCopyInput): WorkingCopy => ({ ...clone(input), id, version: value, updatedAt: dependencies.clock.now().toISOString(), relations: input.relations.map((relation) => ({ ...relation, id: relation.id ?? dependencies.idGenerator.next(), targetId: relation.targetId! })) }) as WorkingCopy;
|
||||||
const summary = (value: WorkingCopy): components["schemas"]["DocumentSummary"] => {
|
const summary = (value: WorkingCopy): components["schemas"]["DocumentSummary"] => {
|
||||||
@@ -197,7 +201,7 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
|||||||
getDocument(documentId, options) { return read(options, () => { uuid(documentId, "/documentId"); return detail(documentId); }); },
|
getDocument(documentId, options) { return read(options, () => { uuid(documentId, "/documentId"); return detail(documentId); }); },
|
||||||
saveDocument(documentId, command, options) { return idempotent("save", documentId, () => command, options, () => {
|
saveDocument(documentId, command, options) { return idempotent("save", documentId, () => command, options, () => {
|
||||||
uuid(documentId, "/documentId"); structure(command.document); const current = document(documentId); version(current, command.expectedVersion);
|
uuid(documentId, "/documentId"); structure(command.document); const current = document(documentId); version(current, command.expectedVersion);
|
||||||
if (state.pendingConflicts.delete(documentId)) { const latest = materialize(documentId, current.version + 1, { ...inputOf(current), title: "서버에서 먼저 수정된 제목", summary: "서버 최신 요약" }); state.documents.set(documentId, latest); state.validations.delete(documentId); throw gatewayProblem(409, "VERSION_CONFLICT", "The server document changed.", { latestDocument: clone(detail(documentId)), conflictingFields: ["/title", "/summary"] }); }
|
if (state.pendingConflicts.delete(documentId)) { const latest = materialize(documentId, current.version + 1, { ...inputOf(current), title: "서버에서 먼저 수정된 제목", summary: "서버 최신 요약" }); state.documents.set(documentId, latest); state.validations.delete(documentId); throw gatewayProblem(409, "VERSION_CONFLICT", "The server document changed.", { details: { latestDocument: clone(detail(documentId)), conflictingFields: ["/title", "/summary"] } }); }
|
||||||
const saved = materialize(documentId, current.version + 1, command.document); state.documents.set(documentId, saved); state.validations.delete(documentId); return detail(documentId);
|
const saved = materialize(documentId, current.version + 1, command.document); state.documents.set(documentId, saved); state.validations.delete(documentId); return detail(documentId);
|
||||||
}); },
|
}); },
|
||||||
validateDocument(documentId, command, options) { return idempotent("validate", documentId, () => command, options, () => { uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const report = validateWorkingCopy(value, { now: dependencies.clock.now(), validationId: dependencies.idGenerator.next(), dependencyRevision: dependencies.dependencyRevision.current(), catalog: state.catalog, documents: [...state.documents.values()], assets: [...dependencies.assets.values()] }); state.validations.set(documentId, report); return report; }); },
|
validateDocument(documentId, command, options) { return idempotent("validate", documentId, () => command, options, () => { uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const report = validateWorkingCopy(value, { now: dependencies.clock.now(), validationId: dependencies.idGenerator.next(), dependencyRevision: dependencies.dependencyRevision.current(), catalog: state.catalog, documents: [...state.documents.values()], assets: [...dependencies.assets.values()] }); state.validations.set(documentId, report); return report; }); },
|
||||||
@@ -215,7 +219,7 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
|||||||
const warnings = validation.issues.filter((issue) => issue.severity === "WARNING").map((issue) => issue.code); if (!exactSet(warnings, command.acknowledgedWarningCodes)) throw requestError([{ path: "/acknowledgedWarningCodes", message: "Acknowledge all current warnings." }]);
|
const warnings = validation.issues.filter((issue) => issue.severity === "WARNING").map((issue) => issue.code); if (!exactSet(warnings, command.acknowledgedWarningCodes)) throw requestError([{ path: "/acknowledgedWarningCodes", message: "Acknowledge all current warnings." }]);
|
||||||
const eventId = dependencies.idGenerator.next(); const publicationId = existing?.publicationId ?? dependencies.idGenerator.next(); const event: PublicationEvent = { publicationEventId: eventId, publicationId, documentId, type: existing ? "REPUBLISHED" : "PUBLISHED", occurredAt: now.toISOString(), publishedVersion: value.version, sourcePublishedEventId: null, snapshotAvailable: true }; const publication: PublicationAggregate = { publicationId, documentId, status: "PUBLISHED", publishedVersion: value.version, publicationRevision: (existing?.publicationRevision ?? 0) + 1, latestEventId: eventId, publicPath: preview.renderModel.publicPath, updatedAt: now.toISOString() }; state.events.set(eventId, event); state.publications.set(documentId, publication); state.snapshots.set(eventId, { event: clone(event), renderModel: clone(preview.renderModel), contentFormatVersion: CONTENT_FORMAT_VERSION, rendererContractVersion: RENDERER_CONTRACT_VERSION }); return { publication, event } satisfies PublishResult;
|
const eventId = dependencies.idGenerator.next(); const publicationId = existing?.publicationId ?? dependencies.idGenerator.next(); const event: PublicationEvent = { publicationEventId: eventId, publicationId, documentId, type: existing ? "REPUBLISHED" : "PUBLISHED", occurredAt: now.toISOString(), publishedVersion: value.version, sourcePublishedEventId: null, snapshotAvailable: true }; const publication: PublicationAggregate = { publicationId, documentId, status: "PUBLISHED", publishedVersion: value.version, publicationRevision: (existing?.publicationRevision ?? 0) + 1, latestEventId: eventId, publicPath: preview.renderModel.publicPath, updatedAt: now.toISOString() }; state.events.set(eventId, event); state.publications.set(documentId, publication); state.snapshots.set(eventId, { event: clone(event), renderModel: clone(preview.renderModel), contentFormatVersion: CONTENT_FORMAT_VERSION, rendererContractVersion: RENDERER_CONTRACT_VERSION }); return { publication, event } satisfies PublishResult;
|
||||||
}); },
|
}); },
|
||||||
unpublishPublication(publicationId, command, options) { return idempotent("unpublish", publicationId, () => command, options, () => { uuid(publicationId, "/publicationId"); const current = [...state.publications.values()].find((item) => item.publicationId === publicationId); if (!current) throw gatewayProblem(404, "PUBLICATION_NOT_FOUND", "Publication not found."); if (current.publicationRevision !== command.expectedPublicationRevision) throw gatewayProblem(409, "PUBLICATION_CONFLICT", "Publication revision changed.", { latestPublication: clone(current) }); if (current.status === "UNPUBLISHED") return { publication: current, event: state.events.get(current.latestEventId)! }; const event: PublicationEvent = { publicationEventId: dependencies.idGenerator.next(), publicationId, documentId: current.documentId, type: "UNPUBLISHED", occurredAt: dependencies.clock.now().toISOString(), publishedVersion: current.publishedVersion, sourcePublishedEventId: current.latestEventId, snapshotAvailable: false }; const publication: PublicationAggregate = { ...current, status: "UNPUBLISHED", publicationRevision: current.publicationRevision + 1, latestEventId: event.publicationEventId, updatedAt: event.occurredAt }; state.events.set(event.publicationEventId, event); state.publications.set(current.documentId, publication); return { publication, event }; }); },
|
unpublishPublication(publicationId, command, options) { return idempotent("unpublish", publicationId, () => command, options, () => { uuid(publicationId, "/publicationId"); const current = [...state.publications.values()].find((item) => item.publicationId === publicationId); if (!current) throw gatewayProblem(404, "PUBLICATION_NOT_FOUND", "Publication not found."); if (current.publicationRevision !== command.expectedPublicationRevision) throw gatewayProblem(409, "PUBLICATION_CONFLICT", "Publication revision changed.", { details: { latestPublication: clone(current) } }); if (current.status === "UNPUBLISHED") return { publication: current, event: state.events.get(current.latestEventId)! }; const event: PublicationEvent = { publicationEventId: dependencies.idGenerator.next(), publicationId, documentId: current.documentId, type: "UNPUBLISHED", occurredAt: dependencies.clock.now().toISOString(), publishedVersion: current.publishedVersion, sourcePublishedEventId: current.latestEventId, snapshotAvailable: false }; const publication: PublicationAggregate = { ...current, status: "UNPUBLISHED", publicationRevision: current.publicationRevision + 1, latestEventId: event.publicationEventId, updatedAt: event.occurredAt }; state.events.set(event.publicationEventId, event); state.publications.set(current.documentId, publication); return { publication, event }; }); },
|
||||||
listPublications(query, options) { return read(options, () => { queryText(query.q); const limit = limitOf(query.limit); const normalized = { q: normalizeQ(query.q), type: query.type ?? null, sort: "OCCURRED_DESC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null; const all = [...state.events.values()].filter((event) => (!normalized.type || event.type === normalized.type) && (!normalized.q || `${document(event.documentId).title} ${document(event.documentId).summary}`.toLocaleLowerCase("ko-KR").includes(normalized.q))).sort((a, b) => b.occurredAt.localeCompare(a.occurredAt) || a.publicationEventId.localeCompare(b.publicationEventId)); const source = cursor ? all.filter((event) => event.occurredAt < cursor.lastValue || (event.occurredAt === cursor.lastValue && event.publicationEventId > cursor.lastId)) : all; const selected = source.slice(0, limit); const last = selected.at(-1); return { items: selected.map(publicationRow), nextCursor: selected.length < source.length && last ? encodeCursor({ binding, lastValue: last.occurredAt, lastId: last.publicationEventId }) : null } satisfies PublicationPage; }); },
|
listPublications(query, options) { return read(options, () => { queryText(query.q); const limit = limitOf(query.limit); const normalized = { q: normalizeQ(query.q), type: query.type ?? null, sort: "OCCURRED_DESC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null; const all = [...state.events.values()].filter((event) => (!normalized.type || event.type === normalized.type) && (!normalized.q || `${document(event.documentId).title} ${document(event.documentId).summary}`.toLocaleLowerCase("ko-KR").includes(normalized.q))).sort((a, b) => b.occurredAt.localeCompare(a.occurredAt) || a.publicationEventId.localeCompare(b.publicationEventId)); const source = cursor ? all.filter((event) => event.occurredAt < cursor.lastValue || (event.occurredAt === cursor.lastValue && event.publicationEventId > cursor.lastId)) : all; const selected = source.slice(0, limit); const last = selected.at(-1); return { items: selected.map(publicationRow), nextCursor: selected.length < source.length && last ? encodeCursor({ binding, lastValue: last.occurredAt, lastId: last.publicationEventId }) : null } satisfies PublicationPage; }); },
|
||||||
getPublicationSnapshot(publicationEventId, options) { return read(options, () => { uuid(publicationEventId, "/publicationEventId"); if (!state.events.has(publicationEventId)) throw gatewayProblem(404, "PUBLICATION_EVENT_NOT_FOUND", "Publication event not found."); const snapshot = state.snapshots.get(publicationEventId); if (!snapshot) throw gatewayProblem(404, "PUBLICATION_SNAPSHOT_NOT_FOUND", "Publication snapshot not found."); return snapshot; }); },
|
getPublicationSnapshot(publicationEventId, options) { return read(options, () => { uuid(publicationEventId, "/publicationEventId"); if (!state.events.has(publicationEventId)) throw gatewayProblem(404, "PUBLICATION_EVENT_NOT_FOUND", "Publication event not found."); const snapshot = state.snapshots.get(publicationEventId); if (!snapshot) throw gatewayProblem(404, "PUBLICATION_SNAPSHOT_NOT_FOUND", "Publication snapshot not found."); return snapshot; }); },
|
||||||
getCatalog(query, options) { return read(options, () => { if (!query.type) throw requestError([{ path: "/type", message: "type is required." }]); queryText(query.q); const limit = limitOf(query.limit); const normalized = { type: query.type, q: normalizeQ(query.q), sort: "LABEL_ASC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null; const all = state.catalog.filter((item) => item.type === query.type && (!normalized.q || item.label.toLocaleLowerCase("ko-KR").includes(normalized.q))).sort((a, b) => a.label.localeCompare(b.label, "ko") || a.id.localeCompare(b.id)); const source = cursor ? all.filter((item) => item.label.localeCompare(cursor.lastValue, "ko") > 0 || (item.label === cursor.lastValue && item.id > cursor.lastId)) : all; const selected = source.slice(0, limit); const last = selected.at(-1); return { items: selected, nextCursor: selected.length < source.length && last ? encodeCursor({ binding, lastValue: last.label, lastId: last.id }) : null } satisfies CatalogPage; }); },
|
getCatalog(query, options) { return read(options, () => { if (!query.type) throw requestError([{ path: "/type", message: "type is required." }]); queryText(query.q); const limit = limitOf(query.limit); const normalized = { type: query.type, q: normalizeQ(query.q), sort: "LABEL_ASC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null; const all = state.catalog.filter((item) => item.type === query.type && (!normalized.q || item.label.toLocaleLowerCase("ko-KR").includes(normalized.q))).sort((a, b) => a.label.localeCompare(b.label, "ko") || a.id.localeCompare(b.id)); const source = cursor ? all.filter((item) => item.label.localeCompare(cursor.lastValue, "ko") > 0 || (item.label === cursor.lastValue && item.id > cursor.lastId)) : all; const selected = source.slice(0, limit); const last = selected.at(-1); return { items: selected, nextCursor: selected.length < source.length && last ? encodeCursor({ binding, lastValue: last.label, lastId: last.id }) : null } satisfies CatalogPage; }); },
|
||||||
|
|||||||
@@ -170,16 +170,16 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
|
|||||||
const has = (id: string | null, type: CatalogEntry["type"]) => Boolean(id && dependencies.catalog.some((entry) => entry.id === id && entry.type === type));
|
const has = (id: string | null, type: CatalogEntry["type"]) => Boolean(id && dependencies.catalog.some((entry) => entry.id === id && entry.type === type));
|
||||||
if (blank(document.title)) error("TITLE_REQUIRED", "/title", "제목을 입력하세요.");
|
if (blank(document.title)) error("TITLE_REQUIRED", "/title", "제목을 입력하세요.");
|
||||||
if (blank(document.slug)) error("SLUG_REQUIRED", "/slug", "slug를 입력하세요."); else if (dependencies.documents.some((item) => item.id !== document.id && item.slug === document.slug)) error("SLUG_DUPLICATE", "/slug", "중복 slug입니다.");
|
if (blank(document.slug)) error("SLUG_REQUIRED", "/slug", "slug를 입력하세요."); else if (dependencies.documents.some((item) => item.id !== document.id && item.slug === document.slug)) error("SLUG_DUPLICATE", "/slug", "중복 slug입니다.");
|
||||||
if (blank(document.summary)) error("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
|
if (blank(document.summary)) warning("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
|
||||||
if (!has(document.topicId, "TOPIC")) error("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
|
if (!has(document.topicId, "TOPIC")) warning("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
|
||||||
if (!document.projectId) {
|
if (!document.projectId) {
|
||||||
if (document.kind === "PROJECT_DECISION") error("DECISION_PROJECT_REQUIRED", "/projectId", "Decision에는 Project가 필요합니다.");
|
if (document.kind === "PROJECT_DECISION") warning("DECISION_PROJECT_REQUIRED", "/projectId", "Decision에는 Project가 필요합니다.");
|
||||||
else warning("PROJECT_MISSING", "/projectId", "Project 연결을 권장합니다.");
|
else warning("PROJECT_MISSING", "/projectId", "Project 연결을 권장합니다.");
|
||||||
} else if (!has(document.projectId, "PROJECT")) error("PROJECT_NOT_FOUND", "/projectId", "Project를 찾을 수 없습니다.");
|
} else if (!has(document.projectId, "PROJECT")) error("PROJECT_NOT_FOUND", "/projectId", "Project를 찾을 수 없습니다.");
|
||||||
document.relations.forEach((relation, index) => { if (!has(relation.targetId, "RELATION")) error("RELATION_TARGET_NOT_FOUND", `/relations/${index}/targetId`, "관계 대상을 찾을 수 없습니다."); });
|
document.relations.forEach((relation, index) => { if (!has(relation.targetId, "RELATION")) error("RELATION_TARGET_NOT_FOUND", `/relations/${index}/targetId`, "관계 대상을 찾을 수 없습니다."); });
|
||||||
if (document.kind === "CASE") {
|
if (document.kind === "CASE") {
|
||||||
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
if (blank(document.problem)) warning("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) warning("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
||||||
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
if (blank(document.bodyMarkdown)) warning("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
||||||
else try {
|
else try {
|
||||||
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
|
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
|
||||||
// The key gate is the preview projection's gate, verbatim: a resolvable
|
// The key gate is the preview projection's gate, verbatim: a resolvable
|
||||||
@@ -202,20 +202,20 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { error("CONTENT_FORMAT_INVALID", "/bodyMarkdown", "지원하는 문법을 사용하세요."); }
|
} catch { error("CONTENT_FORMAT_INVALID", "/bodyMarkdown", "지원하는 문법을 사용하세요."); }
|
||||||
if (!document.lastVerifiedOn) error("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
|
if (!document.lastVerifiedOn) warning("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
|
||||||
} else if (document.kind === "REFERENCE") {
|
} else if (document.kind === "REFERENCE") {
|
||||||
if (blank(document.purpose)) error("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) error("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) error("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) error("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
|
if (blank(document.purpose)) warning("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) warning("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) warning("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) warning("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
|
||||||
} else if (document.kind === "QUESTION") {
|
} else if (document.kind === "QUESTION") {
|
||||||
if (!document.questionStatus) error("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) error("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) error("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
|
if (!document.questionStatus) warning("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) warning("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) warning("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
|
||||||
if (document.questionStatus === "OPEN") { if (!document.unknowns.length) error("QUESTION_UNKNOWN_REQUIRED", "/unknowns", "미확인 사항이 필요합니다."); if (document.resolution) error("OPEN_QUESTION_RESOLUTION_FORBIDDEN", "/resolution", "열린 질문에는 결론을 둘 수 없습니다."); }
|
if (document.questionStatus === "OPEN") { if (!document.unknowns.length) error("QUESTION_UNKNOWN_REQUIRED", "/unknowns", "미확인 사항이 필요합니다."); if (document.resolution) error("OPEN_QUESTION_RESOLUTION_FORBIDDEN", "/resolution", "열린 질문에는 결론을 둘 수 없습니다."); }
|
||||||
if (document.questionStatus === "RESOLVED") { if (!document.resolution) error("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) error("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) error("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
|
if (document.questionStatus === "RESOLVED") { if (!document.resolution) warning("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) warning("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) warning("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
|
||||||
if (document.options.length < 2) warning("QUESTION_OPTIONS_FEWER_THAN_TWO", "/options", "선택지 두 개를 권장합니다.");
|
if (document.options.length < 2) warning("QUESTION_OPTIONS_FEWER_THAN_TWO", "/options", "선택지 두 개를 권장합니다.");
|
||||||
} else {
|
} else {
|
||||||
if (!document.decisionStatus) error("DECISION_STATUS_REQUIRED", "/decisionStatus", "결정 상태가 필요합니다.");
|
if (!document.decisionStatus) warning("DECISION_STATUS_REQUIRED", "/decisionStatus", "결정 상태가 필요합니다.");
|
||||||
if (!document.decidedOn) error("DECIDED_ON_REQUIRED", "/decidedOn", "결정일이 필요합니다.");
|
if (!document.decidedOn) warning("DECIDED_ON_REQUIRED", "/decidedOn", "결정일이 필요합니다.");
|
||||||
if (blank(document.statement)) error("DECISION_STATEMENT_REQUIRED", "/statement", "결정문이 필요합니다.");
|
if (blank(document.statement)) warning("DECISION_STATEMENT_REQUIRED", "/statement", "결정문이 필요합니다.");
|
||||||
if (blank(document.rationale)) error("DECISION_RATIONALE_REQUIRED", "/rationale", "판단 이유가 필요합니다.");
|
if (blank(document.rationale)) warning("DECISION_RATIONALE_REQUIRED", "/rationale", "판단 이유가 필요합니다.");
|
||||||
if (!document.consequences.length) error("DECISION_CONSEQUENCE_REQUIRED", "/consequences", "영향이 하나 이상 필요합니다.");
|
if (!document.consequences.length) warning("DECISION_CONSEQUENCE_REQUIRED", "/consequences", "영향이 하나 이상 필요합니다.");
|
||||||
if (!document.relations.length) error("DECISION_EVIDENCE_REQUIRED", "/relations", "근거 기록이 하나 이상 필요합니다.");
|
if (!document.relations.length) error("DECISION_EVIDENCE_REQUIRED", "/relations", "근거 기록이 하나 이상 필요합니다.");
|
||||||
}
|
}
|
||||||
const validatedAt = dependencies.now.toISOString();
|
const validatedAt = dependencies.now.toISOString();
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ type PublicRecordBase = {
|
|||||||
relations: ReadonlyArray<PublicRelation>;
|
relations: ReadonlyArray<PublicRelation>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 본문이 `:::evidence key="..."` 로 가리키는 Asset. 계약의 `BodyAsset` 과 같은 모양이다. */
|
||||||
|
export type PublicBodyAsset = {
|
||||||
|
assetKey: string;
|
||||||
|
assetId: string;
|
||||||
|
url: string;
|
||||||
|
contentType: string;
|
||||||
|
altText: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
decorative: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CaseRecord = PublicRecordBase & {
|
export type CaseRecord = PublicRecordBase & {
|
||||||
kind: "CASE";
|
kind: "CASE";
|
||||||
problem: string;
|
problem: string;
|
||||||
@@ -42,6 +54,9 @@ export type CaseRecord = PublicRecordBase & {
|
|||||||
environment: string;
|
environment: string;
|
||||||
verification: string;
|
verification: string;
|
||||||
lastVerifiedLabel: string;
|
lastVerifiedLabel: string;
|
||||||
|
/** 본문 Markdown 원문. 정적 기록은 문서 화면이 자체 본문을 쓰므로 비어 있다. */
|
||||||
|
content: string;
|
||||||
|
bodyAssets: ReadonlyArray<PublicBodyAsset>;
|
||||||
sections: ReadonlyArray<RecordSection>;
|
sections: ReadonlyArray<RecordSection>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,6 +188,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
|
|||||||
environment: "PostgreSQL 16 · Hibernate 6 · Spring Data JPA",
|
environment: "PostgreSQL 16 · Hibernate 6 · Spring Data JPA",
|
||||||
verification: "FeedItem 100개, Zipf 편중 Highlight/Mention",
|
verification: "FeedItem 100개, Zipf 편중 Highlight/Mention",
|
||||||
lastVerifiedLabel: "2026.08.11",
|
lastVerifiedLabel: "2026.08.11",
|
||||||
|
content: "",
|
||||||
|
bodyAssets: [],
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "fix-the-problem",
|
id: "fix-the-problem",
|
||||||
@@ -256,6 +273,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
|
|||||||
environment: "Spring Boot · Redis · Testcontainers",
|
environment: "Spring Boot · Redis · Testcontainers",
|
||||||
verification: "동일한 Port 계약으로 In-memory와 Redis Adapter 계약 테스트 실행",
|
verification: "동일한 Port 계약으로 In-memory와 Redis Adapter 계약 테스트 실행",
|
||||||
lastVerifiedLabel: "2026.08.07",
|
lastVerifiedLabel: "2026.08.07",
|
||||||
|
content: "",
|
||||||
|
bodyAssets: [],
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "ownership",
|
id: "ownership",
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
type Release,
|
type Release,
|
||||||
type HomeFocusItem,
|
type HomeFocusItem,
|
||||||
} from "./public-content.ts";
|
} from "./public-content.ts";
|
||||||
import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts";
|
import type {
|
||||||
|
LatestRecordEntry,
|
||||||
|
PublicContentQueries,
|
||||||
|
PublicTopic,
|
||||||
|
} from "../../application/ports/public-content-queries.ts";
|
||||||
|
|
||||||
export type RecordFilters = {
|
export type RecordFilters = {
|
||||||
kind?: RecordKind;
|
kind?: RecordKind;
|
||||||
@@ -50,6 +54,7 @@ export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
|
|||||||
.filter(
|
.filter(
|
||||||
(record) =>
|
(record) =>
|
||||||
!hasTopicFilter ||
|
!hasTopicFilter ||
|
||||||
|
record.topicSlug.toLocaleLowerCase("ko-KR") === requestedTopic ||
|
||||||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
|
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -98,6 +103,56 @@ export function getProjectActivity(projectSlug: string): ProjectActivity[] {
|
|||||||
return [...(getProject(projectSlug)?.activity ?? [])];
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 픽스처판 "최근 기록". HTTP 어댑터가 서버에서 읽어 오는 것과 같은 의미를 픽스처에서 만든다 —
|
||||||
|
* 프로젝트 활동과 릴리스가 원천이다.
|
||||||
|
*/
|
||||||
|
export function getLatestEntries(): LatestRecordEntry[] {
|
||||||
|
const recordByPath = new Map(publicRecords.map((record) => [record.path, record]));
|
||||||
|
const activities = projects.flatMap((project) =>
|
||||||
|
project.activity.map((activity) => {
|
||||||
|
const record = recordByPath.get(activity.recordPath ?? activity.path);
|
||||||
|
const published = activity.type === "PUBLICATION" && record;
|
||||||
|
return {
|
||||||
|
id: activity.id,
|
||||||
|
entryType: (published ? record.kind : "PROJECT_ACTIVITY") as LatestRecordEntry["entryType"],
|
||||||
|
title: published ? record.title : activity.title,
|
||||||
|
summary: activity.summary,
|
||||||
|
path: activity.path,
|
||||||
|
publishedAt: activity.dateTime,
|
||||||
|
topic: record?.topic ?? project.topics[0] ?? "",
|
||||||
|
project: project.title,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
/*
|
||||||
|
릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개
|
||||||
|
투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이
|
||||||
|
릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다.
|
||||||
|
*/
|
||||||
|
return [...activities].sort((left, right) =>
|
||||||
|
right.publishedAt.localeCompare(left.publishedAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getHomeFocusItems(): HomeFocusItem[] {
|
export function getHomeFocusItems(): HomeFocusItem[] {
|
||||||
const project = getProject("backend-skeleton");
|
const project = getProject("backend-skeleton");
|
||||||
const question = getRecord("QUESTION", "validate-edge-token-again");
|
const question = getRecord("QUESTION", "validate-edge-token-again");
|
||||||
@@ -208,14 +263,48 @@ 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({
|
export const publicContentQueries = Object.freeze({
|
||||||
listRecords,
|
async listRecords(filters?: RecordFilters) {
|
||||||
getRecord,
|
return listRecords(filters);
|
||||||
getProject,
|
},
|
||||||
getRelease,
|
async getRecord<K extends RecordKind>(kind: K, slug: string) {
|
||||||
getProjectRecords,
|
return getRecord(kind, slug);
|
||||||
getProjectDecisions,
|
},
|
||||||
getProjectActivity,
|
async getProject(slug: string) {
|
||||||
getHomeFocusItems,
|
return getProject(slug);
|
||||||
searchPublicContent,
|
},
|
||||||
|
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 getLatestEntries() {
|
||||||
|
return getLatestEntries();
|
||||||
|
},
|
||||||
|
async getHomeFocusItems() {
|
||||||
|
return getHomeFocusItems();
|
||||||
|
},
|
||||||
|
async searchPublicContent(query: string) {
|
||||||
|
return searchPublicContent(query);
|
||||||
|
},
|
||||||
}) satisfies PublicContentQueries;
|
}) satisfies PublicContentQueries;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 관리 표면의 실패.
|
||||||
|
*
|
||||||
|
* <p>어댑터가 아니라 포트 계층에 두는 이유는 화면이 이것을 읽어야 하기 때문이다 — `presentation`
|
||||||
|
* 은 `adapters` 를 보지 않는다 (`feature-presentation-does-not-know-outbound-adapters`).
|
||||||
|
* Studio 쪽 `studio-gateway-error.ts` 가 같은 이유로 같은 자리에 있다.
|
||||||
|
*/
|
||||||
|
export class ManagementGatewayError extends Error {
|
||||||
|
readonly operationId: string;
|
||||||
|
readonly code: string;
|
||||||
|
/**
|
||||||
|
* 서버가 준, 사람이 읽을 수 있는 이유.
|
||||||
|
*
|
||||||
|
* <p>이것이 없어서 화면은 실패할 때마다 자기가 지어낸 문구를 보여 줬다 — "게시 중이거나,
|
||||||
|
* 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다" 같은 추측 셋. 서버는 정확히
|
||||||
|
* 무엇인지 알고 그것을 보내 주는데도 그랬고, 그래서 버전 충돌도 "사용 중" 으로 읽혔다.
|
||||||
|
*/
|
||||||
|
readonly detail: string;
|
||||||
|
|
||||||
|
constructor(operationId: string, code: string, detail: string) {
|
||||||
|
super(`${operationId}: ${code}`);
|
||||||
|
this.name = "ManagementGatewayError";
|
||||||
|
this.operationId = operationId;
|
||||||
|
this.code = code;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 실패에서 서버가 준 문구를 꺼낸다. 없으면 부른 쪽이 준 기본값을 쓴다. */
|
||||||
|
export function managementFailureMessage(error: unknown, fallback: string): string {
|
||||||
|
return error instanceof ManagementGatewayError && error.detail ? error.detail : fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type {
|
||||||
|
CreateDraftResponse,
|
||||||
|
HomeFocusRequest,
|
||||||
|
HomeFocusResponse,
|
||||||
|
ProjectActivityRequest,
|
||||||
|
ProjectActivityResponse,
|
||||||
|
UpdateProjectActivityRequest,
|
||||||
|
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>;
|
||||||
|
/**
|
||||||
|
* 프로젝트 게시. 프로젝트는 Studio 문서가 아니라 게시 파이프라인 밖에 있고, 그래서 문서를
|
||||||
|
* 게시해도 그 문서가 속한 프로젝트는 비공개로 남는다 — 공개 화면(프로젝트 목록·프로필의
|
||||||
|
* "현재 프로젝트"·홈의 focus)은 모두 게시된 프로젝트만 읽으므로, 이 호출 없이는 어디에도
|
||||||
|
* 나타나지 않는다.
|
||||||
|
*/
|
||||||
|
publishProject(
|
||||||
|
id: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
visibility?: "PUBLIC" | "UNLISTED",
|
||||||
|
): Promise<PublishResponse>;
|
||||||
|
unpublishProject(id: string, expectedVersion: number): Promise<ProjectEditResponse>;
|
||||||
|
/**
|
||||||
|
* 프로젝트 활동. 공개 프로젝트 화면의 "활동" 은 이 목록을 투영 없이 직접 읽으므로, 여기서
|
||||||
|
* 만든 줄이 곧 그 화면이다.
|
||||||
|
*/
|
||||||
|
listProjectActivities(id: string): Promise<ProjectActivityResponse[]>;
|
||||||
|
createProjectActivity(id: string, body: ProjectActivityRequest): Promise<ProjectActivityResponse>;
|
||||||
|
updateProjectActivity(
|
||||||
|
id: string,
|
||||||
|
activityId: string,
|
||||||
|
body: UpdateProjectActivityRequest,
|
||||||
|
): Promise<ProjectActivityResponse>;
|
||||||
|
deleteProjectActivity(id: string, activityId: string, expectedVersion: number): Promise<void>;
|
||||||
|
/** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */
|
||||||
|
getHomeFocus(): Promise<HomeFocusResponse>;
|
||||||
|
updateHomeFocus(body: HomeFocusRequest): Promise<HomeFocusResponse>;
|
||||||
|
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>;
|
||||||
|
}>;
|
||||||
@@ -36,6 +36,23 @@ type PublicRecordBase = {
|
|||||||
relations: ReadonlyArray<PublicRelation>;
|
relations: ReadonlyArray<PublicRelation>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문이 `:::evidence key="..."` 로 가리키는 Asset.
|
||||||
|
*
|
||||||
|
* <p>본문에는 key 만 있고 `/media/{assetId}` 는 UUID 로만 서빙하므로 — 주소가 추측 불가능한 것이
|
||||||
|
* 의도된 성질이다 — 공개 화면이 key 를 주소로 바꾸려면 이 대응이 함께 와야 한다.
|
||||||
|
*/
|
||||||
|
export type PublicBodyAsset = {
|
||||||
|
assetKey: string;
|
||||||
|
assetId: string;
|
||||||
|
url: string;
|
||||||
|
contentType: string;
|
||||||
|
altText: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
decorative: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CaseRecord = PublicRecordBase & {
|
export type CaseRecord = PublicRecordBase & {
|
||||||
kind: "CASE";
|
kind: "CASE";
|
||||||
problem: string;
|
problem: string;
|
||||||
@@ -43,6 +60,14 @@ export type CaseRecord = PublicRecordBase & {
|
|||||||
environment: string;
|
environment: string;
|
||||||
verification: string;
|
verification: string;
|
||||||
lastVerifiedLabel: string;
|
lastVerifiedLabel: string;
|
||||||
|
/**
|
||||||
|
* 본문 Markdown 원문.
|
||||||
|
*
|
||||||
|
* <p>`sections` 는 이것을 제목·문단·불릿으로만 줄인 것이라 표·코드·callout·evidence 가 사라진다.
|
||||||
|
* 문서 화면은 원문을 직접 파싱한다.
|
||||||
|
*/
|
||||||
|
content: string;
|
||||||
|
bodyAssets: ReadonlyArray<PublicBodyAsset>;
|
||||||
sections: ReadonlyArray<RecordSection>;
|
sections: ReadonlyArray<RecordSection>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,6 +157,31 @@ export type Release = {
|
|||||||
related: ReadonlyArray<{ title: string; path: string }>;
|
related: ReadonlyArray<{ title: string; path: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 계약 `TopicSummary`. 목록에 필요한 만큼만 옮긴다. */
|
||||||
|
export type PublicTopic = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
recordCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈의 "최근 기록" 한 줄. 서버가 공개 투영에서 직접 고른다.
|
||||||
|
*
|
||||||
|
* 화면이 프로젝트를 하나씩 돌며 조립하던 때에는, 게시된 문서라도 그 문서가 매달린 프로젝트가
|
||||||
|
* 공개되어 있지 않으면 목록에서 통째로 빠졌다 — 실제로 게시한 Case 는 안 보이고 릴리스만
|
||||||
|
* 남았다. 무엇이 최근인지는 공개 투영 하나가 알고 있으므로 거기서 그대로 읽는다.
|
||||||
|
*/
|
||||||
|
export type LatestRecordEntry = {
|
||||||
|
id: string;
|
||||||
|
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
path: string;
|
||||||
|
publishedAt: string;
|
||||||
|
topic: string;
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type FocusKey = "current" | "question" | "decision";
|
export type FocusKey = "current" | "question" | "decision";
|
||||||
|
|
||||||
export type HomeFocusItem = {
|
export type HomeFocusItem = {
|
||||||
@@ -165,17 +215,37 @@ export type SearchablePublicEntity = {
|
|||||||
* The application-facing boundary for the immutable source Public catalog.
|
* The application-facing boundary for the immutable source Public catalog.
|
||||||
* Method signatures intentionally retain the source query argument and return shapes.
|
* 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<{
|
export type PublicContentQueries = Readonly<{
|
||||||
listRecords(filters?: RecordFilters): PublicRecord[];
|
listRecords(filters?: RecordFilters): Promise<PublicRecord[]>;
|
||||||
getRecord<K extends RecordKind>(
|
getRecord<K extends RecordKind>(
|
||||||
kind: K,
|
kind: K,
|
||||||
slug: string,
|
slug: string,
|
||||||
): Extract<PublicRecord, { kind: K }> | undefined;
|
): Promise<Extract<PublicRecord, { kind: K }> | undefined>;
|
||||||
getProject(slug: string): Project | undefined;
|
getProject(slug: string): Promise<Project | undefined>;
|
||||||
getRelease(version: string): Release | undefined;
|
getRelease(version: string): Promise<Release | undefined>;
|
||||||
getProjectRecords(projectSlug: string): PublicRecord[];
|
getProjectRecords(projectSlug: string): Promise<PublicRecord[]>;
|
||||||
getProjectDecisions(projectSlug: string): ProjectDecision[];
|
getProjectDecisions(projectSlug: string): Promise<ProjectDecision[]>;
|
||||||
getProjectActivity(projectSlug: string): ProjectActivity[];
|
getProjectActivity(projectSlug: string): Promise<ProjectActivity[]>;
|
||||||
getHomeFocusItems(): HomeFocusItem[];
|
/**
|
||||||
searchPublicContent(query: string): SearchablePublicEntity[];
|
* 공개된 주제 목록. 프로필의 "주요 관심 주제"가 이 값을 그린다 — 그 목록은 코드에 박혀
|
||||||
|
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
|
||||||
|
*/
|
||||||
|
listTopics(): Promise<PublicTopic[]>;
|
||||||
|
getLatestEntries(): Promise<LatestRecordEntry[]>;
|
||||||
|
getHomeFocusItems(): Promise<HomeFocusItem[]>;
|
||||||
|
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
|
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
|
||||||
import type { StudioAssetGateway } from "./ports/studio-asset-gateway.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";
|
import type { StudioGateway } from "./ports/studio-gateway.ts";
|
||||||
|
|
||||||
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
|
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
|
||||||
@@ -8,6 +9,9 @@ export type TechLogFeatureInput = Readonly<{
|
|||||||
publicContent: PublicContentQueries;
|
publicContent: PublicContentQueries;
|
||||||
createStudioGateway(): StudioGateway;
|
createStudioGateway(): StudioGateway;
|
||||||
createStudioAssetGateway(): StudioAssetGateway;
|
createStudioAssetGateway(): StudioAssetGateway;
|
||||||
|
// 주제·프로젝트 관리. MOCK 대응물이 없다 — 이 표면은 백엔드가 없으면 존재할 이유가 없고,
|
||||||
|
// 픽스처를 만들면 실제로는 못 만드는 주제를 화면이 보여주게 된다.
|
||||||
|
createManagementGateway(): ManagementGateway;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
declare module "../../../application/ports/in/application-api.ts" {
|
declare module "../../../application/ports/in/application-api.ts" {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"packageId": "@tech-log/management-contract",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||||
|
"sourceRevision": "ef49d3a",
|
||||||
|
"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",
|
||||||
|
"deleteProjectActivity"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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"];
|
||||||
|
export type HomeFocusRequest = Schemas["HomeFocusRequest"];
|
||||||
|
export type HomeFocusResponse = Schemas["HomeFocusResponse"];
|
||||||
|
export type ProjectActivityRequest = Schemas["ProjectActivityRequest"];
|
||||||
|
export type UpdateProjectActivityRequest = Schemas["UpdateProjectActivityRequest"];
|
||||||
|
export type ProjectActivityResponse = Schemas["ProjectActivityResponse"];
|
||||||
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: "동현",
|
operator: "동현",
|
||||||
contactLabel: "프로필",
|
contactLabel: "프로필",
|
||||||
contactPath: "/profile",
|
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.1.0",
|
||||||
|
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
|
||||||
|
"sourceRevision": "ef49d3a",
|
||||||
|
"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",
|
"packageId": "@tech-log/studio-contract",
|
||||||
"version": "2.0.0",
|
"version": "3.1.0",
|
||||||
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea",
|
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
|
||||||
"sourceRevision": "ce2e748",
|
"sourceRevision": "ef49d3a",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"getStudioSession",
|
"getStudioSession",
|
||||||
"getStudioDashboard",
|
"getStudioDashboard",
|
||||||
|
|||||||
@@ -23,7 +23,48 @@ export type PublicationListItem = Schemas["PublicationListItem"];
|
|||||||
export type PublicationPage = Schemas["PublicationPage"];
|
export type PublicationPage = Schemas["PublicationPage"];
|
||||||
export type PublicationSnapshot = Schemas["PublicationSnapshot"];
|
export type PublicationSnapshot = Schemas["PublicationSnapshot"];
|
||||||
export type CatalogPage = Schemas["CatalogPage"];
|
export type CatalogPage = Schemas["CatalogPage"];
|
||||||
export type ProblemDetails = Schemas["ProblemDetails"];
|
/**
|
||||||
|
* ADR-006으로 canonical 계약의 오류가 봉투(`ErrorEnvelope`/`ApiError`)로
|
||||||
|
* 바뀌면서 `ProblemDetails` 스키마 자체는 canonical에서 삭제됐다. 더 이상
|
||||||
|
* 생성된 스키마에서 뽑지 않고 여기서 손으로 유지하되, 이 모양은 전송 경계
|
||||||
|
* (`tech-log-studio-contract-contribution.ts`의 `envelopeError`)가 실제로
|
||||||
|
* 만드는 모양과 **동일해야 한다** — 그게 이 값이 production에서 채워지는
|
||||||
|
* 유일한 경로다. `ApiError`가 옮겨주는 필드(`type/title/status/detail/code/
|
||||||
|
* category/retryable/details`)만 갖는다.
|
||||||
|
*
|
||||||
|
* (Task 3 fix round 1) 이전에는 ADR-006 이전 평면 wire 모양에서 넘어온
|
||||||
|
* `instance`/`traceId`/`fieldErrors`/`latestDocument`/`latestPublication`/
|
||||||
|
* `conflictingFields`를 최상위 필드로 따로 두고 있었다. `envelopeError`는
|
||||||
|
* 그 필드들을 채우지 않으므로(옮길 대상이 없음) production 값에서는 항상
|
||||||
|
* `undefined`였고, mock 게이트웨이만 채웠다 — 타입은 있는데 mock에 대고
|
||||||
|
* 짜면 통과하고 실제 HTTP 경로에서는 조용히 비는, 봉투 검증 설계가 막으려던
|
||||||
|
* 함정이었다. 그 데이터는 이제 wire와 동일하게 `details` 안에 둔다 — mock도
|
||||||
|
* 여기 채운다(`mock-studio-gateway.ts`, `cursor.ts`).
|
||||||
|
*/
|
||||||
|
export type ValidationErrorDetails = Schemas["ValidationErrorDetails"];
|
||||||
|
export type VersionConflictDetails = Schemas["VersionConflictDetails"];
|
||||||
|
export type PublicationConflictDetails = Schemas["PublicationConflictDetails"];
|
||||||
|
export type ProblemDetailsPayload =
|
||||||
|
| ValidationErrorDetails
|
||||||
|
| VersionConflictDetails
|
||||||
|
| PublicationConflictDetails
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export type ProblemDetails = Readonly<{
|
||||||
|
/** Format: uri-reference */
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
status: number;
|
||||||
|
detail: string;
|
||||||
|
code: Schemas["ApiError"]["code"];
|
||||||
|
category?: Schemas["ApiError"]["category"];
|
||||||
|
// optional 유지: 기존 호출부(테스트의 `new StudioGatewayError({...})` 리터럴
|
||||||
|
// 다수, `synthetic()`의 일부 경로)가 `retryable`을 생략한다. 이번 fix
|
||||||
|
// round의 finding은 `details`/평면 필드 문제이지 이 필드의 필수 여부가
|
||||||
|
// 아니다 — required로 좁히면 무관한 파일들이 깨진다.
|
||||||
|
retryable?: boolean;
|
||||||
|
details?: ProblemDetailsPayload;
|
||||||
|
}>;
|
||||||
export type PublicRenderModel = Schemas["PublicRenderModel"];
|
export type PublicRenderModel = Schemas["PublicRenderModel"];
|
||||||
export type Asset = Schemas["Asset"];
|
export type Asset = Schemas["Asset"];
|
||||||
export type AssetDetail = Schemas["AssetDetail"];
|
export type AssetDetail = Schemas["AssetDetail"];
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ export interface paths {
|
|||||||
* @description 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
|
* @description 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
|
||||||
* Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
|
* Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
|
||||||
*
|
*
|
||||||
* `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails`의
|
* `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details`
|
||||||
* `latestDocument`로 현재 상태를 함께 제공한다.
|
* (`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
put: operations["saveStudioDocument"];
|
put: operations["saveStudioDocument"];
|
||||||
@@ -382,6 +382,130 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
ResponseMeta: {
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
correlationId?: string | null;
|
||||||
|
/** @description Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다. */
|
||||||
|
page?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
ApiError: {
|
||||||
|
/** @enum {string} */
|
||||||
|
code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "DOCUMENT_NOT_FOUND" | "VERSION_CONFLICT" | "REQUEST_VALIDATION_FAILED" | "DOCUMENT_VALIDATION_FAILED" | "VALIDATION_STALE" | "PREVIEW_NOT_FOUND" | "PREVIEW_STALE" | "PREVIEW_EXPIRED" | "PUBLICATION_NOT_FOUND" | "PUBLICATION_CONFLICT" | "PUBLICATION_EVENT_NOT_FOUND" | "PUBLICATION_SNAPSHOT_NOT_FOUND" | "WARNING_ACKNOWLEDGEMENT_REQUIRED" | "IDEMPOTENCY_KEY_REUSED" | "ASSET_NOT_FOUND" | "ASSET_NOT_READY" | "ASSET_IN_USE" | "ASSET_QUARANTINED" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "STUDIO_UNAVAILABLE";
|
||||||
|
/** @enum {string} */
|
||||||
|
category: "VALIDATION" | "AUTH" | "AUTHZ" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMIT" | "TRANSIENT_DEPENDENCY" | "PERMANENT_DEPENDENCY" | "DATA_INTEGRITY" | "INTERNAL";
|
||||||
|
message: string;
|
||||||
|
retryable: boolean;
|
||||||
|
details?: components["schemas"]["ValidationErrorDetails"] | components["schemas"]["VersionConflictDetails"] | components["schemas"]["PublicationConflictDetails"] | null;
|
||||||
|
};
|
||||||
|
ErrorEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: false;
|
||||||
|
error: components["schemas"]["ApiError"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
ValidationErrorDetails: {
|
||||||
|
fieldErrors: components["schemas"]["FieldError"][];
|
||||||
|
};
|
||||||
|
VersionConflictDetails: {
|
||||||
|
latestDocument: components["schemas"]["WorkingCopyDetail"];
|
||||||
|
conflictingFields?: string[];
|
||||||
|
};
|
||||||
|
PublicationConflictDetails: {
|
||||||
|
latestPublication: components["schemas"]["PublicationAggregate"];
|
||||||
|
};
|
||||||
|
StudioSessionEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["StudioSession"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
StudioDashboardEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["StudioDashboard"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
DocumentPageEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["DocumentPage"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
WorkingCopyDetailEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["WorkingCopyDetail"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
WorkingCopyEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["WorkingCopy"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
ValidationReportEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["ValidationReport"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
PreviewDetailEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["PreviewDetail"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
PublicPreviewEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["PublicPreview"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
PublishResultEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["PublishResult"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
PublicationPageEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["PublicationPage"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
PublicationSnapshotEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["PublicationSnapshot"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
CatalogPageEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["CatalogPage"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
AssetPageEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["AssetPage"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
AssetDetailEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["AssetDetail"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
|
AssetEnvelope: {
|
||||||
|
/** @constant */
|
||||||
|
success: true;
|
||||||
|
data: components["schemas"]["Asset"];
|
||||||
|
meta: components["schemas"]["ResponseMeta"];
|
||||||
|
};
|
||||||
StudioSession: {
|
StudioSession: {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -466,7 +590,7 @@ export interface components {
|
|||||||
relations: components["schemas"]["RelationInput"][];
|
relations: components["schemas"]["RelationInput"][];
|
||||||
};
|
};
|
||||||
CaseInput: components["schemas"]["WorkingCopyInputBase"] & {
|
CaseInput: components["schemas"]["WorkingCopyInputBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "CASE";
|
kind: "CASE";
|
||||||
problem: string;
|
problem: string;
|
||||||
conclusion: string;
|
conclusion: string;
|
||||||
@@ -486,7 +610,7 @@ export interface components {
|
|||||||
kind: "CASE";
|
kind: "CASE";
|
||||||
};
|
};
|
||||||
ReferenceInput: components["schemas"]["WorkingCopyInputBase"] & {
|
ReferenceInput: components["schemas"]["WorkingCopyInputBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "REFERENCE";
|
kind: "REFERENCE";
|
||||||
purpose: string;
|
purpose: string;
|
||||||
rules: components["schemas"]["ReferenceRule"][];
|
rules: components["schemas"]["ReferenceRule"][];
|
||||||
@@ -503,7 +627,7 @@ export interface components {
|
|||||||
kind: "REFERENCE";
|
kind: "REFERENCE";
|
||||||
};
|
};
|
||||||
QuestionInput: components["schemas"]["WorkingCopyInputBase"] & {
|
QuestionInput: components["schemas"]["WorkingCopyInputBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "QUESTION";
|
kind: "QUESTION";
|
||||||
/**
|
/**
|
||||||
* @description Backend Inquiry lifecycle의 축약 view다.
|
* @description Backend Inquiry lifecycle의 축약 view다.
|
||||||
@@ -521,7 +645,7 @@ export interface components {
|
|||||||
constraints: components["schemas"]["OrderedText"][];
|
constraints: components["schemas"]["OrderedText"][];
|
||||||
options: components["schemas"]["QuestionOption"][];
|
options: components["schemas"]["QuestionOption"][];
|
||||||
nextValidation: string;
|
nextValidation: string;
|
||||||
resolution: components["schemas"]["QuestionResolution"] | null;
|
resolution?: components["schemas"]["QuestionResolution"] | null;
|
||||||
} & {
|
} & {
|
||||||
/**
|
/**
|
||||||
* @description discriminator enum property added by openapi-typescript
|
* @description discriminator enum property added by openapi-typescript
|
||||||
@@ -530,7 +654,7 @@ export interface components {
|
|||||||
kind: "QUESTION";
|
kind: "QUESTION";
|
||||||
};
|
};
|
||||||
ProjectDecisionInput: components["schemas"]["WorkingCopyInputBase"] & {
|
ProjectDecisionInput: components["schemas"]["WorkingCopyInputBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "PROJECT_DECISION";
|
kind: "PROJECT_DECISION";
|
||||||
/**
|
/**
|
||||||
* @description UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면
|
* @description UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면
|
||||||
@@ -565,7 +689,7 @@ export interface components {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
CaseWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
|
CaseWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "CASE";
|
kind: "CASE";
|
||||||
problem: string;
|
problem: string;
|
||||||
conclusion: string;
|
conclusion: string;
|
||||||
@@ -582,7 +706,7 @@ export interface components {
|
|||||||
kind: "CASE";
|
kind: "CASE";
|
||||||
};
|
};
|
||||||
ReferenceWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
|
ReferenceWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "REFERENCE";
|
kind: "REFERENCE";
|
||||||
purpose: string;
|
purpose: string;
|
||||||
rules: components["schemas"]["ReferenceRule"][];
|
rules: components["schemas"]["ReferenceRule"][];
|
||||||
@@ -599,7 +723,7 @@ export interface components {
|
|||||||
kind: "REFERENCE";
|
kind: "REFERENCE";
|
||||||
};
|
};
|
||||||
QuestionWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
|
QuestionWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "QUESTION";
|
kind: "QUESTION";
|
||||||
/** @enum {string|null} */
|
/** @enum {string|null} */
|
||||||
questionStatus: "OPEN" | "RESOLVED" | null;
|
questionStatus: "OPEN" | "RESOLVED" | null;
|
||||||
@@ -618,7 +742,7 @@ export interface components {
|
|||||||
kind: "QUESTION";
|
kind: "QUESTION";
|
||||||
};
|
};
|
||||||
ProjectDecisionWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
|
ProjectDecisionWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "PROJECT_DECISION";
|
kind: "PROJECT_DECISION";
|
||||||
/** @enum {string|null} */
|
/** @enum {string|null} */
|
||||||
decisionStatus: "PROPOSED" | "ADOPTED" | null;
|
decisionStatus: "PROPOSED" | "ADOPTED" | null;
|
||||||
@@ -789,7 +913,7 @@ export interface components {
|
|||||||
};
|
};
|
||||||
Inline: components["schemas"]["InlineText"] | components["schemas"]["InlineEmphasis"] | components["schemas"]["InlineStrong"] | components["schemas"]["InlineCode"] | components["schemas"]["InlineLink"] | components["schemas"]["InlineStatus"];
|
Inline: components["schemas"]["InlineText"] | components["schemas"]["InlineEmphasis"] | components["schemas"]["InlineStrong"] | components["schemas"]["InlineCode"] | components["schemas"]["InlineLink"] | components["schemas"]["InlineStatus"];
|
||||||
InlineEmphasis: components["schemas"]["InlineContainer"] & {
|
InlineEmphasis: components["schemas"]["InlineContainer"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
type?: "EMPHASIS";
|
type?: "EMPHASIS";
|
||||||
} & {
|
} & {
|
||||||
/**
|
/**
|
||||||
@@ -799,7 +923,7 @@ export interface components {
|
|||||||
type: "EMPHASIS";
|
type: "EMPHASIS";
|
||||||
};
|
};
|
||||||
InlineStrong: components["schemas"]["InlineContainer"] & {
|
InlineStrong: components["schemas"]["InlineContainer"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
type?: "STRONG";
|
type?: "STRONG";
|
||||||
} & {
|
} & {
|
||||||
/**
|
/**
|
||||||
@@ -844,7 +968,7 @@ export interface components {
|
|||||||
items: components["schemas"]["ListItem"][];
|
items: components["schemas"]["ListItem"][];
|
||||||
};
|
};
|
||||||
UnorderedListBlock: components["schemas"]["ListBlockBase"] & {
|
UnorderedListBlock: components["schemas"]["ListBlockBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
type?: "UNORDERED_LIST";
|
type?: "UNORDERED_LIST";
|
||||||
} & {
|
} & {
|
||||||
/**
|
/**
|
||||||
@@ -854,7 +978,7 @@ export interface components {
|
|||||||
type: "UNORDERED_LIST";
|
type: "UNORDERED_LIST";
|
||||||
};
|
};
|
||||||
OrderedListBlock: components["schemas"]["ListBlockBase"] & {
|
OrderedListBlock: components["schemas"]["ListBlockBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
type?: "ORDERED_LIST";
|
type?: "ORDERED_LIST";
|
||||||
} & {
|
} & {
|
||||||
/**
|
/**
|
||||||
@@ -950,9 +1074,34 @@ export interface components {
|
|||||||
height: number | null;
|
height: number | null;
|
||||||
decorative: boolean;
|
decorative: boolean;
|
||||||
};
|
};
|
||||||
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"];
|
/** @description `---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
|
||||||
|
* */
|
||||||
|
ThematicBreakBlock: {
|
||||||
|
/**
|
||||||
|
* @description discriminator enum property added by openapi-typescript
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
type: "THEMATIC_BREAK";
|
||||||
|
};
|
||||||
|
/** @description `` 로 쓴 그림이다.
|
||||||
|
*
|
||||||
|
* `EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
|
||||||
|
* 시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
|
||||||
|
* 링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
|
||||||
|
* */
|
||||||
|
ImageBlock: {
|
||||||
|
/**
|
||||||
|
* @description discriminator enum property added by openapi-typescript
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
type: "IMAGE";
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
title: string | null;
|
||||||
|
};
|
||||||
|
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"] | components["schemas"]["ThematicBreakBlock"] | components["schemas"]["ImageBlock"];
|
||||||
CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "CASE";
|
kind: "CASE";
|
||||||
problem: string;
|
problem: string;
|
||||||
conclusion: string;
|
conclusion: string;
|
||||||
@@ -969,7 +1118,7 @@ export interface components {
|
|||||||
kind: "CASE";
|
kind: "CASE";
|
||||||
};
|
};
|
||||||
ReferencePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
ReferencePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "REFERENCE";
|
kind: "REFERENCE";
|
||||||
purpose: string;
|
purpose: string;
|
||||||
rules: components["schemas"]["ReferenceRule"][];
|
rules: components["schemas"]["ReferenceRule"][];
|
||||||
@@ -991,7 +1140,7 @@ export interface components {
|
|||||||
linkLabel: string;
|
linkLabel: string;
|
||||||
};
|
};
|
||||||
QuestionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
QuestionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "QUESTION";
|
kind: "QUESTION";
|
||||||
/**
|
/**
|
||||||
* @description 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다.
|
* @description 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다.
|
||||||
@@ -1013,12 +1162,12 @@ export interface components {
|
|||||||
kind: "QUESTION";
|
kind: "QUESTION";
|
||||||
};
|
};
|
||||||
ProjectDecisionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
ProjectDecisionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
||||||
/** @constant */
|
/** @enum {string} */
|
||||||
kind: "PROJECT_DECISION";
|
kind: "PROJECT_DECISION";
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "PROPOSED" | "ADOPTED";
|
status: "PROPOSED" | "ADOPTED";
|
||||||
/** Format: date */
|
/** Format: date */
|
||||||
decidedOn: string;
|
decidedOn: string | null;
|
||||||
statement: string;
|
statement: string;
|
||||||
rationale: string;
|
rationale: string;
|
||||||
consequences: components["schemas"]["OrderedText"][];
|
consequences: components["schemas"]["OrderedText"][];
|
||||||
@@ -1246,25 +1395,6 @@ export interface components {
|
|||||||
path: string;
|
path: string;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
ProblemDetails: {
|
|
||||||
/** Format: uri-reference */
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
status: number;
|
|
||||||
detail: string;
|
|
||||||
/** @enum {string} */
|
|
||||||
code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "DOCUMENT_NOT_FOUND" | "VERSION_CONFLICT" | "REQUEST_VALIDATION_FAILED" | "VALIDATION_FAILED" | "VALIDATION_STALE" | "PREVIEW_NOT_FOUND" | "PREVIEW_STALE" | "PREVIEW_EXPIRED" | "PUBLICATION_NOT_FOUND" | "PUBLICATION_CONFLICT" | "PUBLICATION_EVENT_NOT_FOUND" | "PUBLICATION_SNAPSHOT_NOT_FOUND" | "WARNING_ACKNOWLEDGEMENT_REQUIRED" | "IDEMPOTENCY_KEY_REUSED" | "ASSET_NOT_FOUND" | "ASSET_NOT_READY" | "ASSET_IN_USE" | "ASSET_QUARANTINED" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "STUDIO_UNAVAILABLE";
|
|
||||||
/** Format: uri-reference */
|
|
||||||
instance?: string;
|
|
||||||
traceId?: string;
|
|
||||||
fieldErrors?: components["schemas"]["FieldError"][];
|
|
||||||
latestDocument?: components["schemas"]["WorkingCopyDetail"];
|
|
||||||
latestPublication?: components["schemas"]["PublicationAggregate"];
|
|
||||||
conflictingFields?: string[];
|
|
||||||
retryable?: boolean;
|
|
||||||
} & {
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Malformed request */
|
/** @description Malformed request */
|
||||||
@@ -1273,7 +1403,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Authentication required */
|
/** @description Authentication required */
|
||||||
@@ -1282,7 +1412,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Studio access denied */
|
/** @description Studio access denied */
|
||||||
@@ -1291,7 +1421,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Document not found */
|
/** @description Document not found */
|
||||||
@@ -1300,7 +1430,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Document or preview not found */
|
/** @description Document or preview not found */
|
||||||
@@ -1309,7 +1439,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Publication not found */
|
/** @description Publication not found */
|
||||||
@@ -1318,7 +1448,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Publication event or snapshot not found */
|
/** @description Publication event or snapshot not found */
|
||||||
@@ -1327,7 +1457,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Asset not found */
|
/** @description Asset not found */
|
||||||
@@ -1336,7 +1466,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Command conflicts with current state, freshness, or idempotency.
|
/** @description Command conflicts with current state, freshness, or idempotency.
|
||||||
@@ -1348,7 +1478,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Request validation failed */
|
/** @description Request validation failed */
|
||||||
@@ -1357,7 +1487,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Preview 생성이 도메인 규칙으로 거절되었다 */
|
/** @description Preview 생성이 도메인 규칙으로 거절되었다 */
|
||||||
@@ -1366,7 +1496,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Publication validation이 실패했다.
|
/** @description Publication validation이 실패했다.
|
||||||
@@ -1379,7 +1509,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Asset metadata 변경이 거절되었다 */
|
/** @description Asset metadata 변경이 거절되었다 */
|
||||||
@@ -1388,7 +1518,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Upload exceeds the configured size limit */
|
/** @description Upload exceeds the configured size limit */
|
||||||
@@ -1397,7 +1527,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unsupported media type */
|
/** @description Unsupported media type */
|
||||||
@@ -1406,7 +1536,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Studio unavailable */
|
/** @description Studio unavailable */
|
||||||
@@ -1415,7 +1545,7 @@ export interface components {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1470,7 +1600,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["StudioSession"];
|
"application/json": components["schemas"]["StudioSessionEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["AuthenticationRequired"];
|
401: components["responses"]["AuthenticationRequired"];
|
||||||
@@ -1493,7 +1623,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["StudioDashboard"];
|
"application/json": components["schemas"]["StudioDashboardEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["AuthenticationRequired"];
|
401: components["responses"]["AuthenticationRequired"];
|
||||||
@@ -1527,7 +1657,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["DocumentPage"];
|
"application/json": components["schemas"]["DocumentPageEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
@@ -1565,7 +1695,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["WorkingCopy"];
|
"application/json": components["schemas"]["WorkingCopyEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
@@ -1593,7 +1723,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["WorkingCopyDetail"];
|
"application/json": components["schemas"]["WorkingCopyDetailEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["AuthenticationRequired"];
|
401: components["responses"]["AuthenticationRequired"];
|
||||||
@@ -1632,7 +1762,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["WorkingCopyDetail"];
|
"application/json": components["schemas"]["WorkingCopyDetailEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
@@ -1674,7 +1804,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["ValidationReport"];
|
"application/json": components["schemas"]["ValidationReportEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
@@ -1703,7 +1833,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["PreviewDetail"];
|
"application/json": components["schemas"]["PreviewDetailEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["AuthenticationRequired"];
|
401: components["responses"]["AuthenticationRequired"];
|
||||||
@@ -1742,7 +1872,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["PublicPreview"];
|
"application/json": components["schemas"]["PublicPreviewEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
@@ -1784,7 +1914,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["PublishResult"];
|
"application/json": components["schemas"]["PublishResultEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
@@ -1818,7 +1948,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["PublicationPage"];
|
"application/json": components["schemas"]["PublicationPageEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
@@ -1858,7 +1988,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["PublishResult"];
|
"application/json": components["schemas"]["PublishResultEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
@@ -1887,7 +2017,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["PublicationSnapshot"];
|
"application/json": components["schemas"]["PublicationSnapshotEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["AuthenticationRequired"];
|
401: components["responses"]["AuthenticationRequired"];
|
||||||
@@ -1918,7 +2048,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["CatalogPage"];
|
"application/json": components["schemas"]["CatalogPageEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
@@ -1951,7 +2081,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["AssetPage"];
|
"application/json": components["schemas"]["AssetPageEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
@@ -1989,7 +2119,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["Asset"];
|
"application/json": components["schemas"]["AssetEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
@@ -2019,7 +2149,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["AssetDetail"];
|
"application/json": components["schemas"]["AssetDetailEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["AuthenticationRequired"];
|
401: components["responses"]["AuthenticationRequired"];
|
||||||
@@ -2058,7 +2188,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["Asset"];
|
"application/json": components["schemas"]["AssetEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["MalformedRequest"];
|
400: components["responses"]["MalformedRequest"];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: Tech Log Studio API
|
title: Tech Log Studio API
|
||||||
version: 2.0.0
|
version: 3.1.0
|
||||||
description: |
|
description: |
|
||||||
Tech Log Studio orchestration 계약이다.
|
Tech Log Studio orchestration 계약이다.
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ paths:
|
|||||||
tags: [Session]
|
tags: [Session]
|
||||||
summary: 현재 Studio 세션과 CSRF 토큰을 조회한다
|
summary: 현재 Studio 세션과 CSRF 토큰을 조회한다
|
||||||
responses:
|
responses:
|
||||||
"200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSession" } } } }
|
"200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSessionEnvelope" } } } }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
"503": { $ref: "#/components/responses/StudioUnavailable" }
|
"503": { $ref: "#/components/responses/StudioUnavailable" }
|
||||||
@@ -123,7 +123,7 @@ paths:
|
|||||||
`nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다.
|
`nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다.
|
||||||
Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다.
|
Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다.
|
||||||
responses:
|
responses:
|
||||||
"200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboard" } } } }
|
"200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboardEnvelope" } } } }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
"503": { $ref: "#/components/responses/StudioUnavailable" }
|
"503": { $ref: "#/components/responses/StudioUnavailable" }
|
||||||
@@ -150,7 +150,7 @@ paths:
|
|||||||
- { $ref: "#/components/parameters/Cursor" }
|
- { $ref: "#/components/parameters/Cursor" }
|
||||||
- { $ref: "#/components/parameters/Limit" }
|
- { $ref: "#/components/parameters/Limit" }
|
||||||
responses:
|
responses:
|
||||||
"200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPage" } } } }
|
"200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPageEnvelope" } } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -169,7 +169,7 @@ paths:
|
|||||||
"201":
|
"201":
|
||||||
description: Created working copy
|
description: Created working copy
|
||||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||||
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopy" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyEnvelope" } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -184,7 +184,7 @@ paths:
|
|||||||
tags: [Documents]
|
tags: [Documents]
|
||||||
summary: Get a working copy and its current state
|
summary: Get a working copy and its current state
|
||||||
responses:
|
responses:
|
||||||
"200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetail" } } } }
|
"200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } } }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
"404": { $ref: "#/components/responses/DocumentNotFound" }
|
"404": { $ref: "#/components/responses/DocumentNotFound" }
|
||||||
@@ -197,8 +197,8 @@ paths:
|
|||||||
편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
|
편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
|
||||||
Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
|
Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
|
||||||
|
|
||||||
`expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails`의
|
`expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details`
|
||||||
`latestDocument`로 현재 상태를 함께 제공한다.
|
(`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다.
|
||||||
parameters:
|
parameters:
|
||||||
- { $ref: "#/components/parameters/IdempotencyKey" }
|
- { $ref: "#/components/parameters/IdempotencyKey" }
|
||||||
- { $ref: "#/components/parameters/CsrfToken" }
|
- { $ref: "#/components/parameters/CsrfToken" }
|
||||||
@@ -207,7 +207,7 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: Saved working-copy detail
|
description: Saved working-copy detail
|
||||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||||
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetail" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -244,7 +244,7 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: Validation report
|
description: Validation report
|
||||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||||
content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReport" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReportEnvelope" } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -264,7 +264,7 @@ paths:
|
|||||||
anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된
|
anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된
|
||||||
Studio API로만 조회한다.
|
Studio API로만 조회한다.
|
||||||
responses:
|
responses:
|
||||||
"200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetail" } } } }
|
"200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetailEnvelope" } } } }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
"404": { $ref: "#/components/responses/PreviewNotFound" }
|
"404": { $ref: "#/components/responses/PreviewNotFound" }
|
||||||
@@ -285,7 +285,7 @@ paths:
|
|||||||
"201":
|
"201":
|
||||||
description: Created preview
|
description: Created preview
|
||||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||||
content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreview" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreviewEnvelope" } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -329,7 +329,7 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: Publication aggregate and immutable event
|
description: Publication aggregate and immutable event
|
||||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||||
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -350,7 +350,7 @@ paths:
|
|||||||
- { $ref: "#/components/parameters/Cursor" }
|
- { $ref: "#/components/parameters/Cursor" }
|
||||||
- { $ref: "#/components/parameters/Limit" }
|
- { $ref: "#/components/parameters/Limit" }
|
||||||
responses:
|
responses:
|
||||||
"200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPage" } } } }
|
"200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPageEnvelope" } } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -379,7 +379,7 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: Updated publication aggregate and event
|
description: Updated publication aggregate and event
|
||||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||||
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -401,7 +401,7 @@ paths:
|
|||||||
`UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우
|
`UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우
|
||||||
`sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다.
|
`sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다.
|
||||||
responses:
|
responses:
|
||||||
"200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshot" } } } }
|
"200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshotEnvelope" } } } }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
"404": { $ref: "#/components/responses/PublicationSnapshotNotFound" }
|
"404": { $ref: "#/components/responses/PublicationSnapshotNotFound" }
|
||||||
@@ -430,7 +430,7 @@ paths:
|
|||||||
- { $ref: "#/components/parameters/Cursor" }
|
- { $ref: "#/components/parameters/Cursor" }
|
||||||
- { $ref: "#/components/parameters/Limit" }
|
- { $ref: "#/components/parameters/Limit" }
|
||||||
responses:
|
responses:
|
||||||
"200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPage" } } } }
|
"200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPageEnvelope" } } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -449,7 +449,7 @@ paths:
|
|||||||
- { $ref: "#/components/parameters/Cursor" }
|
- { $ref: "#/components/parameters/Cursor" }
|
||||||
- { $ref: "#/components/parameters/Limit" }
|
- { $ref: "#/components/parameters/Limit" }
|
||||||
responses:
|
responses:
|
||||||
"200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPage" } } } }
|
"200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPageEnvelope" } } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -483,7 +483,7 @@ paths:
|
|||||||
"201":
|
"201":
|
||||||
description: Stored asset
|
description: Stored asset
|
||||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||||
content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -500,7 +500,7 @@ paths:
|
|||||||
tags: [Assets]
|
tags: [Assets]
|
||||||
summary: Get an asset with its usage
|
summary: Get an asset with its usage
|
||||||
responses:
|
responses:
|
||||||
"200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetail" } } } }
|
"200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetailEnvelope" } } } }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
"404": { $ref: "#/components/responses/AssetNotFound" }
|
"404": { $ref: "#/components/responses/AssetNotFound" }
|
||||||
@@ -520,7 +520,7 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: Updated asset
|
description: Updated asset
|
||||||
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
|
||||||
content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } }
|
||||||
"400": { $ref: "#/components/responses/MalformedRequest" }
|
"400": { $ref: "#/components/responses/MalformedRequest" }
|
||||||
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
"401": { $ref: "#/components/responses/AuthenticationRequired" }
|
||||||
"403": { $ref: "#/components/responses/AccessDenied" }
|
"403": { $ref: "#/components/responses/AccessDenied" }
|
||||||
@@ -593,43 +593,232 @@ components:
|
|||||||
IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } }
|
IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } }
|
||||||
|
|
||||||
responses:
|
responses:
|
||||||
MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
CommandConflict:
|
CommandConflict:
|
||||||
description: |
|
description: |
|
||||||
Command conflicts with current state, freshness, or idempotency.
|
Command conflicts with current state, freshness, or idempotency.
|
||||||
|
|
||||||
`ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다.
|
`ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다.
|
||||||
x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, ASSET_IN_USE]
|
x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, ASSET_IN_USE]
|
||||||
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
|
||||||
RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
PreviewRejected:
|
PreviewRejected:
|
||||||
description: Preview 생성이 도메인 규칙으로 거절되었다
|
description: Preview 생성이 도메인 규칙으로 거절되었다
|
||||||
x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
|
x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
|
||||||
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
|
||||||
PublishRejected:
|
PublishRejected:
|
||||||
description: |
|
description: |
|
||||||
Publication validation이 실패했다.
|
Publication validation이 실패했다.
|
||||||
|
|
||||||
`WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가
|
`WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가
|
||||||
현재 Validation의 WARNING 집합을 덮지 못한 경우다.
|
현재 Validation의 WARNING 집합을 덮지 못한 경우다.
|
||||||
x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED]
|
x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED]
|
||||||
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
|
||||||
AssetRejected:
|
AssetRejected:
|
||||||
description: Asset metadata 변경이 거절되었다
|
description: Asset metadata 변경이 거절되었다
|
||||||
x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
|
x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
|
||||||
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
|
||||||
PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
|
# ------------------------------------------------------------- envelope
|
||||||
|
# wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고
|
||||||
|
# 응답만 <Payload>Envelope으로 감싼다.
|
||||||
|
ResponseMeta:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [requestId, traceId]
|
||||||
|
properties:
|
||||||
|
requestId: { type: string, minLength: 1, maxLength: 200 }
|
||||||
|
traceId: { type: string, minLength: 1, maxLength: 200 }
|
||||||
|
correlationId: { type: [string, "null"], maxLength: 200 }
|
||||||
|
page: { type: ["object", "null"], additionalProperties: true, description: "Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다." }
|
||||||
|
ApiError:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [code, category, message, retryable]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
enum: [AUTHENTICATION_REQUIRED, STUDIO_ACCESS_DENIED, DOCUMENT_NOT_FOUND, VERSION_CONFLICT,
|
||||||
|
REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, VALIDATION_STALE, PREVIEW_NOT_FOUND,
|
||||||
|
PREVIEW_STALE, PREVIEW_EXPIRED, PUBLICATION_NOT_FOUND, PUBLICATION_CONFLICT,
|
||||||
|
PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND,
|
||||||
|
WARNING_ACKNOWLEDGEMENT_REQUIRED, IDEMPOTENCY_KEY_REUSED, ASSET_NOT_FOUND,
|
||||||
|
ASSET_NOT_READY, ASSET_IN_USE, ASSET_QUARANTINED, PAYLOAD_TOO_LARGE,
|
||||||
|
UNSUPPORTED_MEDIA_TYPE, STUDIO_UNAVAILABLE]
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
enum: [VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT,
|
||||||
|
TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL]
|
||||||
|
message: { type: string, minLength: 1, maxLength: 5000 }
|
||||||
|
retryable: { type: boolean }
|
||||||
|
details:
|
||||||
|
oneOf:
|
||||||
|
- $ref: "#/components/schemas/ValidationErrorDetails"
|
||||||
|
- $ref: "#/components/schemas/VersionConflictDetails"
|
||||||
|
- $ref: "#/components/schemas/PublicationConflictDetails"
|
||||||
|
- type: "null"
|
||||||
|
ErrorEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, error, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: false }
|
||||||
|
error: { $ref: "#/components/schemas/ApiError" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
ValidationErrorDetails:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [fieldErrors]
|
||||||
|
properties:
|
||||||
|
fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } }
|
||||||
|
VersionConflictDetails:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [latestDocument]
|
||||||
|
properties:
|
||||||
|
latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" }
|
||||||
|
conflictingFields:
|
||||||
|
type: array
|
||||||
|
uniqueItems: true
|
||||||
|
maxItems: 200
|
||||||
|
items: { type: string, pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" }
|
||||||
|
PublicationConflictDetails:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [latestPublication]
|
||||||
|
properties:
|
||||||
|
latestPublication: { $ref: "#/components/schemas/PublicationAggregate" }
|
||||||
|
StudioSessionEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/StudioSession" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
StudioDashboardEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/StudioDashboard" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
DocumentPageEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/DocumentPage" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
WorkingCopyDetailEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/WorkingCopyDetail" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
WorkingCopyEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/WorkingCopy" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
ValidationReportEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/ValidationReport" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
PreviewDetailEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/PreviewDetail" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
PublicPreviewEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/PublicPreview" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
PublishResultEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/PublishResult" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
PublicationPageEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/PublicationPage" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
PublicationSnapshotEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/PublicationSnapshot" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
CatalogPageEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/CatalogPage" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
AssetPageEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/AssetPage" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
AssetDetailEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/AssetDetail" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
|
AssetEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [success, data, meta]
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, const: true }
|
||||||
|
data: { $ref: "#/components/schemas/Asset" }
|
||||||
|
meta: { $ref: "#/components/schemas/ResponseMeta" }
|
||||||
# ---------------------------------------------------------------- session
|
# ---------------------------------------------------------------- session
|
||||||
StudioSession:
|
StudioSession:
|
||||||
type: object
|
type: object
|
||||||
@@ -682,7 +871,7 @@ components:
|
|||||||
required: [id, text, order]
|
required: [id, text, order]
|
||||||
properties:
|
properties:
|
||||||
id: { type: string, format: uuid }
|
id: { type: string, format: uuid }
|
||||||
text: { type: string, minLength: 1, maxLength: 100000 }
|
text: { type: string, maxLength: 100000 }
|
||||||
order: { type: integer, minimum: 0 }
|
order: { type: integer, minimum: 0 }
|
||||||
ReferenceRule:
|
ReferenceRule:
|
||||||
type: object
|
type: object
|
||||||
@@ -739,7 +928,7 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown]
|
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, const: CASE }
|
kind: { type: string, enum: [CASE] }
|
||||||
problem: { type: string, maxLength: 100000 }
|
problem: { type: string, maxLength: 100000 }
|
||||||
conclusion: { type: string, maxLength: 100000 }
|
conclusion: { type: string, maxLength: 100000 }
|
||||||
environment: { type: string, maxLength: 100000 }
|
environment: { type: string, maxLength: 100000 }
|
||||||
@@ -758,7 +947,7 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
|
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, const: REFERENCE }
|
kind: { type: string, enum: [REFERENCE] }
|
||||||
purpose: { type: string, maxLength: 100000 }
|
purpose: { type: string, maxLength: 100000 }
|
||||||
rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
|
rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
|
||||||
applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
@@ -770,9 +959,14 @@ components:
|
|||||||
allOf:
|
allOf:
|
||||||
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
|
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
|
||||||
- type: object
|
- 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:
|
properties:
|
||||||
kind: { type: string, const: QUESTION }
|
kind: { type: string, enum: [QUESTION] }
|
||||||
questionStatus:
|
questionStatus:
|
||||||
type: [string, "null"]
|
type: [string, "null"]
|
||||||
enum: [OPEN, RESOLVED, null]
|
enum: [OPEN, RESOLVED, null]
|
||||||
@@ -799,7 +993,7 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
|
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, const: PROJECT_DECISION }
|
kind: { type: string, enum: [PROJECT_DECISION] }
|
||||||
decisionStatus:
|
decisionStatus:
|
||||||
type: [string, "null"]
|
type: [string, "null"]
|
||||||
enum: [PROPOSED, ADOPTED, null]
|
enum: [PROPOSED, ADOPTED, null]
|
||||||
@@ -846,7 +1040,7 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown]
|
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, const: CASE }
|
kind: { type: string, enum: [CASE] }
|
||||||
problem: { type: string, maxLength: 100000 }
|
problem: { type: string, maxLength: 100000 }
|
||||||
conclusion: { type: string, maxLength: 100000 }
|
conclusion: { type: string, maxLength: 100000 }
|
||||||
environment: { type: string, maxLength: 100000 }
|
environment: { type: string, maxLength: 100000 }
|
||||||
@@ -860,7 +1054,7 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
|
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, const: REFERENCE }
|
kind: { type: string, enum: [REFERENCE] }
|
||||||
purpose: { type: string, maxLength: 100000 }
|
purpose: { type: string, maxLength: 100000 }
|
||||||
rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
|
rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
|
||||||
applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
@@ -874,7 +1068,7 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
|
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, const: QUESTION }
|
kind: { type: string, enum: [QUESTION] }
|
||||||
questionStatus: { type: [string, "null"], enum: [OPEN, RESOLVED, null] }
|
questionStatus: { type: [string, "null"], enum: [OPEN, RESOLVED, null] }
|
||||||
facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
@@ -893,7 +1087,7 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
|
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, const: PROJECT_DECISION }
|
kind: { type: string, enum: [PROJECT_DECISION] }
|
||||||
decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] }
|
decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] }
|
||||||
decidedOn: { type: [string, "null"], format: date }
|
decidedOn: { type: [string, "null"], format: date }
|
||||||
statement: { type: string, maxLength: 100000 }
|
statement: { type: string, maxLength: 100000 }
|
||||||
@@ -1048,7 +1242,7 @@ components:
|
|||||||
kind: { $ref: "#/components/schemas/RecordKind" }
|
kind: { $ref: "#/components/schemas/RecordKind" }
|
||||||
slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
|
slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
|
||||||
title: { type: string, minLength: 1, maxLength: 120 }
|
title: { type: string, minLength: 1, maxLength: 120 }
|
||||||
summary: { type: string, minLength: 1, maxLength: 300 }
|
summary: { type: string, maxLength: 300 }
|
||||||
publicPath: { type: string, minLength: 1, maxLength: 500 }
|
publicPath: { type: string, minLength: 1, maxLength: 500 }
|
||||||
topic: { $ref: "#/components/schemas/DisplayTarget" }
|
topic: { $ref: "#/components/schemas/DisplayTarget" }
|
||||||
project:
|
project:
|
||||||
@@ -1062,8 +1256,8 @@ components:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [type, text]
|
required: [type, text]
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, const: TEXT }
|
type: { type: string, enum: [TEXT] }
|
||||||
text: { type: string, minLength: 1, maxLength: 100000 }
|
text: { type: string, maxLength: 100000 }
|
||||||
InlineContainer:
|
InlineContainer:
|
||||||
type: object
|
type: object
|
||||||
required: [type, children]
|
required: [type, children]
|
||||||
@@ -1075,14 +1269,14 @@ components:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [type, code]
|
required: [type, code]
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, const: INLINE_CODE }
|
type: { type: string, enum: [INLINE_CODE] }
|
||||||
code: { type: string, minLength: 1, maxLength: 100000 }
|
code: { type: string, minLength: 1, maxLength: 100000 }
|
||||||
InlineLink:
|
InlineLink:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [type, label, href]
|
required: [type, label, href]
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, const: LINK }
|
type: { type: string, enum: [LINK] }
|
||||||
label: { type: string, minLength: 1, maxLength: 100000 }
|
label: { type: string, minLength: 1, maxLength: 100000 }
|
||||||
href: { type: string, format: uri, maxLength: 2000 }
|
href: { type: string, format: uri, maxLength: 2000 }
|
||||||
InlineStatus:
|
InlineStatus:
|
||||||
@@ -1090,7 +1284,7 @@ components:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [type, label, tone]
|
required: [type, label, tone]
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, const: STATUS }
|
type: { type: string, enum: [STATUS] }
|
||||||
label: { type: string, minLength: 1, maxLength: 120 }
|
label: { type: string, minLength: 1, maxLength: 120 }
|
||||||
tone: { type: string, enum: [warning, evidence, neutral] }
|
tone: { type: string, enum: [warning, evidence, neutral] }
|
||||||
Inline:
|
Inline:
|
||||||
@@ -1114,34 +1308,37 @@ components:
|
|||||||
unevaluatedProperties: false
|
unevaluatedProperties: false
|
||||||
allOf:
|
allOf:
|
||||||
- { $ref: "#/components/schemas/InlineContainer" }
|
- { $ref: "#/components/schemas/InlineContainer" }
|
||||||
- { type: object, properties: { type: { type: string, const: EMPHASIS } } }
|
- { type: object, properties: { type: { type: string, enum: [EMPHASIS] } } }
|
||||||
InlineStrong:
|
InlineStrong:
|
||||||
unevaluatedProperties: false
|
unevaluatedProperties: false
|
||||||
allOf:
|
allOf:
|
||||||
- { $ref: "#/components/schemas/InlineContainer" }
|
- { $ref: "#/components/schemas/InlineContainer" }
|
||||||
- { type: object, properties: { type: { type: string, const: STRONG } } }
|
- { type: object, properties: { type: { type: string, enum: [STRONG] } } }
|
||||||
HeadingBlock:
|
HeadingBlock:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [type, id, level, content]
|
required: [type, id, level, content]
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, const: HEADING }
|
type: { type: string, enum: [HEADING] }
|
||||||
id: { type: string, minLength: 1, maxLength: 200 }
|
id: { type: string, minLength: 1, maxLength: 200 }
|
||||||
level: { type: integer, minimum: 2, maximum: 4 }
|
# 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를
|
||||||
|
# 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한
|
||||||
|
# 평범한 Markdown 이 통째로 렌더링되지 않는다.
|
||||||
|
level: { type: integer, minimum: 1, maximum: 6 }
|
||||||
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
||||||
ParagraphBlock:
|
ParagraphBlock:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [type, content]
|
required: [type, content]
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, const: PARAGRAPH }
|
type: { type: string, enum: [PARAGRAPH] }
|
||||||
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
||||||
BlockquoteBlock:
|
BlockquoteBlock:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [type, content]
|
required: [type, content]
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, const: BLOCKQUOTE }
|
type: { type: string, enum: [BLOCKQUOTE] }
|
||||||
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
||||||
ListItem:
|
ListItem:
|
||||||
type: object
|
type: object
|
||||||
@@ -1160,18 +1357,18 @@ components:
|
|||||||
unevaluatedProperties: false
|
unevaluatedProperties: false
|
||||||
allOf:
|
allOf:
|
||||||
- { $ref: "#/components/schemas/ListBlockBase" }
|
- { $ref: "#/components/schemas/ListBlockBase" }
|
||||||
- { type: object, properties: { type: { type: string, const: UNORDERED_LIST } } }
|
- { type: object, properties: { type: { type: string, enum: [UNORDERED_LIST] } } }
|
||||||
OrderedListBlock:
|
OrderedListBlock:
|
||||||
unevaluatedProperties: false
|
unevaluatedProperties: false
|
||||||
allOf:
|
allOf:
|
||||||
- { $ref: "#/components/schemas/ListBlockBase" }
|
- { $ref: "#/components/schemas/ListBlockBase" }
|
||||||
- { type: object, properties: { type: { type: string, const: ORDERED_LIST } } }
|
- { type: object, properties: { type: { type: string, enum: [ORDERED_LIST] } } }
|
||||||
CodeBlock:
|
CodeBlock:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [type, code, language, label]
|
required: [type, code, language, label]
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, const: CODE_BLOCK }
|
type: { type: string, enum: [CODE_BLOCK] }
|
||||||
code: { type: string, maxLength: 100000 }
|
code: { type: string, maxLength: 100000 }
|
||||||
language: { type: [string, "null"], maxLength: 100 }
|
language: { type: [string, "null"], maxLength: 100 }
|
||||||
label: { type: [string, "null"], maxLength: 200 }
|
label: { type: [string, "null"], maxLength: 200 }
|
||||||
@@ -1202,7 +1399,7 @@ components:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [type, id, caption, rowHeaderColumn, columns, rows]
|
required: [type, id, caption, rowHeaderColumn, columns, rows]
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, const: DATA_TABLE }
|
type: { type: string, enum: [DATA_TABLE] }
|
||||||
id: { type: string, minLength: 1, maxLength: 200 }
|
id: { type: string, minLength: 1, maxLength: 200 }
|
||||||
caption: { type: string, maxLength: 1000 }
|
caption: { type: string, maxLength: 1000 }
|
||||||
rowHeaderColumn: { type: [integer, "null"], minimum: 1 }
|
rowHeaderColumn: { type: [integer, "null"], minimum: 1 }
|
||||||
@@ -1213,7 +1410,7 @@ components:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [type, tone, label, content]
|
required: [type, tone, label, content]
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, const: CALLOUT }
|
type: { type: string, enum: [CALLOUT] }
|
||||||
tone: { type: string, enum: [warning, info] }
|
tone: { type: string, enum: [warning, info] }
|
||||||
label: { type: string, maxLength: 200 }
|
label: { type: string, maxLength: 200 }
|
||||||
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
||||||
@@ -1236,7 +1433,7 @@ components:
|
|||||||
Asset decorative=true → alt="" 허용
|
Asset decorative=true → alt="" 허용
|
||||||
```
|
```
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, const: EVIDENCE_FIGURE }
|
type: { type: string, enum: [EVIDENCE_FIGURE] }
|
||||||
key: { type: string, minLength: 1, maxLength: 200 }
|
key: { type: string, minLength: 1, maxLength: 200 }
|
||||||
alt: { type: string, maxLength: 1000 }
|
alt: { type: string, maxLength: 1000 }
|
||||||
caption: { type: string, maxLength: 1000 }
|
caption: { type: string, maxLength: 1000 }
|
||||||
@@ -1258,6 +1455,29 @@ components:
|
|||||||
width: { type: [integer, "null"], minimum: 1 }
|
width: { type: [integer, "null"], minimum: 1 }
|
||||||
height: { type: [integer, "null"], minimum: 1 }
|
height: { type: [integer, "null"], minimum: 1 }
|
||||||
decorative: { type: boolean }
|
decorative: { type: boolean }
|
||||||
|
ThematicBreakBlock:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
description: |
|
||||||
|
`---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
|
||||||
|
required: [type]
|
||||||
|
properties:
|
||||||
|
type: { type: string, enum: [THEMATIC_BREAK] }
|
||||||
|
ImageBlock:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
description: |
|
||||||
|
`` 로 쓴 그림이다.
|
||||||
|
|
||||||
|
`EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
|
||||||
|
시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
|
||||||
|
링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
|
||||||
|
required: [type, src, alt, title]
|
||||||
|
properties:
|
||||||
|
type: { type: string, enum: [IMAGE] }
|
||||||
|
src: { type: string, minLength: 1, maxLength: 500 }
|
||||||
|
alt: { type: string, maxLength: 300 }
|
||||||
|
title: { type: [string, "null"], maxLength: 300 }
|
||||||
CaseRenderBlock:
|
CaseRenderBlock:
|
||||||
oneOf:
|
oneOf:
|
||||||
- { $ref: "#/components/schemas/HeadingBlock" }
|
- { $ref: "#/components/schemas/HeadingBlock" }
|
||||||
@@ -1269,6 +1489,8 @@ components:
|
|||||||
- { $ref: "#/components/schemas/DataTableBlock" }
|
- { $ref: "#/components/schemas/DataTableBlock" }
|
||||||
- { $ref: "#/components/schemas/CalloutBlock" }
|
- { $ref: "#/components/schemas/CalloutBlock" }
|
||||||
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
|
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
|
||||||
|
- { $ref: "#/components/schemas/ThematicBreakBlock" }
|
||||||
|
- { $ref: "#/components/schemas/ImageBlock" }
|
||||||
discriminator:
|
discriminator:
|
||||||
propertyName: type
|
propertyName: type
|
||||||
mapping:
|
mapping:
|
||||||
@@ -1281,6 +1503,8 @@ components:
|
|||||||
DATA_TABLE: "#/components/schemas/DataTableBlock"
|
DATA_TABLE: "#/components/schemas/DataTableBlock"
|
||||||
CALLOUT: "#/components/schemas/CalloutBlock"
|
CALLOUT: "#/components/schemas/CalloutBlock"
|
||||||
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
|
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
|
||||||
|
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
|
||||||
|
IMAGE: "#/components/schemas/ImageBlock"
|
||||||
CasePublicRenderModel:
|
CasePublicRenderModel:
|
||||||
unevaluatedProperties: false
|
unevaluatedProperties: false
|
||||||
allOf:
|
allOf:
|
||||||
@@ -1288,9 +1512,9 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks]
|
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, const: CASE }
|
kind: { type: string, enum: [CASE] }
|
||||||
problem: { type: string, minLength: 1, maxLength: 100000 }
|
problem: { type: string, maxLength: 100000 }
|
||||||
conclusion: { type: string, minLength: 1, maxLength: 100000 }
|
conclusion: { type: string, maxLength: 100000 }
|
||||||
environment: { type: string, maxLength: 100000 }
|
environment: { type: string, maxLength: 100000 }
|
||||||
reproduction: { type: string, maxLength: 100000 }
|
reproduction: { type: string, maxLength: 100000 }
|
||||||
lastVerifiedOn: { type: string, format: date }
|
lastVerifiedOn: { type: string, format: date }
|
||||||
@@ -1302,8 +1526,8 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
|
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, const: REFERENCE }
|
kind: { type: string, enum: [REFERENCE] }
|
||||||
purpose: { type: string, minLength: 1, maxLength: 100000 }
|
purpose: { type: string, maxLength: 100000 }
|
||||||
rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
|
rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
|
||||||
applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
@@ -1314,7 +1538,7 @@ components:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [summary, evidenceTarget, linkLabel]
|
required: [summary, evidenceTarget, linkLabel]
|
||||||
properties:
|
properties:
|
||||||
summary: { type: string, minLength: 1, maxLength: 100000 }
|
summary: { type: string, maxLength: 100000 }
|
||||||
evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" }
|
evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" }
|
||||||
linkLabel: { type: string, minLength: 1, maxLength: 120 }
|
linkLabel: { type: string, minLength: 1, maxLength: 120 }
|
||||||
QuestionPublicRenderModel:
|
QuestionPublicRenderModel:
|
||||||
@@ -1324,7 +1548,7 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [kind, status, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
|
required: [kind, status, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, const: QUESTION }
|
kind: { type: string, enum: [QUESTION] }
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [OPEN, RESOLVED]
|
enum: [OPEN, RESOLVED]
|
||||||
@@ -1334,7 +1558,7 @@ components:
|
|||||||
unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } }
|
options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } }
|
||||||
nextValidation: { type: string, minLength: 1, maxLength: 100000 }
|
nextValidation: { type: string, maxLength: 100000 }
|
||||||
resolution:
|
resolution:
|
||||||
oneOf:
|
oneOf:
|
||||||
- { $ref: "#/components/schemas/ResolvedQuestionResolution" }
|
- { $ref: "#/components/schemas/ResolvedQuestionResolution" }
|
||||||
@@ -1346,11 +1570,14 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [kind, status, decidedOn, statement, rationale, consequences]
|
required: [kind, status, decidedOn, statement, rationale, consequences]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, const: PROJECT_DECISION }
|
kind: { type: string, enum: [PROJECT_DECISION] }
|
||||||
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
||||||
decidedOn: { type: string, format: date }
|
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
|
||||||
statement: { type: string, minLength: 1, maxLength: 100000 }
|
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
|
||||||
rationale: { type: string, minLength: 1, maxLength: 100000 }
|
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
|
||||||
|
decidedOn: { type: [string, "null"], format: date }
|
||||||
|
statement: { type: string, maxLength: 100000 }
|
||||||
|
rationale: { type: string, maxLength: 100000 }
|
||||||
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
PublicRenderModel:
|
PublicRenderModel:
|
||||||
oneOf:
|
oneOf:
|
||||||
@@ -1645,51 +1872,3 @@ components:
|
|||||||
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
|
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
|
||||||
description: JSON Pointer to the invalid field
|
description: JSON Pointer to the invalid field
|
||||||
message: { type: string, minLength: 1, maxLength: 1000 }
|
message: { type: string, minLength: 1, maxLength: 1000 }
|
||||||
ProblemDetails:
|
|
||||||
type: object
|
|
||||||
additionalProperties: true
|
|
||||||
required: [type, title, status, detail, code]
|
|
||||||
properties:
|
|
||||||
type: { type: string, format: uri-reference }
|
|
||||||
title: { type: string, minLength: 1, maxLength: 200 }
|
|
||||||
status: { type: integer, minimum: 400, maximum: 599 }
|
|
||||||
detail: { type: string, minLength: 1, maxLength: 5000 }
|
|
||||||
code:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- AUTHENTICATION_REQUIRED
|
|
||||||
- STUDIO_ACCESS_DENIED
|
|
||||||
- DOCUMENT_NOT_FOUND
|
|
||||||
- VERSION_CONFLICT
|
|
||||||
- REQUEST_VALIDATION_FAILED
|
|
||||||
- VALIDATION_FAILED
|
|
||||||
- VALIDATION_STALE
|
|
||||||
- PREVIEW_NOT_FOUND
|
|
||||||
- PREVIEW_STALE
|
|
||||||
- PREVIEW_EXPIRED
|
|
||||||
- PUBLICATION_NOT_FOUND
|
|
||||||
- PUBLICATION_CONFLICT
|
|
||||||
- PUBLICATION_EVENT_NOT_FOUND
|
|
||||||
- PUBLICATION_SNAPSHOT_NOT_FOUND
|
|
||||||
- WARNING_ACKNOWLEDGEMENT_REQUIRED
|
|
||||||
- IDEMPOTENCY_KEY_REUSED
|
|
||||||
- ASSET_NOT_FOUND
|
|
||||||
- ASSET_NOT_READY
|
|
||||||
- ASSET_IN_USE
|
|
||||||
- ASSET_QUARANTINED
|
|
||||||
- PAYLOAD_TOO_LARGE
|
|
||||||
- UNSUPPORTED_MEDIA_TYPE
|
|
||||||
- STUDIO_UNAVAILABLE
|
|
||||||
instance: { type: string, format: uri-reference }
|
|
||||||
traceId: { type: string, maxLength: 200 }
|
|
||||||
fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } }
|
|
||||||
latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" }
|
|
||||||
latestPublication: { $ref: "#/components/schemas/PublicationAggregate" }
|
|
||||||
conflictingFields:
|
|
||||||
type: array
|
|
||||||
uniqueItems: true
|
|
||||||
maxItems: 200
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
|
|
||||||
retryable: { type: boolean }
|
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
readOperation("listStudioProjectActivities", `${P}/{id}/activities`, 262_144, byId),
|
||||||
|
writeOperation(
|
||||||
|
"createProjectActivity",
|
||||||
|
"POST",
|
||||||
|
`${P}/{id}/activities`,
|
||||||
|
{ acceptedStatuses: [201], 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(
|
||||||
|
"updateProjectActivity",
|
||||||
|
"PUT",
|
||||||
|
`${P}/{id}/activities/{activityId}`,
|
||||||
|
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
id: string;
|
||||||
|
activityId: string;
|
||||||
|
body: unknown;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: value.body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"deleteProjectActivity",
|
||||||
|
"DELETE",
|
||||||
|
`${P}/{id}/activities/{activityId}`,
|
||||||
|
{
|
||||||
|
acceptedStatuses: [204],
|
||||||
|
emptyBodyStatuses: [204],
|
||||||
|
requestByteLimit: 1_024,
|
||||||
|
responseByteLimit: 1_024,
|
||||||
|
},
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
id: string;
|
||||||
|
activityId: string;
|
||||||
|
expectedVersion: number;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"publishProject",
|
||||||
|
"POST",
|
||||||
|
`${P}/{id}/publish`,
|
||||||
|
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
id: string;
|
||||||
|
expectedVersion: number;
|
||||||
|
visibility: "PUBLIC" | "UNLISTED";
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion, visibility: value.visibility },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"unpublishProject",
|
||||||
|
"POST",
|
||||||
|
`${P}/{id}/unpublish`,
|
||||||
|
{ 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 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
readOperation("getHomeFocus", `${D}/home/focus`, 8_192),
|
||||||
|
writeOperation(
|
||||||
|
"updateHomeFocus",
|
||||||
|
"PUT",
|
||||||
|
`${D}/home/focus`,
|
||||||
|
{ acceptedStatuses: [200], requestByteLimit: 4_096, responseByteLimit: 8_192 },
|
||||||
|
(input: never) =>
|
||||||
|
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
|
||||||
|
),
|
||||||
|
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,10 @@ 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_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_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_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_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }),
|
||||||
|
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
|
||||||
|
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASE_EDIT", path: "/studio/releases/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "릴리즈 편집", navigationLabel: null, navigationOrder: null }),
|
||||||
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
|
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 }),
|
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
|
||||||
] as const;
|
] as const;
|
||||||
@@ -139,7 +143,13 @@ export const TECH_LOG_ROUTE_REGISTRY = Object.freeze(
|
|||||||
spec.routeId,
|
spec.routeId,
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
...spec,
|
...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",
|
loadingSurface: spec.path.endsWith("*") ? "none" : "app-shell",
|
||||||
errorSurface: spec.path.endsWith("*")
|
errorSurface: spec.path.endsWith("*")
|
||||||
? "not-found"
|
? "not-found"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from "../../../contracts/external-contract-runtime.ts";
|
} from "../../../contracts/external-contract-runtime.ts";
|
||||||
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
|
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
|
||||||
import canonicalSource from "./studio/canonical-source.json" with { type: "json" };
|
import canonicalSource from "./studio/canonical-source.json" with { type: "json" };
|
||||||
|
import type { ProblemDetails } from "./studio/contract.ts";
|
||||||
import { STUDIO_ERROR_CODES } from "../adapters/http/studio-error-mapping.ts";
|
import { STUDIO_ERROR_CODES } from "../adapters/http/studio-error-mapping.ts";
|
||||||
import {
|
import {
|
||||||
assertExactlyOneTechLogStudioBootstrapOperation,
|
assertExactlyOneTechLogStudioBootstrapOperation,
|
||||||
@@ -51,21 +52,93 @@ function zodValidator<T>(schemaId: string, schema: z.ZodType<T>): RuntimeValidat
|
|||||||
const passthrough = <T>(schemaId: string) =>
|
const passthrough = <T>(schemaId: string) =>
|
||||||
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>);
|
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>);
|
||||||
|
|
||||||
const problemSchema = z
|
/**
|
||||||
.object({
|
* Shared with the public contribution: both surfaces project their request
|
||||||
// canonical: `format: uri-reference` only, no length bound.
|
* inputs in code, so neither re-validates them at the transport boundary.
|
||||||
type: z.string().min(1),
|
*/
|
||||||
title: z.string().min(1).max(200),
|
export const passthroughInput = passthrough;
|
||||||
status: z.int().min(400).max(599),
|
|
||||||
detail: z.string().min(1).max(5000),
|
/**
|
||||||
code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]),
|
* wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는
|
||||||
})
|
* 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신
|
||||||
|
* 때마다 두 곳을 고치게 만든다. 다만 봉투 자체는 반드시 검증한다: 여기서 통과시키면
|
||||||
|
* 잘못된 모양이 앱 계층까지 조용히 흘러간다.
|
||||||
|
*/
|
||||||
|
const metaSchema = z
|
||||||
|
.object({ requestId: z.string().min(1), traceId: z.string().min(1) })
|
||||||
.loose();
|
.loose();
|
||||||
|
|
||||||
const PROBLEM = zodValidator("StudioProblemDetails", problemSchema);
|
export const envelopeData = <T>(schemaId: string): RuntimeValidator<T> =>
|
||||||
|
zodValidator<T>(
|
||||||
|
schemaId,
|
||||||
|
z
|
||||||
|
.object({ success: z.literal(true), data: z.unknown(), meta: metaSchema })
|
||||||
|
.loose()
|
||||||
|
.transform((envelope) => envelope.data as T) as unknown as z.ZodType<T>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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`)은
|
||||||
|
* 그 모양을 계속 쓰므로 매핑을 여기서 끝내면 아래 계층이 무변경이다.
|
||||||
|
* `status`는 봉투에 없다 — 전송 계층이 실제 HTTP status를 따로 들고 있으므로
|
||||||
|
* 0으로 두고 `toStudioGatewayError`가 outcome의 status로 덮는다.
|
||||||
|
*
|
||||||
|
* (Task 3 fix round 1) `ProblemDetails`는 `contract.ts`에서 가져온다 — 이
|
||||||
|
* transform이 실제로 만드는 모양과 `contract.ts`가 선언하는 모양이 서로 다른
|
||||||
|
* 파일에서 독립적으로 정의되면(원래 상태) 둘이 갈라져도 아무 게이트도 못
|
||||||
|
* 잡는다. `details`는 wire 그대로 통째로 옮긴다 — `fieldErrors`/
|
||||||
|
* `latestDocument`/`conflictingFields`/`latestPublication`으로 분해하지
|
||||||
|
* 않는다. 지금 그 필드들을 평평하게 읽는 소비자가 없고, 분해는 실제 소비자가
|
||||||
|
* 생겼을 때 추가할 투기적 작업이다.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 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>(
|
||||||
|
schemaId,
|
||||||
|
z
|
||||||
|
.object({ success: z.literal(false), error: apiErrorSchema(codes), meta: metaSchema })
|
||||||
|
.loose()
|
||||||
|
.transform((envelope) => ({
|
||||||
|
type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`,
|
||||||
|
title: envelope.error.code,
|
||||||
|
status: 0,
|
||||||
|
detail: envelope.error.message,
|
||||||
|
code: envelope.error.code,
|
||||||
|
retryable: envelope.error.retryable,
|
||||||
|
category: envelope.error.category,
|
||||||
|
details: (envelope.error as { details?: ProblemDetails["details"] }).details ?? null,
|
||||||
|
})) as unknown as z.ZodType<ProblemDetails>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const PROBLEM = envelopeError();
|
||||||
|
|
||||||
/** 4xx 도메인 거절은 적용되지 않았음이 확정이다. 5xx/네트워크는 불확정이다. */
|
/** 4xx 도메인 거절은 적용되지 않았음이 확정이다. 5xx/네트워크는 불확정이다. */
|
||||||
const COMMAND_EFFECT: CommandEffectDescriptor<z.output<typeof problemSchema>> =
|
const COMMAND_EFFECT: CommandEffectDescriptor<ProblemDetails> =
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
successEffect: "APPLIED_CONFIRMED" as const,
|
successEffect: "APPLIED_CONFIRMED" as const,
|
||||||
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
|
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
|
||||||
@@ -93,7 +166,7 @@ function safeOperation(
|
|||||||
method: "GET" as const,
|
method: "GET" as const,
|
||||||
pathTemplate,
|
pathTemplate,
|
||||||
inputValidator: passthrough(`${operationId}Input`),
|
inputValidator: passthrough(`${operationId}Input`),
|
||||||
outputValidator: passthrough(`${operationId}Output`),
|
outputValidator: envelopeData(`${operationId}Output`),
|
||||||
problemValidator: PROBLEM,
|
problemValidator: PROBLEM,
|
||||||
acceptedStatuses: Object.freeze([200]),
|
acceptedStatuses: Object.freeze([200]),
|
||||||
emptyBodyStatuses: Object.freeze([]),
|
emptyBodyStatuses: Object.freeze([]),
|
||||||
@@ -141,7 +214,7 @@ function keyedOperation(
|
|||||||
method,
|
method,
|
||||||
pathTemplate,
|
pathTemplate,
|
||||||
inputValidator: passthrough(`${operationId}Input`),
|
inputValidator: passthrough(`${operationId}Input`),
|
||||||
outputValidator: passthrough(`${operationId}Output`),
|
outputValidator: envelopeData(`${operationId}Output`),
|
||||||
problemValidator: PROBLEM,
|
problemValidator: PROBLEM,
|
||||||
acceptedStatuses: Object.freeze([options.acceptedStatus]),
|
acceptedStatuses: Object.freeze([options.acceptedStatus]),
|
||||||
emptyBodyStatuses: Object.freeze(options.acceptedStatus === 204 ? [204] : []),
|
emptyBodyStatuses: Object.freeze(options.acceptedStatus === 204 ? [204] : []),
|
||||||
|
|||||||
@@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string {
|
|||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
`:::name key="value"` 를 remark-directive 가 읽는 `:::name{key="value"}` 로 바꾼다.
|
||||||
|
|
||||||
|
이름과 나머지 사이의 경계를 `\s` 로 못 박는 것이 중요하다. 예전에는 이름을
|
||||||
|
`[a-z0-9-]*` 로 두고 나머지가 `{` 로 시작하지 않기만 요구했는데, 정규식이 되돌아가며
|
||||||
|
이름의 마지막 글자를 나머지 쪽으로 넘겨 그 조건을 피해 갔다 — `:::note` 는 이름 `not`
|
||||||
|
에 본문 `e` 가 되어 `:::not{e}` 로, 이미 중괄호를 쓴 `:::table{id="t"}` 는
|
||||||
|
`:::tabl{e{id="t"}}` 로 망가졌다. 그래서 속성 없는 디렉티브는 이름이 통째로 바뀌고,
|
||||||
|
중괄호 형태는 아예 해석되지 않았다.
|
||||||
|
*/
|
||||||
return line.replace(
|
return line.replace(
|
||||||
/^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i,
|
/^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i,
|
||||||
(
|
(
|
||||||
_match,
|
_match,
|
||||||
marker: string,
|
marker: string,
|
||||||
attributes: string,
|
attributes: string,
|
||||||
newline: string,
|
newline: string,
|
||||||
) => `${marker}{${attributes.trim()}}${newline}`,
|
) => {
|
||||||
|
const trimmed = attributes.trim();
|
||||||
|
return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
@@ -199,6 +212,14 @@ function assertNever(value: never): never {
|
|||||||
|
|
||||||
const trustedRelativeLinkOrigin = "https://techlog.invalid";
|
const trustedRelativeLinkOrigin = "https://techlog.invalid";
|
||||||
|
|
||||||
|
/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */
|
||||||
|
const CALLOUT_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
note: "참고",
|
||||||
|
tip: "도움말",
|
||||||
|
warning: "주의",
|
||||||
|
danger: "위험",
|
||||||
|
};
|
||||||
|
|
||||||
function hasAsciiControlCharacter(value: string): boolean {
|
function hasAsciiControlCharacter(value: string): boolean {
|
||||||
return Array.from(value).some((character) => {
|
return Array.from(value).some((character) => {
|
||||||
const codePoint = character.codePointAt(0) ?? 0;
|
const codePoint = character.codePointAt(0) ?? 0;
|
||||||
@@ -326,8 +347,8 @@ function headingBlock(
|
|||||||
node: Heading,
|
node: Heading,
|
||||||
usedIds: Set<string>,
|
usedIds: Set<string>,
|
||||||
): components["schemas"]["HeadingBlock"] {
|
): components["schemas"]["HeadingBlock"] {
|
||||||
if (node.depth < 2 || node.depth > 4) {
|
if (node.depth < 1 || node.depth > 6) {
|
||||||
invalid(node, "only heading levels 2 through 4 are supported");
|
invalid(node, "only heading levels 1 through 6 are supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = [...node.children];
|
const children = [...node.children];
|
||||||
@@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] {
|
|||||||
return inlineFromNodes(cell.children);
|
return inlineFromNodes(cell.children);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `:::table` 없이 쓴 GFM 표에 붙일 값.
|
||||||
|
*
|
||||||
|
* <p>서버 렌더러는 파이프 표를 그대로 읽는다 — `:::table` 이라는 directive 자체를 모른다.
|
||||||
|
* 여기서만 감싸기를 요구하면 같은 본문이 Studio 와 공개 화면에서 다르게 읽히므로, 감싸지 않은
|
||||||
|
* 표도 받는다. `id` 는 자리 순서로 만들고 `caption` 은 비운다. 설명이 필요하면 `:::table` 로
|
||||||
|
* 감싸 `caption` 을 주면 된다.
|
||||||
|
*/
|
||||||
|
function bareTableAttributes(index: number): Record<string, string> {
|
||||||
|
return { id: `table-${index}`, caption: "", rowHeaderColumn: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
function tableBlock(
|
function tableBlock(
|
||||||
node: ContainerDirective,
|
node: ContainerDirective | Table,
|
||||||
usedIds: Set<string>,
|
usedIds: Set<string>,
|
||||||
|
bareIndex?: number,
|
||||||
): components["schemas"]["DataTableBlock"] {
|
): components["schemas"]["DataTableBlock"] {
|
||||||
const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]);
|
const bare = node.type === "table";
|
||||||
if (node.children.length !== 1 || node.children[0].type !== "table") {
|
const attributes = bare
|
||||||
invalid(node, "table directive must contain exactly one GFM table");
|
? bareTableAttributes(bareIndex!)
|
||||||
|
: attributesOf(node as ContainerDirective, ["id", "caption", "rowHeaderColumn"]);
|
||||||
|
if (!bare) {
|
||||||
|
const container = node as ContainerDirective;
|
||||||
|
if (container.children.length !== 1 || container.children[0].type !== "table") {
|
||||||
|
invalid(node, "table directive must contain exactly one GFM table");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const table = node.children[0] as Table;
|
const table = (bare ? node : (node as ContainerDirective).children[0]) as Table;
|
||||||
if (table.children.length === 0) invalid(table, "table header is required");
|
if (table.children.length === 0) invalid(table, "table header is required");
|
||||||
|
|
||||||
const id = attributes.id;
|
const id = attributes.id;
|
||||||
@@ -506,6 +546,25 @@ function directiveBlock(
|
|||||||
content: inlineFromNodes((node.children[0] as Paragraph).children),
|
content: inlineFromNodes((node.children[0] as Paragraph).children),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
서버 렌더러가 아는 callout 이름이다(`note`/`tip` 은 정보, `warning`/`danger` 는 경고).
|
||||||
|
`:::callout tone="..."` 만 받으면 서버가 정상으로 읽는 본문을 여기서 거절하게 되므로 둘 다
|
||||||
|
받는다. 이름이 곧 tone 이라 속성이 없고, 라벨은 이름에서 만든다.
|
||||||
|
*/
|
||||||
|
case "note":
|
||||||
|
case "tip":
|
||||||
|
case "warning":
|
||||||
|
case "danger": {
|
||||||
|
if (node.children.length !== 1 || node.children[0].type !== "paragraph") {
|
||||||
|
invalid(node, `${node.name} directive must contain exactly one paragraph`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "CALLOUT",
|
||||||
|
tone: node.name === "note" || node.name === "tip" ? "info" : "warning",
|
||||||
|
label: CALLOUT_LABELS[node.name],
|
||||||
|
content: inlineFromNodes((node.children[0] as Paragraph).children),
|
||||||
|
};
|
||||||
|
}
|
||||||
case "evidence": {
|
case "evidence": {
|
||||||
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
|
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
|
||||||
if (node.children.length !== 0) {
|
if (node.children.length !== 0) {
|
||||||
@@ -533,10 +592,34 @@ function directiveBlock(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
|
||||||
|
*
|
||||||
|
* <p>Markdown 에서 `` 는 문단 안의 inline 이다. 인라인 유니온에는 그림이 없고
|
||||||
|
* 앞으로도 둘 이유가 없다 — 글 가운데 끼워 넣는 그림은 이 문서 형식이 다루는 대상이 아니다.
|
||||||
|
* 그래서 "문단이 그림 하나로만 이루어진 경우"만 블록으로 올린다.
|
||||||
|
*/
|
||||||
|
function imageBlockOf(node: Paragraph): components["schemas"]["ImageBlock"] | null {
|
||||||
|
const visible = node.children.filter(
|
||||||
|
(child) => !(child.type === "text" && child.value.trim() === ""),
|
||||||
|
);
|
||||||
|
if (visible.length !== 1) return null;
|
||||||
|
const only = visible[0]!;
|
||||||
|
if (only.type !== "image") return null;
|
||||||
|
const image = only as unknown as { url: string; alt?: string | null; title?: string | null };
|
||||||
|
if (!isSafeLink(image.url)) invalid(node, `unsafe image URL: ${image.url}`);
|
||||||
|
return {
|
||||||
|
type: "IMAGE",
|
||||||
|
src: image.url,
|
||||||
|
alt: image.alt ?? "",
|
||||||
|
title: image.title ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function paragraphBlock(
|
function paragraphBlock(
|
||||||
node: Paragraph,
|
node: Paragraph,
|
||||||
): components["schemas"]["ParagraphBlock"] {
|
): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] {
|
||||||
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
|
return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||||
@@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
|||||||
listItemCount += 1;
|
listItemCount += 1;
|
||||||
return `list-item-${listItemCount}`;
|
return `list-item-${listItemCount}`;
|
||||||
};
|
};
|
||||||
|
let bareTableCount = 0;
|
||||||
|
|
||||||
return tree.children.map((node: Content): CaseAuthoringBlock => {
|
return tree.children.map((node: Content): CaseAuthoringBlock => {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
@@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
|||||||
return codeBlock(node);
|
return codeBlock(node);
|
||||||
case "containerDirective":
|
case "containerDirective":
|
||||||
return directiveBlock(node, usedIds);
|
return directiveBlock(node, usedIds);
|
||||||
case "html":
|
case "table": {
|
||||||
|
// 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와
|
||||||
|
// 공개 화면에서 다르게 읽힌다.
|
||||||
|
bareTableCount += 1;
|
||||||
|
return tableBlock(node, usedIds, bareTableCount);
|
||||||
|
}
|
||||||
case "thematicBreak":
|
case "thematicBreak":
|
||||||
|
return { type: "THEMATIC_BREAK" };
|
||||||
|
case "html":
|
||||||
case "definition":
|
case "definition":
|
||||||
case "yaml":
|
case "yaml":
|
||||||
case "table":
|
|
||||||
case "footnoteDefinition":
|
case "footnoteDefinition":
|
||||||
case "leafDirective":
|
case "leafDirective":
|
||||||
return invalid(node, `unsupported block syntax: ${node.type}`);
|
return invalid(node, `unsupported block syntax: ${node.type}`);
|
||||||
|
|||||||
@@ -131,7 +131,18 @@ function renderContext(
|
|||||||
|
|
||||||
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
|
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
|
||||||
if (input.kind === "PROJECT_DECISION") {
|
if (input.kind === "PROJECT_DECISION") {
|
||||||
if (!project?.publicPath) fail("PROJECT public path is required");
|
/*
|
||||||
|
Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 프로젝트가
|
||||||
|
공개되어 있지 않으면 이 문서에는 아직 주소가 없다.
|
||||||
|
|
||||||
|
예전 문구는 "PROJECT public path is required" 였다. 사실이지만 작성자가 할 일을 말해 주지
|
||||||
|
않는다 — 무엇을 어디서 눌러야 하는지 적는다.
|
||||||
|
*/
|
||||||
|
if (!project?.publicPath) {
|
||||||
|
fail(
|
||||||
|
"이 Decision 의 공개 주소는 프로젝트 주소 아래에 있습니다. 주제·프로젝트 화면에서 이 프로젝트를 먼저 게시해 주세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
return `${project.publicPath}/decisions#${input.slug}`;
|
return `${project.publicPath}/decisions#${input.slug}`;
|
||||||
}
|
}
|
||||||
const prefix =
|
const prefix =
|
||||||
@@ -186,7 +197,7 @@ export function projectWorkingCopy(
|
|||||||
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
|
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
|
||||||
if (!topic) fail("TOPIC catalog entry is required");
|
if (!topic) fail("TOPIC catalog entry is required");
|
||||||
if (input.kind === "PROJECT_DECISION" && !project) {
|
if (input.kind === "PROJECT_DECISION" && !project) {
|
||||||
fail("PROJECT catalog entry is required");
|
fail("Decision 은 프로젝트에 속합니다. 기본 정보에서 프로젝트를 골라 주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
@@ -273,12 +284,16 @@ export function projectWorkingCopy(
|
|||||||
|
|
||||||
case "PROJECT_DECISION":
|
case "PROJECT_DECISION":
|
||||||
if (!input.decisionStatus) fail("Decision status is required");
|
if (!input.decisionStatus) fail("Decision status is required");
|
||||||
if (!input.decidedOn) fail("Decision date is required");
|
/*
|
||||||
|
결정일은 비어 있어도 모델을 만든다. 검증이 그것을 경고로만 다루므로 날짜 없이 게시할 수
|
||||||
|
있는데, 여기서 막으면 그 문서는 미리보기조차 열리지 않는다 — 작성자는 "경고라면서 왜
|
||||||
|
안 되냐"를 만난다. 화면이 "미정"이라고 말하면 된다.
|
||||||
|
*/
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
kind: "PROJECT_DECISION",
|
kind: "PROJECT_DECISION",
|
||||||
status: input.decisionStatus,
|
status: input.decisionStatus,
|
||||||
decidedOn: input.decidedOn,
|
decidedOn: input.decidedOn ?? null,
|
||||||
statement: input.statement,
|
statement: input.statement,
|
||||||
rationale: input.rationale,
|
rationale: input.rationale,
|
||||||
consequences: ordered(input.consequences),
|
consequences: ordered(input.consequences),
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ function serializeBlock(block: CaseRenderBlock): string {
|
|||||||
`:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
|
`:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
|
||||||
":::",
|
":::",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
case "THEMATIC_BREAK":
|
||||||
|
return "---";
|
||||||
|
case "IMAGE":
|
||||||
|
// 제목은 Markdown 이 따옴표로 감싼다. 없으면 붙이지 않아야 다시 읽었을 때 빈 제목이 되지
|
||||||
|
// 않는다.
|
||||||
|
return block.title === null
|
||||||
|
? ``
|
||||||
|
: `}")`;
|
||||||
default:
|
default:
|
||||||
return assertNever(block);
|
return assertNever(block);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,28 @@ export function createLocalId(
|
|||||||
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
|
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
|
||||||
return `${prefix}-${now()}-${entropy}`;
|
return `${prefix}-${now()}-${entropy}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 편집기가 새로 만든 목록 항목(관계·근거·규칙·선택지·순서 있는 문장)의 id.
|
||||||
|
*
|
||||||
|
* `createLocalId` 와 나눠 두는 이유는 이 값이 화면 밖으로 나가기 때문이다. 그쪽은 접두사를 붙여
|
||||||
|
* `relation-<uuid>` 같은 문자열을 만들고, 그건 React key 나 Idempotency-Key 로는 좋지만 계약에는
|
||||||
|
* 넣을 수 없다 — 계약이 요구하는 형식은 uuid 이고, 접두사가 붙은 값은 서버가 파싱조차 하지 못해
|
||||||
|
* 400 (`InvalidFormatException`) 이 된다. 저장 버튼이 "계약을 어겼다"는 말만 남기고 아무것도 저장하지
|
||||||
|
* 않던 이유가 이것이었다.
|
||||||
|
*
|
||||||
|
* 서버는 자기가 소유하지 않은 id 를 신뢰하지 않고 새로 부여한다({@code StudioRelationStore.replace}).
|
||||||
|
* 그래서 여기서 만드는 값은 "이 줄은 새것"이라는 표시일 뿐, 저장 뒤의 진짜 id 는 서버가 정한다.
|
||||||
|
*/
|
||||||
|
export function createNewItemId(
|
||||||
|
source: RandomUuidSource | null | undefined = globalThis.crypto,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
): string {
|
||||||
|
const uuid = source?.randomUUID?.();
|
||||||
|
if (uuid) return uuid;
|
||||||
|
// randomUUID 가 없는 환경(비보안 컨텍스트)을 위한 대비. 형식만 uuid v4 를 지키면 된다 — 값 자체는
|
||||||
|
// 서버가 어차피 새로 부여한다.
|
||||||
|
const hex = (length: number) =>
|
||||||
|
Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join("");
|
||||||
|
return `${hex(8)}-${hex(4)}-4${hex(3)}-${"89ab"[Math.floor(random() * 4)]}${hex(3)}-${hex(12)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -180,7 +180,42 @@ function resolvePublicEvidenceAssetDescriptor(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시된 본문을 블록으로 바꾼다.
|
||||||
|
*
|
||||||
|
* <p>예전에는 하드코딩된 슬러그 하나만 진짜 파서를 탔고 나머지는 모두 {@link genericCaseBlocks}
|
||||||
|
* 를 거쳤다 — 정규식이 `##` 제목과 `-` 불릿만 알아보므로 표·코드·callout 은 물론 evidence
|
||||||
|
* directive 까지 글자 그대로 문단이 되어 공개 화면에 그대로 보였다.
|
||||||
|
*
|
||||||
|
* <p>본문이 지원하지 않는 문법을 담고 있으면 화면 전체를 잃는 대신 예전 방식으로 돌아간다.
|
||||||
|
* 읽는 사람에게는 덜 정확한 화면이 빈 화면보다 낫다.
|
||||||
|
*/
|
||||||
|
function caseBodyBlocks(record: CaseRecord): CaseAuthoringBlock[] {
|
||||||
|
if (record.slug === "collection-fetch-join-pagination") {
|
||||||
|
return parseCaseContent(fetchJoinBody);
|
||||||
|
}
|
||||||
|
if (!record.content.trim()) return genericCaseBlocks(record);
|
||||||
|
try {
|
||||||
|
return parseCaseContent(record.content);
|
||||||
|
} catch {
|
||||||
|
return genericCaseBlocks(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function CaseDocumentPage({ record }: { record: CaseRecord }) {
|
export function CaseDocumentPage({ record }: { record: CaseRecord }) {
|
||||||
|
/*
|
||||||
|
본문은 evidence 를 key 로만 가리키고 `/media/{assetId}` 는 UUID 로만 서빙하므로, 계약이 함께
|
||||||
|
준 `bodyAssets` 로 key 를 주소로 바꾼다. 대응이 없는 key 는 그 블록을 지운다 — 해석기가
|
||||||
|
던지면 문서 전체가 사라지고, 남겨 두면 주소 없는 그림 자리가 남는다.
|
||||||
|
*/
|
||||||
|
const assetsByKey = new Map(record.bodyAssets.map((asset) => [asset.assetKey, asset]));
|
||||||
|
const blocks = caseBodyBlocks(record).filter(
|
||||||
|
(block) =>
|
||||||
|
block.type !== "EVIDENCE_FIGURE" ||
|
||||||
|
record.slug === "collection-fetch-join-pagination" ||
|
||||||
|
assetsByKey.has(block.key),
|
||||||
|
);
|
||||||
|
|
||||||
const model = resolveCaseEvidenceAssets(
|
const model = resolveCaseEvidenceAssets(
|
||||||
{
|
{
|
||||||
...publicRenderModelBase(record),
|
...publicRenderModelBase(record),
|
||||||
@@ -190,18 +225,37 @@ export function CaseDocumentPage({ record }: { record: CaseRecord }) {
|
|||||||
environment: record.environment,
|
environment: record.environment,
|
||||||
reproduction: record.verification,
|
reproduction: record.verification,
|
||||||
lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"),
|
lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"),
|
||||||
bodyBlocks:
|
bodyBlocks: blocks,
|
||||||
record.slug === "collection-fetch-join-pagination"
|
},
|
||||||
? parseCaseContent(fetchJoinBody)
|
(key) => {
|
||||||
: genericCaseBlocks(record),
|
const asset = assetsByKey.get(key);
|
||||||
|
if (!asset) return resolvePublicEvidenceAssetDescriptor(key);
|
||||||
|
return {
|
||||||
|
assetId: asset.assetId,
|
||||||
|
assetKey: asset.assetKey,
|
||||||
|
mediaType: asset.contentType,
|
||||||
|
publicPath: asset.url,
|
||||||
|
width: asset.width,
|
||||||
|
height: asset.height,
|
||||||
|
decorative: asset.decorative,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
resolvePublicEvidenceAssetDescriptor,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicRecordRenderer
|
<PublicRecordRenderer
|
||||||
model={model}
|
model={model}
|
||||||
resolveEvidenceAsset={resolvePublicEvidenceAsset}
|
resolveEvidenceAsset={(key) => {
|
||||||
|
const asset = assetsByKey.get(key);
|
||||||
|
if (!asset) return resolvePublicEvidenceAsset(key);
|
||||||
|
return {
|
||||||
|
src: asset.url,
|
||||||
|
width: asset.width ?? 0,
|
||||||
|
height: asset.height ?? 0,
|
||||||
|
triggerLabel: `${asset.altText || asset.assetKey} 크게 보기`,
|
||||||
|
dialogLabel: asset.altText || asset.assetKey,
|
||||||
|
};
|
||||||
|
}}
|
||||||
resolvePublishedLabel={(path) =>
|
resolvePublishedLabel={(path) =>
|
||||||
path === record.path ? record.publishedLabel : undefined
|
path === record.path ? record.publishedLabel : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
|
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
|
|
||||||
export function ExploreFilterForm({
|
export function ExploreFilterForm({
|
||||||
action,
|
action,
|
||||||
@@ -18,24 +17,51 @@ export function ExploreFilterForm({
|
|||||||
showType?: boolean;
|
showType?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
// This form sits inside a page that renders its own loading state, so it does
|
||||||
const publicRecords = publicContent.listRecords();
|
// not hand back a fallback of its own — that would put a second skeleton
|
||||||
const topics = [...new Set(publicRecords.map((record) => record.topic))].sort();
|
// inside a screen already showing one, and move the layout under it. It
|
||||||
const projectPrefix = "/projects/";
|
// renders its real structure immediately with empty option lists and fills
|
||||||
const projects = publicContent
|
// them in when the catalog arrives.
|
||||||
.searchPublicContent("")
|
const view = usePublicContent(["tech-log", "explore-filters"], async (queries) => {
|
||||||
.filter((entity) => entity.contentType === "PROJECT")
|
const projectPrefix = "/projects/";
|
||||||
.flatMap((entity) => {
|
const [records, entities] = await Promise.all([
|
||||||
if (!entity.path.startsWith(projectPrefix)) return [];
|
queries.listRecords(),
|
||||||
const item = publicContent.getProject(
|
queries.searchPublicContent(""),
|
||||||
decodeURIComponent(entity.path.slice(projectPrefix.length)),
|
]);
|
||||||
|
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 {
|
||||||
|
// 주제는 이름이 아니라 slug 로 거른다 — 프로젝트와 같다. 이름을 실었을 때는
|
||||||
|
// `topic=OAuth/OIDC 인증 경계` 가 나갔고, slug 로 거르는 API 는 0건을 돌려줬다.
|
||||||
|
// 화면에 보일 이름과 보낼 slug 가 다르므로 짝으로 들고 있어야 한다.
|
||||||
|
topics: [
|
||||||
|
...new Map(
|
||||||
|
records
|
||||||
|
.filter((record) => record.topicSlug)
|
||||||
|
.map((record) => [record.topicSlug, record.topic] as const),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map(([slug, name]) => ({ slug, name }))
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name, "ko-KR")),
|
||||||
|
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 normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
|
||||||
const selectedTopic = topics.find(
|
const selectedTopic = topics.find(
|
||||||
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
(item) =>
|
||||||
);
|
item.slug.toLocaleLowerCase("ko-KR") === normalizedTopic ||
|
||||||
|
item.name.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
||||||
|
)?.slug;
|
||||||
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
|
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
|
||||||
const selectedProject = projects.find(
|
const selectedProject = projects.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -43,14 +69,18 @@ export function ExploreFilterForm({
|
|||||||
item.title.toLocaleLowerCase("ko-KR") === normalizedProject,
|
item.title.toLocaleLowerCase("ko-KR") === normalizedProject,
|
||||||
)?.slug;
|
)?.slug;
|
||||||
const hasActiveFilter = Boolean((showType && kind) || topic || project);
|
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>) {
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const data = new FormData(event.currentTarget);
|
const data = new FormData(event.currentTarget);
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of data) {
|
for (const [key, value] of data) {
|
||||||
if (typeof value === "string") search.append(key, value);
|
if (typeof value === "string" && value !== "") search.append(key, value);
|
||||||
}
|
}
|
||||||
void navigate(`${action}?${search.toString()}`);
|
void navigate(`${action}?${search.toString()}`);
|
||||||
}
|
}
|
||||||
@@ -66,7 +96,7 @@ export function ExploreFilterForm({
|
|||||||
{showType ? (
|
{showType ? (
|
||||||
<label><span>유형</span><select name="type" defaultValue={kind ?? ""}><option value="">전체</option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
|
<label><span>유형</span><select name="type" defaultValue={kind ?? ""}><option value="">전체</option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
|
||||||
) : null}
|
) : null}
|
||||||
<label><span>주제</span><select name="topic" defaultValue={selectedTopic ?? ""}><option value="">전체</option>{topics.map((item) => <option key={item}>{item}</option>)}</select></label>
|
<label><span>주제</span><select name="topic" defaultValue={selectedTopic ?? ""}><option value="">전체</option>{topics.map((item) => <option value={item.slug} key={item.slug}>{item.name}</option>)}</select></label>
|
||||||
<label><span>프로젝트</span><select name="project" defaultValue={selectedProject ?? ""}><option value="">전체</option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
|
<label><span>프로젝트</span><select name="project" defaultValue={selectedProject ?? ""}><option value="">전체</option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
|
||||||
<button type="submit">적용</button>
|
<button type="submit">적용</button>
|
||||||
{hasActiveFilter ? <Link to={action}>필터 초기화</Link> : null}
|
{hasActiveFilter ? <Link to={action}>필터 초기화</Link> : null}
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export function PublicDocumentHeader({ record }: { record: PublicRecord }) {
|
|||||||
{kindLabels[record.kind]}
|
{kindLabels[record.kind]}
|
||||||
</Link>
|
</Link>
|
||||||
<span aria-hidden="true">/</span>
|
<span aria-hidden="true">/</span>
|
||||||
<Link to={`/topics/${record.topicSlug}`}>{record.topic}</Link>
|
{/*
|
||||||
|
주제 페이지가 아니라 그 주제로 거른 탐색으로 보낸다. `/topics/:slug` 는 세 개를
|
||||||
|
하드코딩해 두고 있어 실제 주제는 무엇이든 404 가 되고, 설명·범위·대표 기록이 전부
|
||||||
|
비어 있어 지금 채울 내용도 없다. 독자가 여기서 기대하는 것 — 같은 주제의 기록 목록 —
|
||||||
|
은 탐색 필터가 그대로 준다.
|
||||||
|
*/}
|
||||||
|
<Link to={`/explore?topic=${encodeURIComponent(record.topicSlug)}`}>{record.topic}</Link>
|
||||||
<span aria-hidden="true">/</span>
|
<span aria-hidden="true">/</span>
|
||||||
<Link to={`/projects/${record.projectSlug}`}>
|
<Link to={`/projects/${record.projectSlug}`}>
|
||||||
{record.projectTitle}
|
{record.projectTitle}
|
||||||
@@ -57,7 +63,9 @@ export function publicRenderModelBase(
|
|||||||
topic: {
|
topic: {
|
||||||
id: `topic-${record.topicSlug}`,
|
id: `topic-${record.topicSlug}`,
|
||||||
label: record.topic,
|
label: record.topic,
|
||||||
publicPath: `/topics/${record.topicSlug}`,
|
// 공개 문서의 머리말이 실제로 그리는 주제 링크는 이 값이다 — 위의 breadcrumb 과 같은
|
||||||
|
// 이유로 탐색 필터를 가리킨다. 둘 중 하나만 고치면 화면에서는 그대로 404 로 간다.
|
||||||
|
publicPath: `/explore?topic=${encodeURIComponent(record.topicSlug)}`,
|
||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
id: `project-${record.projectSlug}`,
|
id: `project-${record.projectSlug}`,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useId, useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
|
|
||||||
type SearchDialogProps = {
|
type SearchDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -19,8 +18,21 @@ export function SearchDialog({
|
|||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR");
|
const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR");
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
// Keyed on the empty query, then filtered here, rather than one request per
|
||||||
const results = publicContent.searchPublicContent(normalizedQuery);
|
// 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() {
|
function open() {
|
||||||
onBeforeOpen?.();
|
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 {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { CaseDocumentPage } from "../components/case-document-page.tsx";
|
import { CaseDocumentPage } from "../components/case-document-page.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
function optionalString(value: unknown): string | undefined {
|
function optionalString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
@@ -14,9 +13,12 @@ export function CasePage() {
|
|||||||
const { params, search } = useRouteInput<"TECH_LOG_CASE">();
|
const { params, search } = useRouteInput<"TECH_LOG_CASE">();
|
||||||
const slug = optionalString(params.slug);
|
const slug = optionalString(params.slug);
|
||||||
const requestedState = optionalString(search.state);
|
const requestedState = optionalString(search.state);
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "case", slug], async (queries) => ({
|
||||||
const record = slug ? publicContent.getRecord("CASE", slug) : undefined;
|
record: slug ? await queries.getRecord("CASE", slug) : undefined,
|
||||||
|
}));
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { record } = view.data;
|
||||||
if (!record) return <RegisteredNotFoundRoute />;
|
if (!record) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Link } from "react-router-dom";
|
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 {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
||||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
const kinds = {
|
const kinds = {
|
||||||
cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." },
|
cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." },
|
||||||
@@ -28,18 +27,29 @@ function getKindConfig(value: string | undefined) {
|
|||||||
|
|
||||||
export function ExploreKindPage() {
|
export function ExploreKindPage() {
|
||||||
const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">();
|
const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">();
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
|
||||||
const kind = optionalString(params.kind);
|
const kind = optionalString(params.kind);
|
||||||
const config = getKindConfig(kind);
|
const config = getKindConfig(kind);
|
||||||
if (!config) return <RegisteredNotFoundRoute />;
|
|
||||||
const topic = optionalString(search.topic);
|
const topic = optionalString(search.topic);
|
||||||
const project = optionalString(search.project);
|
const project = optionalString(search.project);
|
||||||
const records = publicContent.listRecords({
|
// The unknown-kind check reads as an early return, but it cannot come before
|
||||||
kind: config.kind,
|
// the query: hooks run unconditionally or React loses the call order. The
|
||||||
...(topic ? { topic } : {}),
|
// loader short-circuits instead, and the not-found route is chosen below.
|
||||||
...(project ? { project } : {}),
|
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">
|
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>
|
<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} />
|
<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 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 { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
||||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
function optionalString(value: unknown): string | undefined {
|
function optionalString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
@@ -17,13 +16,19 @@ export function ExplorePage() {
|
|||||||
const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find(
|
const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find(
|
||||||
(item) => item === requestedKind,
|
(item) => item === requestedKind,
|
||||||
) satisfies RecordKind | undefined;
|
) satisfies RecordKind | undefined;
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(
|
||||||
const records = publicContent.listRecords({
|
["tech-log", "explore", kind, topic, project],
|
||||||
...(kind ? { kind } : {}),
|
async (queries) => ({
|
||||||
...(topic ? { topic } : {}),
|
records: await queries.listRecords({
|
||||||
...(project ? { project } : {}),
|
...(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">
|
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>
|
<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} />
|
<ExploreFilterForm action="/explore" kind={kind} topic={topic} project={project} />
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts";
|
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 { publicSiteConfig } from "../../../contracts/public-site-config.ts";
|
||||||
import { normalizeFocus } from "../../../domain/public/focus-state.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 { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { FatalErrorState } from "../components/fatal-error-state.tsx";
|
import { FatalErrorState } from "../components/fatal-error-state.tsx";
|
||||||
import { HomeFocus } from "../components/home-focus.tsx";
|
import { HomeFocus } from "../components/home-focus.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -40,81 +39,99 @@ function optionalString(value: unknown): string | undefined {
|
|||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
|
/**
|
||||||
const publicRecords = publicContent.listRecords();
|
* 서버가 고른 최근 기록에 릴리스를 얹는다.
|
||||||
const publicRecordByPath = new Map(
|
*
|
||||||
publicRecords.map((record) => [record.path, record]),
|
* 예전에는 이 함수가 공개된 프로젝트를 하나씩 돌며 활동을 모아 타임라인을 만들었다. 그래서 게시된
|
||||||
);
|
* 문서라도 그 문서가 매달린 프로젝트가 공개되어 있지 않으면 홈에서 통째로 사라졌다 — 실제로 Case 를
|
||||||
const searchableEntities = publicContent.searchPublicContent("");
|
* 게시했는데 홈에는 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을
|
||||||
const projectPrefix = "/projects/";
|
* 그대로 읽는다. 프로젝트마다 요청을 하나씩 보내던 N+1 도 같이 사라진다.
|
||||||
const projectSlugs = searchableEntities
|
*
|
||||||
.filter((entity) => entity.contentType === "PROJECT")
|
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
|
||||||
.flatMap((entity) =>
|
* 시간순으로 합친다.
|
||||||
entity.path.startsWith(projectPrefix)
|
*/
|
||||||
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
|
const latestTypeLabels: Readonly<Record<string, string>> = {
|
||||||
: [],
|
PROJECT_ACTIVITY: "PROJECT ACTIVITY",
|
||||||
);
|
QUESTION: "OPEN QUESTION",
|
||||||
const projectTimeline = projectSlugs.flatMap((projectSlug) => {
|
};
|
||||||
const project = publicContent.getProject(projectSlug);
|
|
||||||
if (!project) return [];
|
|
||||||
return publicContent.getProjectActivity(projectSlug).map((activity) => {
|
|
||||||
const record = publicRecordByPath.get(
|
|
||||||
activity.recordPath ?? activity.path,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
id: activity.id,
|
|
||||||
typeLabel:
|
|
||||||
activity.type === "PUBLICATION" && record
|
|
||||||
? record.kind
|
|
||||||
: "PROJECT ACTIVITY",
|
|
||||||
title:
|
|
||||||
activity.type === "PUBLICATION" && record
|
|
||||||
? record.title
|
|
||||||
: activity.title,
|
|
||||||
summary: activity.summary,
|
|
||||||
date: activity.date,
|
|
||||||
dateTime: activity.dateTime,
|
|
||||||
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 [
|
|
||||||
{
|
|
||||||
id: `release-${release.version}`,
|
|
||||||
typeLabel: "RELEASE",
|
|
||||||
title: release.title,
|
|
||||||
summary: release.summary,
|
|
||||||
date: release.publishedLabel,
|
|
||||||
dateTime: release.publishedAt,
|
|
||||||
topic: "TechLog",
|
|
||||||
project: "TechLog",
|
|
||||||
path: release.path,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
|
async function getLatestEntries(
|
||||||
|
publicContent: PublicContentQueries,
|
||||||
|
): Promise<LatestEntry[]> {
|
||||||
|
const [records, searchableEntities] = await Promise.all([
|
||||||
|
publicContent.getLatestEntries(),
|
||||||
|
publicContent.searchPublicContent(""),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const recordTimeline: LatestEntry[] = records.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
// 목록의 다른 이름들과 같은 자리에 놓이므로 표기도 같은 규칙을 쓴다 — 대문자에 공백.
|
||||||
|
typeLabel: latestTypeLabels[entry.entryType] ?? entry.entryType,
|
||||||
|
title: entry.title,
|
||||||
|
summary: entry.summary,
|
||||||
|
date: dateLabel(entry.publishedAt),
|
||||||
|
dateTime: entry.publishedAt,
|
||||||
|
topic: entry.topic,
|
||||||
|
project: entry.project,
|
||||||
|
path: entry.path,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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,
|
||||||
|
summary: release.summary,
|
||||||
|
date: release.publishedLabel,
|
||||||
|
dateTime: release.publishedAt,
|
||||||
|
topic: "TechLog",
|
||||||
|
project: "TechLog",
|
||||||
|
path: release.path,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
|
||||||
|
return [...recordTimeline, ...releaseTimeline].sort((left, right) =>
|
||||||
right.dateTime.localeCompare(left.dateTime),
|
right.dateTime.localeCompare(left.dateTime),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 목록의 날짜 칸은 공개 화면 어디서나 같은 형식이다. */
|
||||||
|
function dateLabel(isoTimestamp: string): string {
|
||||||
|
const parsed = new Date(isoTimestamp);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return "";
|
||||||
|
return `${parsed.getFullYear()}.${String(parsed.getMonth() + 1).padStart(2, "0")}.${String(
|
||||||
|
parsed.getDate(),
|
||||||
|
).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { search } = useRouteInput<"TECH_LOG_HOME">();
|
const { search } = useRouteInput<"TECH_LOG_HOME">();
|
||||||
const requestedKey = optionalString(search.focus);
|
const requestedKey = optionalString(search.focus);
|
||||||
const requestedState = optionalString(search.state);
|
const requestedState = optionalString(search.state);
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "home"], async (queries) => {
|
||||||
const focusItems = publicContent.getHomeFocusItems();
|
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 availableFocusItems = requestedState === "focus-empty" ? [] : focusItems;
|
||||||
const normalizedKey = normalizeFocus(
|
const normalizedKey = normalizeFocus(
|
||||||
requestedKey,
|
requestedKey,
|
||||||
@@ -131,8 +148,6 @@ export function HomePage() {
|
|||||||
return <FatalErrorState traceId="PREVIEW-HOME-500" />;
|
return <FatalErrorState traceId="PREVIEW-HOME-500" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestEntries = getLatestEntries(publicContent);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
<section className="shell home-identity" aria-labelledby="home-title">
|
<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 { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||||
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
|
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||||
|
|
||||||
const principles = [
|
const principles = [
|
||||||
@@ -22,16 +23,31 @@ const principles = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const currentProjectSlugs = ["backend-skeleton", "auth-lab"] as const;
|
|
||||||
const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const;
|
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
// The two project slugs this named were the static fixture's, and they exist
|
||||||
const currentProjects = currentProjectSlugs.flatMap((slug) => {
|
// in no real deployment — the page asked the backend for them, took two 404s,
|
||||||
const project = publicContent.getProject(slug);
|
// and rendered nothing but an error. "Current projects" means the published
|
||||||
return project ? [project] : [];
|
// 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 (
|
return (
|
||||||
<main id="main-content" className="shell profile-page">
|
<main id="main-content" className="shell profile-page">
|
||||||
<header className="profile-header">
|
<header className="profile-header">
|
||||||
@@ -61,29 +77,45 @@ export function ProfilePage() {
|
|||||||
<p className="section-kicker">Current</p>
|
<p className="section-kicker">Current</p>
|
||||||
<h2 id="profile-projects-title">현재 프로젝트</h2>
|
<h2 id="profile-projects-title">현재 프로젝트</h2>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
{!view.ready ? (
|
||||||
{currentProjects.map((project) => (
|
view.fallback
|
||||||
<li key={project.slug}>
|
) : view.data.currentProjects.length === 0 ? (
|
||||||
<Link to={`/projects/${project.slug}`}>
|
<p className="public-empty-note">아직 공개된 프로젝트가 없습니다.</p>
|
||||||
<div>
|
) : (
|
||||||
<strong>{project.title}</strong>
|
<ul>
|
||||||
<span>{project.stage}</span>
|
{view.data.currentProjects.map((project) => (
|
||||||
</div>
|
<li key={project.slug}>
|
||||||
<p>{project.currentGoal}</p>
|
<Link to={`/projects/${project.slug}`}>
|
||||||
<span aria-hidden="true">↗</span>
|
<div>
|
||||||
</Link>
|
<strong>{project.title}</strong>
|
||||||
</li>
|
<span>{project.stage}</span>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
<p>{project.currentGoal}</p>
|
||||||
|
<span aria-hidden="true">↗</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{/*
|
||||||
|
이 목록은 코드에 네 개가 박혀 있었다 — Studio 에서 주제를 만들거나 지워도 프로필은
|
||||||
|
그대로였고, 고치려면 배포를 다시 해야 했다. 이제 공개 주제 목록을 그대로 그린다.
|
||||||
|
*/}
|
||||||
<section className="profile-topics" aria-labelledby="profile-topics-title">
|
<section className="profile-topics" aria-labelledby="profile-topics-title">
|
||||||
<p className="section-kicker">Topics</p>
|
<p className="section-kicker">Topics</p>
|
||||||
<h2 id="profile-topics-title">주요 관심 주제</h2>
|
<h2 id="profile-topics-title">주요 관심 주제</h2>
|
||||||
<ul>
|
{!view.ready ? (
|
||||||
{topics.map((topic) => (
|
view.fallback
|
||||||
<li key={topic}>{topic}</li>
|
) : view.data.topics.length === 0 ? (
|
||||||
))}
|
<p className="public-empty-note">아직 등록한 주제가 없습니다.</p>
|
||||||
</ul>
|
) : (
|
||||||
|
<ul>
|
||||||
|
{view.data.topics.map((topic) => (
|
||||||
|
<li key={topic.slug}>{topic.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,44 +1,51 @@
|
|||||||
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 {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
export function ProjectActivityPage() {
|
export function ProjectActivityPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">();
|
const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">();
|
||||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "project", slug, "activity"], async (queries) => {
|
||||||
const project = publicContent.getProject(slug);
|
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 />;
|
if (!project) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
const activity = publicContent.getProjectActivity(slug);
|
/*
|
||||||
|
활동은 로그다 — 언제 무엇을 올렸는지만 적는다.
|
||||||
|
|
||||||
|
예전에는 각 줄에 그 기록으로 가는 링크가 있었다. 그러면 같은 글에 닿는 길이 둘이 되고,
|
||||||
|
읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다. 글을 읽는 자리는
|
||||||
|
기록 화면 하나로 둔다.
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell project-page">
|
<main id="main-content" className="shell project-page">
|
||||||
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
||||||
<ol className="project-activity-list">
|
{activity.length === 0 ? (
|
||||||
{activity.map((item) => (
|
<p className="public-empty-note">아직 이 프로젝트에서 공개한 기록이 없습니다.</p>
|
||||||
<li key={item.id}>
|
) : (
|
||||||
<article id={item.id}>
|
<ol className="project-activity-list">
|
||||||
<div>
|
{activity.map((item) => (
|
||||||
<span>{item.type}</span>
|
<li key={item.id}>
|
||||||
<time dateTime={item.dateTime}>{item.date}</time>
|
<article id={item.id}>
|
||||||
</div>
|
<div>
|
||||||
<h2>{item.title}</h2>
|
<span>{item.type}</span>
|
||||||
<p>{item.summary}</p>
|
<time dateTime={item.dateTime}>{item.date}</time>
|
||||||
<Link to={item.recordPath ?? item.path}>
|
</div>
|
||||||
{item.recordPath
|
<h2>{item.title}</h2>
|
||||||
? "연결된 공개 기록 읽기"
|
</article>
|
||||||
: "이 활동 위치 열기"}
|
</li>
|
||||||
</Link>
|
))}
|
||||||
</article>
|
</ol>
|
||||||
</li>
|
)}
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
import { Link } from "react-router-dom";
|
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 {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
export function ProjectDecisionsPage() {
|
export function ProjectDecisionsPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">();
|
const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">();
|
||||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "project", slug, "decisions"], async (queries) => {
|
||||||
const project = publicContent.getProject(slug);
|
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 />;
|
if (!project) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
const decisions = publicContent.getProjectDecisions(slug);
|
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell project-page">
|
<main id="main-content" className="shell project-page">
|
||||||
<ProjectPageHeader project={project} title={`${project.title} 결정`} />
|
<ProjectPageHeader project={project} title={`${project.title} 결정`} />
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
import { Link } from "react-router-dom";
|
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 {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
export function ProjectOverviewPage() {
|
export function ProjectOverviewPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_PROJECT">();
|
const { params } = useRouteInput<"TECH_LOG_PROJECT">();
|
||||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "project", slug, "overview"], async (queries) => {
|
||||||
const project = publicContent.getProject(slug);
|
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 />;
|
if (!project) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
const records = publicContent.getProjectRecords(slug);
|
|
||||||
const decisions = publicContent.getProjectDecisions(slug);
|
|
||||||
const activity = publicContent.getProjectActivity(slug);
|
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell project-page">
|
<main id="main-content" className="shell project-page">
|
||||||
<ProjectPageHeader project={project} title={project.title} />
|
<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 {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
export function ProjectRecordsPage() {
|
export function ProjectRecordsPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">();
|
const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">();
|
||||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "project", slug, "records"], async (queries) => {
|
||||||
const project = publicContent.getProject(slug);
|
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 />;
|
if (!project) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
const records = publicContent.getProjectRecords(slug);
|
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell project-page">
|
<main id="main-content" className="shell project-page">
|
||||||
<ProjectPageHeader project={project} title={`${project.title} 기록`} />
|
<ProjectPageHeader project={project} title={`${project.title} 기록`} />
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
|
|
||||||
export function ProjectsPage() {
|
export function ProjectsPage() {
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "projects"], async (queries) => {
|
||||||
const projects = publicContent
|
const entries = (await queries.searchPublicContent("")).filter(
|
||||||
.searchPublicContent("")
|
(item) => item.contentType === "PROJECT",
|
||||||
.filter((item) => item.contentType === "PROJECT")
|
);
|
||||||
.flatMap((item) => {
|
const resolved = await Promise.all(
|
||||||
const project = publicContent.getProject(item.path.replace("/projects/", ""));
|
entries.map((item) => queries.getProject(item.path.replace("/projects/", ""))),
|
||||||
return project ? [project] : [];
|
);
|
||||||
});
|
return { projects: resolved.filter((project) => project !== undefined) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Header first: it is fixed copy and owes the network nothing.
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
@@ -26,8 +27,13 @@ export function ProjectsPage() {
|
|||||||
봅니다.
|
봅니다.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
{!view.ready ? (
|
||||||
|
view.fallback
|
||||||
|
) : view.data.projects.length === 0 ? (
|
||||||
|
<p className="public-empty-note">아직 공개된 프로젝트가 없습니다.</p>
|
||||||
|
) : (
|
||||||
<ol className="project-index-list">
|
<ol className="project-index-list">
|
||||||
{projects.map((project, index) => (
|
{view.data.projects.map((project, index) => (
|
||||||
<li key={project.slug}>
|
<li key={project.slug}>
|
||||||
<Link to={`/projects/${project.slug}`}>
|
<Link to={`/projects/${project.slug}`}>
|
||||||
<span>{String(index + 1).padStart(2, "0")}</span>
|
<span>{String(index + 1).padStart(2, "0")}</span>
|
||||||
@@ -53,6 +59,7 @@ export function ProjectsPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
)}
|
||||||
</main>
|
</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 {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { QuestionDocumentPage } from "../components/question-document-page.tsx";
|
import { QuestionDocumentPage } from "../components/question-document-page.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
function optionalString(value: unknown): string | undefined {
|
function optionalString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
|
|||||||
export function QuestionPage() {
|
export function QuestionPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_QUESTION">();
|
const { params } = useRouteInput<"TECH_LOG_QUESTION">();
|
||||||
const slug = optionalString(params.slug);
|
const slug = optionalString(params.slug);
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(
|
||||||
const record = slug ? publicContent.getRecord("QUESTION", slug) : undefined;
|
["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 ? (
|
return record ? (
|
||||||
<QuestionDocumentPage record={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 {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ReferenceDocumentPage } from "../components/reference-document-page.tsx";
|
import { ReferenceDocumentPage } from "../components/reference-document-page.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
function optionalString(value: unknown): string | undefined {
|
function optionalString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
|
|||||||
export function ReferencePage() {
|
export function ReferencePage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_REFERENCE">();
|
const { params } = useRouteInput<"TECH_LOG_REFERENCE">();
|
||||||
const slug = optionalString(params.slug);
|
const slug = optionalString(params.slug);
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(
|
||||||
const record = slug ? publicContent.getRecord("REFERENCE", slug) : undefined;
|
["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 ? (
|
return record ? (
|
||||||
<ReferenceDocumentPage record={record} />
|
<ReferenceDocumentPage record={record} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { Link } from "react-router-dom";
|
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 {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
export function ReleasePage() {
|
export function ReleasePage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_RELEASE">();
|
const { params } = useRouteInput<"TECH_LOG_RELEASE">();
|
||||||
const version = typeof params.version === "string" ? params.version : "";
|
const version = typeof params.version === "string" ? params.version : "";
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "release", version], async (queries) => ({
|
||||||
const release = publicContent.getRelease(version);
|
release: await queries.getRelease(version),
|
||||||
|
}));
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { release } = view.data;
|
||||||
if (!release) return <RegisteredNotFoundRoute />;
|
if (!release) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
|
|
||||||
export function ReleasesPage() {
|
export function ReleasesPage() {
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "releases"], async (queries) => {
|
||||||
const releases = publicContent
|
const entries = (await queries.searchPublicContent("")).filter(
|
||||||
.searchPublicContent("")
|
(item) => item.contentType === "RELEASE",
|
||||||
.filter((item) => item.contentType === "RELEASE")
|
);
|
||||||
.flatMap((item) => {
|
const resolved = await Promise.all(
|
||||||
const release = publicContent.getRelease(item.path.replace("/releases/", ""));
|
entries.map((item) => queries.getRelease(item.path.replace("/releases/", ""))),
|
||||||
return release ? [release] : [];
|
);
|
||||||
});
|
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 (
|
return (
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
@@ -26,8 +29,13 @@ export function ReleasesPage() {
|
|||||||
생겼는지 남깁니다.
|
생겼는지 남깁니다.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
{!view.ready ? (
|
||||||
|
view.fallback
|
||||||
|
) : view.data.releases.length === 0 ? (
|
||||||
|
<p className="public-empty-note">아직 공개된 릴리즈가 없습니다.</p>
|
||||||
|
) : (
|
||||||
<ol className="release-index-list">
|
<ol className="release-index-list">
|
||||||
{releases.map((release) => (
|
{view.data.releases.map((release) => (
|
||||||
<li key={release.version}>
|
<li key={release.version}>
|
||||||
<Link to={release.path}>
|
<Link to={release.path}>
|
||||||
<div>
|
<div>
|
||||||
@@ -45,6 +53,7 @@ export function ReleasesPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
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 { 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;
|
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 navigate = useNavigate();
|
||||||
const { search } = useRouteInput<"TECH_LOG_SEARCH">();
|
const { search } = useRouteInput<"TECH_LOG_SEARCH">();
|
||||||
const query = optionalString(search.q)?.trim() ?? "";
|
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>) {
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -25,6 +29,9 @@ export function SearchPage() {
|
|||||||
void navigate(`/search?q=${encodeURIComponent(nextQuery)}`);
|
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">
|
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>
|
<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>
|
<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 {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
const topics = {
|
const topics = {
|
||||||
jpa: {
|
jpa: {
|
||||||
@@ -37,11 +36,18 @@ function topicConfig(value: unknown) {
|
|||||||
export function TopicPage() {
|
export function TopicPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_TOPIC">();
|
const { params } = useRouteInput<"TECH_LOG_TOPIC">();
|
||||||
const topic = topicConfig(params.slug);
|
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 slug = typeof params.slug === "string" ? params.slug : "";
|
||||||
|
const view = usePublicContent(
|
||||||
|
["tech-log", "topic", slug],
|
||||||
|
async (queries) =>
|
||||||
|
topic ? { records: await queries.listRecords({ topic: slug }) } : { records: [] },
|
||||||
|
);
|
||||||
if (!topic) return <RegisteredNotFoundRoute />;
|
if (!topic) return <RegisteredNotFoundRoute />;
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
const records = publicContent.listRecords({ topic: topic.title });
|
const { records } = view.data;
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell public-index-page">
|
<main id="main-content" className="shell public-index-page">
|
||||||
<header className="public-page-header">
|
<header className="public-page-header">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function PublicShell({ children, currentPath }: PublicShellProps) {
|
|||||||
<Link to={publicSiteConfig.contactPath}>
|
<Link to={publicSiteConfig.contactPath}>
|
||||||
{publicSiteConfig.contactLabel}
|
{publicSiteConfig.contactLabel}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to={publicSiteConfig.latestRelease}>최신 Release</Link>
|
<Link to={publicSiteConfig.releasesPath}>변경 기록</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,8 +53,12 @@ export type ResolvedAssetLike = Readonly<{
|
|||||||
function fromDescriptor(descriptor: ResolvedAssetLike): EvidenceAsset {
|
function fromDescriptor(descriptor: ResolvedAssetLike): EvidenceAsset {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
src: descriptor.publicPath,
|
src: descriptor.publicPath,
|
||||||
width: descriptor.width ?? 1,
|
// 치수를 모르면 0 으로 둔다 — 1 이 아니라. 1×1 은 "아주 작은 그림" 이라는 거짓말이고,
|
||||||
height: descriptor.height ?? 1,
|
// `loading="lazy"` 와 만나면 브라우저는 화면에 걸리지 않는 1×1 상자를 영영 가져오지 않는다.
|
||||||
|
// 실제로 업로드가 치수를 기록하지 않아 모든 그림이 그렇게 사라졌다. 0 은 figure 가
|
||||||
|
// 자기 값을 모른다는 뜻이고, 렌더러가 그때 속성을 빼고 즉시 로드로 바꾼다.
|
||||||
|
width: descriptor.width ?? 0,
|
||||||
|
height: descriptor.height ?? 0,
|
||||||
triggerLabel: `${descriptor.assetKey} 이미지 크게 보기`,
|
triggerLabel: `${descriptor.assetKey} 이미지 크게 보기`,
|
||||||
dialogLabel: `${descriptor.assetKey} 확대`,
|
dialogLabel: `${descriptor.assetKey} 확대`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ function renderBlock(
|
|||||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "THEMATIC_BREAK":
|
||||||
|
return <hr key={key} className="document-rule" />;
|
||||||
|
case "IMAGE":
|
||||||
|
/*
|
||||||
|
작성자가 적은 경로를 그대로 쓴다. 경로 검증은 파싱할 때 끝났다(`isSafeLink`).
|
||||||
|
`title` 이 있으면 그림 설명으로 보여 준다 — Markdown 이 제목을 그런 뜻으로 쓴다.
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<figure key={key} className="document-image">
|
||||||
|
<img src={block.src} alt={block.alt} loading="lazy" />
|
||||||
|
{block.title ? <figcaption>{block.title}</figcaption> : null}
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return assertNever(block);
|
return assertNever(block);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,20 @@ type DocumentTocProps = {
|
|||||||
|
|
||||||
export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
||||||
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
|
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
|
||||||
|
const empty = headings.length === 0;
|
||||||
const detailsRef = useRef<HTMLDetailsElement>(null);
|
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
/*
|
||||||
|
`IntersectionObserver` 가 없는 환경이 있다 — jsdom 이 그렇고, 오래된 브라우저도 그렇다.
|
||||||
|
없으면 "지금 읽는 절" 표시만 못 할 뿐 목차 자체는 쓸 수 있으므로, 없다고 문서를 통째로
|
||||||
|
못 그리게 두지 않는다.
|
||||||
|
|
||||||
|
한동안 이 컴포넌트는 픽스처 Case 하나에서만 쓰여 그 환경을 만난 적이 없었다. 모든 Case 가
|
||||||
|
목차를 받게 되면서 드러났다.
|
||||||
|
*/
|
||||||
|
if (typeof IntersectionObserver === "undefined") return undefined;
|
||||||
|
|
||||||
const elements = headings
|
const elements = headings
|
||||||
.map((heading) => document.getElementById(heading.id))
|
.map((heading) => document.getElementById(heading.id))
|
||||||
.filter((element): element is HTMLElement => Boolean(element));
|
.filter((element): element is HTMLElement => Boolean(element));
|
||||||
@@ -41,6 +52,12 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
|||||||
if (detailsRef.current) detailsRef.current.open = false;
|
if (detailsRef.current) detailsRef.current.open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
제목이 없는 문서에는 목차를 그리지 않는다. 예전에는 이 컴포넌트가 픽스처 문서 하나에서만
|
||||||
|
쓰여 빈 경우를 만날 일이 없었지만, 이제 모든 Case 가 지나므로 빈 레일이 남을 수 있다.
|
||||||
|
*/
|
||||||
|
if (empty) return null;
|
||||||
|
|
||||||
if (variant === "mobile") {
|
if (variant === "mobile") {
|
||||||
const current =
|
const current =
|
||||||
headings.find((heading) => heading.id === currentId) ?? headings[0];
|
headings.find((heading) => heading.id === currentId) ?? headings[0];
|
||||||
|
|||||||
@@ -30,16 +30,20 @@ export function PublicEvidenceFigure({
|
|||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 치수를 아는 그림만 자리를 미리 잡는다. 모르는데 숫자를 적으면 그 상자가 진짜 크기가 되고,
|
||||||
|
* `loading="lazy"` 는 화면에 걸리지 않는 상자를 끝내 가져오지 않는다 — 그림이 통째로 사라진다.
|
||||||
|
* 모를 때는 속성을 빼고 즉시 로드해, 브라우저가 원래 크기로 그리게 둔다.
|
||||||
|
*/
|
||||||
|
const known = asset.width > 0 && asset.height > 0;
|
||||||
|
const sizing = known
|
||||||
|
? ({ width: asset.width, height: asset.height, loading: "lazy" } as const)
|
||||||
|
: ({ loading: "eager" } as const);
|
||||||
|
|
||||||
if (!zoom) {
|
if (!zoom) {
|
||||||
return (
|
return (
|
||||||
<figure className="evidence-figure">
|
<figure className="evidence-figure">
|
||||||
<img
|
<img src={asset.src} alt={alt} {...sizing} />
|
||||||
src={asset.src}
|
|
||||||
width={asset.width}
|
|
||||||
height={asset.height}
|
|
||||||
alt={alt}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<figcaption>{caption}</figcaption>
|
<figcaption>{caption}</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
);
|
);
|
||||||
@@ -56,13 +60,7 @@ export function PublicEvidenceFigure({
|
|||||||
aria-describedby={descriptionId}
|
aria-describedby={descriptionId}
|
||||||
onClick={open}
|
onClick={open}
|
||||||
>
|
>
|
||||||
<img
|
<img src={asset.src} alt={alt} {...sizing} />
|
||||||
src={asset.src}
|
|
||||||
width={asset.width}
|
|
||||||
height={asset.height}
|
|
||||||
alt={alt}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<span>크게 보기</span>
|
<span>크게 보기</span>
|
||||||
<span className="visually-hidden" id={descriptionId}>
|
<span className="visually-hidden" id={descriptionId}>
|
||||||
{alt}
|
{alt}
|
||||||
@@ -84,13 +82,7 @@ export function PublicEvidenceFigure({
|
|||||||
<button type="button" className="dialog-close" onClick={close}>
|
<button type="button" className="dialog-close" onClick={close}>
|
||||||
닫기
|
닫기
|
||||||
</button>
|
</button>
|
||||||
<img
|
<img src={asset.src} alt={alt} {...sizing} />
|
||||||
src={asset.src}
|
|
||||||
width={asset.width}
|
|
||||||
height={asset.height}
|
|
||||||
alt={alt}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<p>{caption}</p>
|
<p>{caption}</p>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -8,10 +8,44 @@ function assertNever(value: never): never {
|
|||||||
throw new Error(`Unsupported inline value: ${JSON.stringify(value)}`);
|
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) {
|
function renderInline(inline: Inline, key: number) {
|
||||||
switch (inline.type) {
|
switch (inline.type) {
|
||||||
case "TEXT":
|
case "TEXT":
|
||||||
return <Fragment key={key}>{inline.text}</Fragment>;
|
return renderText(inline.text, key);
|
||||||
case "INLINE_CODE":
|
case "INLINE_CODE":
|
||||||
return <code key={key}>{inline.code}</code>;
|
return <code key={key}>{inline.code}</code>;
|
||||||
case "EMPHASIS":
|
case "EMPHASIS":
|
||||||
|
|||||||
+30
-72
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
|||||||
|
|
||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { inlinePlainText } from "../../../domain/content-format/inline-plain-text.ts";
|
import { inlinePlainText } from "../../../domain/content-format/inline-plain-text.ts";
|
||||||
|
import { PlainText } from "./inline-renderer.tsx";
|
||||||
import type {
|
import type {
|
||||||
ResolveEvidenceAsset,
|
ResolveEvidenceAsset,
|
||||||
ResolvePublishedLabel,
|
ResolvePublishedLabel,
|
||||||
@@ -92,7 +93,7 @@ function ModelDocumentHeader({
|
|||||||
) : null}
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
<h1>{model.title}</h1>
|
<h1>{model.title}</h1>
|
||||||
<p>{model.summary}</p>
|
<p><PlainText text={model.summary} /></p>
|
||||||
<dl>
|
<dl>
|
||||||
<div>
|
<div>
|
||||||
<dt>유형</dt>
|
<dt>유형</dt>
|
||||||
@@ -127,57 +128,18 @@ function publicRelations(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function GenericCase({
|
|
||||||
model,
|
|
||||||
embedded,
|
|
||||||
resolveEvidenceAsset,
|
|
||||||
resolvePublishedLabel,
|
|
||||||
}: {
|
|
||||||
model: CasePublicRenderModel;
|
|
||||||
embedded: boolean;
|
|
||||||
} & RenderDependencies) {
|
|
||||||
const Root = embedded ? "div" : "main";
|
|
||||||
return (
|
|
||||||
<Root
|
|
||||||
id={embedded ? undefined : "main-content"}
|
|
||||||
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
|
|
||||||
>
|
|
||||||
<ModelDocumentHeader
|
|
||||||
model={model}
|
|
||||||
resolvePublishedLabel={resolvePublishedLabel}
|
|
||||||
/>
|
|
||||||
<section className="document-snapshot" aria-label="문제와 결론">
|
|
||||||
<div>
|
|
||||||
<p className="snapshot-label">문제</p>
|
|
||||||
<p>{model.problem}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="snapshot-label snapshot-label--answer">결론</p>
|
|
||||||
<p>{model.conclusion}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<dl className="document-facts">
|
|
||||||
<div>
|
|
||||||
<dt>검증 환경</dt>
|
|
||||||
<dd>{model.environment}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>검증 데이터</dt>
|
|
||||||
<dd>{model.reproduction}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<article className="public-document-body">
|
|
||||||
<CaseBodyRenderer
|
|
||||||
blocks={model.bodyBlocks}
|
|
||||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
|
||||||
</Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FetchJoinCase({
|
/**
|
||||||
|
* Case 한 편.
|
||||||
|
*
|
||||||
|
* 한동안 Case 렌더러가 둘이었다. 이 완성된 배치와, 목차도 breadcrumb 도 없는 축약본. 어느 쪽을
|
||||||
|
* 쓸지는 `publicPath === "/cases/collection-fetch-join-pagination"` 라는 슬러그 비교가 정했다 —
|
||||||
|
* 설계 픽스처로 만든 문서 하나만 제대로 된 화면을 받고, 실제로 작성한 Case 는 전부 축약본으로
|
||||||
|
* 떨어졌다. 그래서 오른쪽 목차가 어떤 문서에서도 나타나지 않았다.
|
||||||
|
*
|
||||||
|
* 배치는 하나다. 목차는 본문에 제목이 있을 때 나온다.
|
||||||
|
*/
|
||||||
|
function CaseDocument({
|
||||||
model,
|
model,
|
||||||
embedded,
|
embedded,
|
||||||
resolveEvidenceAsset,
|
resolveEvidenceAsset,
|
||||||
@@ -213,27 +175,27 @@ function FetchJoinCase({
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h1>{model.title}</h1>
|
<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="문제와 결론">
|
<section className="case-snapshot" aria-label="문제와 결론">
|
||||||
<div>
|
<div>
|
||||||
<p className="snapshot-label">문제</p>
|
<p className="snapshot-label">문제</p>
|
||||||
<p>{model.problem}</p>
|
<p><PlainText text={model.problem} /></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="snapshot-label snapshot-label--answer">결론</p>
|
<p className="snapshot-label snapshot-label--answer">결론</p>
|
||||||
<p>{model.conclusion}</p>
|
<p><PlainText text={model.conclusion} /></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<dl className="case-meta">
|
<dl className="case-meta">
|
||||||
<div>
|
<div>
|
||||||
<dt>검증 환경</dt>
|
<dt>검증 환경</dt>
|
||||||
<dd>{model.environment}</dd>
|
<dd><PlainText text={model.environment} /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>데이터셋</dt>
|
<dt>검증 데이터</dt>
|
||||||
<dd>{model.reproduction.replace(/^Dataset:\s*/, "")}</dd>
|
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>기록</dt>
|
<dt>기록</dt>
|
||||||
@@ -287,7 +249,7 @@ function ReferenceDocument({
|
|||||||
<section className="reference-purpose" aria-labelledby="purpose-title">
|
<section className="reference-purpose" aria-labelledby="purpose-title">
|
||||||
<p className="section-kicker">Purpose</p>
|
<p className="section-kicker">Purpose</p>
|
||||||
<h2 id="purpose-title">이 기준을 쓰는 이유</h2>
|
<h2 id="purpose-title">이 기준을 쓰는 이유</h2>
|
||||||
<p>{model.purpose}</p>
|
<p><PlainText text={model.purpose} /></p>
|
||||||
</section>
|
</section>
|
||||||
<article className="public-document-body reference-body">
|
<article className="public-document-body reference-body">
|
||||||
<section aria-labelledby="rules-title">
|
<section aria-labelledby="rules-title">
|
||||||
@@ -434,7 +396,7 @@ function QuestionDocument({
|
|||||||
>
|
>
|
||||||
<p className="section-kicker">Next</p>
|
<p className="section-kicker">Next</p>
|
||||||
<h2 id="next-validation-title">다음 검증</h2>
|
<h2 id="next-validation-title">다음 검증</h2>
|
||||||
<p>{model.nextValidation}</p>
|
<p><PlainText text={model.nextValidation} /></p>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
||||||
@@ -473,14 +435,18 @@ function ProjectDecisionDocument({
|
|||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<span>{model.status}</span>
|
<span>{model.status}</span>
|
||||||
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
|
{model.decidedOn ? (
|
||||||
|
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
|
||||||
|
) : (
|
||||||
|
<span>결정일 미정</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h2>{model.title}</h2>
|
<h2>{model.title}</h2>
|
||||||
<p>{model.statement}</p>
|
<p><PlainText text={model.statement} /></p>
|
||||||
</header>
|
</header>
|
||||||
<section>
|
<section>
|
||||||
<h3>판단 이유</h3>
|
<h3>판단 이유</h3>
|
||||||
<p>{model.rationale}</p>
|
<p><PlainText text={model.rationale} /></p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h3>영향</h3>
|
<h3>영향</h3>
|
||||||
@@ -518,16 +484,8 @@ export function PublicRecordRenderer({
|
|||||||
} & RenderDependencies) {
|
} & RenderDependencies) {
|
||||||
switch (model.kind) {
|
switch (model.kind) {
|
||||||
case "CASE":
|
case "CASE":
|
||||||
return model.publicPath ===
|
return (
|
||||||
"/cases/collection-fetch-join-pagination" ? (
|
<CaseDocument
|
||||||
<FetchJoinCase
|
|
||||||
model={model}
|
|
||||||
embedded={embedded}
|
|
||||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
|
||||||
resolvePublishedLabel={resolvePublishedLabel}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<GenericCase
|
|
||||||
model={model}
|
model={model}
|
||||||
embedded={embedded}
|
embedded={embedded}
|
||||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useId, useState, type FormEvent } from "react";
|
|||||||
|
|
||||||
import type { Asset } from "../../../contracts/studio/contract.ts";
|
import type { Asset } from "../../../contracts/studio/contract.ts";
|
||||||
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.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. */
|
/** One screenful of candidates; searching, not scrolling, reaches the rest. */
|
||||||
const PAGE_SIZE = 50;
|
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
|
// than fewer, and no trailing request after the author stops), and it is the
|
||||||
// pair `document-list.tsx` already uses for the same job.
|
// pair `document-list.tsx` already uses for the same job.
|
||||||
const [searchDraft, setSearchDraft] = useState("");
|
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 [q, setQ] = useState("");
|
||||||
const searchId = useId();
|
const searchId = useId();
|
||||||
|
|
||||||
@@ -99,6 +109,27 @@ export function AssetPicker({
|
|||||||
setQ(searchDraft.trim());
|
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"
|
const listMessage = status === "LOADING"
|
||||||
? "Asset 목록을 불러오는 중입니다."
|
? "Asset 목록을 불러오는 중입니다."
|
||||||
: selectable.length > 0
|
: selectable.length > 0
|
||||||
@@ -124,6 +155,15 @@ export function AssetPicker({
|
|||||||
<button type="submit">검색</button>
|
<button type="submit">검색</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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"
|
{status === "ERROR"
|
||||||
? <p className="studio-error" role="alert">Asset 목록을 불러오지 못했습니다.</p>
|
? <p className="studio-error" role="alert">Asset 목록을 불러오지 못했습니다.</p>
|
||||||
: <p className="studio-asset-picker-empty" role="status">{listMessage}</p>}
|
: <p className="studio-asset-picker-empty" role="status">{listMessage}</p>}
|
||||||
@@ -135,11 +175,23 @@ export function AssetPicker({
|
|||||||
assetKey: asset.assetKey,
|
assetKey: asset.assetKey,
|
||||||
alt: asset.decorative ? "" : (asset.altText ?? ""),
|
alt: asset.decorative ? "" : (asset.altText ?? ""),
|
||||||
caption: "",
|
caption: "",
|
||||||
zoom: asset.kind === "DIAGRAM",
|
zoom: allowZoom && asset.mediaType.startsWith("image/"),
|
||||||
}))}
|
}))}
|
||||||
>
|
>
|
||||||
{asset.assetKey}
|
{asset.assetKey}
|
||||||
</button>
|
</button>
|
||||||
|
{/*
|
||||||
|
문서를 쓰다가 잘못 올린 Asset 을 여기서 바로 지운다. 예전에는 Asset 화면으로 나가야
|
||||||
|
했고, 그러면 편집 중인 작업본을 떠나야 했다. 쓰이고 있는 Asset 은 서버가 거절한다.
|
||||||
|
*/}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="studio-secondary-button"
|
||||||
|
disabled={removingId !== null}
|
||||||
|
onClick={() => { void removeAsset(asset); }}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
</li>)}
|
</li>)}
|
||||||
</ul> : null}
|
</ul> : null}
|
||||||
</div>;
|
</div>;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user