From 6c73b845bd27b6614795f2910a1955f32b7df51f Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sun, 26 Jul 2026 17:57:04 +0900 Subject: [PATCH] feat: add optional frontend adapter recipes --- config/ci/gates.json | 31 +- .../recipes/frontend-capability-recipes.json | 233 ++++++++ config/security/secret-scan-policy.json | 1 + .../VD-10-optional-capability-recipes.md | 99 ++++ .../frontend-platform-capability-review.md | 41 +- ...rontend-platform-implementation-roadmap.md | 25 + .../frontend-ports-adapters-and-boundaries.md | 33 +- docs/architecture/optional-adapter-recipes.md | 156 +++++ .../typescript-state-and-data-flow.md | 5 + docs/security/supply-chain.md | 2 +- .../frontend-platform-testing-strategy.md | 10 + eslint.config.js | 1 + package.json | 10 +- recipes/frontend-capabilities/contracts.ts | 207 +++++++ .../frontend-capabilities/fake-adapters.ts | 542 ++++++++++++++++++ recipes/frontend-capabilities/index.ts | 2 + .../frontend-capability-recipes.schema.json | 87 +++ scripts/check-optional-recipe-fixtures.mjs | 93 +++ scripts/check-optional-recipes.mjs | 74 +++ scripts/lib/optional-recipes.mjs | 265 +++++++++ scripts/test-optional-recipe-removal.mjs | 113 ++++ scripts/test-sample-removal.mjs | 2 + .../src/adapters/bad-storage.ts | 3 + .../forbidden/production-import/src/bad.ts | 3 + .../forbidden/server-state/src/bad-store.ts | 3 + .../src/presentation/bad-vendor.ts | 3 + .../optional-capability-contracts.test.ts | 280 +++++++++ tsconfig.json | 3 +- tsconfig.recipes.json | 9 + 29 files changed, 2291 insertions(+), 45 deletions(-) create mode 100644 config/recipes/frontend-capability-recipes.json create mode 100644 docs/architecture/decisions/VD-10-optional-capability-recipes.md create mode 100644 docs/architecture/optional-adapter-recipes.md create mode 100644 recipes/frontend-capabilities/contracts.ts create mode 100644 recipes/frontend-capabilities/fake-adapters.ts create mode 100644 recipes/frontend-capabilities/index.ts create mode 100644 schemas/config/frontend-capability-recipes.schema.json create mode 100644 scripts/check-optional-recipe-fixtures.mjs create mode 100644 scripts/check-optional-recipes.mjs create mode 100644 scripts/lib/optional-recipes.mjs create mode 100644 scripts/test-optional-recipe-removal.mjs create mode 100644 tests/fixtures/optional-recipes/forbidden/credential-leak/src/adapters/bad-storage.ts create mode 100644 tests/fixtures/optional-recipes/forbidden/production-import/src/bad.ts create mode 100644 tests/fixtures/optional-recipes/forbidden/server-state/src/bad-store.ts create mode 100644 tests/fixtures/optional-recipes/forbidden/vendor-import/src/presentation/bad-vendor.ts create mode 100644 tests/recipes/optional-capability-contracts.test.ts create mode 100644 tsconfig.recipes.json diff --git a/config/ci/gates.json b/config/ci/gates.json index 1970fa9..e3ca319 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -77,6 +77,7 @@ "name": "typecheck", "steps": [ { "script": "check:types", "expect": "pass" }, + { "script": "check:types:recipes", "expect": "pass" }, { "script": "check:types:fixture", "expect": "fail" }, { "script": "check:types:fixture:ts-port", "expect": "fail" }, { "script": "check:types:fixture:ts-result", "expect": "fail" }, @@ -129,12 +130,14 @@ "name": "integration", "steps": [ { "script": "test:integration", "expect": "pass" }, - { "script": "test:reference-feature", "expect": "pass" } + { "script": "test:reference-feature", "expect": "pass" }, + { "script": "test:recipes", "expect": "pass" } ], "logPath": "artifacts/quality/gates/FE-GATE-007.txt", "evidence": [ "artifacts/tests/integration.xml", - "artifacts/tests/reference-feature.xml" + "artifacts/tests/reference-feature.xml", + "artifacts/tests/optional-recipes.xml" ], "retentionClass": "merge-cycle" }, @@ -189,6 +192,8 @@ { "script": "check:i18n:fixture", "expect": "fail" }, { "script": "check:diagnostics", "expect": "pass" }, { "script": "check:diagnostics:fixture", "expect": "fail" }, + { "script": "check:optional-recipes:source", "expect": "pass" }, + { "script": "check:optional-recipe-fixtures", "expect": "pass" }, { "script": "check:registries", "expect": "pass" }, { "script": "check:registries:compatibility-fixtures", @@ -207,6 +212,8 @@ "artifacts/quality/i18n-fixture.json", "artifacts/quality/diagnostics.json", "artifacts/quality/diagnostics-fixture.json", + "artifacts/quality/optional-recipes.json", + "artifacts/quality/optional-recipe-fixtures.json", "artifacts/quality/registries.json", "artifacts/quality/registry-compatibility-fixtures.json", "artifacts/quality/registry-baseline-fixture.json", @@ -233,10 +240,14 @@ "name": "bundle", "steps": [ { "script": "build", "expect": "pass" }, - { "script": "check:bundle", "expect": "pass" } + { "script": "check:bundle", "expect": "pass" }, + { "script": "check:optional-recipes", "expect": "pass" } ], "logPath": "artifacts/quality/gates/FE-GATE-012.txt", - "evidence": ["artifacts/performance/bundle.json"], + "evidence": [ + "artifacts/performance/bundle.json", + "artifacts/quality/optional-recipes.json" + ], "retentionClass": "release-coherence" }, "FE-GATE-013": { @@ -340,10 +351,16 @@ "retentionClass": "release-coherence" }, "FE-GATE-020": { - "name": "reference-feature-removal", - "steps": [{ "script": "test:sample-removal", "expect": "pass" }], + "name": "removability", + "steps": [ + { "script": "test:sample-removal", "expect": "pass" }, + { "script": "test:optional-recipe-removal", "expect": "pass" } + ], "logPath": "artifacts/quality/gates/FE-GATE-020.txt", - "evidence": ["artifacts/tests/sample-removal.xml"], + "evidence": [ + "artifacts/tests/sample-removal.xml", + "artifacts/tests/optional-recipe-removal.xml" + ], "retentionClass": "merge-cycle" }, "FE-GATE-021": { diff --git a/config/recipes/frontend-capability-recipes.json b/config/recipes/frontend-capability-recipes.json new file mode 100644 index 0000000..277676a --- /dev/null +++ b/config/recipes/frontend-capability-recipes.json @@ -0,0 +1,233 @@ +{ + "$schema": "../../schemas/config/frontend-capability-recipes.schema.json", + "schemaVersion": 1, + "decisionId": "VD-10", + "defaultStatus": "NOT_INSTALLED", + "productionRuntimeDependencies": [], + "catalogOwner": "frontend-platform", + "reviewOn": "project-capability-selection", + "vendorPackagePatterns": [ + "@launchdarkly/*", + "@sentry/*", + "@opentelemetry/*", + "@openapitools/openapi-generator-cli", + "@reduxjs/toolkit", + "@tanstack/react-virtual", + "@uppy/*", + "firebase", + "idb", + "react-window", + "redux", + "socket.io-client", + "tus-js-client", + "workbox-window", + "xstate", + "zustand" + ], + "recipes": [ + { + "id": "realtime", + "status": "RECIPE_AVAILABLE", + "trigger": "The backend exposes ordered push events with a documented resume and authorization protocol.", + "forbiddenWhen": ["Polling satisfies the measured freshness requirement.", "Event ordering and reconnect ownership are undefined."], + "boundary": "outbound connection plus inbound validated event adapter", + "port": "RealtimePort", + "fake": "FakeRealtimeAdapter", + "failureKinds": ["disconnect", "duplicate", "out-of-order", "auth-expiry"], + "lifecycleMethods": ["unsubscribe"], + "owner": "project-owner-required", + "securityPrivacy": ["Validate every event envelope.", "Never place credentials in URLs or telemetry.", "Refresh authorization through the session boundary."], + "bundleBudgetGzipBytes": 12000, + "fallback": "Bounded polling or explicitly stale UI.", + "removal": ["Remove composition registration.", "Remove adapter and vendor dependency.", "Run recipe-removal and production-bundle gates."], + "serverStatePolicy": "query-cache-owned" + }, + { + "id": "offline-indexeddb", + "status": "RECIPE_AVAILABLE", + "trigger": "A product requirement needs durable offline data or a durable command queue beyond small public preferences.", + "forbiddenWhen": ["The data contains credentials.", "The browser would connect directly to a database or object store.", "A normal HTTP cache is sufficient."], + "boundary": "application-owned versioned repository output port", + "port": "VersionedOfflineRepository", + "fake": "MemoryOfflineRepository", + "failureKinds": ["quota", "corruption", "migration-rollback"], + "lifecycleMethods": ["close"], + "owner": "project-owner-required", + "securityPrivacy": ["Classify persisted fields.", "Encrypting in the same client is not a credential protection boundary.", "Version and test every migration."], + "bundleBudgetGzipBytes": 8000, + "fallback": "Online-only query path with an explicit offline state.", + "removal": ["Stop writes.", "Migrate or purge owned stores.", "Remove repository composition and dependency."], + "serverStatePolicy": "reference-or-command-only" + }, + { + "id": "service-worker-pwa", + "status": "RECIPE_AVAILABLE", + "trigger": "Installability or a measured offline-shell requirement is approved with cache ownership.", + "forbiddenWhen": ["Hosting cache and worker cache ownership conflict.", "Update and rollback UX is undefined."], + "boundary": "bootstrap update controller and cache policy adapter", + "port": "ServiceWorkerUpdatePort", + "fake": "FakeServiceWorkerUpdateAdapter", + "failureKinds": ["stale-worker", "update-loop", "offline-fallback"], + "lifecycleMethods": ["unregister", "rollback"], + "owner": "project-owner-required", + "securityPrivacy": ["Never cache authenticated API responses by default.", "Bind cache names to release identity.", "Fail closed on malformed update metadata."], + "bundleBudgetGzipBytes": 10000, + "fallback": "Normal network application with hosting cache headers.", + "removal": ["Deploy an unregister migration.", "Delete owned caches.", "Remove worker registration and manifest."], + "serverStatePolicy": "network-cache-policy-only" + }, + { + "id": "file-transfer", + "status": "RECIPE_AVAILABLE", + "trigger": "The product accepts or delivers files with progress and cancellation requirements.", + "forbiddenWhen": ["Allowed size and MIME policy is missing.", "Long-lived credentials would be embedded in URLs."], + "boundary": "application file transfer output port behind an authorized backend protocol", + "port": "FileTransferPort", + "fake": "FakeFileTransferAdapter", + "failureKinds": ["size-rejection", "type-rejection", "abort", "expired-url"], + "lifecycleMethods": ["cancel-via-AbortSignal"], + "owner": "project-owner-required", + "securityPrivacy": ["Treat MIME as untrusted metadata.", "Use short-lived opaque resource identifiers.", "Redact file names when classified as personal data."], + "bundleBudgetGzipBytes": 6000, + "fallback": "Standard request with bounded size and no background continuation.", + "removal": ["Cancel active transfers.", "Remove route actions and composition.", "Remove transfer dependency."], + "serverStatePolicy": "query-cache-metadata-only" + }, + { + "id": "generated-api", + "status": "RECIPE_AVAILABLE", + "trigger": "A versioned backend contract justifies generated transport code.", + "forbiddenWhen": ["Generated DTOs would escape into domain or presentation.", "Contract drift cannot block CI."], + "boundary": "generated client wrapped by a feature gateway facade and mapper", + "port": "GeneratedApiFacade", + "fake": "FakeGeneratedApiAdapter", + "failureKinds": ["contract-drift", "unsupported-field"], + "lifecycleMethods": ["cancel-via-AbortSignal"], + "owner": "project-owner-required", + "securityPrivacy": ["Generate from an authenticated source.", "Review generator execution and output.", "Do not log request bodies."], + "bundleBudgetGzipBytes": 16000, + "fallback": "Existing typed request builder and runtime response schema.", + "removal": ["Restore handwritten gateway.", "Remove generated output and generator.", "Verify DTOs do not remain in public types."], + "serverStatePolicy": "query-cache-owned" + }, + { + "id": "feature-flag", + "status": "RECIPE_AVAILABLE", + "trigger": "A staged rollout or kill switch has a named owner, default and stale policy.", + "forbiddenWhen": ["A flag is used as authorization.", "Unknown and unavailable behavior is undefined."], + "boundary": "application feature policy output port", + "port": "FeatureFlagPort", + "fake": "FakeFeatureFlagAdapter", + "failureKinds": ["provider-unavailable", "unknown-flag", "stale-value"], + "lifecycleMethods": ["dispose-provider-if-installed"], + "owner": "project-owner-required", + "securityPrivacy": ["Flags are hints, never access control.", "Minimize targeting attributes.", "Apply consent rules to personal attributes."], + "bundleBudgetGzipBytes": 10000, + "fallback": "Typed local default with an explicit stale decision.", + "removal": ["Resolve the rollout permanently.", "Delete flag key and branches.", "Remove provider composition and dependency."], + "serverStatePolicy": "policy-cache-only" + }, + { + "id": "web-worker", + "status": "RECIPE_AVAILABLE", + "trigger": "Profiling shows CPU work blocking the main thread beyond the performance budget.", + "forbiddenWhen": ["The task is primarily network I/O.", "Cancellation and stale-result ownership are undefined."], + "boundary": "request/result/cancel output port with a validated message adapter", + "port": "WorkerTaskPort", + "fake": "FakeWorkerTaskAdapter", + "failureKinds": ["crash", "stale-result", "transfer-failure"], + "lifecycleMethods": ["cancel", "dispose"], + "owner": "project-owner-required", + "securityPrivacy": ["Validate worker messages.", "Do not send credentials.", "Bound transferred data and worker count."], + "bundleBudgetGzipBytes": 14000, + "fallback": "Chunked or deferred main-thread execution within a measured limit.", + "removal": ["Stop and dispose workers.", "Restore synchronous facade implementation.", "Remove worker entry and chunk."], + "serverStatePolicy": "no-server-state" + }, + { + "id": "multi-tab", + "status": "RECIPE_AVAILABLE", + "trigger": "A documented workflow must synchronize non-sensitive events across tabs.", + "forbiddenWhen": ["The server is the correct conflict authority.", "Event version and source identity are undefined."], + "boundary": "versioned browser event output/input adapter", + "port": "MultiTabPort", + "fake": "FakeMultiTabAdapter", + "failureKinds": ["self-echo", "duplicate", "conflict"], + "lifecycleMethods": ["unsubscribe", "close"], + "owner": "project-owner-required", + "securityPrivacy": ["Broadcast no credentials or personal payload.", "Validate versions.", "Treat events as hints rather than authorization."], + "bundleBudgetGzipBytes": 4000, + "fallback": "Refresh from the authoritative server on focus.", + "removal": ["Close channels.", "Remove event registry entries.", "Restore focus-based refresh."], + "serverStatePolicy": "invalidation-only" + }, + { + "id": "browser-permission", + "status": "RECIPE_AVAILABLE", + "trigger": "A user-initiated flow requires clipboard, notification or media access.", + "forbiddenWhen": ["Permission would be requested at boot.", "Denied, dismissed and unsupported UX are not designed."], + "boundary": "presentation input action through a browser capability output port", + "port": "BrowserPermissionPort", + "fake": "FakeBrowserPermissionAdapter", + "failureKinds": ["denied", "dismissed", "unsupported"], + "lifecycleMethods": ["stop-media-tracks-if-opened"], + "owner": "project-owner-required", + "securityPrivacy": ["Require an explicit user gesture.", "Minimize requested scope.", "Do not persist permission as authorization."], + "bundleBudgetGzipBytes": 3000, + "fallback": "Manual input or copy/download instruction.", + "removal": ["Stop acquired resources.", "Remove permission action and adapter.", "Retest denied-path accessibility."], + "serverStatePolicy": "no-server-state" + }, + { + "id": "client-workflow", + "status": "RECIPE_AVAILABLE", + "trigger": "A measured cross-page client-only workflow cannot be represented by URL, local state, context or query cache.", + "forbiddenWhen": ["The store would duplicate server response collections.", "A library is selected before state ownership is documented.", "Zustand and Redux Toolkit would both be installed."], + "boundary": "workflow-specific local facade; vendor types remain in its adapter", + "port": "ClientWorkflowPort", + "fake": "FakeClientWorkflowAdapter", + "failureKinds": ["reset", "version-mismatch", "server-state-duplication"], + "lifecycleMethods": ["unsubscribe", "reset"], + "owner": "project-owner-required", + "securityPrivacy": ["Persist only explicitly classified workflow fields.", "Never persist credentials.", "Define logout and version reset."], + "bundleBudgetGzipBytes": 9000, + "fallback": "URL, component state, context and TanStack Query ownership.", + "removal": ["Move remaining state to its natural owner.", "Remove facade and one selected store dependency.", "Verify logout/reset."], + "serverStatePolicy": "reference-only" + }, + { + "id": "large-data-ui", + "status": "RECIPE_AVAILABLE", + "trigger": "Production-like profiling proves a list or grid exceeds interaction and rendering budgets.", + "forbiddenWhen": ["Pagination solves the scale requirement.", "Keyboard and screen-reader focus behavior is undefined."], + "boundary": "presentation facade around virtualizer or data-grid behavior", + "port": "LargeDataUiFacade", + "fake": "FakeLargeDataUiAdapter", + "failureKinds": ["focus-loss", "stale-row", "scale-limit"], + "lifecycleMethods": ["dispose-observers-if-installed"], + "owner": "project-owner-required", + "securityPrivacy": ["Render only authorized rows.", "Do not expose hidden row data to telemetry.", "Preserve accessible row identity."], + "bundleBudgetGzipBytes": 30000, + "fallback": "Accessible pagination and bounded result sets.", + "removal": ["Restore paginated primitive.", "Remove facade adapter and dependency.", "Run keyboard and performance evidence."], + "serverStatePolicy": "query-cache-owned" + }, + { + "id": "analytics-error-sink", + "status": "RECIPE_AVAILABLE", + "trigger": "A production provider, consent policy, retention owner and event registry are approved.", + "forbiddenWhen": ["Consent and essential diagnostics are not separated.", "Arbitrary message or attribute keys can bypass redaction."], + "boundary": "closed diagnostics/analytics port with provider adapter", + "port": "AnalyticsErrorSink", + "fake": "RecordingAnalyticsAdapter", + "failureKinds": ["consent-denied", "queue-full", "provider-unavailable"], + "lifecycleMethods": ["flush", "dispose"], + "owner": "project-owner-required", + "securityPrivacy": ["Allowlist events and attributes.", "Redact before queueing.", "Apply consent, sampling and retention policy."], + "bundleBudgetGzipBytes": 25000, + "fallback": "Existing bounded local diagnostics and best-effort telemetry port.", + "removal": ["Disable provider delivery.", "Flush or discard by policy.", "Remove adapter, runtime config and dependency."], + "serverStatePolicy": "no-server-state" + } + ] +} diff --git a/config/security/secret-scan-policy.json b/config/security/secret-scan-policy.json index d680326..eb21d40 100644 --- a/config/security/secret-scan-policy.json +++ b/config/security/secret-scan-policy.json @@ -2,6 +2,7 @@ "schemaVersion": 1, "trackedRoots": [ "src", + "recipes", "scripts", "tests", "config", diff --git a/docs/architecture/decisions/VD-10-optional-capability-recipes.md b/docs/architecture/decisions/VD-10-optional-capability-recipes.md new file mode 100644 index 0000000..77bf75c --- /dev/null +++ b/docs/architecture/decisions/VD-10-optional-capability-recipes.md @@ -0,0 +1,99 @@ +# VD-10: 선택형 frontend capability recipe + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-optional-adapter-recipes` +- 현재 선택 capability: 없음 +- 재검토: 실제 프로젝트가 realtime, offline, PWA, file, generated API, + feature flag, worker, multi-tab, browser permission, client workflow, + large-data UI 또는 production analytics/error provider를 요구할 때 + +## 배경 + +서버의 PostgreSQL, MongoDB, Redis, Kafka, MinIO 같은 기술을 브라우저가 직접 +소비하지는 않는다. 프론트의 변화 지점은 권한 있는 HTTP/BFF, push event, +offline persistence, file protocol, browser runtime, 사용자 동의와 UI 성능 +경계다. 이 capability를 “언젠가 필요할 수 있다”는 이유로 모두 설치하면 초기 +bundle, 공급망, runtime config, 보안 표면과 업데이트 비용만 늘어난다. + +반대로 문서에 이름만 적으면 실제 프로젝트에서 port 위치, cancellation, +fallback, fake와 제거 기준을 다시 설계해야 한다. 따라서 production runtime에 +아무것도 설치하지 않되 검증 가능한 vendor-neutral recipe를 저장소 밖이 아닌 +별도 opt-in 경계에 유지한다. + +## 결정 + +1. `config/recipes/frontend-capability-recipes.json`이 12개 recipe의 선택 기준, + 금지 조건, port/fake, failure matrix, lifecycle cleanup, owner, + security/privacy, gzip budget, fallback, server-state 정책과 제거 절차의 + machine-readable SSOT다. +2. 현재 실제 소비 요구와 project owner가 없으므로 12개 상태는 모두 + `RECIPE_AVAILABLE`이며 `INSTALLED`가 아니다. production runtime dependency와 + composition registration은 0개다. +3. `recipes/frontend-capabilities`의 TypeScript port와 fake/unavailable adapter는 + 실행 가능한 설계 예시다. `src` 또는 production entry가 이 디렉터리를 import할 + 수 없다. +4. 프로젝트가 capability를 선택하면 필요한 최소 contract를 + application-owned output port 또는 presentation facade로 이동하고, concrete + vendor adapter는 local adapter 경계에 둔다. recipe 디렉터리를 production에서 + 그대로 import하지 않는다. +5. WebSocket/SSE처럼 연결은 outbound이고 수신 event는 inbound인 양방향 기술도 + 한 종류의 “adapter”로 뭉개지 않는다. 연결·credential·reconnect 정책과 + event validation·input invocation을 분리한다. +6. Zustand/Redux Toolkit/state machine은 실제 cross-page client-only workflow가 + 확인된 경우 하나만 선택한다. URL, component state, Context, TanStack Query가 + 이미 소유한 상태를 복제하지 않는다. +7. browser credential은 localStorage, URL, recipe store, telemetry 또는 + BroadcastChannel에 넣지 않는다. 브라우저가 database/object store에 직접 + 접속하는 recipe도 금지한다. +8. lifecycle이 있는 capability는 unsubscribe, close, unregister, dispose, + cancel 또는 `AbortSignal`을 계약과 contract test에 포함해야 한다. +9. 선택하지 않은 recipe sentinel이나 vendor dependency가 production bundle에 + 들어가면 gate를 실패시킨다. +10. recipe 전체를 제거한 임시 worktree에서 base typecheck, architecture, + unit/component/integration test와 production build가 통과해야 한다. + +## 선택과 설치 절차 + +```text +measured product/runtime need + -> project owner + security/privacy classification + -> recipe trigger/forbidden/fallback review + -> VD-10 amendment with one selected capability + -> application port or presentation facade copied into src + -> one concrete adapter under local adapter boundary + -> composition-only wiring + -> contract/failure/cleanup/integration tests + -> bundle + dependency baseline approval + -> INSTALLED only after all evidence passes +``` + +도입 커밋에는 owner, 선택 이유, 대안, gzip 차이, runtime config, browser support, +failure UX, observability, rollback과 제거 명령을 기록한다. vendor가 필요한 +behavior를 fake만으로 확인하고 `INSTALLED`로 바꾸지 않는다. + +## 증적 + +- catalog: `config/recipes/frontend-capability-recipes.json` +- contracts/fakes: `recipes/frontend-capabilities` +- 상세 runbook: `docs/architecture/optional-adapter-recipes.md` +- contract test: `tests/recipes/optional-capability-contracts.test.ts` +- negative fixture: + `tests/fixtures/optional-recipes/forbidden` +- validation: + `scripts/check-optional-recipes.mjs` +- removal: + `scripts/test-optional-recipe-removal.mjs` +- evidence: + `artifacts/quality/optional-recipes.json`, + `artifacts/quality/optional-recipe-fixtures.json`, + `artifacts/tests/optional-recipes.xml`, + `artifacts/tests/optional-recipe-removal.xml` + +## Rollback + +현재 branch는 runtime dependency나 production composition을 바꾸지 않으므로 +recipe catalog, example과 gate를 함께 revert하면 RP-11 상태로 돌아간다. 실제 +프로젝트에서 선택한 capability는 그 capability의 port/adapter/composition/ +dependency commit만 revert한다. 여러 vendor 도입을 하나의 되돌릴 수 없는 +commit으로 묶지 않는다. diff --git a/docs/architecture/frontend-platform-capability-review.md b/docs/architecture/frontend-platform-capability-review.md index 3b0250e..222b8fd 100644 --- a/docs/architecture/frontend-platform-capability-review.md +++ b/docs/architecture/frontend-platform-capability-review.md @@ -12,7 +12,7 @@ - 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다. - 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다. -최초 검토 기준은 `develop`의 `cb195f8`이며, RP-01~RP-10 구현 결과를 이 문서에 +최초 검토 기준은 `develop`의 `cb195f8`이며, RP-01~RP-12 구현 결과를 이 문서에 누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은 문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다. @@ -27,27 +27,21 @@ - Vitest, Testing Library, MSW, Playwright, axe를 이용한 테스트 계층 - CI 게이트 taxonomy와 호환성·보안·성능·릴리스 계약 문서 -그러나 “도메인 기능을 바로 추가할 수 있는 프론트엔드 플랫폼” 기준으로는 아직 -중요한 연결부가 빠져 있다. 가장 큰 문제는 공통 기능이 없다는 것보다 이미 있는 -기능이 실제 기능 화면의 표준 호출 경로로 조립되지 않았다는 점이다. - -특히 다음은 선행 해결이 필요하다. - -RP-01~RP-09에서 TypeScript 도구 안전망, application runtime 주입, +RP-01~RP-12에서 TypeScript 도구 안전망, application runtime 주입, query/mutation inbound adapter, HTTP 실행 계약과 executable route/release recovery 계약, 제거 가능한 reference 수직 슬라이스, form/page, design system과 -i18n 실행 경계와 diagnostics/telemetry production wiring은 구현됐다. 현재 선행 해결 -대상은 다음과 같다. +i18n 실행 경계, diagnostics/telemetry production wiring, registry/test 증거, +local 공급망 검증과 제거 가능한 optional adapter recipe가 구현됐다. 저장소 내부 +P0/P1 acceptance와 P2 recipe 기본값은 `LOCAL_TEMPLATE_READY`다. 다만 실제 제품 +도메인과 hosting, IdP, vulnerability/signing provider, analytics consent/provider, +지원 browser/접근성·field 증거는 프로젝트가 선택하고 검증해야 한다. -1. optional adapter의 opt-in 경계와 제거 가능한 recipe - -따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. -더 정확한 표현은 다음과 같다. +따라서 더 정확한 표현은 다음과 같다. > application API, 서버 상태, 폼, 라우팅, 페이지, 디자인 시스템, 테스트와 -> local 공급망 증적의 표준 수직 경로는 갖춰졌다. 현재 남은 저장소 내부 범위는 -> opt-in adapter recipe이며 실제 hosting·IdP·취약점/서명/운영 provider는 -> 프로젝트 통합 범위다. +> local 공급망 증적의 표준 수직 경로와 opt-in adapter recipe는 갖춰졌다. +> 실제 capability 설치와 hosting·IdP·취약점/서명/운영 provider는 프로젝트 +> 통합 범위이며, 없는 외부 증거를 완료로 표시하지 않는다. ## 3. 판정 기준 @@ -71,8 +65,8 @@ i18n 실행 경계와 diagnostics/telemetry production wiring은 구현됐다. | 검증 | 준비됨 | runtime/API/route/form Zod parse 결과를 실행 경계에서 사용하고 domain invariant와 분리 | feature별 schema 소유권 유지 | | 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 | | 서버 상태 | 준비됨 | reference route의 query/mutation, cancellation, stale, optimistic/conflict/rollback | feature별 query contribution recipe 유지 | -| 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 | -| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 | +| 클라이언트 상태 | 준비됨/프로젝트 선택 | local/URL/query/context 소유권, session external store, typed workflow recipe | 실제 cross-page workflow가 생길 때 하나의 store 선택 | +| 범용 global store | 프로젝트 선택 | runtime library 없음, typed facade/fake와 server-state duplication gate | VD-10 조건에 따라 Zustand/Redux Toolkit/state machine 중 하나 선택 | | 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 | | 앱 셸·반응형 | 준비됨 | native modal Drawer, compact/desktop layout, Escape/link dismiss/focus restore, pseudo reflow와 RTL direction | compact browser matrix 유지 | | 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status와 public design-system entry | feature별 slot 조합 유지 | @@ -89,7 +83,7 @@ i18n 실행 경계와 diagnostics/telemetry production wiring은 구현됐다. | 샘플 제거 | 준비됨 | feature/catalog/test 제거 후 type/architecture/registry/test/home/build 9단계 검증 | 새 contribution도 같은 제거 gate에 포함 | | registry·compatibility 집행 | 준비됨 | 10개 registry type/reference/consumer/orphan, 승인 digest와 actual semantic diff, breaking evidence | public 계약 변경 시 baseline review 유지 | | 공급망 검사 | 준비됨/프로젝트 선택 | 561개 transitive inventory/integrity/license, actual diff, CycloneDX, local provenance, secret/reproducible build gate | 실제 vulnerability scanner와 signed attestation 없이는 promotion `FAIL_UNVERIFIED` | -| realtime·offline·file 등 | 프로젝트 선택 | 현재 없음 | port/adapter recipe와 선택 기준 제공 | +| realtime·offline·file 등 | 준비됨/프로젝트 선택 | 12개 opt-in TypeScript port/fake/unavailable, failure/security/bundle/removal gate | 실제 요구·owner 승인 시 해당 recipe만 설치 | ## 5. 우선순위별 발견 사항 @@ -292,6 +286,13 @@ vendor facade, 선택 조건, 실패 정책, 테스트 fixture를 문서로 제 | large data UI | virtualization, data grid | owned component facade | 데이터 규모가 측정 기준을 넘을 때 | | analytics/error sink | vendor SDK, OpenTelemetry | redaction, consent, sampling adapter | 운영 provider와 정책이 정해졌을 때 | +12개 항목의 현재 상태는 모두 `RECIPE_AVAILABLE / NOT_INSTALLED`다. +`config/recipes/frontend-capability-recipes.json`이 선택/금지 조건, failure, +cleanup, security/privacy, bundle budget, fallback과 제거 절차의 SSOT이며, +`recipes/frontend-capabilities`에 production-excluded TypeScript port와 +fake/unavailable adapter가 있다. 도입 절차는 +`docs/architecture/optional-adapter-recipes.md`를 따른다. + 서버의 Redis, MongoDB, PostgreSQL, MinIO를 브라우저가 직접 연결하는 구조는 기본 frontend adapter catalog에 넣지 않는다. 브라우저는 권한 있는 backend API/BFF를 통해 이 자원에 접근해야 한다. 프론트에서 대응되는 변화 지점은 데이터베이스 diff --git a/docs/architecture/frontend-platform-implementation-roadmap.md b/docs/architecture/frontend-platform-implementation-roadmap.md index 28aa17c..2d04e9b 100644 --- a/docs/architecture/frontend-platform-implementation-roadmap.md +++ b/docs/architecture/frontend-platform-implementation-roadmap.md @@ -1003,6 +1003,31 @@ RP-12는 recipe별 merge commit이다. optional adapter 문제 시 해당 recipe revert하고 RP-11을 유지한다. 여러 vendor를 되돌릴 수 없는 한 commit에 묶지 않는다. +**구현 증거 (2026-07-26)** + +- VD-10에서 실제 project 요구가 선택되지 않았음을 기록하고 12개 capability를 + 모두 `RECIPE_AVAILABLE`, production runtime dependency 0개로 유지했다. +- machine-readable catalog에 recipe별 trigger/forbidden 조건, boundary, + port/fake, failure matrix, lifecycle cleanup, project owner 요구, + security/privacy, gzip budget, fallback, server-state 정책과 제거 순서를 + 등록했다. +- production-excluded `recipes/frontend-capabilities`에 12개 vendor-neutral + TypeScript port와 deterministic fake, fail-closed unavailable adapter를 + 제공한다. 프로젝트는 선택한 최소 계약만 application/presentation 경계로 + 복사하고 concrete adapter를 composition에서 연결한다. +- realtime ordering/unsubscribe, offline migration/close, worker cancel, + multi-tab dedupe, permission result, workflow reset, large-data stale + generation, analytics consent/redaction/queue와 나머지 facade contract를 + runnable test로 검증한다. +- cleanup 누락, 승인되지 않은 dependency, vendor direct import, credential + storage/URL/telemetry 경로, server-state store 복제와 production recipe import + negative fixture를 blocking gate에 연결했다. +- recipe와 recipe test를 통째로 제거한 임시 사본에서 base typecheck, + architecture, 전체 test와 production build를 실행하며, opt-in하지 않은 + sentinel이 built `dist`에 없는지 검사한다. +- 상세 도입/배치/검증/제거 절차는 + `docs/architecture/optional-adapter-recipes.md`에 기록했다. + ## 11. Vendor decision gate | ID | 시점 | 결정 | 기본값 또는 미결정 시 처리 | 차단 범위 | diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md index 88c7cb2..313c93c 100644 --- a/docs/architecture/frontend-ports-adapters-and-boundaries.md +++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md @@ -885,27 +885,32 @@ capability의 기본 정책, port 또는 안전한 no-op 구현과 composition | Adapter | 도입 조건 | 기본 상태 | | --- | --- | --- | -| WebSocket/SSE | 실시간 server event 필요 | 미설치 recipe | -| IndexedDB | 큰 offline data 또는 durable queue 필요 | 미설치 recipe | -| Service Worker/PWA | offline shell과 installability 필요 | 미설치 recipe | +| WebSocket/SSE | 실시간 server event 필요 | opt-in recipe 제공, 미설치 | +| IndexedDB | 큰 offline data 또는 durable queue 필요 | opt-in recipe 제공, 미설치 | +| Service Worker/PWA | offline shell과 installability 필요 | opt-in recipe 제공, 미설치 | | Offline mutation queue | 재연결 후 명령 재처리 필요 | 미설치 recipe | -| Feature flag | remote rollout/kill switch 필요 | 미설치 recipe | +| Feature flag | remote rollout/kill switch 필요 | opt-in recipe 제공, 미설치 | | Translation catalog vendor | 원격 catalog·복수 namespace 운영 필요 | 기본 locale facade 뒤에 미설치 | -| Analytics | 사용자 동의 기반 product analytics 필요 | 미설치 recipe | -| Error-reporting SDK | 운영 예외 집계 필요 | 미설치 recipe | -| OpenTelemetry | 조직 trace 연계 필요 | 미설치 recipe | -| Web Worker | CPU 작업이 main thread를 막음 | 미설치 recipe | -| Notification | 사용자 권한 기반 browser notification 필요 | 미설치 recipe | -| Clipboard/File/Media | 해당 browser capability 필요 | 미설치 recipe | +| Analytics | 사용자 동의 기반 product analytics 필요 | opt-in recipe 제공, 미설치 | +| Error-reporting SDK | 운영 예외 집계 필요 | opt-in recipe 제공, 미설치 | +| OpenTelemetry | 조직 trace 연계 필요 | opt-in recipe 제공, 미설치 | +| Web Worker | CPU 작업이 main thread를 막음 | opt-in recipe 제공, 미설치 | +| Notification | 사용자 권한 기반 browser notification 필요 | opt-in recipe 제공, 미설치 | +| Clipboard/File/Media | 해당 browser capability 필요 | opt-in recipe 제공, 미설치 | | Image CDN adapter | responsive image transform 필요 | 미설치 recipe | -| Virtualization | 대량 list rendering이 측정상 병목 | 미설치 recipe | -| OpenAPI generator | backend 계약에서 client 생성 필요 | 미설치 recipe | -| Zustand/Redux/Jotai | 복잡한 cross-page client state 확인 | 미설치 recipe | -| XState 등 state machine | 장기 workflow 상태 전이가 복잡함 | 미설치 recipe | +| Virtualization | 대량 list rendering이 측정상 병목 | opt-in recipe 제공, 미설치 | +| OpenAPI generator | backend 계약에서 client 생성 필요 | opt-in recipe 제공, 미설치 | +| Zustand/Redux/Jotai | 복잡한 cross-page client state 확인 | opt-in recipe 제공, 미설치 | +| XState 등 state machine | 장기 workflow 상태 전이가 복잡함 | opt-in recipe 제공, 미설치 | | Cloud visual-review service | 외부 승인·호스팅 workflow 필요 | 로컬 Storybook/visual gate 뒤에 미설치 | 선택 adapter는 “나중에 쓸 수 있으므로” 기본 bundle에 넣지 않는다. 도입 조건, 보안 영향, bundle 비용과 제거 방법이 확인된 경우에만 추가한다. +현재 구현된 공통 catalog, TypeScript contract/fake와 blocking gate는 +`docs/architecture/optional-adapter-recipes.md`와 +`config/recipes/frontend-capability-recipes.json`을 따른다. 이 recipe source를 +production에서 직접 import하는 것은 금지하며 선택한 contract만 application +소유 경계로 이동한다. ## 19. 새 outbound adapter 추가 recipe diff --git a/docs/architecture/optional-adapter-recipes.md b/docs/architecture/optional-adapter-recipes.md new file mode 100644 index 0000000..8046985 --- /dev/null +++ b/docs/architecture/optional-adapter-recipes.md @@ -0,0 +1,156 @@ +# Optional frontend adapter recipes + +이 문서는 도메인과 무관한 선택형 frontend capability를 실제 프로젝트에 +도입하는 실행 가이드다. 기본 스켈레톤에는 vendor runtime을 설치하지 않는다. +`RECIPE_AVAILABLE`은 계약·fake·failure policy가 준비됐다는 뜻이며 실제 provider, +runtime behavior 또는 production readiness를 뜻하지 않는다. + +## 1. 현재 상태와 파일 지도 + +| 항목 | 경로 | production 포함 | +| --- | --- | --- | +| 선택/금지/예산 SSOT | `config/recipes/frontend-capability-recipes.json` | 정책만 | +| catalog JSON schema | `schemas/config/frontend-capability-recipes.schema.json` | 아니오 | +| TypeScript port | `recipes/frontend-capabilities/contracts.ts` | 아니오 | +| fake/unavailable | `recipes/frontend-capabilities/fake-adapters.ts` | 아니오 | +| contract test | `tests/recipes/optional-capability-contracts.test.ts` | 아니오 | +| 정적/번들 gate | `scripts/check-optional-recipes.mjs` | build 도구 | +| negative fixture | `scripts/check-optional-recipe-fixtures.mjs` | 아니오 | +| 완전 제거 gate | `scripts/test-optional-recipe-removal.mjs` | 아니오 | + +현재 `productionRuntimeDependencies`는 빈 배열이며 12개 recipe 모두 선택되지 +않았다. TypeScript example은 product source가 import할 library가 아니라 선택 +시 복사하고 좁힐 출발점이다. + +## 2. 어느 경계에 두는가 + +| capability 성격 | port 소유자 | adapter 방향 | concrete 위치 예 | +| --- | --- | --- | --- | +| application이 외부 결과를 요청 | application | outbound | `src/adapters/` | +| URL/browser event가 의도를 전달 | application input | inbound | `src/presentation/adapters` | +| React rendering behavior만 교체 | presentation | local facade | `src/presentation/` | +| feature 전용 protocol | feature application | in/out 분리 | `src/features//adapters` | + +WebSocket 연결 생성, reconnect와 credential attachment는 outbound다. 수신 JSON +검증과 application input 호출은 inbound다. Service Worker update event, +BroadcastChannel event도 같은 원칙을 적용한다. generated DTO와 vendor SDK +type은 facade 밖으로 노출하지 않는다. + +## 3. 12개 recipe 선택표 + +| recipe | 설치하는 경우 | 설치하면 안 되는 경우 | 핵심 fallback | +| --- | --- | --- | --- | +| realtime | ordered push/resume protocol이 확정됨 | polling이 충분하거나 ordering owner 없음 | bounded polling/stale UI | +| offline/IndexedDB | durable offline data/queue가 제품 요구 | credential 저장, DB 직접 연결, HTTP cache로 충분 | online-only + offline state | +| Service Worker/PWA | install/offline shell과 cache owner 승인 | update/rollback UX 없음 | hosting cache 기반 network app | +| file transfer | progress/cancel/size/type 정책 필요 | long-lived credential URL | bounded normal request | +| generated API | versioned source와 drift CI가 있음 | DTO가 domain/UI로 노출됨 | typed request builder + schema | +| feature flag | rollout/kill switch owner와 default 있음 | authorization에 사용 | typed local default | +| Web Worker | profiler가 main-thread 병목을 증명 | 단순 network I/O | chunked/deferred execution | +| multi-tab | 비민감 event 동기화가 필요 | server가 conflict authority | focus 시 authoritative refresh | +| browser permission | user gesture 기반 기능 필요 | boot 요청, denied UX 없음 | manual input/instruction | +| client workflow | cross-page client-only state가 실재 | query/server state 복제 | URL/local/context/query | +| large data UI | 실측 scale이 budget 초과 | pagination으로 충분, a11y 미정 | accessible pagination | +| analytics/error sink | provider·consent·retention 승인 | arbitrary payload/redaction 우회 | bounded local diagnostics | + +정확한 failure matrix, security/privacy, gzip budget과 제거 순서는 JSON catalog가 +SSOT다. 문서와 catalog가 다르면 gate가 검사하는 catalog를 우선 고치고 이 표도 +같이 갱신한다. + +## 4. 공통 구현 순서 + +1. 문제를 vendor 이름이 아닌 capability와 측정값으로 기록한다. +2. catalog의 trigger와 forbidden 조건을 모두 검토한다. +3. project owner, security/privacy reviewer, gzip budget과 재검토 날짜를 VD-10 + amendment에 기록한다. +4. existing URL/local/context/query/application port로 해결되지 않는지 확인한다. +5. 필요한 contract만 `recipes`에서 해당 application/presentation 경계로 복사해 + 실제 payload와 failure union으로 좁힌다. +6. concrete SDK는 `src/adapters/...` 또는 local presentation facade adapter에서만 + import한다. +7. composition root가 concrete adapter를 주입한다. page/use case가 constructor를 + 직접 호출하지 않는다. +8. fake, unavailable, timeout/cancel, cleanup, malformed input, redaction과 + integration test를 작성한다. +9. runtime config schema, dependency inventory/approval, SBOM, bundle budget, + browser support와 runbook을 갱신한다. +10. 실제 provider integration과 negative behavior가 통과한 뒤에만 catalog 상태를 + 별도 project catalog에서 `INSTALLED`로 바꾼다. + +## 5. capability별 필수 검증 + +### Realtime + +- runtime schema로 envelope/version/event ID/sequence/timestamp를 검증한다. +- reconnect는 exponential backoff 상한, visibility/offline 상태, auth refresh와 + resume token expiry를 정의한다. +- duplicate/out-of-order는 domain use case에 전달하기 전에 정책화한다. +- route unmount/logout에서 unsubscribe하고 heartbeat timer를 종료한다. + +### Offline/Service Worker + +- store/cache 이름과 schema는 release와 독립적인 migration version을 가진다. +- quota, corrupt row, partial migration, downgrade/rollback을 fixture로 만든다. +- authenticated response와 credential은 기본 cache 대상이 아니다. +- stale worker loop를 막고 unregister 후 owned cache 삭제가 가능한지 검증한다. + +### File/generated API + +- upload는 client MIME을 신뢰하지 않고 size/type/server rejection을 모두 다룬다. +- progress는 unknown total을 허용하며 navigation/unmount에서 AbortSignal로 + 취소한다. +- generated code는 facade 뒤 DTO이며 runtime response schema와 contract drift + gate를 유지한다. + +### Flag/worker/multi-tab/browser + +- flag unknown/unavailable/stale에서 명시적 typed fallback을 사용하고 access + control로 사용하지 않는다. +- worker는 task ID/generation/cancel을 사용해 stale result를 폐기하고 crash를 + normalized failure로 바꾼다. +- multi-tab은 source/event/version으로 self-echo와 duplicate를 막고 payload를 + 비민감 invalidation hint로 제한한다. +- browser permission은 user gesture에서만 요청하고 denied/dismissed/unsupported를 + 서로 다른 UX 결과로 처리한다. + +### Client workflow/large data/analytics + +- workflow store는 server entity/collection을 복제하지 않고 query key나 ID 참조만 + 보관한다. logout/reset/version mismatch 정책을 테스트한다. +- virtualization은 profiler와 production-like row count로 정당화하며 keyboard, + focus restoration, screen reader와 stale row identity를 검증한다. +- analytics는 essential diagnostics와 consent-required event를 분리하고 closed + event/attribute registry, pre-queue redaction, sampling, bounded queue와 + retention을 적용한다. + +## 6. 검증 명령 + +```bash +corepack pnpm check:types:recipes +corepack pnpm test:recipes +corepack pnpm build +corepack pnpm check:optional-recipes +corepack pnpm check:optional-recipe-fixtures +corepack pnpm test:optional-recipe-removal +``` + +negative gate는 cleanup 누락, unselected dependency, local adapter 밖 vendor +import, credential localStorage/URL/telemetry 경로, workflow store의 server-state +복제와 production source의 recipe import를 거절한다. removal gate는 recipe와 +recipe test를 삭제한 임시 사본에서 base typecheck, architecture, test와 build를 +실행한다. + +## 7. 제거 체크리스트 + +1. 신규 호출과 background 작업을 중지한다. +2. subscription, worker, channel, media track, observer를 cleanup한다. +3. persisted store/cache/event queue의 migrate 또는 purge 정책을 실행한다. +4. composition registration과 runtime config를 제거한다. +5. concrete adapter, facade/port와 vendor dependency를 제거한다. +6. dependency baseline, SBOM과 bundle baseline을 갱신한다. +7. typecheck/test/build, production bundle absence와 도메인 기능 fallback을 + 검증한다. + +provider 장애 시 fake로 바꾸어 production을 PASS 처리하지 않는다. 문서화된 +unavailable fallback만 사용하고 provider가 필수인 promotion은 +`FAIL_UNVERIFIED` 또는 blocked 상태로 유지한다. diff --git a/docs/architecture/typescript-state-and-data-flow.md b/docs/architecture/typescript-state-and-data-flow.md index cd3b185..9bc186c 100644 --- a/docs/architecture/typescript-state-and-data-flow.md +++ b/docs/architecture/typescript-state-and-data-flow.md @@ -179,6 +179,11 @@ export type AppFailure = vendor를 선택하더라도 feature 외부에는 hook/facade만 export한다. 제품 코드가 store instance의 `getState`와 `setState`를 임의 호출하지 않게 한다. +현재 `recipes/frontend-capabilities`의 `ClientWorkflowPort`와 +`FakeClientWorkflowAdapter`가 vendor-neutral opt-in 예제를 제공한다. 기본 +production에는 Zustand/Redux Toolkit/state-machine dependency가 없고, +`check:optional-recipe-fixtures`가 server response collection을 client workflow +store에 복제하는 패턴을 거절한다. ### 3.3 persistence diff --git a/docs/security/supply-chain.md b/docs/security/supply-chain.md index 3c38f63..b967dc4 100644 --- a/docs/security/supply-chain.md +++ b/docs/security/supply-chain.md @@ -10,7 +10,7 @@ - independent review for new direct production dependencies - CycloneDX 1.6 SBOM and inventory component/edge coherence - source/lock/SBOM/dist-linked local provenance statement -- source, scripts, tests, tracked config/schema, public, built asset and generated +- source, opt-in recipes, scripts, tests, tracked config/schema, public, built asset and generated release metadata secret scan - two-build `SOURCE_DATE_EPOCH` reproducibility check diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md index 22787e0..8c8dbe9 100644 --- a/docs/testing/frontend-platform-testing-strategy.md +++ b/docs/testing/frontend-platform-testing-strategy.md @@ -1299,6 +1299,16 @@ TypeScript test, Storybook, coverage, visual과 built-dist 명령은 - flaky test를 owner/만료일 없이 skip - CI gate에 `continue-on-error` +### 선택형 adapter recipe gate + +선택형 capability example은 `tests/recipes`에서 contract/fake/unavailable을 +실행하지만 production entry에는 포함하지 않는다. `check:optional-recipes`는 +12개 catalog 완전성, unselected dependency, production source import와 built +bundle sentinel 부재를 검사한다. negative fixture는 lifecycle cleanup 누락, +vendor direct import, credential storage/URL/telemetry 경로와 workflow store의 +server-state 복제를 거절한다. `test:optional-recipe-removal`은 recipe 전체를 +제거한 사본에서 base typecheck/test/build를 다시 실행한다. + ## 20. 단계별 도입 순서 1. `tsconfig.test.json`과 test typecheck gate를 추가한다. diff --git a/eslint.config.js b/eslint.config.js index bf1aca3..a5fd490 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -79,6 +79,7 @@ export default [ "tests/fixtures/diagnostics/forbidden/**", "tests/fixtures/i18n/forbidden/**", "tests/fixtures/security/forbidden/**", + "tests/fixtures/optional-recipes/**", ], }, eslint.configs.recommended, diff --git a/package.json b/package.json index 7ee68cd..3ef240e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build": "vite build && node scripts/generate-build-manifest.mjs", "build:release": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security", "preview": "vite preview", - "lint": "eslint src scripts tests .storybook vite.config.js vitest.config.js playwright*.config.js --max-warnings=0", + "lint": "eslint src scripts tests recipes .storybook vite.config.js vitest.config.js playwright*.config.js --max-warnings=0", "check:architecture": "node scripts/check-architecture.mjs", "check:design-system": "node scripts/check-design-system.mjs", "check:design-system:fixture": "node scripts/check-design-system.mjs --fixture", @@ -25,6 +25,7 @@ "check:types:app": "tsc --project tsconfig.app.json", "check:types:node": "tsc --project tsconfig.node.json", "check:types:test": "tsc --project tsconfig.test.json", + "check:types:recipes": "tsc --project tsconfig.recipes.json", "check:types:fixture": "tsc --ignoreConfig --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js", "check:types:fixture:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts", "check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts", @@ -41,6 +42,7 @@ "test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", "test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml", + "test:recipes": "vitest run tests/recipes --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/optional-recipes.xml --passWithNoTests", "test:e2e": "playwright test", "test:e2e:dev": "playwright test --config playwright.dev.config.js", "storybook": "storybook dev -p 6006", @@ -53,10 +55,11 @@ "test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs", "review:a11y-manual": "node scripts/verify-a11y-manual.mjs", "test:sample-removal": "node scripts/test-sample-removal.mjs", + "test:optional-recipe-removal": "node scripts/test-optional-recipe-removal.mjs", "test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests", "test:coverage": "vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.mjs", "check:coverage:fixture": "node scripts/check-risk-coverage.mjs --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json", - "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature", + "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes", "verify:lockfile": "corepack pnpm install --frozen-lockfile", "check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.mjs", "generate:supply-chain": "node scripts/generate-supply-chain.mjs", @@ -69,6 +72,9 @@ "scan:security": "node scripts/security-scan.mjs", "scan:security:fixture": "node scripts/security-scan.mjs --policy tests/fixtures/security/secret-detection/forbidden-policy.json --artifact artifacts/security/scan-fixture.sarif", "check:browser-security": "node scripts/check-browser-security.mjs", + "check:optional-recipes": "node scripts/check-optional-recipes.mjs --require-dist", + "check:optional-recipes:source": "node scripts/check-optional-recipes.mjs", + "check:optional-recipe-fixtures": "node scripts/check-optional-recipe-fixtures.mjs", "check:registries": "node scripts/check-registries.mjs", "check:registries:structure": "node scripts/check-registries.mjs --no-baseline", "check:registries:compatibility-fixtures": "node scripts/check-registry-compatibility-fixtures.mjs", diff --git a/recipes/frontend-capabilities/contracts.ts b/recipes/frontend-capabilities/contracts.ts new file mode 100644 index 0000000..38d0b8e --- /dev/null +++ b/recipes/frontend-capabilities/contracts.ts @@ -0,0 +1,207 @@ +/** + * Opt-in capability contracts. + * + * This directory is a copyable recipe source, not a production entry. A project + * moves only the selected contract into its application-owned boundary and puts + * a concrete implementation behind that port. + */ +export const OPTIONAL_RECIPE_RUNTIME_SENTINEL = + "frontend-optional-recipe-must-not-reach-production"; + +export type CapabilityFailureCode = + | "ABORTED" + | "AUTH_EXPIRED" + | "CONFLICT" + | "CONSENT_DENIED" + | "CONTRACT_DRIFT" + | "CORRUPT_DATA" + | "DISCONNECTED" + | "EXPIRED_RESOURCE" + | "INVALID_INPUT" + | "LIMIT_EXCEEDED" + | "MIGRATION_FAILED" + | "NOT_FOUND" + | "PROVIDER_UNAVAILABLE" + | "QUOTA_EXCEEDED" + | "STALE_RESULT" + | "UNSUPPORTED"; + +export type CapabilityFailure = Readonly<{ + code: CapabilityFailureCode; + retryable: boolean; + safeMessage: string; +}>; + +export type CapabilityResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ ok: false; failure: CapabilityFailure }>; + +export type Cleanup = () => void; + +export type RealtimeEvent = Readonly<{ + id: string; + sequence: number; + occurredAt: string; + payload: T; +}>; + +export interface RealtimeSubscription { + readonly resumeToken: string | null; + unsubscribe(): void; +} + +export interface RealtimePort { + subscribe(input: { + channel: string; + resumeToken?: string; + signal?: AbortSignal; + onEvent(event: CapabilityResult>): void; + }): Promise>; + heartbeat(signal?: AbortSignal): Promise>; +} + +export interface VersionedOfflineRepository { + open(input: { + schemaVersion: number; + signal?: AbortSignal; + }): Promise>; + get(id: string, signal?: AbortSignal): Promise>; + put(value: T, signal?: AbortSignal): Promise>; + migrate(input: { + from: number; + to: number; + signal?: AbortSignal; + }): Promise>; + close(): void; +} + +export interface ServiceWorkerUpdatePort { + inspect(signal?: AbortSignal): Promise< + CapabilityResult> + >; + activate(version: string, signal?: AbortSignal): Promise>; + rollback(signal?: AbortSignal): Promise>; + unregister(): Promise>; +} + +export type TransferProgress = Readonly<{ + transferredBytes: number; + totalBytes: number | null; +}>; + +export interface FileTransferPort { + upload(input: { + file: Readonly<{ name: string; size: number; type: string }>; + signal: AbortSignal; + onProgress(progress: TransferProgress): void; + }): Promise>>; + download(input: { + resourceId: string; + signal: AbortSignal; + onProgress(progress: TransferProgress): void; + }): Promise>; +} + +export interface GeneratedApiFacade { + execute(input: { + operationId: string; + contractVersion: string; + body?: unknown; + signal?: AbortSignal; + }): Promise>; +} + +export interface FeatureFlagPort> { + evaluate(input: { + key: TKey; + fallback: TFlags[TKey]; + maxAgeMs: number; + }): Promise>; +} + +export interface WorkerTaskPort { + run(input: { + taskId: string; + generation: number; + payload: TInput; + signal: AbortSignal; + }): Promise>; + cancel(taskId: string): void; + dispose(): void; +} + +export type MultiTabEvent = Readonly<{ + eventId: string; + sourceId: string; + version: number; + payload: T; +}>; + +export interface MultiTabPort { + publish(event: MultiTabEvent): CapabilityResult; + subscribe(input: { + sourceId: string; + onEvent(event: CapabilityResult>): void; + }): Cleanup; + close(): void; +} + +export type BrowserCapability = + | "clipboard-read" + | "clipboard-write" + | "media" + | "notification"; + +export type PermissionDecision = "granted" | "denied" | "dismissed"; + +export interface BrowserPermissionPort { + request(input: { + capability: BrowserCapability; + signal?: AbortSignal; + }): Promise>; +} + +export interface ClientWorkflowPort { + snapshot(): Readonly; + dispatch(event: TEvent): CapabilityResult>; + reset(): void; + subscribe(listener: (state: Readonly) => void): Cleanup; +} + +export interface LargeDataUiFacade { + window(input: { + offset: number; + limit: number; + generation: number; + }): CapabilityResult>; + focus(rowId: string): CapabilityResult; + replace(rows: ReadonlyArray, generation: number): void; +} + +export type SafeAnalyticsValue = boolean | number | string | null; + +export interface AnalyticsErrorSink { + record(input: { + kind: "analytics" | "error"; + eventId: string; + consent: "granted" | "denied" | "not-required"; + attributes: Readonly>; + }): CapabilityResult; + flush(signal?: AbortSignal): Promise>; + dispose(): void; +} + +export type OptionalCapabilityPorts = Readonly<{ + realtime: RealtimePort; + offline: VersionedOfflineRepository<{ id: string }>; + serviceWorker: ServiceWorkerUpdatePort; + fileTransfer: FileTransferPort; + generatedApi: GeneratedApiFacade; + featureFlag: FeatureFlagPort>; + worker: WorkerTaskPort; + multiTab: MultiTabPort; + browserPermission: BrowserPermissionPort; + clientWorkflow: ClientWorkflowPort; + largeDataUi: LargeDataUiFacade<{ id: string }>; + analytics: AnalyticsErrorSink; +}>; diff --git a/recipes/frontend-capabilities/fake-adapters.ts b/recipes/frontend-capabilities/fake-adapters.ts new file mode 100644 index 0000000..0e22ca0 --- /dev/null +++ b/recipes/frontend-capabilities/fake-adapters.ts @@ -0,0 +1,542 @@ +import type { + AnalyticsErrorSink, + BrowserCapability, + BrowserPermissionPort, + CapabilityFailure, + CapabilityResult, + ClientWorkflowPort, + FeatureFlagPort, + FileTransferPort, + GeneratedApiFacade, + LargeDataUiFacade, + MultiTabEvent, + MultiTabPort, + OptionalCapabilityPorts, + PermissionDecision, + RealtimeEvent, + RealtimePort, + RealtimeSubscription, + SafeAnalyticsValue, + ServiceWorkerUpdatePort, + VersionedOfflineRepository, + WorkerTaskPort, +} from "./contracts.js"; + +export function success(value: T): CapabilityResult { + return Object.freeze({ ok: true, value }); +} + +export function failure( + code: CapabilityFailure["code"], + retryable = false, + safeMessage = "Optional capability is unavailable.", +): CapabilityResult { + return Object.freeze({ + ok: false, + failure: Object.freeze({ code, retryable, safeMessage }), + }); +} + +function aborted(signal?: AbortSignal): CapabilityResult | null { + return signal?.aborted + ? failure("ABORTED", false, "The operation was cancelled.") + : null; +} + +export class FakeRealtimeAdapter implements RealtimePort { + readonly #subscriptions = new Map< + string, + { + lastSequence: number; + onEvent(event: CapabilityResult>): void; + } + >(); + + async subscribe(input: { + channel: string; + resumeToken?: string; + signal?: AbortSignal; + onEvent(event: CapabilityResult>): void; + }): Promise> { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + const key = `${input.channel}:${this.#subscriptions.size + 1}`; + this.#subscriptions.set(key, { lastSequence: -1, onEvent: input.onEvent }); + const unsubscribe = () => this.#subscriptions.delete(key); + input.signal?.addEventListener("abort", unsubscribe, { once: true }); + return success( + Object.freeze({ + resumeToken: input.resumeToken ?? null, + unsubscribe, + }), + ); + } + + async heartbeat(signal?: AbortSignal): Promise> { + return aborted(signal) ?? success(undefined); + } + + emit(channel: string, event: RealtimeEvent): void { + for (const [key, subscription] of this.#subscriptions) { + if (!key.startsWith(`${channel}:`)) continue; + if (event.sequence <= subscription.lastSequence) { + subscription.onEvent( + failure( + "STALE_RESULT", + false, + "A duplicate or out-of-order event was ignored.", + ), + ); + continue; + } + subscription.lastSequence = event.sequence; + subscription.onEvent(success(event)); + } + } + + get activeSubscriptionCount(): number { + return this.#subscriptions.size; + } +} + +export class MemoryOfflineRepository + implements VersionedOfflineRepository +{ + readonly #records = new Map(); + #openVersion: number | null = null; + + async open(input: { + schemaVersion: number; + signal?: AbortSignal; + }): Promise> { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + if (!Number.isInteger(input.schemaVersion) || input.schemaVersion < 1) { + return failure("CORRUPT_DATA", false, "Invalid offline schema version."); + } + this.#openVersion = input.schemaVersion; + return success(undefined); + } + + async get(id: string, signal?: AbortSignal): Promise> { + const cancelled = aborted(signal); + if (cancelled) return cancelled; + if (this.#openVersion === null) { + return failure("PROVIDER_UNAVAILABLE", false, "Repository is closed."); + } + return success(this.#records.get(id) ?? null); + } + + async put(value: T, signal?: AbortSignal): Promise> { + const cancelled = aborted(signal); + if (cancelled) return cancelled; + if (this.#openVersion === null) { + return failure("PROVIDER_UNAVAILABLE", false, "Repository is closed."); + } + this.#records.set(value.id, structuredClone(value)); + return success(undefined); + } + + async migrate(input: { + from: number; + to: number; + signal?: AbortSignal; + }): Promise> { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + if (this.#openVersion !== input.from || input.to <= input.from) { + return failure("MIGRATION_FAILED", false, "Offline migration was rejected."); + } + this.#openVersion = input.to; + return success(undefined); + } + + close(): void { + this.#openVersion = null; + } +} + +export class FakeServiceWorkerUpdateAdapter implements ServiceWorkerUpdatePort { + #activeVersion: string | null; + #candidateVersion: string | null; + + constructor(activeVersion: string | null, candidateVersion: string | null) { + this.#activeVersion = activeVersion; + this.#candidateVersion = candidateVersion; + } + + async inspect(signal?: AbortSignal) { + return ( + aborted(signal) ?? + success({ + updateAvailable: this.#candidateVersion !== null, + version: this.#candidateVersion, + }) + ); + } + + async activate(version: string, signal?: AbortSignal) { + const cancelled = aborted(signal); + if (cancelled) return cancelled; + if (version !== this.#candidateVersion) { + return failure("STALE_RESULT", false, "Worker update is no longer current."); + } + this.#activeVersion = version; + this.#candidateVersion = null; + return success(undefined); + } + + async rollback(signal?: AbortSignal) { + const cancelled = aborted(signal); + if (cancelled) return cancelled; + if (!this.#activeVersion) { + return failure("NOT_FOUND", false, "No active worker can be rolled back."); + } + this.#activeVersion = null; + return success(undefined); + } + + async unregister() { + this.#activeVersion = null; + this.#candidateVersion = null; + return success(undefined); + } +} + +export class FakeFileTransferAdapter implements FileTransferPort { + constructor( + private readonly maxBytes = 5_000_000, + private readonly acceptedTypes: ReadonlySet = new Set([ + "application/pdf", + "image/png", + ]), + ) {} + + async upload(input: Parameters[0]) { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + if ( + input.file.size > this.maxBytes || + !this.acceptedTypes.has(input.file.type) + ) { + return failure("LIMIT_EXCEEDED", false, "File size or type is not allowed."); + } + input.onProgress({ + transferredBytes: input.file.size, + totalBytes: input.file.size, + }); + return success({ resourceId: `fake:${input.file.name}` }); + } + + async download(input: Parameters[0]) { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + if (input.resourceId.startsWith("expired:")) { + return failure("EXPIRED_RESOURCE", true, "The download link expired."); + } + const bytes = new TextEncoder().encode(input.resourceId); + input.onProgress({ + transferredBytes: bytes.byteLength, + totalBytes: bytes.byteLength, + }); + return success(bytes); + } +} + +export class FakeGeneratedApiAdapter implements GeneratedApiFacade { + constructor( + private readonly contractVersion: string, + private readonly handlers: Readonly< + Record unknown | Promise> + >, + ) {} + + async execute( + input: Parameters[0], + ): Promise> { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + if (input.contractVersion !== this.contractVersion) { + return failure("CONTRACT_DRIFT", false, "API contract version is unsupported."); + } + const handler = this.handlers[input.operationId]; + if (!handler) { + return failure("UNSUPPORTED", false, "API operation is unsupported."); + } + return success((await handler(input.body)) as TOutput); + } +} + +export class FakeFeatureFlagAdapter< + TFlags extends Record, +> implements FeatureFlagPort +{ + constructor( + private readonly values: Readonly>, + private readonly available = true, + ) {} + + async evaluate(input: { + key: TKey; + fallback: TFlags[TKey]; + maxAgeMs: number; + }): Promise> { + if (!this.available) { + return failure("PROVIDER_UNAVAILABLE", true, "Flag provider is unavailable."); + } + const value = this.values[input.key]; + return success((value ?? input.fallback) as TFlags[TKey]); + } +} + +export class FakeWorkerTaskAdapter + implements WorkerTaskPort +{ + readonly #cancelled = new Set(); + + constructor( + private readonly handler: (input: TInput) => TOutput | Promise, + ) {} + + async run(input: { + taskId: string; + generation: number; + payload: TInput; + signal: AbortSignal; + }): Promise> { + if (input.signal.aborted || this.#cancelled.has(input.taskId)) { + return failure("ABORTED", false, "Worker task was cancelled."); + } + const output = await this.handler(input.payload); + if (input.signal.aborted || this.#cancelled.has(input.taskId)) { + return failure("STALE_RESULT", false, "Stale worker result was discarded."); + } + return success(output); + } + + cancel(taskId: string): void { + this.#cancelled.add(taskId); + } + + dispose(): void { + this.#cancelled.clear(); + } +} + +export class FakeMultiTabAdapter implements MultiTabPort { + readonly #seen = new Set(); + readonly #listeners = new Set<{ + sourceId: string; + onEvent(event: CapabilityResult>): void; + }>(); + + publish(event: MultiTabEvent): CapabilityResult { + if (this.#seen.has(event.eventId)) { + return failure("CONFLICT", false, "Duplicate multi-tab event was ignored."); + } + this.#seen.add(event.eventId); + for (const listener of this.#listeners) { + if (listener.sourceId !== event.sourceId) { + listener.onEvent(success(event)); + } + } + return success(undefined); + } + + subscribe(input: { + sourceId: string; + onEvent(event: CapabilityResult>): void; + }) { + this.#listeners.add(input); + return () => this.#listeners.delete(input); + } + + close(): void { + this.#listeners.clear(); + this.#seen.clear(); + } +} + +export class FakeBrowserPermissionAdapter implements BrowserPermissionPort { + constructor( + private readonly decisions: Readonly< + Partial> + >, + ) {} + + async request(input: { + capability: BrowserCapability; + signal?: AbortSignal; + }): Promise> { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + const decision = this.decisions[input.capability]; + return decision + ? success(decision) + : failure("UNSUPPORTED", false, "Browser capability is unsupported."); + } +} + +export class FakeClientWorkflowAdapter + implements ClientWorkflowPort +{ + readonly #initial: TState; + readonly #listeners = new Set<(state: Readonly) => void>(); + #state: TState; + + constructor( + initial: TState, + private readonly transition: (state: TState, event: TEvent) => TState, + ) { + this.#initial = structuredClone(initial); + this.#state = structuredClone(initial); + } + + snapshot(): Readonly { + return structuredClone(this.#state); + } + + dispatch(event: TEvent): CapabilityResult> { + this.#state = this.transition(this.#state, event); + const snapshot = this.snapshot(); + this.#listeners.forEach((listener) => listener(snapshot)); + return success(snapshot); + } + + reset(): void { + this.#state = structuredClone(this.#initial); + const snapshot = this.snapshot(); + this.#listeners.forEach((listener) => listener(snapshot)); + } + + subscribe(listener: (state: Readonly) => void) { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } +} + +export class FakeLargeDataUiAdapter + implements LargeDataUiFacade +{ + #rows: ReadonlyArray = []; + #generation = 0; + + window(input: { offset: number; limit: number; generation: number }) { + if (input.generation !== this.#generation) { + return failure("STALE_RESULT", false, "Stale row window was discarded."); + } + if (input.offset < 0 || input.limit < 1) { + return failure("INVALID_INPUT", false, "Invalid row window."); + } + return success(this.#rows.slice(input.offset, input.offset + input.limit)); + } + + focus(rowId: string) { + return this.#rows.some((row) => row.id === rowId) + ? success(undefined) + : failure("NOT_FOUND", false, "Row is no longer available."); + } + + replace(rows: ReadonlyArray, generation: number): void { + this.#rows = rows; + this.#generation = generation; + } +} + +const sensitiveAttribute = /credential|authorization|cookie|password|secret|token/i; + +export class RecordingAnalyticsAdapter implements AnalyticsErrorSink { + readonly records: Array< + Readonly<{ + kind: "analytics" | "error"; + eventId: string; + attributes: Readonly>; + }> + > = []; + + constructor(private readonly capacity = 100) {} + + record(input: Parameters[0]) { + if (input.kind === "analytics" && input.consent !== "granted") { + return failure("CONSENT_DENIED", false, "Analytics consent was not granted."); + } + if (this.records.length >= this.capacity) { + return failure("LIMIT_EXCEEDED", true, "Analytics queue is full."); + } + const attributes = Object.fromEntries( + Object.entries(input.attributes).filter(([key]) => !sensitiveAttribute.test(key)), + ); + this.records.push( + Object.freeze({ kind: input.kind, eventId: input.eventId, attributes }), + ); + return success(undefined); + } + + async flush(signal?: AbortSignal) { + return aborted(signal) ?? success(undefined); + } + + dispose(): void { + this.records.length = 0; + } +} + +const unavailableAsync = async () => + failure("PROVIDER_UNAVAILABLE", true, "Capability was not installed."); +const unavailableSync = () => + failure("PROVIDER_UNAVAILABLE", true, "Capability was not installed."); + +export function createUnavailableAdapters(): OptionalCapabilityPorts { + return Object.freeze({ + realtime: { + subscribe: unavailableAsync, + heartbeat: unavailableAsync, + }, + offline: { + open: unavailableAsync, + get: unavailableAsync, + put: unavailableAsync, + migrate: unavailableAsync, + close() {}, + }, + serviceWorker: { + inspect: unavailableAsync, + activate: unavailableAsync, + rollback: unavailableAsync, + unregister: unavailableAsync, + }, + fileTransfer: { + upload: unavailableAsync, + download: unavailableAsync, + }, + generatedApi: { execute: unavailableAsync }, + featureFlag: { evaluate: unavailableAsync }, + worker: { + run: unavailableAsync, + cancel() {}, + dispose() {}, + }, + multiTab: { + publish: unavailableSync, + subscribe: () => () => {}, + close() {}, + }, + browserPermission: { request: unavailableAsync }, + clientWorkflow: { + snapshot: () => Object.freeze({ unavailable: true }), + dispatch: unavailableSync, + reset() {}, + subscribe: () => () => {}, + }, + largeDataUi: { + window: unavailableSync, + focus: unavailableSync, + replace() {}, + }, + analytics: { + record: unavailableSync, + flush: unavailableAsync, + dispose() {}, + }, + }); +} diff --git a/recipes/frontend-capabilities/index.ts b/recipes/frontend-capabilities/index.ts new file mode 100644 index 0000000..2de0ecd --- /dev/null +++ b/recipes/frontend-capabilities/index.ts @@ -0,0 +1,2 @@ +export * from "./contracts.js"; +export * from "./fake-adapters.js"; diff --git a/schemas/config/frontend-capability-recipes.schema.json b/schemas/config/frontend-capability-recipes.schema.json new file mode 100644 index 0000000..28721a7 --- /dev/null +++ b/schemas/config/frontend-capability-recipes.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "frontend-capability-recipes.schema.json", + "title": "Frontend optional capability recipe catalog", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "decisionId", + "defaultStatus", + "productionRuntimeDependencies", + "catalogOwner", + "reviewOn", + "vendorPackagePatterns", + "recipes" + ], + "properties": { + "$schema": { "type": "string", "minLength": 1 }, + "schemaVersion": { "const": 1 }, + "decisionId": { "const": "VD-10" }, + "defaultStatus": { "const": "NOT_INSTALLED" }, + "productionRuntimeDependencies": { + "type": "array", + "maxItems": 0 + }, + "catalogOwner": { "type": "string", "minLength": 1 }, + "reviewOn": { "type": "string", "minLength": 1 }, + "vendorPackagePatterns": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "recipes": { + "type": "array", + "minItems": 12, + "maxItems": 12, + "items": { "$ref": "#/$defs/recipe" } + } + }, + "$defs": { + "recipe": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "status", + "trigger", + "forbiddenWhen", + "boundary", + "port", + "fake", + "failureKinds", + "lifecycleMethods", + "owner", + "securityPrivacy", + "bundleBudgetGzipBytes", + "fallback", + "removal", + "serverStatePolicy" + ], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "status": { "const": "RECIPE_AVAILABLE" }, + "trigger": { "type": "string", "minLength": 1 }, + "forbiddenWhen": { "$ref": "#/$defs/nonEmptyStrings" }, + "boundary": { "type": "string", "minLength": 1 }, + "port": { "type": "string", "minLength": 1 }, + "fake": { "type": "string", "minLength": 1 }, + "failureKinds": { "$ref": "#/$defs/nonEmptyStrings" }, + "lifecycleMethods": { "$ref": "#/$defs/nonEmptyStrings" }, + "owner": { "type": "string", "minLength": 1 }, + "securityPrivacy": { "$ref": "#/$defs/nonEmptyStrings" }, + "bundleBudgetGzipBytes": { "type": "integer", "minimum": 1 }, + "fallback": { "type": "string", "minLength": 1 }, + "removal": { "$ref": "#/$defs/nonEmptyStrings" }, + "serverStatePolicy": { "type": "string", "minLength": 1 } + } + }, + "nonEmptyStrings": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/scripts/check-optional-recipe-fixtures.mjs b/scripts/check-optional-recipe-fixtures.mjs new file mode 100644 index 0000000..3893f9b --- /dev/null +++ b/scripts/check-optional-recipe-fixtures.mjs @@ -0,0 +1,93 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +import { + scanOptionalRecipeSources, + validateRecipeCatalog, +} from "./lib/optional-recipes.mjs"; + +const catalog = JSON.parse( + await readFile("config/recipes/frontend-capability-recipes.json", "utf8"), +); +const packageDocument = JSON.parse(await readFile("package.json", "utf8")); + +const cleanupCatalog = structuredClone(catalog); +cleanupCatalog.recipes.find( + /** @param {{id: string}} recipe */ (recipe) => recipe.id === "realtime", +).lifecycleMethods = []; + +const dependencyCatalog = structuredClone(catalog); +dependencyCatalog.productionRuntimeDependencies = ["zustand"]; + +const workflowCatalog = structuredClone(catalog); +workflowCatalog.recipes.find( + /** @param {{id: string}} recipe */ (recipe) => recipe.id === "client-workflow", +).serverStatePolicy = "copied-server-state"; + +const sourceViolations = await scanOptionalRecipeSources( + "tests/fixtures/optional-recipes/forbidden", + { scanProductionBoundary: false }, +); +const productionViolations = await scanOptionalRecipeSources( + "tests/fixtures/optional-recipes/forbidden/production-import", + { scanProductionBoundary: true }, +); +sourceViolations.push(...productionViolations); +const ruleIds = new Set(sourceViolations.map(({ ruleId }) => ruleId)); +const results = [ + { + id: "cleanup-omission", + passed: validateRecipeCatalog(cleanupCatalog, packageDocument).some( + (violation) => violation === "realtime:CLEANUP_CONTRACT_MISSING", + ), + }, + { + id: "unselected-runtime-dependency", + passed: validateRecipeCatalog(dependencyCatalog, packageDocument).includes( + "UNSELECTED_RUNTIME_DEPENDENCY", + ), + }, + { + id: "server-state-policy", + passed: validateRecipeCatalog(workflowCatalog, packageDocument).includes( + "client-workflow:SERVER_STATE_DUPLICATION_POLICY", + ), + }, + { + id: "vendor-direct-import", + passed: ruleIds.has("VENDOR_IMPORT_OUTSIDE_ADAPTER"), + }, + { + id: "credential-leak", + passed: ruleIds.has("CREDENTIAL_LEAK_PATH"), + }, + { + id: "server-state-source-duplication", + passed: ruleIds.has("CLIENT_STORE_DUPLICATES_SERVER_STATE"), + }, + { + id: "production-imports-recipe", + passed: ruleIds.has("PRODUCTION_IMPORTS_RECIPE"), + }, +]; +const report = { + schemaVersion: 1, + results, + passed: results.every(({ passed }) => passed), +}; +await mkdir("artifacts/quality", { recursive: true }); +await writeFile( + "artifacts/quality/optional-recipe-fixtures.json", + `${JSON.stringify(report, null, 2)}\n`, +); +if (!report.passed) { + process.stderr.write( + `Optional recipe negative fixtures failed: ${results + .filter(({ passed }) => !passed) + .map(({ id }) => id) + .join(", ")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Optional recipe negative fixtures: PASS (${results.length} forbidden cases rejected)\n`, +); diff --git a/scripts/check-optional-recipes.mjs b/scripts/check-optional-recipes.mjs new file mode 100644 index 0000000..04d24bf --- /dev/null +++ b/scripts/check-optional-recipes.mjs @@ -0,0 +1,74 @@ +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; + +import { + scanOptionalRecipeSources, + scanProductionBundle, + validateRecipeCatalog, +} from "./lib/optional-recipes.mjs"; + +/** @param {string} name @param {string} fallback */ +const argument = (name, fallback) => { + const index = process.argv.indexOf(name); + return index === -1 ? fallback : process.argv[index + 1]; +}; + +const catalogPath = argument( + "--catalog", + "config/recipes/frontend-capability-recipes.json", +); +const sourceRoot = argument("--source-root", "src"); +const distRoot = argument("--dist-root", "dist"); +const artifactPath = argument( + "--artifact", + "artifacts/quality/optional-recipes.json", +); +const requireDist = process.argv.includes("--require-dist"); + +const catalog = JSON.parse(await readFile(catalogPath, "utf8")); +const packageDocument = JSON.parse(await readFile("package.json", "utf8")); +const catalogViolations = validateRecipeCatalog(catalog, packageDocument); +const sourceViolations = await scanOptionalRecipeSources(sourceRoot); +const bundlePresent = await stat(`${distRoot}/.vite/manifest.json`) + .then(() => true) + .catch(() => false); +const bundleViolations = await scanProductionBundle(distRoot); +const violations = [ + ...catalogViolations.map((ruleId) => ({ ruleId, path: catalogPath })), + ...sourceViolations, + ...bundleViolations.map((path) => ({ + ruleId: "UNSELECTED_RECIPE_IN_PRODUCTION_BUNDLE", + path, + })), + ...(requireDist && !bundlePresent + ? [{ ruleId: "PRODUCTION_BUNDLE_MISSING", path: distRoot }] + : []), +]; +const report = { + schemaVersion: 1, + decisionId: "VD-10", + selectedCapabilities: [], + recipeCount: Array.isArray(catalog.recipes) ? catalog.recipes.length : 0, + productionRuntimeDependencies: + catalog.productionRuntimeDependencies ?? null, + bundleStatus: bundlePresent + ? bundleViolations.length === 0 + ? "PASS" + : "FAIL" + : "NOT_BUILT", + violations, + passed: violations.length === 0, +}; +await mkdir("artifacts/quality", { recursive: true }); +await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`); + +if (violations.length > 0) { + process.stderr.write( + `Optional recipe contract failed:\n${violations + .map((violation) => `${violation.ruleId}: ${violation.path}`) + .join("\n")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Optional recipes: PASS (${report.recipeCount} recipe-only capabilities, bundle=${report.bundleStatus})\n`, +); diff --git a/scripts/lib/optional-recipes.mjs b/scripts/lib/optional-recipes.mjs new file mode 100644 index 0000000..44609f1 --- /dev/null +++ b/scripts/lib/optional-recipes.mjs @@ -0,0 +1,265 @@ +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +export const REQUIRED_RECIPE_IDS = Object.freeze([ + "analytics-error-sink", + "browser-permission", + "client-workflow", + "feature-flag", + "file-transfer", + "generated-api", + "large-data-ui", + "multi-tab", + "offline-indexeddb", + "realtime", + "service-worker-pwa", + "web-worker", +]); + +const lifecycleRecipes = new Set([ + "analytics-error-sink", + "browser-permission", + "client-workflow", + "file-transfer", + "generated-api", + "multi-tab", + "offline-indexeddb", + "realtime", + "service-worker-pwa", + "web-worker", +]); + +/** @param {unknown} value */ +function nonEmptyStrings(value) { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((entry) => typeof entry === "string" && entry.trim().length > 0) + ); +} + +/** + * @param {unknown} input + * @param {Readonly>} packageDocument + * @returns {string[]} + */ +export function validateRecipeCatalog(input, packageDocument) { + const document = + /** @type {Record} */ ( + input && typeof input === "object" ? input : {} + ); + /** @type {string[]} */ + const violations = []; + if (document.schemaVersion !== 1) violations.push("CATALOG_SCHEMA_VERSION"); + if (document.decisionId !== "VD-10") violations.push("CATALOG_DECISION"); + if (document.defaultStatus !== "NOT_INSTALLED") { + violations.push("CATALOG_DEFAULT_MUST_BE_NOT_INSTALLED"); + } + if ( + !Array.isArray(document.productionRuntimeDependencies) || + document.productionRuntimeDependencies.length > 0 + ) { + violations.push("UNSELECTED_RUNTIME_DEPENDENCY"); + } + if (!nonEmptyStrings(document.vendorPackagePatterns)) { + violations.push("VENDOR_PATTERN_CATALOG"); + } + if (!Array.isArray(document.recipes)) { + return [...violations, "RECIPE_CATALOG_MISSING"]; + } + + const actualIds = document.recipes + .map(/** @param {Record} recipe */ (recipe) => recipe.id) + .sort(); + if (JSON.stringify(actualIds) !== JSON.stringify(REQUIRED_RECIPE_IDS)) { + violations.push("RECIPE_ID_SET"); + } + if (new Set(actualIds).size !== actualIds.length) { + violations.push("RECIPE_ID_DUPLICATE"); + } + + for (const recipe of document.recipes) { + const id = typeof recipe.id === "string" ? recipe.id : "unknown"; + if (recipe.status !== "RECIPE_AVAILABLE") { + violations.push(`${id}:STATUS_MUST_NOT_CLAIM_INSTALLED`); + } + for (const field of [ + "trigger", + "boundary", + "port", + "fake", + "owner", + "fallback", + "serverStatePolicy", + ]) { + if (typeof recipe[field] !== "string" || recipe[field].trim().length === 0) { + violations.push(`${id}:MISSING_${field.toUpperCase()}`); + } + } + for (const field of [ + "forbiddenWhen", + "failureKinds", + "securityPrivacy", + "removal", + ]) { + if (!nonEmptyStrings(recipe[field])) { + violations.push(`${id}:MISSING_${field.toUpperCase()}`); + } + } + if ( + !Number.isInteger(recipe.bundleBudgetGzipBytes) || + recipe.bundleBudgetGzipBytes < 1 + ) { + violations.push(`${id}:INVALID_BUNDLE_BUDGET`); + } + if (recipe.owner === "frontend-platform") { + violations.push(`${id}:PROJECT_OWNER_NOT_ASSIGNED`); + } + if ( + lifecycleRecipes.has(id) && + !nonEmptyStrings(recipe.lifecycleMethods) + ) { + violations.push(`${id}:CLEANUP_CONTRACT_MISSING`); + } + if ( + id === "client-workflow" && + recipe.serverStatePolicy !== "reference-only" + ) { + violations.push(`${id}:SERVER_STATE_DUPLICATION_POLICY`); + } + } + + const dependencies = { + .../** @type {Record} */ (packageDocument.dependencies ?? {}), + .../** @type {Record} */ ( + packageDocument.devDependencies ?? {} + ), + }; + for (const pattern of document.vendorPackagePatterns ?? []) { + const wildcard = String(pattern).endsWith("*"); + const prefix = String(pattern).replace(/\/?\*$/, ""); + if ( + Object.keys(dependencies).some( + (dependency) => + dependency === prefix || + dependency.startsWith(`${prefix}/`) || + (wildcard && dependency.startsWith(prefix)), + ) + ) { + violations.push(`UNSELECTED_VENDOR_INSTALLED:${prefix}`); + } + } + return violations; +} + +/** @param {string} directory @returns {Promise} */ +export async function sourceFiles(directory) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + return []; + } + throw error; + } + const groups = await Promise.all( + entries.map((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() + ? sourceFiles(target) + : /\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(entry.name) + ? [target] + : []; + }), + ); + return groups.flat(); +} + +/** + * @param {string} root + * @param {{scanProductionBoundary?: boolean}} [options] + */ +export async function scanOptionalRecipeSources( + root, + { scanProductionBoundary = true } = {}, +) { + /** @type {Array<{ruleId: string; path: string}>} */ + const violations = []; + for (const file of await sourceFiles(root)) { + const relative = path.relative(process.cwd(), file).replaceAll("\\", "/"); + const relativeToRoot = path.relative(root, file).replaceAll("\\", "/"); + const content = await readFile(file, "utf8"); + const imports = [ + ...content.matchAll( + /(?:from\s*|import\s*\(\s*)["']([^"']+)["']/g, + ), + ].map((match) => match[1]); + + if ( + scanProductionBoundary && + (relativeToRoot.startsWith("src/") || + (path.basename(path.resolve(root)) === "src" && + !relativeToRoot.startsWith(".."))) && + imports.some((specifier) => + /(?:^|\/)recipes\/frontend-capabilities(?:\/|$)/.test(specifier), + ) + ) { + violations.push({ ruleId: "PRODUCTION_IMPORTS_RECIPE", path: relative }); + } + + const localVendorAdapter = + relative.includes("recipes/") && relative.includes("/adapters/"); + if ( + !localVendorAdapter && + imports.some((specifier) => + /^(?:@launchdarkly\/|@sentry\/|@opentelemetry\/|@openapitools\/openapi-generator-cli$|@reduxjs\/toolkit$|@tanstack\/react-virtual$|@uppy\/|firebase(?:\/|$)|idb$|react-window$|redux(?:\/|$)|socket\.io-client$|tus-js-client$|workbox-window$|xstate$|zustand$)/.test( + specifier, + ), + ) + ) { + violations.push({ ruleId: "VENDOR_IMPORT_OUTSIDE_ADAPTER", path: relative }); + } + + if ( + /localStorage\s*\.\s*(?:setItem|getItem)\s*\([^)]*(?:credential|password|secret|token)/is.test( + content, + ) || + /searchParams\s*\.\s*set\s*\(\s*["'](?:credential|password|secret|token)/is.test( + content, + ) || + /(?:record|track|emit)\s*\(\s*\{[\s\S]{0,400}(?:credential|password|secret|token)\s*:/i.test( + content, + ) + ) { + violations.push({ ruleId: "CREDENTIAL_LEAK_PATH", path: relative }); + } + + if ( + /(?:createStore|configureStore|create\s*\()\s*\([\s\S]{0,600}(?:apiResponse|queryData|serverState)\s*:/i.test( + content, + ) + ) { + violations.push({ ruleId: "CLIENT_STORE_DUPLICATES_SERVER_STATE", path: relative }); + } + } + return violations; +} + +/** @param {string} distRoot */ +export async function scanProductionBundle(distRoot) { + /** @type {string[]} */ + const violations = []; + for (const file of await sourceFiles(distRoot)) { + const content = await readFile(file, "utf8"); + if (content.includes("frontend-optional-recipe-must-not-reach-production")) { + violations.push(path.relative(process.cwd(), file)); + } + } + return violations; +} diff --git a/scripts/test-optional-recipe-removal.mjs b/scripts/test-optional-recipe-removal.mjs new file mode 100644 index 0000000..ba23b06 --- /dev/null +++ b/scripts/test-optional-recipe-removal.mjs @@ -0,0 +1,113 @@ +import { spawnSync } from "node:child_process"; +import { + cp, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +const fixtureRoot = path.resolve(".tmp/optional-recipe-removal"); +const pnpmCli = /** @type {string} */ (process.env.npm_execpath); +const copyTargets = [ + "src", + "tests", + "recipes", + "scripts", + "config", + "public", + "index.html", + "package.json", + "tsconfig.base.json", + "tsconfig.json", + "tsconfig.app.json", + "tsconfig.node.json", + "tsconfig.test.json", + "tsconfig.recipes.json", + "vite.config.js", + "vitest.config.js", + "playwright.config.js", + "eslint.config.js", + ".dependency-cruiser.cjs", +]; + +/** @param {string} script */ +function runPnpm(script) { + return ( + spawnSync(process.execPath, [pnpmCli, script], { + cwd: fixtureRoot, + stdio: "inherit", + }).status === 0 + ); +} + +/** @param {string} directory @returns {Promise} */ +async function filesBelow(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const groups = await Promise.all( + entries.map((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? filesBelow(target) : [target]; + }), + ); + return groups.flat(); +} + +await rm(fixtureRoot, { recursive: true, force: true }); +await mkdir(fixtureRoot, { recursive: true }); +for (const target of copyTargets) { + await cp(target, path.join(fixtureRoot, target), { recursive: true }); +} +await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir"); +await rm(path.join(fixtureRoot, "recipes"), { recursive: true, force: true }); +await rm(path.join(fixtureRoot, "tests/recipes"), { + recursive: true, + force: true, +}); + +const checks = [ + ["typecheck", runPnpm("check:types")], + ["architecture", runPnpm("check:architecture")], + ["test", runPnpm("test:all")], + ["build", runPnpm("build")], +]; +/** @type {string[]} */ +const residue = []; +for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) { + if (!/\.(?:js|css|html|json)$/.test(file)) continue; + const content = await readFile(file, "utf8"); + if (content.includes("frontend-optional-recipe-must-not-reach-production")) { + residue.push(path.relative(fixtureRoot, file)); + } +} +checks.push(["bundle-residue", residue.length === 0]); +const passed = checks.every(([, result]) => result); +await mkdir("artifacts/tests", { recursive: true }); +await writeFile( + "artifacts/tests/optional-recipe-removal.xml", + `\n` + + `` + + checks + .map( + ([name, result]) => + `${result ? "" : `${residue.join(", ")}`}`, + ) + .join("") + + `\n`, +); +await rm(fixtureRoot, { recursive: true, force: true }); +if (!passed) { + process.stderr.write( + `Optional recipe removal failed: ${checks + .filter(([, result]) => !result) + .map(([name]) => name) + .join(", ")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Optional recipe removal: PASS (${checks.length} base checks)\n`, +); diff --git a/scripts/test-sample-removal.mjs b/scripts/test-sample-removal.mjs index 9d9e46d..fe4fa1f 100644 --- a/scripts/test-sample-removal.mjs +++ b/scripts/test-sample-removal.mjs @@ -24,6 +24,7 @@ const featureOwnedPaths = [ const copyTargets = [ "src", "tests", + "recipes", "scripts", "config", "public", @@ -34,6 +35,7 @@ const copyTargets = [ "tsconfig.app.json", "tsconfig.node.json", "tsconfig.test.json", + "tsconfig.recipes.json", "vite.config.js", "vitest.config.js", "playwright.config.js", diff --git a/tests/fixtures/optional-recipes/forbidden/credential-leak/src/adapters/bad-storage.ts b/tests/fixtures/optional-recipes/forbidden/credential-leak/src/adapters/bad-storage.ts new file mode 100644 index 0000000..8783127 --- /dev/null +++ b/tests/fixtures/optional-recipes/forbidden/credential-leak/src/adapters/bad-storage.ts @@ -0,0 +1,3 @@ +export function persistCredential(token: string) { + localStorage.setItem("authToken", token); +} diff --git a/tests/fixtures/optional-recipes/forbidden/production-import/src/bad.ts b/tests/fixtures/optional-recipes/forbidden/production-import/src/bad.ts new file mode 100644 index 0000000..771e3bb --- /dev/null +++ b/tests/fixtures/optional-recipes/forbidden/production-import/src/bad.ts @@ -0,0 +1,3 @@ +import { OPTIONAL_RECIPE_RUNTIME_SENTINEL } from "../../../../../../recipes/frontend-capabilities/index.js"; + +export const recipeInProduction = OPTIONAL_RECIPE_RUNTIME_SENTINEL; diff --git a/tests/fixtures/optional-recipes/forbidden/server-state/src/bad-store.ts b/tests/fixtures/optional-recipes/forbidden/server-state/src/bad-store.ts new file mode 100644 index 0000000..b98ce79 --- /dev/null +++ b/tests/fixtures/optional-recipes/forbidden/server-state/src/bad-store.ts @@ -0,0 +1,3 @@ +export const store = createStore({ + serverState: [{ id: "copied-from-query-cache" }], +}); diff --git a/tests/fixtures/optional-recipes/forbidden/vendor-import/src/presentation/bad-vendor.ts b/tests/fixtures/optional-recipes/forbidden/vendor-import/src/presentation/bad-vendor.ts new file mode 100644 index 0000000..ba3c43a --- /dev/null +++ b/tests/fixtures/optional-recipes/forbidden/vendor-import/src/presentation/bad-vendor.ts @@ -0,0 +1,3 @@ +import { initialize } from "@launchdarkly/client-sdk"; + +export const leakedVendor = initialize; diff --git a/tests/recipes/optional-capability-contracts.test.ts b/tests/recipes/optional-capability-contracts.test.ts new file mode 100644 index 0000000..abc5f32 --- /dev/null +++ b/tests/recipes/optional-capability-contracts.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from "vitest"; + +import { + FakeBrowserPermissionAdapter, + FakeClientWorkflowAdapter, + FakeFeatureFlagAdapter, + FakeFileTransferAdapter, + FakeGeneratedApiAdapter, + FakeLargeDataUiAdapter, + FakeMultiTabAdapter, + FakeRealtimeAdapter, + FakeServiceWorkerUpdateAdapter, + FakeWorkerTaskAdapter, + MemoryOfflineRepository, + RecordingAnalyticsAdapter, + createUnavailableAdapters, +} from "../../recipes/frontend-capabilities/index.js"; + +describe("optional capability recipes", () => { + it("requires realtime cleanup and rejects duplicate or out-of-order events", async () => { + const adapter = new FakeRealtimeAdapter<{ value: string }>(); + const received: string[] = []; + const subscription = await adapter.subscribe({ + channel: "orders", + onEvent(result) { + received.push(result.ok ? result.value.payload.value : result.failure.code); + }, + }); + expect(subscription.ok).toBe(true); + adapter.emit("orders", { + id: "event-2", + sequence: 2, + occurredAt: "2026-07-26T00:00:00.000Z", + payload: { value: "new" }, + }); + adapter.emit("orders", { + id: "event-1", + sequence: 1, + occurredAt: "2026-07-26T00:00:00.000Z", + payload: { value: "old" }, + }); + expect(received).toEqual(["new", "STALE_RESULT"]); + if (subscription.ok) subscription.value.unsubscribe(); + expect(adapter.activeSubscriptionCount).toBe(0); + }); + + it("models offline schema migration and closed-repository fallback", async () => { + const repository = new MemoryOfflineRepository<{ id: string; name: string }>(); + expect(await repository.open({ schemaVersion: 1 })).toEqual({ + ok: true, + value: undefined, + }); + await repository.put({ id: "one", name: "offline" }); + expect(await repository.migrate({ from: 1, to: 2 })).toEqual({ + ok: true, + value: undefined, + }); + expect(await repository.get("one")).toEqual({ + ok: true, + value: { id: "one", name: "offline" }, + }); + repository.close(); + expect((await repository.get("one")).ok).toBe(false); + }); + + it("covers service-worker stale update and explicit unregister", async () => { + const adapter = new FakeServiceWorkerUpdateAdapter("v1", "v2"); + expect((await adapter.inspect()).ok).toBe(true); + expect((await adapter.activate("stale")).ok).toBe(false); + expect(await adapter.activate("v2")).toEqual({ ok: true, value: undefined }); + expect(await adapter.unregister()).toEqual({ ok: true, value: undefined }); + }); + + it("covers file validation, progress and AbortSignal cancellation", async () => { + const adapter = new FakeFileTransferAdapter(10, new Set(["text/plain"])); + const progress: number[] = []; + const controller = new AbortController(); + const uploaded = await adapter.upload({ + file: { name: "safe.txt", size: 4, type: "text/plain" }, + signal: controller.signal, + onProgress(value) { + progress.push(value.transferredBytes); + }, + }); + expect(uploaded.ok).toBe(true); + expect(progress).toEqual([4]); + controller.abort(); + const cancelled = await adapter.download({ + resourceId: "one", + signal: controller.signal, + onProgress() {}, + }); + expect(cancelled).toMatchObject({ + ok: false, + failure: { code: "ABORTED" }, + }); + }); + + it("keeps generated API and feature flag vendors behind typed facades", async () => { + const api = new FakeGeneratedApiAdapter("2026-07", { + list: () => [{ id: "one" }], + }); + expect( + await api.execute>({ + operationId: "list", + contractVersion: "2026-07", + }), + ).toEqual({ ok: true, value: [{ id: "one" }] }); + expect( + ( + await api.execute({ + operationId: "list", + contractVersion: "old", + }) + ).ok, + ).toBe(false); + + const flags = new FakeFeatureFlagAdapter<{ checkout: boolean }>({ + checkout: true, + }); + expect( + await flags.evaluate({ + key: "checkout", + fallback: false, + maxAgeMs: 1_000, + }), + ).toEqual({ ok: true, value: true }); + }); + + it("discards cancelled worker results and de-duplicates multi-tab events", async () => { + const worker = new FakeWorkerTaskAdapter((value) => value * 2); + const controller = new AbortController(); + worker.cancel("cancelled"); + expect( + ( + await worker.run({ + taskId: "cancelled", + generation: 1, + payload: 2, + signal: controller.signal, + }) + ).ok, + ).toBe(false); + + const tabs = new FakeMultiTabAdapter<{ refreshed: boolean }>(); + const observed: string[] = []; + const cleanup = tabs.subscribe({ + sourceId: "tab-b", + onEvent(result) { + if (result.ok) observed.push(result.value.eventId); + }, + }); + expect( + tabs.publish({ + eventId: "one", + sourceId: "tab-a", + version: 1, + payload: { refreshed: true }, + }).ok, + ).toBe(true); + expect( + tabs.publish({ + eventId: "one", + sourceId: "tab-a", + version: 1, + payload: { refreshed: true }, + }).ok, + ).toBe(false); + expect(observed).toEqual(["one"]); + cleanup(); + tabs.close(); + }); + + it("separates browser permission, local workflow and large-data behavior", async () => { + const permissions = new FakeBrowserPermissionAdapter({ + notification: "denied", + }); + expect( + await permissions.request({ capability: "notification" }), + ).toEqual({ ok: true, value: "denied" }); + expect( + ( + await permissions.request({ + capability: "clipboard-read", + }) + ).ok, + ).toBe(false); + + const workflow = new FakeClientWorkflowAdapter( + { step: 0 }, + (state, event: "next") => ({ + step: event === "next" ? state.step + 1 : state.step, + }), + ); + workflow.dispatch("next"); + expect(workflow.snapshot()).toEqual({ step: 1 }); + workflow.reset(); + expect(workflow.snapshot()).toEqual({ step: 0 }); + + const data = new FakeLargeDataUiAdapter<{ id: string; name: string }>(); + data.replace([{ id: "one", name: "row" }], 2); + expect(data.window({ offset: 0, limit: 1, generation: 1 }).ok).toBe(false); + expect(data.window({ offset: 0, limit: 1, generation: 2 })).toEqual({ + ok: true, + value: [{ id: "one", name: "row" }], + }); + }); + + it("enforces analytics consent, redaction and bounded queues", () => { + const adapter = new RecordingAnalyticsAdapter(1); + expect( + adapter.record({ + kind: "analytics", + eventId: "page-viewed", + consent: "denied", + attributes: {}, + }).ok, + ).toBe(false); + expect( + adapter.record({ + kind: "error", + eventId: "render-failed", + consent: "not-required", + attributes: { routeId: "HOME", authToken: "must-not-survive" }, + }).ok, + ).toBe(true); + expect(adapter.records[0]?.attributes).toEqual({ routeId: "HOME" }); + expect( + adapter.record({ + kind: "error", + eventId: "second", + consent: "not-required", + attributes: {}, + }).ok, + ).toBe(false); + }); + + it("provides fail-closed unavailable adapters for every capability", async () => { + const adapters = createUnavailableAdapters(); + const controller = new AbortController(); + const results = await Promise.all([ + adapters.realtime.heartbeat(), + adapters.offline.open({ schemaVersion: 1 }), + adapters.serviceWorker.unregister(), + adapters.fileTransfer.download({ + resourceId: "one", + signal: controller.signal, + onProgress() {}, + }), + adapters.generatedApi.execute({ + operationId: "one", + contractVersion: "one", + }), + adapters.featureFlag.evaluate({ + key: "one", + fallback: false, + maxAgeMs: 0, + }), + adapters.worker.run({ + taskId: "one", + generation: 1, + payload: null, + signal: controller.signal, + }), + adapters.browserPermission.request({ capability: "notification" }), + adapters.analytics.flush(), + ]); + expect(results.every((result) => !result.ok)).toBe(true); + expect(adapters.multiTab.publish({ + eventId: "one", + sourceId: "one", + version: 1, + payload: null, + }).ok).toBe(false); + expect(adapters.clientWorkflow.dispatch(null).ok).toBe(false); + expect( + adapters.largeDataUi.window({ offset: 0, limit: 1, generation: 1 }).ok, + ).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 01490aa..105b2be 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.test.json" } + { "path": "./tsconfig.test.json" }, + { "path": "./tsconfig.recipes.json" } ] } diff --git a/tsconfig.recipes.json b/tsconfig.recipes.json new file mode 100644 index 0000000..e2601f9 --- /dev/null +++ b/tsconfig.recipes.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"] + }, + "include": ["recipes/**/*.ts"], + "exclude": ["dist", "node_modules"] +}