From caf3bc99ce0e720646ac766023a459a1e4dcc642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 01:51:27 +0700 Subject: [PATCH 01/51] docs(plans): approve platform implementation program --- docs/plans/000-platform-program.md | 62 +++++++++++ docs/plans/010-engineering-foundation.md | 130 +++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 docs/plans/000-platform-program.md create mode 100644 docs/plans/010-engineering-foundation.md diff --git a/docs/plans/000-platform-program.md b/docs/plans/000-platform-program.md new file mode 100644 index 00000000..a1c0ae3f --- /dev/null +++ b/docs/plans/000-platform-program.md @@ -0,0 +1,62 @@ +# DataBreeze Platform Implementation Program + +**Status:** Approved +**Implementation branch:** `dev` through short-lived `feat/*` and `fix/*` branches +**Primary specifications:** `docs/product/`, `docs/architecture/`, `docs/specs/`, and accepted ADRs + +## Goal + +Implement DataBreeze as one Vietnamese-first, local-first business data platform across Web, Windows Desktop, and Android. The platform turns user-controlled files, documents, captures, and governed datasets into traceable jobs, evidence, reviews, approvals, reports, and safe actions without depending on restricted marketplace APIs. + +The program covers all 611 normative requirements. P0 requirements are release gates, P1 requirements complete the generally available capability, and P2 requirements are preserved as extension seams but are not scheduled for the first release. + +## Locked decisions + +- One clean monorepo with independently releasable deployables. +- TypeScript for Web, Desktop, shared packages, and the NestJS/Fastify control plane. +- Native Kotlin/Compose for Android and Python for the shared processing engine. +- PostgreSQL is authoritative; S3-compatible storage holds cloud bytes; Redis is non-authoritative. +- Local, Hybrid, and Cloud data modes remain visible and enforceable throughout every workflow. +- First usable release is a private dogfood alpha built on the full multi-tenant architecture. +- The first cross-platform workflow is Folder Autopilot plus Spreadsheet Auditor. +- Core value does not require Shopee, TikTok Shop, accounting, advertising, or ERP partnerships. +- AWS Singapore is the first hosted target through portable containers and OpenTofu. +- The existing DataBreeze name and canonical logo files are retained without redrawing. + +## Delivery program + +| Phase | Child plan | Release gate | +|---|---|---| +| 0 | `010-engineering-foundation.md` | Toolchains, contracts, brand, deployable shells, local dependencies, and CI build reproducibly. | +| 1A | `020-identity-audit-entitlements.md` | IAM, AUD, and provider-independent BUA foundations pass tenant and security gates. | +| 1B | `030-artifacts-datasets-evidence.md` | IAE and DSM provide immutable artifacts, evidence, datasets, schemas, rules, and mappings. | +| 1C | `040-jobs-processing-approvals.md` | JRA, admission coordination, cloud workers, and the local engine execute signed typed jobs. | +| 1D | `050-devices-sync-offline.md` | Desktop and Android enroll, sync, recover, conflict, and revoke safely. | +| 1E | `060-collaboration-integrations.md` | NCO and INT provide governed collaboration, notifications, API keys, and webhooks. | +| 2 | `070-dogfood-folder-spreadsheet.md` | One spreadsheet-folder workflow crosses all three applications and preserves the original. | +| 3 | `1xx-wave-1-*.md` | Folder Autopilot, Spreadsheet Auditor, Quote Intelligence, and Operations Capture. | +| 4 | `2xx-wave-2-*.md` | Invoice Leak Detector, Client Report Factory, and Private Data Analyst. | +| 5 | `3xx-wave-3-*.md` | Migration Ready, Data Quality Guard, and Embedded Importer. | +| 6 | `400-production-readiness.md` | Signing, restoration, scaling, security, support, and progressive releases pass. | + +Child plans are written and approved before their product slice begins. Each names exact requirement IDs, paths, contract changes, migrations, tests, telemetry, failure behavior, rollback, and intentionally deferred requirements. + +## Branch, commit, and review policy + +- `main` contains stable releases. `dev` is the integration branch. +- New capabilities use `feat/`; corrections use `fix/`; operational and documentation work use conventional prefixes when more accurate. +- Commit one coherent tested unit at a time. Do not combine unrelated applications or domains merely to reduce commit count. +- Pull requests target `dev`, normally contain 30–50 commits, and must not exceed 70 commits. +- Invoke CodeRabbit once per pull request after the branch is ready for review. Validate every comment against the specifications and tests; fix valid findings and document why invalid findings are not applied. +- Promote `dev` to `main` only through a separate release pull request after the relevant production gates pass. + +## Cross-cutting definition of done + +- Requirement-to-task-to-test traceability is complete. +- Generated TypeScript, Kotlin, and Python contracts agree. +- Tenant scope, authorization, data mode, evidence, approval, audit, and retention rules cannot be bypassed. +- Vietnamese and English user-facing copy are complete for the delivered slice. +- Relevant unit, integration, contract, end-to-end, security, accessibility, recovery, and performance tests pass. +- Migrations, observability, operations, rollback, and release evidence are present. +- No critical or high security finding remains unresolved for a production release. + diff --git a/docs/plans/010-engineering-foundation.md b/docs/plans/010-engineering-foundation.md new file mode 100644 index 00000000..0716d346 --- /dev/null +++ b/docs/plans/010-engineering-foundation.md @@ -0,0 +1,130 @@ +# Engineering Foundation Implementation Plan + +**Status:** Approved +**Parent:** `000-platform-program.md` +**Branch:** `feat/platform-foundation` + +## Outcome + +Create the reproducible monorepo foundation required by Stage 0 of the product roadmap. This plan introduces no customer workflow or production data migration. It establishes tested build, contract, brand, application-shell, infrastructure, observability, security, and delivery boundaries on which every normative requirement will depend. + +## Global constraints + +- Node.js 24 LTS, pnpm/Corepack, Turborepo, strict TypeScript, Python 3.13 through `uv`, JDK 21, PostgreSQL 17, and Redis 7.4 are pinned by repository-controlled configuration. +- Web and Desktop may share React packages. Android consumes generated contracts and tokens but remains native Kotlin/Compose. +- Clients never import service implementation packages. +- No client or processing worker receives database credentials. +- Generated artifacts must be reproducible and checked for drift in CI. +- New behavior follows test-first red/green/refactor development. +- Canonical legacy brand sources retain their exact bytes and documented SHA-256 values. +- No secret, credential, runtime database, customer file, generated report, signing key, APK, or installer is committed. + +## Tasks + +### Task 1: Root workspace and runtime pins + +Create the root pnpm/Turborepo workspace, package scripts, TypeScript base configurations, editor-neutral formatting/linting configuration, runtime-version files, and package-manager pin. Add a smoke test that validates workspace package discovery and runtime policy. Commit as `chore(repo): bootstrap the monorepo toolchain`. + +### Task 2: Repository dependency-boundary enforcement + +Add executable checks that prevent clients from importing service implementations, prevent feature-to-feature persistence imports, and require public package exports. Cover allowed and rejected fixture graphs before enabling the check in root `lint`. Commit as `test(architecture): enforce repository dependency boundaries`. + +### Task 3: Requirement traceability tooling + +Implement a read-only parser that discovers all stable requirement IDs, rejects duplicates or malformed priorities, and produces a deterministic traceability index. Add fixtures for duplicates, gaps, and valid documents, then generate the initial index for all 611 requirements. Commit as `feat(traceability): index normative requirements`. + +### Task 4: Contract source layout and base envelopes + +Create versioned JSON Schemas for UUID identifiers, UTC timestamps, revisions, tenant scope, correlation metadata, RFC 7807-compatible problems, idempotent commands, cursor pages, and the canonical event envelope. Test valid and invalid examples with a standards-compliant validator. Commit as `feat(contracts): define shared protocol envelopes`. + +### Task 5: Cross-language contract generation + +Create deterministic generators and generated-package layouts for TypeScript, Kotlin, and Python. Add a drift command that regenerates into a temporary directory and byte-compares outputs. Commit as `feat(contracts): generate typescript kotlin and python models`. + +### Task 6: Contract compatibility and fixture package + +Add schema compatibility policy, shared valid/invalid protocol fixtures, and consumer tests proving all three generated model sets accept and reject equivalent payloads. Commit as `test(contracts): enforce cross-language parity`. + +### Task 7: Permission and tenant-scope primitives + +Create versioned permission constants, the six initial role bundles, tenant-scope value objects, and deny-by-default helpers without implementing IAM persistence. Test narrowing and cross-scope rejection. Link IAM-001 through IAM-004, IAM-009, and IAM-019 as partial foundation coverage. Commit as `feat(permissions): add scoped authorization primitives`. + +### Task 8: Configuration and provider ports + +Create typed configuration loading with explicit development/test/preview/staging/production profiles and ports for object storage, email, push, OCR, AI, payments, telemetry, and secrets. Reject missing production configuration and unknown keys. Commit as `feat(config): define portable provider boundaries`. + +### Task 9: Vietnamese and English terminology package + +Create the canonical `vi-VN` and `en` message catalogs, locale negotiation, formatting helpers, and completeness tests. Vietnamese is the default and missing keys fail CI. Commit as `feat(i18n): establish complete bilingual catalogs`. + +### Task 10: Immutable legacy brand sources + +Copy the three canonical named logo files into the design-system source directory. Add a manifest containing dimensions, intended use, and the approved SHA-256 hashes, plus a checksum test that fails on byte changes. Commit as `feat(brand): preserve canonical databreeze assets`. + +### Task 11: Reproducible brand derivatives + +Build a deterministic image pipeline for Web favicons/social assets, Desktop icons, and Android launcher/notification sources. Preserve aspect ratio, colors, and safe zones; prohibit wordmark duplication. Add dimension, checksum, and visual-regression fixtures. Commit as `feat(brand): generate platform logo derivatives`. + +### Task 12: Design tokens and accessible UI primitives + +Create shared color, typography, spacing, motion, focus, status, and logo-usage tokens. Export TypeScript/CSS and generated Android resources. Add contrast, reduced-motion, and generation-drift tests. Commit as `feat(design-system): add shared accessible tokens`. + +### Task 13: Web application shell + +Create the React/Vite shell with React Router, TanStack Query, Tailwind, accessible primitives, bilingual routing/layout, error boundaries, and placeholder authenticated navigation. Add Vitest/Testing Library and Playwright smoke coverage. Commit as `feat(web): create the governed workspace shell`. + +### Task 14: Control-plane API shell + +Create the NestJS/Fastify modular-monolith shell, health/readiness endpoints, request correlation, RFC 7807 errors, structured validation, OpenAPI generation, Prisma multi-schema layout, and domain boundary structure. Test boot, validation, and error behavior. Commit as `feat(api): create the modular control plane shell`. + +### Task 15: Windows Desktop security shell + +Create the Electron/React/Vite shell with sandboxing, context isolation, disabled Node integration, restrictive navigation/CSP, a versioned allowlisted preload API, local-state abstraction, and sidecar lifecycle port. Add security preference and IPC rejection tests. Link DSK-001, DSK-002, and DSK-008 as partial coverage. Commit as `feat(desktop): create the secure local agent shell`. + +### Task 16: Python engine shell + +Create the `uv` project, Pydantic protocol models, versioned action-manifest registry, deterministic handler interface, framed JSON-RPC entry point, cloud-worker entry point, Ruff/type/pytest configuration, and a test processor. Test malformed frames, unsupported actions, deterministic output, and resource metadata. Commit as `feat(engine): create the typed processing runtime`. + +### Task 17: Native Android shell + +Create the Gradle wrapper/version catalog and Kotlin/Compose application with bilingual resources, navigation, Room/WorkManager boundaries, Keystore and sync ports, network security configuration, backup exclusions, and baseline unit/instrumentation tests. Commit as `feat(android): create the offline companion shell`. + +### Task 18: Local development infrastructure + +Create Docker Compose definitions for PostgreSQL 17, Redis 7.4, MinIO, Mailpit, and an OpenTelemetry collector. Add health checks, named development volumes, `.env.example`, initialization scripts without credentials, and a smoke script that validates readiness. Commit as `feat(infra): add portable local dependencies`. + +### Task 19: AWS OpenTofu foundation + +Create reusable OpenTofu modules and environment compositions for AWS Singapore networking, S3/CloudFront Web hosting, ECS API/worker services, RDS, ElastiCache, KMS, Secrets Manager, logs, and GitHub OIDC. Use safe alpha defaults and explicit production scaling/PITR variables. Validate and lint without applying. Commit as `feat(infra): define the portable aws baseline`. + +### Task 20: Shared observability and safe diagnostics + +Create structured logging, correlation propagation, OpenTelemetry conventions, safe attribute allowlists, and content-redaction tests shared by API, Web, Desktop, Android, and engine adapters. Commit as `feat(observability): establish content-safe telemetry`. + +### Task 21: Continuous integration and supply-chain gates + +Create path-aware GitHub Actions for format, lint, typecheck, contract drift, unit/integration tests, builds, SBOM, dependency/license/secret scanning, container scanning, and release provenance. Workflows use least-privilege permissions and no long-lived AWS keys. Commit as `ci: add monorepo quality and security gates`. + +### Task 22: Developer workflow and operational foundations + +Document clean-checkout setup, branch/commit/PR policy, local services, contract changes, troubleshooting, provider adapters, release channels, and initial deployment/rollback/secret-rotation runbooks. Commit as `docs: document foundation development and operations`. + +### Task 23: Clean-checkout verification and release evidence + +Run the complete root verification from a clean worktree, build each deployable, regenerate contracts/assets, validate Compose and OpenTofu, and record requirement/test/build evidence without committing runtime artifacts. Fix only failures within this plan. Commit any necessary corrections in narrowly scoped `fix(...)` commits, then prepare the pull request to `dev`. + +## Acceptance and rollback + +- One documented bootstrap path prepares every available toolchain. +- Root format, lint, typecheck, contract, unit, integration, and build commands exit successfully. +- Web, API, Desktop, engine, and Android empty deployables build independently. +- Local dependencies reach healthy state and can be torn down without deleting user-owned files. +- Contract and brand regeneration is reproducible and drift-free. +- CI uses synthetic fixtures only and emits no secrets or customer content. +- Every task is a separate rollback unit. Reverting an application shell must not remove shared contracts used by another completed shell. +- AWS resources are not applied by this plan; rollback is therefore repository reversion plus removal of local disposable containers/volumes when explicitly requested. + +## Deferred requirements + +All business workflows and persistent IAM/IAE/DSM/JRA/DSO/NCO/INT/BUA/AUD behavior beyond the explicitly named primitives remain deferred to the subsequent child plans. A passing engineering-foundation build does not mark those requirements implemented. + From b53c90a0fdd1572250468a662f1ae1f0b0b0e300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 01:59:32 +0700 Subject: [PATCH 02/51] chore(repo): bootstrap the monorepo toolchain --- .editorconfig | 12 + .node-version | 1 + .npmrc | 3 + .prettierignore | 7 + .tool-versions | 4 + eslint.config.mjs | 28 + package.json | 31 + pnpm-lock.yaml | 1102 +++++++++++++++++ pnpm-workspace.yaml | 6 + prettier.config.mjs | 9 + .../test/workspace-runtime-policy.test.mjs | 67 + tsconfig.base.json | 21 + tsconfig.json | 5 + turbo.json | 24 + 14 files changed, 1320 insertions(+) create mode 100644 .editorconfig create mode 100644 .node-version create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .tool-versions create mode 100644 eslint.config.mjs create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 prettier.config.mjs create mode 100644 tools/repo-cli/test/workspace-runtime-policy.test.mjs create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json create mode 100644 turbo.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..1014ba78 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..1dd37d53 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.17.0 diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..79390353 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +engine-strict=true +manage-package-manager-versions=true +use-node-version=24.17.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..06fe426b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +.github/workflows/README.md +.superpowers/ +AGENTS.md +README.md +docs/ +**/README.md +pnpm-lock.yaml diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..ca109f27 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,4 @@ +nodejs 24.17.0 +pnpm 11.9.0 +python 3.13.0 +java temurin-21 diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..b354bbee --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,28 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['**/build/**', '**/coverage/**', '**/dist/**', '**/node_modules/**', '**/out/**'], + }, + eslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + extends: [tseslint.configs.recommendedTypeChecked], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + files: ['**/*.{cjs,js,mjs}'], + languageOptions: { + globals: { + console: 'readonly', + process: 'readonly', + }, + }, + }, +); diff --git a/package.json b/package.json new file mode 100644 index 00000000..2d533f16 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "@databreeze/platform", + "version": "0.0.0", + "private": true, + "packageManager": "pnpm@11.9.0", + "engines": { + "node": "24.17.0", + "pnpm": "11.9.0" + }, + "scripts": { + "build": "turbo run build", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "repo:bootstrap": "corepack pnpm install --frozen-lockfile", + "repo:build": "corepack pnpm build", + "repo:check": "corepack pnpm format:check && corepack pnpm lint && corepack pnpm typecheck && corepack pnpm test", + "repo:dev": "turbo run dev --parallel", + "repo:test": "corepack pnpm test", + "test": "node --test tools/repo-cli/test/**/*.test.mjs", + "typecheck": "tsc --noEmit --project tsconfig.json" + }, + "devDependencies": { + "@eslint/js": "9.36.0", + "eslint": "9.36.0", + "prettier": "3.6.2", + "turbo": "2.5.6", + "typescript": "5.9.2", + "typescript-eslint": "8.43.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..9743ac49 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1102 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: 9.36.0 + version: 9.36.0 + eslint: + specifier: 9.36.0 + version: 9.36.0 + prettier: + specifier: 3.6.2 + version: 3.6.2 + turbo: + specifier: 2.5.6 + version: 2.5.6 + typescript: + specifier: 5.9.2 + version: 5.9.2 + typescript-eslint: + specifier: 8.43.0 + version: 8.43.0(eslint@9.36.0)(typescript@5.9.2) + +packages: + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.3.1': + resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.15.2': + resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.36.0': + resolution: {integrity: sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.3.5': + resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@typescript-eslint/eslint-plugin@8.43.0': + resolution: {integrity: sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.43.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.43.0': + resolution: {integrity: sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.43.0': + resolution: {integrity: sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.43.0': + resolution: {integrity: sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.43.0': + resolution: {integrity: sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.43.0': + resolution: {integrity: sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.43.0': + resolution: {integrity: sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.43.0': + resolution: {integrity: sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.43.0': + resolution: {integrity: sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.43.0': + resolution: {integrity: sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.36.0: + resolution: {integrity: sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + turbo-darwin-64@2.5.6: + resolution: {integrity: sha512-3C1xEdo4aFwMJAPvtlPqz1Sw/+cddWIOmsalHFMrsqqydcptwBfu26WW2cDm3u93bUzMbBJ8k3zNKFqxJ9ei2A==} + cpu: [x64] + os: [darwin] + + turbo-darwin-arm64@2.5.6: + resolution: {integrity: sha512-LyiG+rD7JhMfYwLqB6k3LZQtYn8CQQUePbpA8mF/hMLPAekXdJo1g0bUPw8RZLwQXUIU/3BU7tXENvhSGz5DPA==} + cpu: [arm64] + os: [darwin] + + turbo-linux-64@2.5.6: + resolution: {integrity: sha512-GOcUTT0xiT/pSnHL4YD6Yr3HreUhU8pUcGqcI2ksIF9b2/r/kRHwGFcsHgpG3+vtZF/kwsP0MV8FTlTObxsYIA==} + cpu: [x64] + os: [linux] + + turbo-linux-arm64@2.5.6: + resolution: {integrity: sha512-10Tm15bruJEA3m0V7iZcnQBpObGBcOgUcO+sY7/2vk1bweW34LMhkWi8svjV9iDF68+KJDThnYDlYE/bc7/zzQ==} + cpu: [arm64] + os: [linux] + + turbo-windows-64@2.5.6: + resolution: {integrity: sha512-FyRsVpgaj76It0ludwZsNN40ytHN+17E4PFJyeliBEbxrGTc5BexlXVpufB7XlAaoaZVxbS6KT8RofLfDRyEPg==} + cpu: [x64] + os: [win32] + + turbo-windows-arm64@2.5.6: + resolution: {integrity: sha512-j/tWu8cMeQ7HPpKri6jvKtyXg9K1gRyhdK4tKrrchH8GNHscPX/F71zax58yYtLRWTiK04zNzPcUJuoS0+v/+Q==} + cpu: [arm64] + os: [win32] + + turbo@2.5.6: + resolution: {integrity: sha512-gxToHmi9oTBNB05UjUsrWf0OyN5ZXtD0apOarC1KIx232Vp3WimRNy3810QzeNSgyD5rsaIDXlxlbnOzlouo+w==} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.43.0: + resolution: {integrity: sha512-FyRGJKUGvcFekRRcBKFBlAhnp4Ng8rhe8tuvvkR9OiU0gfd4vyvTRQHEckO6VDlH57jbeUQem2IpqPq9kLJH+w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@eslint-community/eslint-utils@4.10.1(eslint@9.36.0)': + dependencies: + eslint: 9.36.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.3.1': {} + + '@eslint/core@0.15.2': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.36.0': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.3.5': + dependencies: + '@eslint/core': 0.15.2 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0)(typescript@5.9.2)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.43.0(eslint@9.36.0)(typescript@5.9.2) + '@typescript-eslint/scope-manager': 8.43.0 + '@typescript-eslint/type-utils': 8.43.0(eslint@9.36.0)(typescript@5.9.2) + '@typescript-eslint/utils': 8.43.0(eslint@9.36.0)(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.43.0 + eslint: 9.36.0 + graphemer: 1.4.0 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.43.0(eslint@9.36.0)(typescript@5.9.2)': + dependencies: + '@typescript-eslint/scope-manager': 8.43.0 + '@typescript-eslint/types': 8.43.0 + '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.43.0 + debug: 4.4.3 + eslint: 9.36.0 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.43.0(typescript@5.9.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2) + '@typescript-eslint/types': 8.43.0 + debug: 4.4.3 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.43.0': + dependencies: + '@typescript-eslint/types': 8.43.0 + '@typescript-eslint/visitor-keys': 8.43.0 + + '@typescript-eslint/tsconfig-utils@8.43.0(typescript@5.9.2)': + dependencies: + typescript: 5.9.2 + + '@typescript-eslint/type-utils@8.43.0(eslint@9.36.0)(typescript@5.9.2)': + dependencies: + '@typescript-eslint/types': 8.43.0 + '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) + '@typescript-eslint/utils': 8.43.0(eslint@9.36.0)(typescript@5.9.2) + debug: 4.4.3 + eslint: 9.36.0 + ts-api-utils: 2.5.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.43.0': {} + + '@typescript-eslint/typescript-estree@8.43.0(typescript@5.9.2)': + dependencies: + '@typescript-eslint/project-service': 8.43.0(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2) + '@typescript-eslint/types': 8.43.0 + '@typescript-eslint/visitor-keys': 8.43.0 + debug: 4.4.3 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.9 + semver: 7.8.5 + ts-api-utils: 2.5.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.43.0(eslint@9.36.0)(typescript@5.9.2)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.36.0) + '@typescript-eslint/scope-manager': 8.43.0 + '@typescript-eslint/types': 8.43.0 + '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) + eslint: 9.36.0 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.43.0': + dependencies: + '@typescript-eslint/types': 8.43.0 + eslint-visitor-keys: 4.2.1 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.36.0: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.36.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.3.1 + '@eslint/core': 0.15.2 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.36.0 + '@eslint/plugin-kit': 0.3.5 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + isexe@2.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + ms@2.1.3: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picomatch@2.3.2: {} + + prelude-ls@1.2.1: {} + + prettier@3.6.2: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + resolve-from@4.0.0: {} + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.9.2): + dependencies: + typescript: 5.9.2 + + turbo-darwin-64@2.5.6: + optional: true + + turbo-darwin-arm64@2.5.6: + optional: true + + turbo-linux-64@2.5.6: + optional: true + + turbo-linux-arm64@2.5.6: + optional: true + + turbo-windows-64@2.5.6: + optional: true + + turbo-windows-arm64@2.5.6: + optional: true + + turbo@2.5.6: + optionalDependencies: + turbo-darwin-64: 2.5.6 + turbo-darwin-arm64: 2.5.6 + turbo-linux-64: 2.5.6 + turbo-linux-arm64: 2.5.6 + turbo-windows-64: 2.5.6 + turbo-windows-arm64: 2.5.6 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.43.0(eslint@9.36.0)(typescript@5.9.2): + dependencies: + '@typescript-eslint/eslint-plugin': 8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0)(typescript@5.9.2) + '@typescript-eslint/parser': 8.43.0(eslint@9.36.0)(typescript@5.9.2) + '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) + '@typescript-eslint/utils': 8.43.0(eslint@9.36.0)(typescript@5.9.2) + eslint: 9.36.0 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + typescript@5.9.2: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..8f0e3467 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - 'apps/desktop' + - 'apps/web' + - 'packages/*' + - 'services/api' + - 'tools/*' diff --git a/prettier.config.mjs b/prettier.config.mjs new file mode 100644 index 00000000..5c5cea8b --- /dev/null +++ b/prettier.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('prettier').Config} */ +const config = { + printWidth: 100, + proseWrap: 'always', + singleQuote: true, + trailingComma: 'all', +}; + +export default config; diff --git a/tools/repo-cli/test/workspace-runtime-policy.test.mjs b/tools/repo-cli/test/workspace-runtime-policy.test.mjs new file mode 100644 index 00000000..f3660535 --- /dev/null +++ b/tools/repo-cli/test/workspace-runtime-policy.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +const expectedRuntimeVersions = { + nodejs: '24.17.0', + pnpm: '11.9.0', + python: '3.13.0', + java: 'temurin-21', +}; + +function run(command, args) { + const commandLine = [command, ...args].join(' '); + const executable = process.platform === 'win32' ? 'cmd.exe' : command; + const executableArgs = process.platform === 'win32' ? ['/d', '/s', '/c', commandLine] : args; + + return execFileSync(executable, executableArgs, { + cwd: repositoryRoot, + encoding: 'utf8', + }).trim(); +} + +function readToolVersions() { + return Object.fromEntries( + readFileSync(path.join(repositoryRoot, '.tool-versions'), 'utf8') + .trim() + .split(/\r?\n/) + .filter(Boolean) + .map((line) => line.split(/\s+/, 2)), + ); +} + +test('discovers the root workspace and enforces the repository runtime policy', () => { + for (const requiredFile of [ + 'package.json', + 'pnpm-workspace.yaml', + '.node-version', + '.tool-versions', + ]) { + assert.equal(existsSync(path.join(repositoryRoot, requiredFile)), true); + } + + const packageManifest = JSON.parse( + readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8'), + ); + const discoveredPackages = JSON.parse( + run('corepack', ['pnpm', '--recursive', 'list', '--depth', '-1', '--json']), + ).map(({ name }) => name); + + assert.ok(discoveredPackages.includes('@databreeze/platform')); + assert.equal( + run('corepack', ['pnpm', 'exec', 'node', '--version']), + `v${expectedRuntimeVersions.nodejs}`, + ); + assert.equal(run('corepack', ['pnpm', '--version']), expectedRuntimeVersions.pnpm); + assert.equal(packageManifest.packageManager, `pnpm@${expectedRuntimeVersions.pnpm}`); + assert.equal( + readFileSync(path.join(repositoryRoot, '.node-version'), 'utf8').trim(), + expectedRuntimeVersions.nodejs, + ); + assert.deepEqual(readToolVersions(), expectedRuntimeVersions); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..afa0c969 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2024", + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "useUnknownInCatchVariables": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "skipLibCheck": true + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..d6378d69 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.base.json", + "files": [] +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 00000000..5b49bbc6 --- /dev/null +++ b/turbo.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [".node-version", ".npmrc", ".tool-versions"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", "out/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "lint": { + "dependsOn": ["^lint"] + }, + "test": { + "dependsOn": ["^build"], + "outputs": ["coverage/**"] + }, + "typecheck": { + "dependsOn": ["^typecheck"] + } + } +} From cbd8722ed73e7b88e75c8d46cda3bd9d3b407af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 02:07:01 +0700 Subject: [PATCH 03/51] fix(repo): pin PostgreSQL and Redis runtimes --- .tool-versions | 2 ++ tools/repo-cli/test/workspace-runtime-policy.test.mjs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.tool-versions b/.tool-versions index ca109f27..c2506c54 100644 --- a/.tool-versions +++ b/.tool-versions @@ -2,3 +2,5 @@ nodejs 24.17.0 pnpm 11.9.0 python 3.13.0 java temurin-21 +postgres 17 +redis 7.4 diff --git a/tools/repo-cli/test/workspace-runtime-policy.test.mjs b/tools/repo-cli/test/workspace-runtime-policy.test.mjs index f3660535..be9dc857 100644 --- a/tools/repo-cli/test/workspace-runtime-policy.test.mjs +++ b/tools/repo-cli/test/workspace-runtime-policy.test.mjs @@ -12,6 +12,8 @@ const expectedRuntimeVersions = { pnpm: '11.9.0', python: '3.13.0', java: 'temurin-21', + postgres: '17', + redis: '7.4', }; function run(command, args) { From 6e253cc7af615310ab896ad3a6f2bd38ae76eee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 02:16:01 +0700 Subject: [PATCH 04/51] test(architecture): enforce repository dependency boundaries --- eslint.config.mjs | 9 +- package.json | 2 +- tools/repo-cli/README.md | 7 +- .../src/check-dependency-boundaries.mjs | 249 ++++++++++++++++++ .../test/dependency-boundaries.test.mjs | 68 +++++ .../packages/domain/package.json | 5 + .../packages/domain/src/index.ts | 1 + .../allowed-imports/services/api/src/main.ts | 3 + .../apps/web/src/client.ts | 3 + .../services/api/package.json | 7 + .../services/api/src/internal.ts | 1 + .../billing/persistence/repository.ts | 1 + .../src/features/inbox/application/handler.ts | 3 + .../src/features/inbox/application/handler.ts | 3 + .../packages/contracts/package.json | 5 + .../packages/contracts/package.json | 4 + .../packages/contracts/src/index.ts | 1 + .../apps/desktop/src/bridge.ts | 3 + .../packages/contracts/package.json | 7 + .../packages/contracts/src/index.ts | 1 + 20 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 tools/repo-cli/src/check-dependency-boundaries.mjs create mode 100644 tools/repo-cli/test/dependency-boundaries.test.mjs create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/packages/domain/package.json create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/packages/domain/src/index.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/services/api/src/main.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/apps/web/src/client.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/services/api/package.json create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/services/api/src/internal.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-feature-persistence/services/api/src/features/billing/persistence/repository.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-feature-persistence/services/api/src/features/inbox/application/handler.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-own-persistence/services/api/src/features/inbox/application/handler.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/package-with-empty-exports/packages/contracts/package.json create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/package-without-exports/packages/contracts/package.json create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/package-without-exports/packages/contracts/src/index.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/apps/desktop/src/bridge.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/package.json create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/src/index.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index b354bbee..35427d09 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,14 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( { - ignores: ['**/build/**', '**/coverage/**', '**/dist/**', '**/node_modules/**', '**/out/**'], + ignores: [ + '**/build/**', + '**/coverage/**', + '**/dist/**', + '**/node_modules/**', + '**/out/**', + 'tools/repo-cli/test/fixtures/**', + ], }, eslint.configs.recommended, { diff --git a/package.json b/package.json index 2d533f16..03b10429 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "turbo run build", "format": "prettier --write .", "format:check": "prettier --check .", - "lint": "eslint .", + "lint": "eslint . && node tools/repo-cli/src/check-dependency-boundaries.mjs", "repo:bootstrap": "corepack pnpm install --frozen-lockfile", "repo:build": "corepack pnpm build", "repo:check": "corepack pnpm format:check && corepack pnpm lint && corepack pnpm typecheck && corepack pnpm test", diff --git a/tools/repo-cli/README.md b/tools/repo-cli/README.md index c66f8cab..36939eb0 100644 --- a/tools/repo-cli/README.md +++ b/tools/repo-cli/README.md @@ -1,3 +1,8 @@ # Repository CLI -Future TypeScript orchestration for consistent bootstrap, check, test, build, and development commands across Windows and CI. +Cross-platform repository checks for Windows and CI. + +`node tools/repo-cli/src/check-dependency-boundaries.mjs` scans the repository for +client-to-service implementation imports, cross-feature persistence imports, and +workspace packages without public `exports` declarations. Root `pnpm lint` runs this +checker after ESLint. diff --git a/tools/repo-cli/src/check-dependency-boundaries.mjs b/tools/repo-cli/src/check-dependency-boundaries.mjs new file mode 100644 index 00000000..9ac47cb0 --- /dev/null +++ b/tools/repo-cli/src/check-dependency-boundaries.mjs @@ -0,0 +1,249 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import ts from 'typescript'; + +const sourceExtensions = new Set(['.ts', '.tsx', '.mts', '.cts']); +const ignoredDirectories = new Set(['node_modules', 'dist', 'build', 'coverage', 'out']); + +function parseRoot(argumentsList) { + const rootFlagIndex = argumentsList.indexOf('--root'); + + if (rootFlagIndex === -1) { + return path.resolve(import.meta.dirname, '..', '..', '..'); + } + + const specifiedRoot = argumentsList[rootFlagIndex + 1]; + if (specifiedRoot === undefined) { + throw new Error('The --root option requires a repository path.'); + } + + return path.resolve(specifiedRoot); +} + +function listFiles(directory) { + if (!existsSync(directory)) { + return []; + } + + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + files.push(...listFiles(entryPath)); + } + } else if (entry.isFile() && sourceExtensions.has(path.extname(entry.name))) { + files.push(entryPath); + } + } + return files; +} + +function listPackageManifests(packagesDirectory) { + if (!existsSync(packagesDirectory)) { + return []; + } + + return readdirSync(packagesDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(packagesDirectory, entry.name, 'package.json')) + .filter(existsSync); +} + +function readPackageManifest(manifestPath) { + return JSON.parse(readFileSync(manifestPath, 'utf8')); +} + +function sourceFileKind(filePath) { + if (filePath.endsWith('.tsx')) { + return ts.ScriptKind.TSX; + } + return ts.ScriptKind.TS; +} + +function importedModules(filePath) { + const sourceFile = ts.createSourceFile( + filePath, + readFileSync(filePath, 'utf8'), + ts.ScriptTarget.Latest, + true, + sourceFileKind(filePath), + ); + const moduleSpecifiers = []; + + function visit(node) { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier !== undefined && + ts.isStringLiteral(node.moduleSpecifier) + ) { + moduleSpecifiers.push(node.moduleSpecifier.text); + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return moduleSpecifiers; +} + +function isWithin(candidatePath, parentPath) { + const relativePath = path.relative(parentPath, candidatePath); + return relativePath !== '' && !relativePath.startsWith(`..${path.sep}`) && relativePath !== '..'; +} + +function featureName(filePath, apiDirectory) { + const relativePath = path.relative(apiDirectory, filePath).split(path.sep).join('/'); + return /^src\/features\/([^/]+)\//.exec(relativePath)?.[1]; +} + +function relativePath(repositoryRoot, filePath) { + return path.relative(repositoryRoot, filePath).split(path.sep).join('/'); +} + +function diagnostic(rule, repositoryRoot, filePath, detail) { + return `${relativePath(repositoryRoot, filePath)}: rule=${rule} ${detail}`; +} + +function servicePackageNames(apiDirectory) { + const manifestPath = path.join(apiDirectory, 'package.json'); + if (!existsSync(manifestPath)) { + return []; + } + const manifest = readPackageManifest(manifestPath); + return typeof manifest.name === 'string' ? [manifest.name] : []; +} + +function matchesPackageSpecifier(moduleSpecifier, packageName) { + return moduleSpecifier === packageName || moduleSpecifier.startsWith(`${packageName}/`); +} + +function checkClientImports(repositoryRoot, apiDirectory) { + const diagnostics = []; + const clientDirectories = ['web', 'desktop'].map((name) => + path.join(repositoryRoot, 'apps', name), + ); + const apiPackageNames = servicePackageNames(apiDirectory); + + for (const clientDirectory of clientDirectories) { + for (const filePath of listFiles(clientDirectory)) { + for (const moduleSpecifier of importedModules(filePath)) { + const relativeTarget = moduleSpecifier.startsWith('.') + ? path.resolve(path.dirname(filePath), moduleSpecifier) + : undefined; + const importsServiceDirectory = + relativeTarget !== undefined && isWithin(relativeTarget, apiDirectory); + const importsServicePackage = apiPackageNames.some((packageName) => + matchesPackageSpecifier(moduleSpecifier, packageName), + ); + + if (importsServiceDirectory || importsServicePackage) { + diagnostics.push( + diagnostic( + 'clients-must-not-import-service-implementations', + repositoryRoot, + filePath, + `import=${moduleSpecifier}`, + ), + ); + } + } + } + } + + return diagnostics; +} + +function checkFeaturePersistenceImports(repositoryRoot, apiDirectory) { + const diagnostics = []; + + for (const filePath of listFiles(path.join(apiDirectory, 'src', 'features'))) { + const importingFeature = featureName(filePath, apiDirectory); + if (importingFeature === undefined) { + continue; + } + + for (const moduleSpecifier of importedModules(filePath)) { + const resolvedTarget = moduleSpecifier.startsWith('.') + ? path.resolve(path.dirname(filePath), moduleSpecifier) + : undefined; + const importedFeature = + resolvedTarget === undefined ? undefined : featureName(resolvedTarget, apiDirectory); + const importsOtherFeaturePersistence = + importedFeature !== undefined && + importedFeature !== importingFeature && + relativePath(apiDirectory, resolvedTarget).includes( + `/features/${importedFeature}/persistence/`, + ); + const aliasedFeaturePersistence = /(?:^|\/)features\/([^/]+)\/persistence(?:\/|$)/.exec( + moduleSpecifier, + ); + const importsAliasedFeaturePersistence = + !moduleSpecifier.startsWith('.') && + aliasedFeaturePersistence?.[1] !== undefined && + aliasedFeaturePersistence[1] !== importingFeature; + + if (importsOtherFeaturePersistence || importsAliasedFeaturePersistence) { + diagnostics.push( + diagnostic( + 'features-must-not-import-other-feature-persistence', + repositoryRoot, + filePath, + `import=${moduleSpecifier}`, + ), + ); + } + } + } + + return diagnostics; +} + +function checkPackageExports(repositoryRoot) { + const diagnostics = []; + for (const manifestPath of listPackageManifests(path.join(repositoryRoot, 'packages'))) { + const manifest = readPackageManifest(manifestPath); + const hasPublicExports = + (typeof manifest.exports === 'string' && manifest.exports.length > 0) || + (typeof manifest.exports === 'object' && + manifest.exports !== null && + !Array.isArray(manifest.exports) && + Object.keys(manifest.exports).length > 0); + if (!hasPublicExports) { + diagnostics.push( + diagnostic( + 'workspace-packages-must-declare-public-exports', + repositoryRoot, + manifestPath, + 'missing-or-empty=exports', + ), + ); + } + } + return diagnostics; +} + +function checkRepository(repositoryRoot) { + const apiDirectory = path.join(repositoryRoot, 'services', 'api'); + return [ + ...checkClientImports(repositoryRoot, apiDirectory), + ...checkFeaturePersistenceImports(repositoryRoot, apiDirectory), + ...checkPackageExports(repositoryRoot), + ].sort(); +} + +try { + const repositoryRoot = parseRoot(process.argv.slice(2)); + if (!statSync(repositoryRoot).isDirectory()) { + throw new Error(`Repository root is not a directory: ${repositoryRoot}`); + } + + const diagnostics = checkRepository(repositoryRoot); + if (diagnostics.length > 0) { + process.stderr.write(`${diagnostics.join('\n')}\n`); + process.exitCode = 1; + } +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 2; +} diff --git a/tools/repo-cli/test/dependency-boundaries.test.mjs b/tools/repo-cli/test/dependency-boundaries.test.mjs new file mode 100644 index 00000000..6de8836d --- /dev/null +++ b/tools/repo-cli/test/dependency-boundaries.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const checkerPath = path.join(testDirectory, '..', 'src', 'check-dependency-boundaries.mjs'); +const fixturesDirectory = path.join(testDirectory, 'fixtures', 'dependency-boundaries'); + +function checkFixture(name) { + return spawnSync(process.execPath, [checkerPath, '--root', path.join(fixturesDirectory, name)], { + encoding: 'utf8', + }); +} + +test('accepts an allowed API dependency on a shared pure package', () => { + const result = checkFixture('allowed-imports'); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); +}); + +test('rejects a client import of a service implementation', () => { + const result = checkFixture('client-imports-service'); + + assert.equal(result.status, 1); + assert.match(result.stderr, /apps[\\/]web[\\/]src[\\/]client\.ts/); + assert.match(result.stderr, /rule=clients-must-not-import-service-implementations/); +}); + +test('rejects a feature import of another feature persistence adapter', () => { + const result = checkFixture('feature-imports-feature-persistence'); + + assert.equal(result.status, 1); + assert.match(result.stderr, /features[\\/]inbox[\\/]application[\\/]handler\.ts/); + assert.match(result.stderr, /rule=features-must-not-import-other-feature-persistence/); +}); + +test('accepts a feature import of its own persistence adapter', () => { + const result = checkFixture('feature-imports-own-persistence'); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); +}); + +test('rejects a workspace package that omits its public exports map', () => { + const result = checkFixture('package-without-exports'); + + assert.equal(result.status, 1); + assert.match(result.stderr, /packages[\\/]contracts[\\/]package\.json/); + assert.match(result.stderr, /rule=workspace-packages-must-declare-public-exports/); +}); + +test('rejects a workspace package with an empty public exports map', () => { + const result = checkFixture('package-with-empty-exports'); + + assert.equal(result.status, 1); + assert.match(result.stderr, /packages[\\/]contracts[\\/]package\.json/); + assert.match(result.stderr, /rule=workspace-packages-must-declare-public-exports/); +}); + +test('accepts a client import of a shared contract through its public package', () => { + const result = checkFixture('public-contract-import'); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); +}); diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/packages/domain/package.json b/tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/packages/domain/package.json new file mode 100644 index 00000000..cd3fd8e1 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/packages/domain/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fixture/domain", + "private": true, + "exports": "./src/index.ts" +} diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/packages/domain/src/index.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/packages/domain/src/index.ts new file mode 100644 index 00000000..a5381222 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/packages/domain/src/index.ts @@ -0,0 +1 @@ +export const domainValue = 'shared'; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/services/api/src/main.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/services/api/src/main.ts new file mode 100644 index 00000000..8ca8f5eb --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/allowed-imports/services/api/src/main.ts @@ -0,0 +1,3 @@ +import { domainValue } from '@fixture/domain'; + +export const apiValue = domainValue; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/apps/web/src/client.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/apps/web/src/client.ts new file mode 100644 index 00000000..5a8a580f --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/apps/web/src/client.ts @@ -0,0 +1,3 @@ +import { internalHandler } from '@fixture/api/internal'; + +export const clientHandler = internalHandler; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/services/api/package.json b/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/services/api/package.json new file mode 100644 index 00000000..c73b98eb --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/services/api/package.json @@ -0,0 +1,7 @@ +{ + "name": "@fixture/api", + "exports": { + ".": "./src/public.ts", + "./internal": "./src/internal.ts" + } +} diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/services/api/src/internal.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/services/api/src/internal.ts new file mode 100644 index 00000000..c8f36eb2 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service/services/api/src/internal.ts @@ -0,0 +1 @@ +export const internalHandler = 'implementation'; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-feature-persistence/services/api/src/features/billing/persistence/repository.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-feature-persistence/services/api/src/features/billing/persistence/repository.ts new file mode 100644 index 00000000..32cc128a --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-feature-persistence/services/api/src/features/billing/persistence/repository.ts @@ -0,0 +1 @@ +export const billingRepository = 'persistence'; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-feature-persistence/services/api/src/features/inbox/application/handler.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-feature-persistence/services/api/src/features/inbox/application/handler.ts new file mode 100644 index 00000000..d48f653a --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-feature-persistence/services/api/src/features/inbox/application/handler.ts @@ -0,0 +1,3 @@ +import { billingRepository } from '../../billing/persistence/repository.js'; + +export const inboxHandler = billingRepository; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-own-persistence/services/api/src/features/inbox/application/handler.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-own-persistence/services/api/src/features/inbox/application/handler.ts new file mode 100644 index 00000000..b6f4d2a7 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-own-persistence/services/api/src/features/inbox/application/handler.ts @@ -0,0 +1,3 @@ +import { inboxRepository } from '@fixture/api/features/inbox/persistence'; + +export const inboxHandler = inboxRepository; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/package-with-empty-exports/packages/contracts/package.json b/tools/repo-cli/test/fixtures/dependency-boundaries/package-with-empty-exports/packages/contracts/package.json new file mode 100644 index 00000000..8ffb3433 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/package-with-empty-exports/packages/contracts/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fixture/contracts", + "private": true, + "exports": {} +} diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/package-without-exports/packages/contracts/package.json b/tools/repo-cli/test/fixtures/dependency-boundaries/package-without-exports/packages/contracts/package.json new file mode 100644 index 00000000..22287d93 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/package-without-exports/packages/contracts/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/contracts", + "private": true +} diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/package-without-exports/packages/contracts/src/index.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/package-without-exports/packages/contracts/src/index.ts new file mode 100644 index 00000000..58971810 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/package-without-exports/packages/contracts/src/index.ts @@ -0,0 +1 @@ +export const contractVersion = 'v1'; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/apps/desktop/src/bridge.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/apps/desktop/src/bridge.ts new file mode 100644 index 00000000..d147909d --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/apps/desktop/src/bridge.ts @@ -0,0 +1,3 @@ +import { contractVersion } from '@fixture/contracts'; + +export const bridgeVersion = contractVersion; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/package.json b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/package.json new file mode 100644 index 00000000..b523da7e --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/package.json @@ -0,0 +1,7 @@ +{ + "name": "@fixture/contracts", + "private": true, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/src/index.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/src/index.ts new file mode 100644 index 00000000..58971810 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/src/index.ts @@ -0,0 +1 @@ +export const contractVersion = 'v1'; From 3635ea896ef5607c4593c8ad3fcea86d63c8d8a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 02:22:23 +0700 Subject: [PATCH 05/51] fix(architecture): close dependency boundary bypasses --- tools/repo-cli/README.md | 4 +- .../src/check-dependency-boundaries.mjs | 93 ++++++++++++++++++- .../test/dependency-boundaries.test.mjs | 24 +++++ .../apps/web/src/client.ts | 3 + .../src/features/inbox/application/handler.ts | 3 + .../apps/web/src/client.ts | 3 + .../packages/contracts/package.json | 7 ++ .../packages/contracts/src/index.ts | 1 + .../packages/contracts/src/internal.ts | 1 + .../apps/desktop/src/bridge.ts | 2 +- .../packages/contracts/package.json | 3 +- .../packages/contracts/src/schema.ts | 1 + 12 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service-directory/apps/web/src/client.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-persistence-directory/services/api/src/features/inbox/application/handler.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/apps/web/src/client.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/package.json create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/src/index.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/src/internal.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/src/schema.ts diff --git a/tools/repo-cli/README.md b/tools/repo-cli/README.md index 36939eb0..99f744f5 100644 --- a/tools/repo-cli/README.md +++ b/tools/repo-cli/README.md @@ -4,5 +4,5 @@ Cross-platform repository checks for Windows and CI. `node tools/repo-cli/src/check-dependency-boundaries.mjs` scans the repository for client-to-service implementation imports, cross-feature persistence imports, and -workspace packages without public `exports` declarations. Root `pnpm lint` runs this -checker after ESLint. +workspace packages without public `exports` declarations or imports of their private +subpaths. Root `pnpm lint` runs this checker after ESLint. diff --git a/tools/repo-cli/src/check-dependency-boundaries.mjs b/tools/repo-cli/src/check-dependency-boundaries.mjs index 9ac47cb0..810aa2b3 100644 --- a/tools/repo-cli/src/check-dependency-boundaries.mjs +++ b/tools/repo-cli/src/check-dependency-boundaries.mjs @@ -89,7 +89,9 @@ function importedModules(filePath) { function isWithin(candidatePath, parentPath) { const relativePath = path.relative(parentPath, candidatePath); - return relativePath !== '' && !relativePath.startsWith(`..${path.sep}`) && relativePath !== '..'; + return ( + relativePath === '' || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== '..') + ); } function featureName(filePath, apiDirectory) { @@ -172,8 +174,9 @@ function checkFeaturePersistenceImports(repositoryRoot, apiDirectory) { const importsOtherFeaturePersistence = importedFeature !== undefined && importedFeature !== importingFeature && - relativePath(apiDirectory, resolvedTarget).includes( - `/features/${importedFeature}/persistence/`, + isWithin( + resolvedTarget, + path.join(apiDirectory, 'src', 'features', importedFeature, 'persistence'), ); const aliasedFeaturePersistence = /(?:^|\/)features\/([^/]+)\/persistence(?:\/|$)/.exec( moduleSpecifier, @@ -199,6 +202,89 @@ function checkFeaturePersistenceImports(repositoryRoot, apiDirectory) { return diagnostics; } +function workspacePackages(repositoryRoot) { + return listPackageManifests(path.join(repositoryRoot, 'packages')) + .map((manifestPath) => readPackageManifest(manifestPath)) + .filter((manifest) => typeof manifest.name === 'string'); +} + +function workspacePackageImport(moduleSpecifier, packages) { + return packages + .map((manifest) => ({ + manifest, + subpath: + moduleSpecifier === manifest.name + ? '.' + : `./${moduleSpecifier.slice(manifest.name.length + 1)}`, + })) + .find(({ manifest }) => matchesPackageSpecifier(moduleSpecifier, manifest.name)); +} + +function escapesForRegularExpression(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function exportsSubpath(exportsField, subpath) { + if (typeof exportsField === 'string') { + return subpath === '.'; + } + if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) { + return false; + } + + const exportKeys = Object.keys(exportsField); + const publicSubpaths = exportKeys.filter((key) => key.startsWith('.')); + if (publicSubpaths.length === 0) { + return subpath === '.'; + } + + return publicSubpaths.some((exportedSubpath) => { + if (exportedSubpath === subpath) { + return true; + } + if (!exportedSubpath.includes('*')) { + return false; + } + return new RegExp( + `^${escapesForRegularExpression(exportedSubpath).replace('\\*', '.+')}$`, + ).test(subpath); + }); +} + +function checkPrivateWorkspacePackageImports(repositoryRoot) { + const diagnostics = []; + const packages = workspacePackages(repositoryRoot); + const consumerDirectories = [ + path.join(repositoryRoot, 'apps', 'web'), + path.join(repositoryRoot, 'apps', 'desktop'), + path.join(repositoryRoot, 'services', 'api'), + path.join(repositoryRoot, 'packages'), + ]; + + for (const consumerDirectory of consumerDirectories) { + for (const filePath of listFiles(consumerDirectory)) { + for (const moduleSpecifier of importedModules(filePath)) { + const workspaceImport = workspacePackageImport(moduleSpecifier, packages); + if ( + workspaceImport !== undefined && + !exportsSubpath(workspaceImport.manifest.exports, workspaceImport.subpath) + ) { + diagnostics.push( + diagnostic( + 'workspace-packages-must-not-import-private-subpaths', + repositoryRoot, + filePath, + `import=${moduleSpecifier}`, + ), + ); + } + } + } + } + + return diagnostics; +} + function checkPackageExports(repositoryRoot) { const diagnostics = []; for (const manifestPath of listPackageManifests(path.join(repositoryRoot, 'packages'))) { @@ -229,6 +315,7 @@ function checkRepository(repositoryRoot) { ...checkClientImports(repositoryRoot, apiDirectory), ...checkFeaturePersistenceImports(repositoryRoot, apiDirectory), ...checkPackageExports(repositoryRoot), + ...checkPrivateWorkspacePackageImports(repositoryRoot), ].sort(); } diff --git a/tools/repo-cli/test/dependency-boundaries.test.mjs b/tools/repo-cli/test/dependency-boundaries.test.mjs index 6de8836d..f0e9d5da 100644 --- a/tools/repo-cli/test/dependency-boundaries.test.mjs +++ b/tools/repo-cli/test/dependency-boundaries.test.mjs @@ -29,6 +29,14 @@ test('rejects a client import of a service implementation', () => { assert.match(result.stderr, /rule=clients-must-not-import-service-implementations/); }); +test('rejects a client import of the API directory itself', () => { + const result = checkFixture('client-imports-service-directory'); + + assert.equal(result.status, 1); + assert.match(result.stderr, /apps[\\/]web[\\/]src[\\/]client\.ts/); + assert.match(result.stderr, /rule=clients-must-not-import-service-implementations/); +}); + test('rejects a feature import of another feature persistence adapter', () => { const result = checkFixture('feature-imports-feature-persistence'); @@ -37,6 +45,14 @@ test('rejects a feature import of another feature persistence adapter', () => { assert.match(result.stderr, /rule=features-must-not-import-other-feature-persistence/); }); +test('rejects a feature import of another feature persistence directory', () => { + const result = checkFixture('feature-imports-persistence-directory'); + + assert.equal(result.status, 1); + assert.match(result.stderr, /features[\\/]inbox[\\/]application[\\/]handler\.ts/); + assert.match(result.stderr, /rule=features-must-not-import-other-feature-persistence/); +}); + test('accepts a feature import of its own persistence adapter', () => { const result = checkFixture('feature-imports-own-persistence'); @@ -66,3 +82,11 @@ test('accepts a client import of a shared contract through its public package', assert.equal(result.status, 0, result.stderr); assert.equal(result.stderr, ''); }); + +test('rejects a client import of a private workspace-package subpath', () => { + const result = checkFixture('private-deep-import'); + + assert.equal(result.status, 1); + assert.match(result.stderr, /apps[\\/]web[\\/]src[\\/]client\.ts/); + assert.match(result.stderr, /rule=workspace-packages-must-not-import-private-subpaths/); +}); diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service-directory/apps/web/src/client.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service-directory/apps/web/src/client.ts new file mode 100644 index 00000000..b93960f3 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/client-imports-service-directory/apps/web/src/client.ts @@ -0,0 +1,3 @@ +import { apiRoot } from '../../../services/api'; + +export const clientApi = apiRoot; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-persistence-directory/services/api/src/features/inbox/application/handler.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-persistence-directory/services/api/src/features/inbox/application/handler.ts new file mode 100644 index 00000000..154f949a --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/feature-imports-persistence-directory/services/api/src/features/inbox/application/handler.ts @@ -0,0 +1,3 @@ +import { billingRepository } from '../../billing/persistence'; + +export const inboxHandler = billingRepository; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/apps/web/src/client.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/apps/web/src/client.ts new file mode 100644 index 00000000..ad0a3f22 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/apps/web/src/client.ts @@ -0,0 +1,3 @@ +import { internalContract } from '@fixture/contracts/internal'; + +export const clientContract = internalContract; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/package.json b/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/package.json new file mode 100644 index 00000000..b523da7e --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/package.json @@ -0,0 +1,7 @@ +{ + "name": "@fixture/contracts", + "private": true, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/src/index.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/src/index.ts new file mode 100644 index 00000000..fd5b9222 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/src/index.ts @@ -0,0 +1 @@ +export const publicContract = 'public'; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/src/internal.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/src/internal.ts new file mode 100644 index 00000000..29a6218b --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/private-deep-import/packages/contracts/src/internal.ts @@ -0,0 +1 @@ +export const internalContract = 'private'; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/apps/desktop/src/bridge.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/apps/desktop/src/bridge.ts index d147909d..eb7f5982 100644 --- a/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/apps/desktop/src/bridge.ts +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/apps/desktop/src/bridge.ts @@ -1,3 +1,3 @@ -import { contractVersion } from '@fixture/contracts'; +import { contractVersion } from '@fixture/contracts/schema'; export const bridgeVersion = contractVersion; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/package.json b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/package.json index b523da7e..89040330 100644 --- a/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/package.json +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/package.json @@ -2,6 +2,7 @@ "name": "@fixture/contracts", "private": true, "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./schema": "./src/schema.ts" } } diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/src/schema.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/src/schema.ts new file mode 100644 index 00000000..58971810 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/public-contract-import/packages/contracts/src/schema.ts @@ -0,0 +1 @@ +export const contractVersion = 'v1'; From f5d52e5c11930684fb95ca04f6131d149caa4ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 02:30:51 +0700 Subject: [PATCH 06/51] feat(traceability): index normative requirements --- docs/specs/requirement-index.json | 5504 +++++++++++++++++ package.json | 4 +- tools/repo-cli/README.md | 5 + .../src/generate-requirement-index.mjs | 239 + .../docs/specs/features/duplicate.md | 5 + .../duplicate/docs/specs/foundation/foo.md | 5 + .../gap/docs/specs/foundation/foo.md | 6 + .../malformed/docs/specs/foundation/foo.md | 6 + .../valid/docs/specs/features/bar.md | 5 + .../valid/docs/specs/foundation/foo.md | 6 + .../test/requirement-traceability.test.mjs | 130 + 11 files changed, 5914 insertions(+), 1 deletion(-) create mode 100644 docs/specs/requirement-index.json create mode 100644 tools/repo-cli/src/generate-requirement-index.mjs create mode 100644 tools/repo-cli/test/fixtures/requirement-traceability/duplicate/docs/specs/features/duplicate.md create mode 100644 tools/repo-cli/test/fixtures/requirement-traceability/duplicate/docs/specs/foundation/foo.md create mode 100644 tools/repo-cli/test/fixtures/requirement-traceability/gap/docs/specs/foundation/foo.md create mode 100644 tools/repo-cli/test/fixtures/requirement-traceability/malformed/docs/specs/foundation/foo.md create mode 100644 tools/repo-cli/test/fixtures/requirement-traceability/valid/docs/specs/features/bar.md create mode 100644 tools/repo-cli/test/fixtures/requirement-traceability/valid/docs/specs/foundation/foo.md create mode 100644 tools/repo-cli/test/requirement-traceability.test.mjs diff --git a/docs/specs/requirement-index.json b/docs/specs/requirement-index.json new file mode 100644 index 00000000..ba41f2a4 --- /dev/null +++ b/docs/specs/requirement-index.json @@ -0,0 +1,5504 @@ +{ + "requirements": [ + { + "id": "AND-001", + "priority": "P0", + "requirement": "Android shall be implemented natively in Kotlin with Jetpack Compose and shall use Room, WorkManager, CameraX, scoped storage, Android Keystore, and Android share intents for their defined responsibilities.", + "source": { + "line": 92, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-002", + "priority": "P0", + "requirement": "Each organization enrollment shall use an IAM-defined DeviceIdentity with a distinct Keystore-backed signing key, device-bound short sessions, and rotating refresh credentials; DSO shall consume that identity only for capabilities, grants, operational health, synchronization, routing, and transfer.", + "source": { + "line": 93, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-003", + "priority": "P0", + "requirement": "Sensitive tokens and local encryption-key envelopes shall be non-exportable where Android permits and never stored in plaintext preferences, logs, backups, intents, or Compose state restoration.", + "source": { + "line": 94, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-004", + "priority": "P0", + "requirement": "Camera capture shall preserve each original byte stream immutably; crop, rotate, perspective correction, enhancement, OCR, redaction, and compression shall create derived versions.", + "source": { + "line": 95, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-005", + "priority": "P0", + "requirement": "`ACTION_SEND` and `ACTION_SEND_MULTIPLE` intake shall accept `content://` streams through scoped grants, validate actual content, copy to app-private staging, and never request or infer an unrestricted filesystem path.", + "source": { + "line": 96, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-006", + "priority": "P0", + "requirement": "The application shall not request `MANAGE_EXTERNAL_STORAGE`; exported components shall be minimal, permission-protected where possible, and validate every intent/deep link.", + "source": { + "line": 97, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-007", + "priority": "P0", + "requirement": "WorkManager jobs shall be unique and idempotent by capture/operation ID, resumable, constraint-aware, and safe across process death, reboot, duplicate scheduling, and lost acknowledgement.", + "source": { + "line": 98, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-008", + "priority": "P0", + "requirement": "`LOCAL` mode shall prevent original bytes, previews, OCR text, thumbnails, voice content, source snippets, and reconstructable chunks from uploading; UI shall state that local-only content may be unavailable on other devices.", + "source": { + "line": 99, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-009", + "priority": "P0", + "requirement": "Offline queues shall be encrypted, account/workspace-scoped, dependency-aware, and reconciled through `DSO` with explicit conflicts and no silent last-write-wins for protected fields.", + "source": { + "line": 100, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-010", + "priority": "P0", + "requirement": "Approval decisions shall require online server authorization, the current bound subject hash/policy, and MFA when required; notification actions and cached roles shall never finalize approval.", + "source": { + "line": 101, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-011", + "priority": "P0", + "requirement": "Push and lock-screen notifications shall comply with `NCO` and contain no file/client name, extracted value, amount, evidence, comment text, voice transcript, or other sensitive content.", + "source": { + "line": 102, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-012", + "priority": "P0", + "requirement": "Evidence review shall preserve the exact ArtifactVersion and coordinate, request the minimum authorized representation, and report `SOURCE_OFFLINE` or another explicit reason rather than substituting newer content; `LOCAL` Desktop evidence opens on that Desktop and is not streamed to Android without explicit derived publication.", + "source": { + "line": 103, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-013", + "priority": "P1", + "requirement": "Camera capture shall support multi-page ordering, retake/removal before finalization, orientation, flash, focus, blur/glare hints, and a user-confirmed quality override.", + "source": { + "line": 104, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-014", + "priority": "P1", + "requirement": "Voice capture shall be user-initiated and visibly foregrounded, enforce workspace duration/size policy, preserve the original recording, and version transcript/audio enhancements separately.", + "source": { + "line": 105, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-015", + "priority": "P1", + "requirement": "Users shall be able to select Wi-Fi-only, charging, battery, roaming, and cellular-size behavior within stricter organization policy; the app shall display queued bytes and reasons.", + "source": { + "line": 106, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-016", + "priority": "P1", + "requirement": "The app shall provide focused Inbox, capture, review, approval, comment, notification, report, sync, conflict, device, and account screens and shall direct full administration to Web.", + "source": { + "line": 107, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-017", + "priority": "P1", + "requirement": "Vietnamese shall be the default complete locale with English fallback, and all critical workflows shall support TalkBack, switch access, font scaling to 200%, high contrast, and non-color cues.", + "source": { + "line": 108, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-018", + "priority": "P1", + "requirement": "Cached source previews and staged captures shall have visible storage usage, policy retention, explicit cleanup, and safeguards preventing cleanup of unfinalized or unsynchronized user data without confirmation.", + "source": { + "line": 109, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-019", + "priority": "P1", + "requirement": "Account switch/sign-out shall stop work, close account databases, clear session material, and prevent one account or workspace from observing another's cached metadata.", + "source": { + "line": 110, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-020", + "priority": "P1", + "requirement": "The app shall use verified Android App Links for DataBreeze web origins, reject unrecognized schemes/hosts/actions, and re-authorize every resolved resource.", + "source": { + "line": 111, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-021", + "priority": "P1", + "requirement": "Background execution shall comply with Android limits, use foreground services only for user-visible capture or policy-compliant long transfer, and never run hidden continuous polling.", + "source": { + "line": 112, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-022", + "priority": "P1", + "requirement": "The app shall expose content-redacted diagnostics, sync status, app/protocol version, device revocation state, and safe recovery/export guidance.", + "source": { + "line": 113, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AND-023", + "priority": "P0", + "requirement": "Android shall implement the DSO user-mediated offline-package exporter for strict-Local handoff: explicit item/destination/purpose consent, source Device signature, authenticated encryption and destination key/passphrase envelope, exact manifest/hash/expiry, OS-selected user transfer, content-safe receipt state, and zero cloud upload, live relay, background peer discovery, or unregistered destination.", + "source": { + "line": 114, + "path": "docs/specs/platforms/android.md" + } + }, + { + "id": "AUD-001", + "priority": "P0", + "requirement": "AUD shall be the sole authoritative audit ledger; application logs, analytics, outboxes, provider dashboards, and module-specific timelines shall not substitute for it.", + "source": { + "line": 95, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-002", + "priority": "P0", + "requirement": "Every mandatory audited mutation shall persist its domain change and canonical AuditEvent in one PostgreSQL transaction; failure to append the audit event shall abort the mutation.", + "source": { + "line": 96, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-003", + "priority": "P0", + "requirement": "Audit events shall be insert-only to application roles; corrections shall append a linked correction event, and no public or internal application API shall update or delete an existing event.", + "source": { + "line": 97, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-004", + "priority": "P0", + "requirement": "Every AuditEvent shall carry full applicable TenantScope, action key/version, category, risk class, outcome, server time, principal snapshot, subject references, source subsystem, correlation ID, idempotency identity, schema version, and content hash.", + "source": { + "line": 98, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-005", + "priority": "P0", + "requirement": "Human, service-account, Device, system, and provider actors shall use explicit actor types and immutable identifiers; display labels shall be snapshots for interpretation and shall never become authorization evidence.", + "source": { + "line": 99, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-006", + "priority": "P0", + "requirement": "Every subject reference shall include resource type and tenant-scoped ID plus version/revision/hash when the action depends on exact content; cross-scope subjects shall be rejected before append.", + "source": { + "line": 100, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-007", + "priority": "P0", + "requirement": "AUD shall deduplicate a repeated producer operation/action/subject identity and return the original event ID; a replay with the same identity and different canonical content shall be quarantined and alerted.", + "source": { + "line": 101, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-008", + "priority": "P0", + "requirement": "Each workspace shall have a server-assigned monotonic AuditEvent sequence; organization-only events shall use a separate organization sequence, and neither sequence shall depend on client clocks.", + "source": { + "line": 102, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-009", + "priority": "P0", + "requirement": "Audit action definitions shall be versioned, immutable after publication, and declare mandatory context, permitted outcomes, reason policy, safe-change fields, retention class, and whether failure must block the owning action.", + "source": { + "line": 103, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-010", + "priority": "P0", + "requirement": "Safe before/after summaries shall use allowlisted typed fields or salted hashes and shall exclude secrets, credentials, raw source values, evidence excerpts, unrestricted paths, full external payloads, and payment credentials.", + "source": { + "line": 104, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-011", + "priority": "P0", + "requirement": "Login/recovery, authorization denial where safe, membership/role/policy change, privileged read/export, Device lifecycle, data-mode/content movement, retention/deletion, definition publication, job effect, review/approval, connector/credential, billing, and support action classes shall be registered and audited.", + "source": { + "line": 105, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-012", + "priority": "P0", + "requirement": "Audit query and export shall require explicit IAM permissions and TenantScope/resource checks; audit access shall never imply access to linked source content, evidence, billing secrets, or another workspace.", + "source": { + "line": 106, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-013", + "priority": "P0", + "requirement": "Reading privileged audit categories, creating or downloading an export, changing retention, applying a legal hold, verifying a seal, or using support tooling shall itself create a bounded non-recursive AuditEvent.", + "source": { + "line": 107, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-014", + "priority": "P0", + "requirement": "Periodic seals shall cover contiguous closed sequence ranges using deterministic event hashes and a Merkle root, be signed by a rotating control-plane key, and be copied to storage unavailable to the event-table write role.", + "source": { + "line": 108, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-015", + "priority": "P0", + "requirement": "Integrity verification shall detect missing, reordered, duplicated, or altered events and invalid or missing seals; a failure shall raise a security alert and mark the affected range and exports unverified without rewriting history.", + "source": { + "line": 109, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-016", + "priority": "P0", + "requirement": "Retention expiry shall follow the action's published retention class, active legal holds, tenant policy, and applicable deployment policy; it shall be auditable and preserve content-safe tombstones plus seal continuity.", + "source": { + "line": 110, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-017", + "priority": "P0", + "requirement": "Query APIs shall use stable cursor pagination, bounded time ranges, deterministic ordering, field allowlists, and safe filters; callers shall not supply arbitrary SQL, expressions, or export templates.", + "source": { + "line": 111, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-018", + "priority": "P0", + "requirement": "Audit exports shall pin TenantScope, filters, upper sequence watermark, event/action schema versions, redaction policy, event count, checksums, signer/key version, creation actor/time, purpose, and expiry in an immutable manifest.", + "source": { + "line": 112, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-019", + "priority": "P0", + "requirement": "Local and Hybrid policy shall permit only content-safe AuditEvent metadata as `CONTROL_METADATA`; Local source content, paths, values, previews, and evidence snippets shall never enter the canonical ledger.", + "source": { + "line": 113, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-020", + "priority": "P0", + "requirement": "An offline action shall create a Device-signed LocalAuditFragment linked to its operation and authorization snapshot; it becomes a canonical AuditEvent only after server verification and acceptance. A rejected, tampered, wrong-scope, or revoked-Device fragment shall remain quarantined and exportable under policy, and the server shall append its own content-safe canonical rejection AuditEvent without treating the fragment's claimed action as accepted.", + "source": { + "line": 114, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-021", + "priority": "P0", + "requirement": "Restored deployments shall verify sequence continuity and the latest independent seals before privileged mutations resume; audit partitions, action definitions, holds, exports, keys, and seal records shall be included in disaster recovery.", + "source": { + "line": 115, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-022", + "priority": "P1", + "requirement": "Authorized administrators shall create scoped legal holds and retention exceptions with reason, authority reference, effective period, and immutable release history; a hold shall not broaden event visibility.", + "source": { + "line": 116, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-023", + "priority": "P1", + "requirement": "AUD shall support signed JSON Lines and CSV exports with a canonical JSON manifest and independently documented verification procedure; presentation PDFs may be derived but shall not be the verification source.", + "source": { + "line": 117, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "AUD-024", + "priority": "P1", + "requirement": "New action definitions and actor/subject types shall pass schema, privacy, retention, authorization, idempotency, and golden-fixture review before registration; extensions shall not emit arbitrary untyped payloads.", + "source": { + "line": 118, + "path": "docs/specs/foundation/audit-ledger.md" + } + }, + { + "id": "BUA-001", + "priority": "P0", + "requirement": "The control plane shall enforce effective entitlements and limits server-side before every billable upload, processing job, paid-module action, seat addition, export class, and API operation.", + "source": { + "line": 115, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-002", + "priority": "P0", + "requirement": "Client entitlement displays and offline caches shall be advisory; a modified Web, Desktop, or Android client shall not bypass server admission control.", + "source": { + "line": 116, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-003", + "priority": "P0", + "requirement": "Plan versions and optional commercial price mappings shall be immutable, effective-dated, and referenced by subscriptions and entitlement snapshots.", + "source": { + "line": 117, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-004", + "priority": "P0", + "requirement": "Usage shall be recorded in an append-only PostgreSQL ledger with stable idempotency keys; corrections shall be new adjustment records, never updates or deletes.", + "source": { + "line": 118, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-005", + "priority": "P0", + "requirement": "Redis, analytics stores, provider dashboards, and client counters shall not be authoritative for subscription state, entitlements, or usage.", + "source": { + "line": 119, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-006", + "priority": "P1", + "requirement": "When a commercial billing provider is enabled, its webhooks shall be signature-verified, stored idempotently, ordered per provider object, and reconciled with provider APIs before ambiguous state changes.", + "source": { + "line": 120, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-007", + "priority": "P0", + "requirement": "The `ExecutionAdmissionCoordinator` shall persist a BUA quota reservation and JRA job/admission creation in one modular-monolith transaction so concurrent requests cannot oversubscribe a hard limit; BUA and JRA shall expose contracts to the coordinator and shall not import each other's services or persistence.", + "source": { + "line": 121, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-008", + "priority": "P0", + "requirement": "Infrastructure retries and failed attempts that produce no customer result shall not create duplicate or unjustified billable usage.", + "source": { + "line": 122, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-009", + "priority": "P0", + "requirement": "`PAST_DUE`, `SUSPENDED`, cancellation, downgrade, or quota excess shall never delete or overwrite artifacts, versions, evidence, results, comments, audit history, or local data.", + "source": { + "line": 123, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-010", + "priority": "P0", + "requirement": "Suspended and cancelled organizations shall retain authenticated read, authorized download/export, billing remediation, and explicit deletion-request access while data remains under retention policy.", + "source": { + "line": 124, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-011", + "priority": "P0", + "requirement": "Billing changes, provider-link changes, organization deletion, and manual credits/debits shall require Owner authority, recent MFA where sensitive, idempotency, and immutable audit records.", + "source": { + "line": 125, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-012", + "priority": "P0", + "requirement": "`LOCAL` original bytes shall not count toward cloud storage usage and shall never upload for metering; only verified synchronized classes may contribute to cloud usage.", + "source": { + "line": 126, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-013", + "priority": "P1", + "requirement": "A 14-day grace period shall follow verified payment failure before suspension, unless fraud, abuse, or legal restrictions require a separately audited immediate safety suspension.", + "source": { + "line": 127, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-014", + "priority": "P1", + "requirement": "Downgrades shall normally take effect at period end; over-limit dimensions shall block new growth while preserving read, export, and user-directed cleanup.", + "source": { + "line": 128, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-015", + "priority": "P1", + "requirement": "Entitlement responses shall include stable reason codes, effective/expiry timestamps, limit, used, reserved, and reset time without exposing provider secrets.", + "source": { + "line": 129, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-016", + "priority": "P1", + "requirement": "Usage aggregation shall reconcile ledger totals, object-storage inventory, successful job results, membership counts, and provider-reported quantities on a scheduled basis.", + "source": { + "line": 130, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-017", + "priority": "P1", + "requirement": "Manual adjustments shall require a reason, actor, related organization, unit, quantity, effective period, and optional prior usage-event reference.", + "source": { + "line": 131, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-018", + "priority": "P1", + "requirement": "Billing communications shall follow `NCO`, use content-minimized templates, and never include source names, values, or payment credentials.", + "source": { + "line": 132, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-019", + "priority": "P1", + "requirement": "Reactivation shall rebuild entitlements and re-authorize eligible nonterminal jobs whose dispatch was blocked by entitlement policy; it shall not automatically execute stale, destructive, external, or approval-gated work.", + "source": { + "line": 133, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-020", + "priority": "P1", + "requirement": "The platform shall provide machine-readable usage export and invoice metadata in organization currency while preserving raw quantities in canonical units.", + "source": { + "line": 134, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-021", + "priority": "P0", + "requirement": "The foundation shall issue signed, Device- and workspace-bound offline entitlement leases that expire within 24 hours, bind plan/entitlement and authorization revisions plus allowed action limits, cannot authorize cloud or external effects, and fail closed after expiry or revocation is observed.", + "source": { + "line": 135, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "BUA-022", + "priority": "P0", + "requirement": "Every deployment shall support provider-independent `BUILT_IN_FREE`, `DEVELOPMENT`, or `ADMIN_GRANTED` subscription sources; provider absence or outage shall not block creating or enforcing one of those sources, and all sources shall use the same immutable PlanVersion, EntitlementSnapshot, reservation, usage, and authorization contracts.", + "source": { + "line": 136, + "path": "docs/specs/foundation/billing-usage-administration.md" + } + }, + { + "id": "CRF-001", + "priority": "P0", + "requirement": "Every report definition, template, run, version, output, and release shall be scoped to a workspace and client/project.", + "source": { + "line": 98, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-002", + "priority": "P0", + "requirement": "Published template versions shall be immutable and shall declare supported output formats, exact `DSM` dataset/schema contract references, parameters, blocks, and renderer requirements.", + "source": { + "line": 99, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-003", + "priority": "P0", + "requirement": "Report definitions shall pin a template version or an explicit version-selection policy and shall never switch a released report implicitly.", + "source": { + "line": 100, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-004", + "priority": "P0", + "requirement": "Each report run shall freeze exact `DSM` dataset, metric, and rule versions, parameters, template version, renderer versions, locale, timezone, effective `DSO` policy, `jraJobId`, and pinned `resultManifestId` in a manifest.", + "source": { + "line": 101, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-005", + "priority": "P0", + "requirement": "Preflight shall enforce client scope, permissions, schema compatibility, data-quality gates, freshness, required evidence, and output capability.", + "source": { + "line": 102, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-006", + "priority": "P0", + "requirement": "A blocked preflight shall enumerate every blocking and warning condition and shall not produce an approvable report.", + "source": { + "line": 103, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-007", + "priority": "P0", + "requirement": "Metric values, table records, and chart series shall be generated by deterministic implementations bound to exact immutable `DSM` metric/rule versions.", + "source": { + "line": 104, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-008", + "priority": "P0", + "requirement": "Every consequential metric and derived table/chart value shall have evidence lineage to governed dataset fields and source evidence.", + "source": { + "line": 105, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-009", + "priority": "P0", + "requirement": "Report blocks shall support stable IDs, conditional inclusion, page/section behavior, localization, accessibility labels, and per-format fallbacks.", + "source": { + "line": 106, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-010", + "priority": "P0", + "requirement": "Narrative content shall distinguish authored text, generated draft text, parameter substitution, and deterministic fact insertion.", + "source": { + "line": 107, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-011", + "priority": "P0", + "requirement": "AI-generated narrative shall use a bounded approved fact manifest, be provider-neutral, be labeled during review, and require human acceptance before approval.", + "source": { + "line": 108, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-012", + "priority": "P0", + "requirement": "AI or free text shall not alter deterministic metrics, datasets, chart series, or evidence references.", + "source": { + "line": 109, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-013", + "priority": "P0", + "requirement": "The system shall create report versions rather than mutating a submitted, approved, released, or withdrawn version.", + "source": { + "line": 110, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-014", + "priority": "P0", + "requirement": "Comments shall attach to a report version and stable block or evidence anchor and shall preserve resolution history.", + "source": { + "line": 111, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-015", + "priority": "P0", + "requirement": "A material change to data, parameters, bound `DSM` metric/rule versions, template, narrative, or output shall change the subject version/hash and shall invalidate the bound `JRA` `ApprovalRequest`; the module shall not carry a prior decision forward.", + "source": { + "line": 112, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-016", + "priority": "P0", + "requirement": "Release shall require a valid `JRA` `ApprovalDecision` for the exact requested action and report subject type/ID/version/hash plus an explicit audience, format set, evidence policy, and expiry/retention constraint.", + "source": { + "line": 113, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-017", + "priority": "P0", + "requirement": "Generated DOCX, PPTX, XLSX, PDF, and web outputs shall identify report/version, generation time, client, period, and confidentiality classification where configured.", + "source": { + "line": 114, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-018", + "priority": "P0", + "requirement": "Format-specific generation failure shall be visible and shall not mark that output ready or silently substitute another format.", + "source": { + "line": 115, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-019", + "priority": "P0", + "requirement": "A released report shall never grant access to source datasets or evidence beyond the release policy and viewer permissions.", + "source": { + "line": 116, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-020", + "priority": "P0", + "requirement": "Repeated run, `JRA` approval-facade, release, or export requests shall be idempotent and shall not create duplicate requests, versions, or notifications.", + "source": { + "line": 117, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-021", + "priority": "P1", + "requirement": "Templates shall support reusable blocks, nested sections, client brand tokens, headers/footers, tables, charts, images, appendices, and references.", + "source": { + "line": 118, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-022", + "priority": "P1", + "requirement": "Report authors shall preview with fixture or authorized data and compare visual/content changes between versions.", + "source": { + "line": 119, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-023", + "priority": "P1", + "requirement": "Schedules shall support calendar periods, timezone, client sets, parameter derivation, dataset selection rules, and failure policy.", + "source": { + "line": 120, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-024", + "priority": "P1", + "requirement": "Batch runs shall isolate client data and expose per-run status, retry, and audit history.", + "source": { + "line": 121, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-025", + "priority": "P1", + "requirement": "Users shall be able to clone a definition or template while preserving attribution and creating independent future versions.", + "source": { + "line": 122, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-026", + "priority": "P1", + "requirement": "Evidence manifests shall be exportable in a machine-readable format with stable block/value identifiers.", + "source": { + "line": 123, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-027", + "priority": "P1", + "requirement": "Released web reports shall support revocable links, expiry, optional authentication, download policy, and view audit subject to privacy policy.", + "source": { + "line": 124, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "CRF-028", + "priority": "P2", + "requirement": "Provider-neutral AI may draft summaries, explanations, and transitions, but generated material shall remain reviewable and removable without changing deterministic report content.", + "source": { + "line": 125, + "path": "docs/specs/features/client-report-factory.md" + } + }, + { + "id": "DQG-001", + "priority": "P0", + "requirement": "The system shall create a quality-dataset binding to an existing `DSM` `Dataset` and exact immutable `DatasetVersion` records, then record module-specific criticality, intended use, cadence, locale, `dataModeConstraint`, `effectiveDataModePolicyRef`, `retentionConstraint`, and `effectiveRetentionPolicyRef` without registering a parallel dataset identity or broadening workspace policy.", + "source": { + "line": 124, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-002", + "priority": "P0", + "requirement": "A quality contract shall bind immutable `DSM` dataset, schema, semantic-definition, key, rule-set, and reference-dataset versions plus module-owned ownership, fitness, incident, and waiver policy.", + "source": { + "line": 125, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-003", + "priority": "P0", + "requirement": "Published quality-contract binding versions shall be immutable; edits shall create a draft with a named parent and machine-readable diff, while referenced rule-suite publication and versioning remain canonical in `DSM`.", + "source": { + "line": 126, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-004", + "priority": "P0", + "requirement": "Quality contracts shall select schema, type, requiredness, completeness, uniqueness, format, range, allowed-set, reference, cross-field, and referential rules from the canonical `DSM` rule catalog.", + "source": { + "line": 127, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-005", + "priority": "P0", + "requirement": "Every bound `DSM` `RuleDefinitionVersion` or `RuleSetVersion` shall expose the scope, severity, typed parameters, null behavior, evidence fields, cost class, version, and stable failure reason code required by the quality engine.", + "source": { + "line": 128, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-006", + "priority": "P0", + "requirement": "Quality-contract activation shall reject arbitrary code, unknown functions, type mismatches, cycles, missing or incompatible `DSM` references, ambiguous locale/rounding, and resource-unbounded definitions without republishing the referenced rules.", + "source": { + "line": 129, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-007", + "priority": "P0", + "requirement": "The system shall support exact count, distinct-count, sum, signed-balance, and grouped control-total reconciliation between named dataset versions.", + "source": { + "line": 130, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-008", + "priority": "P0", + "requirement": "Reconciliation definitions shall state join/group keys, units or currencies, decimal precision, rounding mode, tolerance, missing-key behavior, and severity.", + "source": { + "line": 131, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-009", + "priority": "P0", + "requirement": "The system shall support volume, schema, category-frequency, numeric-distribution, null-rate, and freshness drift against a versioned baseline.", + "source": { + "line": 132, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-010", + "priority": "P0", + "requirement": "Every drift rule shall record metric, baseline population/window, minimum sample, comparison method, threshold, direction, and multiple-comparison policy where applicable.", + "source": { + "line": 133, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-011", + "priority": "P0", + "requirement": "A monitor shall bind an immutable contract version, source selector, execution location, trigger/schedule, late-arrival policy, and escalation policy.", + "source": { + "line": 134, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-012", + "priority": "P0", + "requirement": "Monitors shall support manual, cron-like scheduled, governed dataset-version arrival, and pre-release invocation without unrestricted event subscriptions.", + "source": { + "line": 135, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-013", + "priority": "P0", + "requirement": "A run shall bind exact dataset, contract, rule, reference, baseline, engine, and parsing versions plus `jraJobId` and a pinned `resultManifestId` before its result is accepted.", + "source": { + "line": 136, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-014", + "priority": "P0", + "requirement": "Deterministic rule results shall be `PASS`, `FAIL`, `NOT_EVALUATED`, or `ERROR`; statistical drift results shall additionally expose statistic, threshold, sample size, and significance.", + "source": { + "line": 137, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-015", + "priority": "P0", + "requirement": "Every failed or errored result shall expose stable reason codes, affected counts, denominators, and evidence or an explicit reason evidence could not be produced.", + "source": { + "line": 138, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-016", + "priority": "P0", + "requirement": "Every record-level `QualityFindingDetail` shall be immutable and retain page/sheet/cell/row/column or dataset-row evidence, the exact observed value subject to masking policy, its stable fingerprint, and `sharedFindingId` when linked to actionable work.", + "source": { + "line": 139, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-017", + "priority": "P0", + "requirement": "Repeated failures with the same diagnostic fingerprint shall retain immutable occurrence detail and shall link to the same canonical `JRA` `Finding` envelope when policy considers them one actionable issue rather than creating duplicate workflow records.", + "source": { + "line": 140, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-018", + "priority": "P0", + "requirement": "Incident projections shall group immutable diagnostic-detail and `sharedFindingId` references without copying or overriding their `JRA` workflow state, assignment, disposition, evidence references, or history.", + "source": { + "line": 141, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-019", + "priority": "P0", + "requirement": "The canonical `JRA` `Finding` and `ReviewTask` envelopes shall own severity, status, owner, acknowledgement and resolution targets, comments, timeline, disposition, and escalation state; module incident views shall be permission-filtered projections only.", + "source": { + "line": 142, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-020", + "priority": "P0", + "requirement": "Every module finding or incident transition facade shall delegate to `JRA`, enforce its permission and reason requirements, and return the canonical revision without persisting an independent transition or decision.", + "source": { + "line": 143, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-021", + "priority": "P0", + "requirement": "A repair proposal shall use allowlisted typed transformations and bind exact diagnostic-detail IDs, linked `sharedFindingId` values, source dataset version, contract version, expected outcome, and plan hash.", + "source": { + "line": 144, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-022", + "priority": "P0", + "requirement": "Repair preview shall show exact affected count, bounded before/after examples, rule impacts, control-total changes, collisions, and unrepairable findings.", + "source": { + "line": 145, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-023", + "priority": "P0", + "requirement": "Applying a repair shall create a derived artifact/dataset version and shall never mutate an original source artifact or dataset version.", + "source": { + "line": 146, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-024", + "priority": "P0", + "requirement": "A consequential repair shall create or reuse one `JRA` `ApprovalRequest` bound to the requested action, exact repair-plan subject type/ID/version/hash, and source fingerprint; the module shall store only `jraApprovalRequestId` plus those subject bindings, and any change shall invalidate the request through `JRA`.", + "source": { + "line": 147, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-025", + "priority": "P0", + "requirement": "A verification run shall evaluate the same or explicitly superseding contract version and link before/after results to the repair.", + "source": { + "line": 148, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-026", + "priority": "P0", + "requirement": "A module incident projection shall not close solely because a repair job completed; verified results shall be required before requesting an authorized canonical `JRA` transition.", + "source": { + "line": 149, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-027", + "priority": "P0", + "requirement": "Waivers shall require scope, reason, risk owner, compensating control, start, expiry, affected rule/dataset versions, requested action, exact subject type/ID/version/hash, and `jraApprovalRequestId`; approver eligibility and decision shall remain owned by `JRA`.", + "source": { + "line": 150, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-028", + "priority": "P0", + "requirement": "Waived failures shall remain visible and excluded from pass-rate numerators unless a report explicitly presents a separate policy-compliant metric.", + "source": { + "line": 151, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-029", + "priority": "P0", + "requirement": "Monitor and incident notifications shall use canonical `JRA` finding/review state and honor permissions, severity, quiet hours, escalation paths, and deduplication windows.", + "source": { + "line": 152, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-030", + "priority": "P1", + "requirement": "Users shall compare runs by rule result, affected rate, distribution, finding set, incident impact, contract diff, and dataset version.", + "source": { + "line": 153, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-031", + "priority": "P1", + "requirement": "`DSM` rule templates may be reused across compatible semantic field types; a quality-contract binding shall pin the immutable template or rule version and expose only declared module-local parameter overrides.", + "source": { + "line": 154, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-032", + "priority": "P1", + "requirement": "Data owners shall publish a fit-for-use scorecard showing critical-rule status, freshness, reconciliation, open incidents, waivers, and trend without hiding raw results.", + "source": { + "line": 155, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-033", + "priority": "P1", + "requirement": "Desktop and cloud execution of the same deterministic fixture shall produce equivalent rule states, reason codes, counts, and exact reconciliation totals.", + "source": { + "line": 156, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-034", + "priority": "P1", + "requirement": "The system shall support backfill evaluation of a published contract over a bounded set of historical dataset versions without altering their original monitoring history.", + "source": { + "line": 157, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-035", + "priority": "P1", + "requirement": "Users shall export a signed quality report and machine-readable result manifest with checksums and evidence references.", + "source": { + "line": 158, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DQG-036", + "priority": "P2", + "requirement": "The system may suggest new rules from recurring data patterns, but every suggestion shall remain an unpublished draft until a steward reviews parameters and estimated impact and publishes it through `DSM`.", + "source": { + "line": 159, + "path": "docs/specs/features/data-quality-guard.md" + } + }, + { + "id": "DSK-001", + "priority": "P0", + "requirement": "Electron renderer windows shall use `contextIsolation: true`, `nodeIntegration: false`, sandboxing, navigation restrictions, and a restrictive Content Security Policy.", + "source": { + "line": 96, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-002", + "priority": "P0", + "requirement": "The preload shall expose only a versioned allowlist of schema-validated capabilities; renderer code shall have no raw IPC, filesystem, process, keychain, updater, or shell access.", + "source": { + "line": 97, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-003", + "priority": "P0", + "requirement": "IPC handlers shall verify sender frame/origin, workspace context, permission, argument size/schema, and current window capability before invoking the main process.", + "source": { + "line": 98, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-004", + "priority": "P0", + "requirement": "Folder access shall require a local OS picker and explicit workspace/action grant; the cloud shall receive only opaque grant IDs and shall never specify arbitrary paths.", + "source": { + "line": 99, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-005", + "priority": "P0", + "requirement": "File access shall be read-only by default, originals shall remain immutable, and every correction or transformation shall produce a new version or staged copy.", + "source": { + "line": 100, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-006", + "priority": "P0", + "requirement": "Desktop shall execute a canonical `JRA` Job only from a signed, unexpired, nonce-protected envelope whose schemas, handler digests, data mode, capabilities, device, workspace, and resources verify locally.", + "source": { + "line": 101, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-007", + "priority": "P0", + "requirement": "Desktop shall not expose or implement cloud-triggered shell commands, arbitrary scripts, arbitrary URL navigation, raw keyboard/mouse control, or unrestricted file operations.", + "source": { + "line": 102, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-008", + "priority": "P0", + "requirement": "The Python engine shall be bundled, versioned, started without a shell, receive a scrubbed environment and attempt-specific handles, and communicate only through bounded framed JSON-RPC.", + "source": { + "line": 103, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-009", + "priority": "P0", + "requirement": "Sidecar requests/responses, progress, errors, and result manifests shall be runtime-validated; malformed, oversized, timed-out, or wrong-attempt messages shall terminate or quarantine the attempt.", + "source": { + "line": 104, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-010", + "priority": "P0", + "requirement": "`LOCAL` mode shall prevent original bytes and reconstructable derived content, including previews, OCR/transcripts, row/cell values, thumbnails, source snippets, paths, and chunks, from reaching cloud endpoints or telemetry.", + "source": { + "line": 105, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-011", + "priority": "P0", + "requirement": "Local queues, metadata, capability paths, keys, and staged sensitive outputs shall be encrypted with a device-protected key and separated by Windows user profile.", + "source": { + "line": 106, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-012", + "priority": "P0", + "requirement": "File-watcher intake shall be debounced, stable-file checked, content-hashed, idempotent, and resistant to partial writes, rename storms, junction loops, and duplicate events.", + "source": { + "line": 107, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-013", + "priority": "P0", + "requirement": "Write actions shall use proposed effect manifests, policy approval, atomic operations where supported, effect idempotency, receipts, and an undo path; they shall never overwrite an original silently.", + "source": { + "line": 108, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-014", + "priority": "P0", + "requirement": "Installers, executables, update manifests, and update packages shall be signed and verified; an invalid or downgraded update shall not execute.", + "source": { + "line": 109, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-015", + "priority": "P1", + "requirement": "Desktop shall provide local Inbox, job/review/approval status, evidence navigation, conflict resolution, device health, queue state, and data-location indicators without duplicating full Web administration.", + "source": { + "line": 110, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-016", + "priority": "P1", + "requirement": "Offline-capable actions shall use expiring authorization and entitlement leases, durable operation IDs, append-only local events, and `DSO` conflict rules.", + "source": { + "line": 111, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-017", + "priority": "P1", + "requirement": "A user shall be able to pause all watchers and local execution immediately; pause state shall persist across restart and be visible to authorized Web users as content-free device state.", + "source": { + "line": 112, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-018", + "priority": "P1", + "requirement": "Sidecar supervision shall enforce time, process-tree, temporary-storage, and configurable memory/CPU limits, and shall kill the full child process tree on cancellation or timeout.", + "source": { + "line": 113, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-019", + "priority": "P1", + "requirement": "Diagnostics export shall be user-initiated, previewable, content-redacted, and exclude file names, paths, source values, keys, tokens, comments, and evidence snippets.", + "source": { + "line": 114, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-020", + "priority": "P1", + "requirement": "The application shall recover from process crash, Windows restart, sleep, network change, and update without duplicating intake, jobs, file effects, or sync operations.", + "source": { + "line": 115, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-021", + "priority": "P1", + "requirement": "Vietnamese shall be the default complete locale with English fallback, and the application shall support keyboard, screen reader, high-contrast, reduced-motion, and Windows scaling settings.", + "source": { + "line": 116, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-022", + "priority": "P1", + "requirement": "Device revocation shall block new sync, dispatch, blob transfer, and session refresh immediately; local content shall remain encrypted and the UI shall provide sign-out/export guidance without claiming remote wipe.", + "source": { + "line": 117, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-023", + "priority": "P0", + "requirement": "A control-plane request to open `LOCAL` evidence shall contain only an opaque EvidenceReference and content-free context; Desktop shall re-authorize it and render locally, and shall never upload or stream the rendition unless the user separately publishes a governed derivative.", + "source": { + "line": 118, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-024", + "priority": "P0", + "requirement": "Offline recipe work shall use `JRA` ProvisionalExecution records with client execution IDs, signed cached definitions, valid offline leases, immutable manifests, and no canonical Job/approval claim; server acceptance shall create at most one canonical Job and rejection shall quarantine the local result.", + "source": { + "line": 119, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-025", + "priority": "P0", + "requirement": "Before offline recipe execution, Desktop shall verify the complete JRA RecipePublicationEnvelope, workspace/recipe version and hash, referenced action handler/input/output schema hashes, DSM definition hashes, policy references, supported envelope schema, signer/key version, signature, and offline-validity time; encrypted cache storage alone shall never satisfy authenticity.", + "source": { + "line": 120, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSK-026", + "priority": "P0", + "requirement": "Desktop shall implement DSO offline-package import through explicit user selection, isolated staging, full manifest/signature/recipient/workspace/purpose/policy/expiry/classification/hash verification, idempotent IAE placement/provisional-intake creation, content-safe receipt reconciliation, and quarantine on any mismatch; it shall not use cloud staging, live relay, or automatic peer discovery.", + "source": { + "line": 121, + "path": "docs/specs/platforms/desktop.md" + } + }, + { + "id": "DSM-001", + "priority": "P0", + "requirement": "Every Dataset, definition, mapping, rule set, run, and lineage record shall have a stable UUID and an owning workspace; project scope shall be recorded when applicable.", + "source": { + "line": 108, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-002", + "priority": "P0", + "requirement": "A DatasetVersion shall be immutable and shall reference exact input versions, schema version, mapping version, rule-set version, engine build, content fingerprint, row counts, quality state, and lineage manifest.", + "source": { + "line": 109, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-003", + "priority": "P0", + "requirement": "Dataset bytes and snapshots shall remain owned by `IAE`; this foundation shall store governed metadata and opaque storage references rather than create an alternate artifact or storage authority.", + "source": { + "line": 110, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-004", + "priority": "P0", + "requirement": "Each published SchemaVersion shall use stable field IDs and declare field type, nullability, constraints, unit, semantic role, aliases, localized labels, sensitivity, and default behavior.", + "source": { + "line": 111, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-005", + "priority": "P0", + "requirement": "Schema publication shall classify compatibility as additive-compatible, validation-tightening, migration-required, or breaking and shall reject a claim contradicted by structural comparison.", + "source": { + "line": 112, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-006", + "priority": "P0", + "requirement": "Published schema, semantic, metric, rule-set, and mapping versions shall be immutable, canonical-hashed, and retained as historical readers while referenced by an active result.", + "source": { + "line": 113, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-007", + "priority": "P0", + "requirement": "A MetricDefinitionVersion shall declare grain, typed inputs, filters, aggregation, unit, null/zero behavior, rounding, deterministic implementation, evidence policy, and executable fixtures.", + "source": { + "line": 114, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-008", + "priority": "P0", + "requirement": "A RuleSetVersion shall contain only published deterministic rules and allowlisted typed functions and shall declare scope, severity, parameters, missing-input behavior, and blocking behavior.", + "source": { + "line": 115, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-009", + "priority": "P0", + "requirement": "A MappingVersion shall bind a source schema or fingerprint range to stable target field IDs and record every transform, default, exclusion, reviewer, compatibility decision, and parent version.", + "source": { + "line": 116, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-010", + "priority": "P0", + "requirement": "Saved mappings shall not apply automatically after material source drift, ambiguous header matching, incompatible type change, or target breaking change; the system shall require review.", + "source": { + "line": 117, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-011", + "priority": "P0", + "requirement": "Profiling shall disclose whether it is complete or sampled, the deterministic sample method and seed where applicable, excluded scopes, scanned counts, and resource limits.", + "source": { + "line": 118, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-012", + "priority": "P0", + "requirement": "Validation and transformation runs shall pin immutable inputs and definition versions and execute through registered `JRA` typed actions with idempotent result acceptance.", + "source": { + "line": 119, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-013", + "priority": "P0", + "requirement": "Every validation finding shall include a stable fingerprint, rule/version, severity, subject, actual/expected typed values where safe, evidence references, and run/input versions.", + "source": { + "line": 120, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-014", + "priority": "P0", + "requirement": "Every material derived field, metric, aggregate, or release-gating conclusion shall carry reproducible lineage and `IAE` evidence or be explicitly marked `UNSUPPORTED_BY_SOURCE`.", + "source": { + "line": 121, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-015", + "priority": "P0", + "requirement": "Missing, null, blank, invalid, zero, not-applicable, and redacted states shall remain distinct through mapping, rules, metrics, APIs, and exports.", + "source": { + "line": 122, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-016", + "priority": "P0", + "requirement": "AI-assisted labels, mappings, semantic definitions, or rule suggestions shall remain drafts, identify provider/configuration provenance, and require deterministic validation plus authorized confirmation before publication.", + "source": { + "line": 123, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-017", + "priority": "P0", + "requirement": "Local, Hybrid, and Cloud processing shall follow `DSO`; `LOCAL` originals, source values, reconstructable previews, and evidence excerpts shall never synchronize, while content-free metadata and explicitly approved derived outputs may synchronize only as `DSO` permits.", + "source": { + "line": 124, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-018", + "priority": "P0", + "requirement": "Authorization shall be enforced through `IAM` for catalog discovery, definition management, execution, row/field access, evidence resolution, export, certification, and deprecation.", + "source": { + "line": 125, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-019", + "priority": "P1", + "requirement": "Reprocessing after input, schema, mapping, rule, metric, or engine change shall create a new run and DatasetVersion and shall never revise a historical result in place.", + "source": { + "line": 126, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-020", + "priority": "P1", + "requirement": "Data-quality gates shall bind an exact schema, rule-set, and policy version and shall expose `PASS`, `PASS_WITH_WARNINGS`, `BLOCKED`, or `INCOMPLETE` with contributing findings.", + "source": { + "line": 127, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-021", + "priority": "P1", + "requirement": "Dataset and definition APIs shall use idempotency keys for creation, revision preconditions for mutable drafts, stable cursor pagination, and machine-readable compatibility errors.", + "source": { + "line": 128, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-022", + "priority": "P1", + "requirement": "Governed-data exports shall include data permitted by policy plus schema, semantic, metric, mapping, rule-set, quality, lineage, evidence, and checksum manifests sufficient for independent verification.", + "source": { + "line": 129, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-023", + "priority": "P1", + "requirement": "Rule and transform extensions shall use a versioned typed registry, deterministic contract, declared resource limits, security review, and golden fixtures and shall not execute arbitrary customer code.", + "source": { + "line": 130, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-024", + "priority": "P2", + "requirement": "The system shall allow authorized administrators to promote compatible definitions or templates across workspaces only as sanitized unsigned drafts with no source values, evidence, secrets, access policy, certification, or automatic activation.", + "source": { + "line": 131, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-025", + "priority": "P0", + "requirement": "A shared ReferenceEntity shall have a stable workspace-scoped identity and immutable versions; a `BUSINESS_PARTY` version shall declare supplier/customer roles, canonical display name, localized aliases, typed external identifiers, status, default business attributes, visibility policy, provenance, and canonical hash.", + "source": { + "line": 132, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-026", + "priority": "P0", + "requirement": "Feature modules shall keep extracted party text separate and bind an exact authorized ReferenceEntityVersion; no feature shall own a second canonical supplier/customer identity, alias registry, identifier authority, project-visibility rule, or merge history.", + "source": { + "line": 133, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSM-027", + "priority": "P1", + "requirement": "Reference-entity merge, split, redirect, and correction shall append immutable resolution history and new versions, preserve every historical binding, require actor/reason/evidence, reject cross-workspace targets, and never silently retarget a prior result.", + "source": { + "line": 134, + "path": "docs/specs/foundation/datasets-schemas-rules-mappings.md" + } + }, + { + "id": "DSO-001", + "priority": "P0", + "requirement": "DSO shall use the IAM DeviceIdentity ID as its only Device identity key and shall not store a second public key, organization/user ownership record, enrollment state, activation state, or authoritative revocation status.", + "source": { + "line": 134, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-002", + "priority": "P0", + "requirement": "DSO capabilities and grants shall require an `ACTIVE` IAM DeviceIdentity with a matching organization and current security epoch; IAM alone shall own the enrollment challenge, proof-of-possession registration, explicit activation, and identity limits.", + "source": { + "line": 135, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-003", + "priority": "P0", + "requirement": "IAM revocation or security-epoch change shall make DSO block new sync, blob, stream, route, transfer, and job-dispatch operations immediately and terminate connected-client grants within 60 seconds; cached offline grants shall expire within 24 hours and fail closed on reconnect, without claiming remote wipe.", + "source": { + "line": 136, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-004", + "priority": "P0", + "requirement": "Synchronization shall use an append-only workspace change log and opaque cursor; it shall not rely on timestamp polling, client clocks, or Redis history.", + "source": { + "line": 137, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-005", + "priority": "P0", + "requirement": "Pull batches and offline pushes shall be idempotent and safely repeatable after timeout, crash, lost acknowledgement, or cursor replay.", + "source": { + "line": 138, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-006", + "priority": "P0", + "requirement": "All synchronized commands shall be re-authorized server-side for principal, device, workspace, project, resource, action, data mode, and entitlement.", + "source": { + "line": 139, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-007", + "priority": "P0", + "requirement": "`LOCAL` mode shall technically prevent upload of original bytes and reconstructable derived content, including chunks, previews, OCR/transcripts, thumbnails, row/cell values, and source snippets, regardless of client request; only a separately confirmed approved derived result may synchronize.", + "source": { + "line": 140, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-008", + "priority": "P0", + "requirement": "Hybrid mode shall be the default and shall synchronize only the explicit data classifications and synchronization payload classes enabled by the workspace policy manifest.", + "source": { + "line": 141, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-009", + "priority": "P0", + "requirement": "Blob transfer shall be resumable, chunk-hashed, content-hash verified, encrypted in transit and at rest, and published only after complete verification.", + "source": { + "line": 142, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-010", + "priority": "P0", + "requirement": "Offline queues shall be encrypted, append-only until acknowledged, dependency-aware, and keyed by stable operation IDs generated before first execution.", + "source": { + "line": 143, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-011", + "priority": "P0", + "requirement": "Conflict handling shall follow the explicit per-entity rules in this specification and shall never silently use last-write-wins for assignments, workflow state, approvals, security, billing, or overlapping corrections.", + "source": { + "line": 144, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-012", + "priority": "P0", + "requirement": "A sync cursor shall advance on a client only after the entire corresponding local transaction commits; partial application shall replay the same batch.", + "source": { + "line": 145, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-013", + "priority": "P0", + "requirement": "Folder capabilities shall be created only through a local OS picker, represented to the cloud by opaque IDs and policy metadata, and limited to approved typed actions.", + "source": { + "line": 146, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-014", + "priority": "P1", + "requirement": "The server shall retain tombstones for at least 90 days and longer than the maximum supported offline interval, with administrative export before a stale device is forced to resnapshot.", + "source": { + "line": 147, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-015", + "priority": "P1", + "requirement": "Initial sync shall use a bounded consistent snapshot plus change-log watermark so concurrent mutations are neither missed nor duplicated.", + "source": { + "line": 148, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-016", + "priority": "P1", + "requirement": "Clients shall support schema-version negotiation and preserve unknown forward-compatible fields; an unsupported breaking version shall require upgrade without corrupting the queue.", + "source": { + "line": 149, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-017", + "priority": "P1", + "requirement": "A device shall report capability versions, local engine version, last sync, queue depth, and coarse health without sending local paths, file names, or source values.", + "source": { + "line": 150, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-018", + "priority": "P1", + "requirement": "Data-mode transitions shall be audited, require Admin authority and recent MFA, and use explicit migration or verified purge workflows for existing replicas.", + "source": { + "line": 151, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-019", + "priority": "P1", + "requirement": "Offline operations rejected after authorization or policy change shall be quarantined with a stable reason and export option; the client shall not repeatedly resubmit them.", + "source": { + "line": 152, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-020", + "priority": "P1", + "requirement": "The system shall expose whether each artifact version is `LOCAL_ONLY`, `CLOUD_ONLY`, or `REPLICATED` and identify available devices without revealing filesystem paths.", + "source": { + "line": 153, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-021", + "priority": "P0", + "requirement": "Every sync cursor shall be bound to principal, device, workspace, effective authorization scope, authorization epoch, data-mode policy, audience, and schema version; any scope change shall invalidate it, force an authorized resnapshot, backfill newly visible history, and lock then purge managed cache that is no longer authorized.", + "source": { + "line": 154, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-022", + "priority": "P0", + "requirement": "Synchronizing an `APPROVED_DERIVED_RESULT` from Local mode shall require an immutable confirmation bound to resource/version, content hash, schema, data classification, policy revision, actor, source Device, destination, and time; a changed subject or policy shall require a new confirmation.", + "source": { + "line": 155, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-023", + "priority": "P0", + "requirement": "DSO shall never reactivate an IAM-revoked DeviceIdentity; recovery shall reference a newly enrolled IAM identity and use an authorized import/reconciliation workflow for preserved local records.", + "source": { + "line": 156, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-024", + "priority": "P0", + "requirement": "An execution route decision shall bind workspace, input placement/version hashes, action type/version, required capabilities, selected target/device when local, data-mode policy revision, authorization epoch, decision subject hash, and expiry; JRA creation shall reject a stale or mismatched decision, and DSO shall not create Jobs directly.", + "source": { + "line": 157, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-025", + "priority": "P0", + "requirement": "A Local `ORIGINAL_CONTENT` item may leave its source Device only through an explicit user-mediated offline package whose manifest binds workspace, source/destination Devices or approved passphrase mode, exact content IDs/hashes/sizes/classifications, purpose, policy and authorization revisions, expiry, encryption/key-envelope profile, source signature, and package hash; import shall verify every binding, create IAE placement/lineage plus an auditable receipt, and shall never use cloud storage, a live relay, background peer transfer, or an unregistered destination.", + "source": { + "line": 158, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-026", + "priority": "P0", + "requirement": "Workspace DataMode shall be the maximum authority; project, resource, module, recipe, and job constraints may only narrow it, and every placement, route, execution, transfer, resume, or sync admission shall enforce the intersection with current Workspace policy and fail closed when a cached effective-policy reference is stale.", + "source": { + "line": 159, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "DSO-027", + "priority": "P0", + "requirement": "DSO shall own immutable WorkspaceDataModePolicyVersions and signed DataModePolicyManifests binding workspace, mode, classification-by-payload matrix, allowed placements/executors/destinations, confirmation/offline-package rules, canonical hash, authorization epoch, audience/Device, schema version, issue/expiry no later than the associated IAM offline snapshot or 24 hours, and signer/key version; clients shall reject tampered, stale, wrong-audience, or unsupported manifests, and cache encryption shall not replace signature verification.", + "source": { + "line": 160, + "path": "docs/specs/foundation/devices-sync-offline.md" + } + }, + { + "id": "EI-001", + "priority": "P0", + "requirement": "Every resource shall be scoped to an owning organization, workspace, `ImporterCustomerPartition`, environment, and schema as applicable; a customer partition shall never replace the IAM workspace authorization boundary.", + "source": { + "line": 90, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-002", + "priority": "P0", + "requirement": "Every importer schema binding shall reference an immutable published `DSM` SchemaVersion whose stable field identifiers are independent of importer display labels.", + "source": { + "line": 91, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-003", + "priority": "P0", + "requirement": "Importer schema publication shall use `DSM` validation and compatibility classification for field types, requiredness, constraints, transforms, rule references, and version changes rather than create an importer-specific schema authority.", + "source": { + "line": 92, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-004", + "priority": "P0", + "requirement": "The module shall support CSV, TSV, XLSX, JSON arrays, JSON Lines, and configured delimited text within declared limits.", + "source": { + "line": 93, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-005", + "priority": "P0", + "requirement": "Hosted sessions shall use short-lived, single-purpose tokens; browser code shall never receive a long-lived API key or gateway credential.", + "source": { + "line": 94, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-006", + "priority": "P0", + "requirement": "Session tokens shall bind `customerPartitionId`, environment, schema version or allowed version range, permissions, expiry, and optional external user reference.", + "source": { + "line": 95, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-007", + "priority": "P0", + "requirement": "The hosted component shall enforce configured origins, frame policy, and secure cross-window message validation.", + "source": { + "line": 96, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-008", + "priority": "P0", + "requirement": "Uploads shall support checksums, resumable parts, size/type limits, malware-scan state where configured, and idempotent completion.", + "source": { + "line": 97, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-009", + "priority": "P0", + "requirement": "Source files shall be immutable; re-upload or replacement shall create a new artifact version.", + "source": { + "line": 98, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-010", + "priority": "P0", + "requirement": "Importer mappings shall use `DSM` MappingVersions and preserve source column identity, target stable field ID, transform chain, suggestion provenance, and reviewer decision.", + "source": { + "line": 99, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-011", + "priority": "P0", + "requirement": "Required or incompatible mappings shall block full validation and commit.", + "source": { + "line": 100, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-012", + "priority": "P0", + "requirement": "Importer validation shall execute published `DSM` schema/rule versions and project their results as structured file-, column-, row-, field-, and cross-row errors with stable rule codes.", + "source": { + "line": 101, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-013", + "priority": "P0", + "requirement": "Row corrections shall create an overlay or new dataset version and shall not modify the uploaded file.", + "source": { + "line": 102, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-014", + "priority": "P0", + "requirement": "Commit shall be idempotent and shall create at most one result for a session version and idempotency key. When policy requires consequential approval, commit release shall bind to an accepted `JRA` ApprovalDecision whose `subjectRef` contains the exact session subject type/ID/version/hash; Embedded Importer shall not create an independent approval decision.", + "source": { + "line": 103, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-015", + "priority": "P0", + "requirement": "Import results shall identify accepted, rejected, and skipped row counts and shall never silently drop a row.", + "source": { + "line": 104, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-016", + "priority": "P0", + "requirement": "Saved importer mapping bindings shall reference customer-partition-isolated `DSM` mapping drafts/versions and compatible schema lineage unless explicitly promoted as sanitized drafts by an authorized admin.", + "source": { + "line": 105, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-017", + "priority": "P0", + "requirement": "Importer API credentials shall use `IAM` service-account identity and `INT` credential conventions, including hashed-at-rest or signed secrets, one-time display, rotation, revocation, environment scope, and capability scope.", + "source": { + "line": 106, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-018", + "priority": "P0", + "requirement": "Importer webhook bindings shall use authoritative `INT` subscriptions and deliveries so signing, timestamps, replay protection, retry, stable event/delivery identifiers, SSRF policy, and secret rotation are not reimplemented by this module.", + "source": { + "line": 107, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-019", + "priority": "P0", + "requirement": "Support tooling and logs shall enforce IAM workspace access plus customer-partition scope and redact row values and secrets by default.", + "source": { + "line": 108, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-020", + "priority": "P0", + "requirement": "A local gateway shall accept only signed JRA import jobs whose `jraJobId`, pinned `resultManifestId`, active IAM `iamDeviceId` and security epoch, DSO DeviceGrant/capability IDs, allowed importer schema/environment, `effectiveDataModePolicyRef`, and signature validate; no arbitrary path, key, command, feature-owned heartbeat, or copied IAM identity/DSO operational lifecycle state is allowed.", + "source": { + "line": 109, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-021", + "priority": "P0", + "requirement": "Android shall expose administrative alerts and safe status metadata only, with no end-user import actions.", + "source": { + "line": 110, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-022", + "priority": "P1", + "requirement": "Branding shall support logo, colors, typography tokens, help text, and custom domain where configured without permitting arbitrary executable content.", + "source": { + "line": 111, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-023", + "priority": "P1", + "requirement": "The hosted UI shall support Vietnamese and English labels, locale-aware dates/numbers, keyboard navigation, and screen readers.", + "source": { + "line": 112, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-024", + "priority": "P1", + "requirement": "Developers shall have sandbox and production environments with separate `IAM`/`INT` credentials, `DSM` schema bindings, webhook subscriptions, quotas, and data.", + "source": { + "line": 113, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-025", + "priority": "P1", + "requirement": "Importer webhook delivery history and authorized manual replay shall use `INT` delivery resources, preserve the original event ID and import result, and apply importer-specific filters without creating another delivery record authority.", + "source": { + "line": 114, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-026", + "priority": "P1", + "requirement": "The API shall support asynchronous status polling, event cursors, and downloadable structured error reports. Every asynchronous session/run shall store `jraJobId` and pinned `resultManifestId`; JRA owns dispatch, progress, cancel, retry, and terminal state, while importer status is an idempotent business projection.", + "source": { + "line": 115, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-027", + "priority": "P1", + "requirement": "Administrators shall configure file, row, column, size, concurrency, execution-location, `dataModeConstraint`, and `retentionConstraint` limits within plan ceilings. Data mode shall resolve to `effectiveDataModePolicyRef` and only narrow the DSO workspace maximum; retention shall resolve to IAE `effectiveRetentionPolicyRef` and never authorize Embedded Importer to delete bytes.", + "source": { + "line": 116, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "EI-028", + "priority": "P2", + "requirement": "A provider-neutral AI adapter may suggest mappings and transformations from bounded samples, but its suggestions shall require deterministic validation and configured human confirmation.", + "source": { + "line": 117, + "path": "docs/specs/features/embedded-importer.md" + } + }, + { + "id": "FA-001", + "priority": "P0", + "requirement": "Desktop shall create its encrypted local folder authorization only after an authorized user selects the folder through a native picker and confirms scope plus effective DSO data-mode behavior; Folder Autopilot shall never receive or persist that path.", + "source": { + "line": 125, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-002", + "priority": "P0", + "requirement": "Desktop alone shall store canonical root, volume identity, descendant policy, read/write capabilities, reparse-point policy, and local grant metadata. `DSO` alone shall own the content-free DeviceCapability/DeviceGrant, workspace/action authorization, status, expiry, and revocation; Folder Autopilot shall store none of those fields independently.", + "source": { + "line": 126, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-003", + "priority": "P0", + "requirement": "Cloud APIs and jobs shall reference `deviceGrantId`, `AutopilotFolderBinding`, and `expectedCapabilityDigest` and shall never contain an unrestricted local path, local handle, independent grant copy, or instruction for Desktop to discover a new path.", + "source": { + "line": 127, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-004", + "priority": "P0", + "requirement": "Every observed and destination path shall be canonicalized and verified by Desktop to remain inside its encrypted local authorization, while DSO DeviceGrant status/action scope and the expected capability digest shall be revalidated before access.", + "source": { + "line": 128, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-005", + "priority": "P0", + "requirement": "The canonical `JRA` RecipeVersion shall contain the versioned typed triggers, conditions, and actions from a workspace-allowed catalog; Folder Autopilot shall not persist a second trigger or graph authority.", + "source": { + "line": 129, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-006", + "priority": "P0", + "requirement": "JRA recipe validation shall reject arbitrary code, unknown action types, type mismatches, cycles, unreachable steps, and unbounded traversal; Folder Autopilot profile validation shall additionally reject missing/invalid DSO bindings, capability-digest mismatch, output recursion, and incompatible product-specific settings.", + "source": { + "line": 130, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-007", + "priority": "P0", + "requirement": "`JRA` RecipeVersions shall be immutable after publication; edits, including a changed pinned Folder Autopilot profile payload/hash, create a JRA draft derived from a named parent and shall not create a separate feature-owned recipe version lineage.", + "source": { + "line": 131, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-008", + "priority": "P0", + "requirement": "A designer shall preview a JRA draft plus its Folder Autopilot profile and DSO bindings against selected samples or a bounded scan before publication, with affected count and per-file action plans.", + "source": { + "line": 132, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-009", + "priority": "P0", + "requirement": "Preview shall identify destination collisions, source/destination permission errors, insufficient disk space, unsupported files, recursive re-entry, and approval gates.", + "source": { + "line": 133, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-010", + "priority": "P0", + "requirement": "Desktop shall wait for configurable file stability and retry transient locks before fingerprinting or processing a file.", + "source": { + "line": 134, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-011", + "priority": "P0", + "requirement": "Each input shall receive a content hash, size, modified-time observation, stable execution key, and immutable artifact-version reference before an action.", + "source": { + "line": 135, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-012", + "priority": "P0", + "requirement": "Recipe matching shall be deterministic for path, type, metadata, and validation conditions; classifier suggestions shall include confidence and evidence.", + "source": { + "line": 136, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-013", + "priority": "P0", + "requirement": "An uncertain classification shall route to review rather than execute a class-dependent file mutation when it falls below the published threshold.", + "source": { + "line": 137, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-014", + "priority": "P0", + "requirement": "Rename, copy, and move steps shall use computed relative destinations constrained to `OUTPUT` AutopilotFolderBindings backed by active DSO DeviceGrants and matching Desktop-local authorizations.", + "source": { + "line": 138, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-015", + "priority": "P0", + "requirement": "The system shall never silently overwrite a destination; collision policy shall be `REVIEW`, `SKIP`, or deterministic unique-name generation.", + "source": { + "line": 139, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-016", + "priority": "P0", + "requirement": "Content conversion and normalization shall create a new derivative file and shall not modify source file bytes in place.", + "source": { + "line": 140, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-017", + "priority": "P0", + "requirement": "Permanent deletion shall not be available in the P0/P1 action catalog; removal workflows may move a file to a configured recovery folder with undo.", + "source": { + "line": 141, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-018", + "priority": "P0", + "requirement": "A sensitive action plan shall require an authoritative `JRA` ApprovalRequest according to the applicable ApprovalPolicy, including moves across DSO DeviceGrants, externally synchronized outputs, or low-confidence classification.", + "source": { + "line": 142, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-019", + "priority": "P0", + "requirement": "The authoritative `JRA` ApprovalRequest and ApprovalDecision shall bind approver, an exact subject type/ID/version/hash, plan hash, JRA RecipeVersion, source fingerprint, destinations, expiry, and decision reason; Folder Autopilot shall not create an independent approval decision.", + "source": { + "line": 143, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-020", + "priority": "P0", + "requirement": "Desktop shall revalidate its local authorization, DSO DeviceGrant action scope/status, expected capability digest, `effectiveDataModePolicyRef`, source fingerprint, destination state, and the applicable `JRA` ApprovalDecision immediately before committing actions.", + "source": { + "line": 144, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-021", + "priority": "P0", + "requirement": "Multi-step file operations shall use a staged plan and compensating actions so a failure cannot present a partially completed execution as successful.", + "source": { + "line": 145, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-022", + "priority": "P0", + "requirement": "Every asynchronous execution shall store `jraJobId` and a pinned JRA `resultManifestId`. `JRA` owns dispatch, progress, cancellation, retry, steps, and terminal execution state; Folder Autopilot stores only an idempotent business projection with input/output fingerprints, evidence, reason codes, and actor/device attribution.", + "source": { + "line": 146, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-023", + "priority": "P0", + "requirement": "Repeated file-system events or job delivery shall not create duplicate derivatives, moves, notifications, review items, or module submissions.", + "source": { + "line": 147, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-024", + "priority": "P0", + "requirement": "Eligible executions shall expose an inverse plan until the configured undo expiry; ineligible steps shall be labeled before approval.", + "source": { + "line": 148, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-025", + "priority": "P0", + "requirement": "Undo shall refuse to overwrite or discard a file changed after the execution and shall create a guided conflict item.", + "source": { + "line": 149, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-026", + "priority": "P0", + "requirement": "Users with permission shall pause a RecipeAssignment immediately; retiring or replacing its canonical recipe/version shall use the JRA facade. In-flight jobs may finish only through the next safe checkpoint defined by JRA and the recipe policy.", + "source": { + "line": 150, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-027", + "priority": "P0", + "requirement": "Revoking a `DSO` DeviceGrant shall invalidate its AutopilotFolderBindings, stop new access, cancel undispatched JRA work, and require revalidation of in-flight work without erasing audit history; Folder Autopilot shall not own or rewrite revocation state.", + "source": { + "line": 151, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-028", + "priority": "P1", + "requirement": "A canonical JRA recipe step shall be permitted to submit an immutable artifact version to another DataBreeze module only through a typed module-intake action with an idempotency key.", + "source": { + "line": 152, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-029", + "priority": "P1", + "requirement": "The system shall support scheduled reconciliation scans to recover file events missed during device sleep or watcher overflow.", + "source": { + "line": 153, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-030", + "priority": "P1", + "requirement": "Operators shall filter, assign, and bulk-retry only failures they are authorized to retry. An actor eligible under the current `JRA` ApprovalPolicy may bulk-approve homogeneous plans only through authoritative JRA decisions, with each subject independently bound to its exact type/ID/version/hash and plan hash, separation-of-duties rules passing, required MFA current, and an explicit expiry; every bulk action shall preview exact affected items and policy boundaries.", + "source": { + "line": 154, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-031", + "priority": "P1", + "requirement": "Workspace admins shall set profile/assignment-level concurrency, throughput, file-size, extension, schedule, confidence, approval, retention, and undo constraints within JRA, DSO, BUA, and IAE ceilings. `dataModeConstraint` may only narrow the DSO workspace maximum and shall resolve to `effectiveDataModePolicyRef`; `retentionConstraint` shall resolve to canonical IAE `effectiveRetentionPolicyRef` and shall never authorize Folder Autopilot to delete artifact bytes.", + "source": { + "line": 155, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-032", + "priority": "P1", + "requirement": "Desktop shall maintain an output-lineage marker outside user file contents so recipe outputs do not recursively trigger the same lineage unless explicitly permitted.", + "source": { + "line": 156, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-033", + "priority": "P1", + "requirement": "Web shall report the current JRA RecipeVersion assignment, last DSO device heartbeat and DeviceGrant status/revocation projection, watcher health, queue age, recent outcomes, and assignment pause state without making Folder Autopilot authoritative for those external states.", + "source": { + "line": 157, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-034", + "priority": "P1", + "requirement": "An authorized user shall export a redacted execution ledger and evidence manifest without exporting local file contents.", + "source": { + "line": 158, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "FA-035", + "priority": "P2", + "requirement": "JRA recipe templates plus Folder Autopilot profile payloads shall be shareable across workspaces only as unsigned drafts with all DSO grant/binding IDs, secrets, and policies removed.", + "source": { + "line": 159, + "path": "docs/specs/features/folder-autopilot.md" + } + }, + { + "id": "IAE-001", + "priority": "P0", + "requirement": "Every intake shall create at most one InboxItem and one initial ArtifactVersion for a given workspace and idempotency key.", + "source": { + "line": 92, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-002", + "priority": "P0", + "requirement": "Original ArtifactVersion bytes shall be immutable, stored under non-overwritable keys or device handles, and verified with SHA-256 plus byte length.", + "source": { + "line": 93, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-003", + "priority": "P0", + "requirement": "Corrections, conversions, redactions, OCR text, thumbnails, and exports shall create new versioned records and shall never mutate or replace an original.", + "source": { + "line": 94, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-004", + "priority": "P0", + "requirement": "`LOCAL` mode shall never upload original bytes or reconstructable derived content, including previews, OCR/transcripts, row/cell values, thumbnails, source snippets, or chunks; only policy-approved metadata and separately confirmed approved derived outputs may synchronize.", + "source": { + "line": 95, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-005", + "priority": "P0", + "requirement": "Every extracted material value, finding, and report assertion shall carry one or more EvidenceReferences or be explicitly marked `UNSUPPORTED_BY_SOURCE`.", + "source": { + "line": 96, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-006", + "priority": "P0", + "requirement": "EvidenceReference coordinates shall be typed, version-bound, validated against media geometry, and resolvable to the exact source version used for processing.", + "source": { + "line": 97, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-007", + "priority": "P0", + "requirement": "Derived outputs shall store source version IDs, processor/recipe versions, and coordinate lineage so evidence survives conversion, normalization, filtering, and aggregation.", + "source": { + "line": 98, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-008", + "priority": "P0", + "requirement": "Object downloads, previews, evidence tiles, and local-render requests shall re-evaluate `IAM` resource authorization at access time and use short-lived single-resource grants.", + "source": { + "line": 99, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-009", + "priority": "P0", + "requirement": "Upload finalization shall verify content digest, actual media signature, size policy, scan state, and tenant ownership before publishing the artifact-created event.", + "source": { + "line": 100, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-010", + "priority": "P0", + "requirement": "Suspected malicious content shall be quarantined, excluded from processing and preview, and visible only to permitted administrators through content-free metadata.", + "source": { + "line": 101, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-011", + "priority": "P0", + "requirement": "Content hashes shall not enable cross-workspace existence queries or cross-tenant deduplication side channels.", + "source": { + "line": 102, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-012", + "priority": "P0", + "requirement": "Source changes detected during processing shall create a new version and mark affected outputs stale; running work shall finish against its pinned source or stop according to recipe policy.", + "source": { + "line": 103, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-013", + "priority": "P1", + "requirement": "Inbox items shall support assignment, labels, priority, due date, and states `NEW`, `ROUTED`, `NEEDS_REVIEW`, `PROCESSING`, `RESOLVED`, `QUARANTINED`, and `ARCHIVED`.", + "source": { + "line": 104, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-014", + "priority": "P1", + "requirement": "Resumable cloud uploads shall resume at verified part boundaries and reject a final digest mismatch without exposing partial objects.", + "source": { + "line": 105, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-015", + "priority": "P1", + "requirement": "Password-protected documents shall retain the original and request credentials locally or through a secret input that is never persisted in logs or artifact metadata.", + "source": { + "line": 106, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-016", + "priority": "P1", + "requirement": "Retention deletion shall use explicit authorization, recent MFA for destructive organization-wide operations, legal-hold checks, tombstones, and verified object erasure.", + "source": { + "line": 107, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-017", + "priority": "P1", + "requirement": "Same-workspace duplicate detection shall preserve separate intake context and shall not merge artifacts with distinct project, supplier, period, or approval history automatically.", + "source": { + "line": 108, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-018", + "priority": "P1", + "requirement": "Export packages shall include a machine-readable manifest of artifact/version hashes, lineage, evidence references, processor versions, and approval state.", + "source": { + "line": 109, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-019", + "priority": "P0", + "requirement": "Resolving evidence whose source is `LOCAL` shall return an open-on-source-device descriptor or `SOURCE_OFFLINE`; another Device receives content only through a verified DSO-025 user-mediated offline package that creates its own `DEVICE_LOCAL` placement or through explicit publication as a governed derived artifact, never through an implicit live-render relay.", + "source": { + "line": 110, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-020", + "priority": "P0", + "requirement": "An ArtifactVersion or DatasetSnapshot shall support zero or more typed content placements across authorized devices and cloud objects; availability shall be derived from verified placements rather than one mutable storage class or locator.", + "source": { + "line": 111, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAE-021", + "priority": "P0", + "requirement": "IAE shall alone determine authoritative deletion eligibility from the Workspace retention minimum, resource/module retention constraints, evidence/report lineage, active approvals, legal holds, AUD retention class, and recovery window; features shall never delete IAE bytes directly, and local cache cleanup shall not represent authoritative retention or deletion.", + "source": { + "line": 112, + "path": "docs/specs/foundation/inbox-artifacts-evidence.md" + } + }, + { + "id": "IAM-001", + "priority": "P0", + "requirement": "Every user, organization, workspace, project, membership, service account, session, and device shall use a non-guessable stable UUID and store timestamps in UTC.", + "source": { + "line": 132, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-002", + "priority": "P0", + "requirement": "The server shall enforce organization, workspace, project, resource, action, and channel authorization on every request, stream subscription, job execution, sync mutation, download, export, and shared-link access.", + "source": { + "line": 133, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-003", + "priority": "P0", + "requirement": "Authorization shall deny by default and shall not trust role, tenant, or resource claims supplied outside a verified credential and server-side lookup.", + "source": { + "line": 134, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-004", + "priority": "P0", + "requirement": "The initial roles shall be Owner, Admin, Analyst, Operator, Approver, and Viewer; permission constants shall be versioned independently so bundles can expand without renaming roles.", + "source": { + "line": 135, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-005", + "priority": "P0", + "requirement": "Access tokens shall expire within 15 minutes; refresh tokens shall be rotating and single-use, and detected reuse shall revoke the token family.", + "source": { + "line": 136, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-006", + "priority": "P0", + "requirement": "Organization policy shall support required MFA for all members, privileged roles, or privileged actions, with WebAuthn or TOTP and one-time recovery codes.", + "source": { + "line": 137, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-007", + "priority": "P0", + "requirement": "Each organization enrollment on a Desktop or Android installation shall create a distinct asymmetric Device-identity key pair; private keys shall remain in the OS credential store and server records shall be independently and permanently revocable.", + "source": { + "line": 138, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-008", + "priority": "P0", + "requirement": "Membership removal, device revocation, account suspension, and ownership changes shall invalidate server-side and connected-client authorization within 60 seconds and reject newly authenticated operations immediately; an offline device receives no remote-wipe guarantee and its narrowly allowed cached authorization shall expire within 24 hours.", + "source": { + "line": 139, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-009", + "priority": "P0", + "requirement": "Resource lookup shall prove that the resource belongs to the evaluated workspace before a handler reads metadata or object bytes, preventing identifier-based tenant probing.", + "source": { + "line": 140, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-010", + "priority": "P0", + "requirement": "Invitations shall be single-use, hashed at rest, email-bound, scope-bound, role-bound, and expire in no more than seven days.", + "source": { + "line": 141, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-011", + "priority": "P0", + "requirement": "An organization shall always have at least one active Owner; the last Owner cannot leave or be removed without a completed transfer or organization deletion workflow.", + "source": { + "line": 142, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-012", + "priority": "P0", + "requirement": "Privileged actions shall require a step-up MFA assertion no older than 10 minutes and an immutable audit event containing actor, target, before/after summary, IP class, device, and correlation ID.", + "source": { + "line": 143, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-013", + "priority": "P0", + "requirement": "Service accounts shall be organization-owned, workspace-scoped, action-scoped, non-interactive, and authenticated with hashed rotating secrets or signed keys that show their last-use time.", + "source": { + "line": 144, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-014", + "priority": "P1", + "requirement": "Project membership may only narrow workspace access unless an explicit project guest policy grants access to that project alone; it shall never imply access to sibling projects.", + "source": { + "line": 145, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-015", + "priority": "P1", + "requirement": "Account recovery shall revoke all refresh-token families and require MFA re-enrollment confirmation before privileged actions resume.", + "source": { + "line": 146, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-016", + "priority": "P1", + "requirement": "User locale shall default to Vietnamese (`vi-VN`) while allowing English (`en`) per user without changing stored business values or audit semantics.", + "source": { + "line": 147, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-017", + "priority": "P1", + "requirement": "Client applications may use permission hints to hide controls, but all authoritative enforcement shall remain server-side.", + "source": { + "line": 148, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-018", + "priority": "P1", + "requirement": "Bulk membership and policy changes shall use idempotency keys, return per-item outcomes, and never partially apply an ownership transfer.", + "source": { + "line": 149, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-019", + "priority": "P0", + "requirement": "Every tenant-owned record and repository operation shall declare either organization or workspace scope, validate the complete tenant ancestry for nested resources, and reject optional, missing, or mismatched tenant filters before data access.", + "source": { + "line": 150, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-020", + "priority": "P0", + "requirement": "IAM shall issue versioned signed OfflineAuthorizationSnapshots bound to organization/workspace/project, principal, Device, security and authorization epochs, allowed action/resource scopes, policy revisions, issue time, expiry no later than 24 hours, and signer/key version; a snapshot shall not authorize approval, membership, security/data-mode/retention/billing policy change, deletion, cloud/external effects, or access broader than the last online decision, and every reconnect shall re-authorize current state.", + "source": { + "line": 151, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "IAM-021", + "priority": "P0", + "requirement": "IAM shall be the sole authority for DeviceIdentity ID, organization/user ownership, public key, enrollment challenge, activation status, security epoch, and permanent revocation; a revoked identity shall never reactivate, recovery shall create a new identity, and DSO shall reference the IAM identity without maintaining a second identity, key, or authoritative status.", + "source": { + "line": 152, + "path": "docs/specs/foundation/identity-workspaces-permissions.md" + } + }, + { + "id": "ILD-001", + "priority": "P0", + "requirement": "The system shall create each invoice record from an immutable `IAE` artifact version, retain its content hash, and bind every asynchronous audit to `jraJobId` and a pinned `resultManifestId`.", + "source": { + "line": 96, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-002", + "priority": "P0", + "requirement": "The engine shall extract invoice identifiers, dates, supplier, currency, totals, tax, payment reference, service period, and line items with field evidence.", + "source": { + "line": 97, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-003", + "priority": "P0", + "requirement": "Users shall manage versioned contracts, amendments, POs, receipts/service records, and rate cards without overwriting prior effective versions.", + "source": { + "line": 98, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-004", + "priority": "P0", + "requirement": "Supplier identity shall be a binding to an exact `DSM` BusinessParty `ReferenceEntityVersion`, separate from extracted supplier text; aliases, identifiers, project visibility, and merge history shall remain canonical in `DSM` and shall not be persisted independently by ILD.", + "source": { + "line": 99, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-005", + "priority": "P0", + "requirement": "Candidate matching shall prioritize explicit identifiers and expose feature contributions and disqualifying conflicts.", + "source": { + "line": 100, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-006", + "priority": "P0", + "requirement": "Ambiguous or low-confidence matches shall require review before a consequential variance is confirmed.", + "source": { + "line": 101, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-007", + "priority": "P0", + "requirement": "The system shall support one invoice to many POs/contracts and split invoice lines across governing records with explicit allocations.", + "source": { + "line": 102, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-008", + "priority": "P0", + "requirement": "Expected-charge calculations shall be deterministic, versioned, reproducible, and show every intermediate component.", + "source": { + "line": 103, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-009", + "priority": "P0", + "requirement": "The system shall distinguish missing, zero, not applicable, unknown, estimated, and confirmed values.", + "source": { + "line": 104, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-010", + "priority": "P0", + "requirement": "The engine shall detect exact and near duplicate invoices using identifiers, supplier, dates, amounts, line fingerprints, and artifact hashes.", + "source": { + "line": 105, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-011", + "priority": "P0", + "requirement": "Duplicate `LeakFindingDetail` records shall disclose which signals matched, shall not rely on file name alone, and shall link `sharedFindingId` when actionable.", + "source": { + "line": 106, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-012", + "priority": "P0", + "requirement": "The engine shall support price, quantity, unit, tiered-rate, discount, fee, freight, tax, service-period, and cumulative-cap checks.", + "source": { + "line": 107, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-013", + "priority": "P0", + "requirement": "Unit and currency conversions shall require compatible dimensions and versioned rate provenance.", + "source": { + "line": 108, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-014", + "priority": "P0", + "requirement": "Missing governing terms shall produce an incomplete calculation and a canonical `JRA` `ReviewTask` reference, not an assumed entitlement or module-owned review workflow.", + "source": { + "line": 109, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-015", + "priority": "P0", + "requirement": "Every immutable financial diagnostic detail shall link billed evidence, governing evidence, calculation version, tolerance, variance, stable fingerprint, and `sharedFindingId` when actionable.", + "source": { + "line": 110, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-016", + "priority": "P0", + "requirement": "Estimated exposure, reviewer-validated amount, approved dispute amount, and user-entered recovered amount shall be separate fields.", + "source": { + "line": 111, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-017", + "priority": "P0", + "requirement": "Manual corrections and relationship overrides shall require an actor, reason, and retained prior value.", + "source": { + "line": 112, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-018", + "priority": "P0", + "requirement": "Feature-specific case status changes, package generation, and closure shall enforce configured permissions; materiality or package approval shall use `JRA` with requested action and exact subject type/ID/version/hash, and the module shall persist only `jraApprovalRequestId` plus that binding.", + "source": { + "line": 113, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-019", + "priority": "P0", + "requirement": "Approved evidence packages and closed case versions shall be immutable.", + "source": { + "line": 114, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-020", + "priority": "P0", + "requirement": "The module shall expose no payment-execution, payment-status-changing, banking, or autonomous supplier-contact action.", + "source": { + "line": 115, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-021", + "priority": "P1", + "requirement": "Users shall configure tolerances by supplier, contract, charge type, currency, amount, and percentage with an effective period.", + "source": { + "line": 116, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-022", + "priority": "P1", + "requirement": "The system shall compare invoice totals and quantities across configurable historical windows for duplicate and cap analysis.", + "source": { + "line": 117, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-023", + "priority": "P1", + "requirement": "Users shall assign actionable findings, request information, comment, and set due dates through the canonical `JRA` finding/review facade, while supporting-artifact links remain subject details owned by this module.", + "source": { + "line": 118, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-024", + "priority": "P1", + "requirement": "The system shall support redaction profiles and preview redactions before evidence-package generation.", + "source": { + "line": 119, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-025", + "priority": "P1", + "requirement": "The system shall export PDF, web, XLSX, and JSON case packages with stable evidence identifiers.", + "source": { + "line": 120, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-026", + "priority": "P1", + "requirement": "Dashboards shall separate gross flagged exposure, validated amount, approved dispute amount, and user-confirmed recovery.", + "source": { + "line": 121, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-027", + "priority": "P1", + "requirement": "Recurring approved-folder intake shall deduplicate identical artifacts and link supplier revisions.", + "source": { + "line": 122, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "ILD-028", + "priority": "P2", + "requirement": "Provider-neutral AI may suggest classification, line descriptions, or candidate relationships, but no AI suggestion shall establish a confirmed financial finding or amount.", + "source": { + "line": 123, + "path": "docs/specs/features/invoice-leak-detector.md" + } + }, + { + "id": "INT-001", + "priority": "P0", + "requirement": "Every public API call shall authenticate an `IAM` user or service account; requested API scopes shall be intersected with current `IAM` permissions and `BUA` entitlements and shall never grant authority independently.", + "source": { + "line": 119, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-002", + "priority": "P0", + "requirement": "Every API, webhook-management, connector, import, export, and replay operation shall resolve organization, workspace, project, and resource ownership server-side and enforce the owning subsystem's current authorization before reading or changing protected state.", + "source": { + "line": 120, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-003", + "priority": "P0", + "requirement": "Public API authentication shall consume the `IAM` service-account and credential contract; `INT` shall persist only credential identifiers and safe request metadata, honor `IAM` overlapping rotation, and stop accepting a credential immediately when `IAM` revokes it.", + "source": { + "line": 121, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-004", + "priority": "P0", + "requirement": "Every public mutation shall accept an idempotency key scoped to principal, tenant, method, and route; concurrent identical retries shall produce one effect and the same outcome, while reuse with a different request hash shall return `409 IDEMPOTENCY_KEY_REUSED`.", + "source": { + "line": 122, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-005", + "priority": "P0", + "requirement": "Unbounded public lists shall use opaque cursor pagination with deterministic ordering; a cursor shall bind filters, projection, tenant scope, authorization epoch, and snapshot watermark and shall be rejected when those bindings no longer match.", + "source": { + "line": 123, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-006", + "priority": "P0", + "requirement": "Public REST routes shall use an explicit major version, and OpenAPI, JSON Schema, webhook payloads, connector manifests, and SDK releases shall identify their contract versions; a breaking change shall require a new major contract, and a supported major shall receive a published successor and at least 12 months' deprecation notice before removal.", + "source": { + "line": 124, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-007", + "priority": "P0", + "requirement": "API admission shall enforce rate and concurrency limits by principal, tenant, route cost class, and abuse source, return `429` with `Retry-After`, rate-limit headers, and a structured `RATE_LIMITED` body containing the limiting scope and reset time, and defer commercial quota and usage authority to `BUA`.", + "source": { + "line": 125, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-008", + "priority": "P0", + "requirement": "Every outbound webhook delivery shall have PostgreSQL-backed durable state, be at-least-once, include event ID, delivery ID, schema version, UTC timestamp, attempt number, and signing-key ID, and carry an HMAC-SHA-256 signature over the timestamp and exact raw body using a versioned endpoint secret.", + "source": { + "line": 126, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-009", + "priority": "P0", + "requirement": "Outbound webhook destinations shall pass creation-time and send-time SSRF, private-network, redirect, DNS-rebinding, and scheme validation; payloads shall contain only documented, permission-safe fields and short-lived retrieval references where content is required.", + "source": { + "line": 127, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-010", + "priority": "P0", + "requirement": "Inbound callbacks for `INT`-managed connectors shall verify the signature over exact raw bytes, accepted key version, bounded timestamp, and provider event identity before parsing; authenticated events shall be recorded in the connector's PostgreSQL-backed durable inbox before success acknowledgement and replays shall be deduplicated or quarantined on hash mismatch. Billing and notification callbacks remain owned by `BUA` and `NCO`.", + "source": { + "line": 128, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-011", + "priority": "P0", + "requirement": "A connector shall access only documented public APIs, published feeds or downloads, customer-authorized databases or storage, or other sources the customer is entitled to use; no production capability or release gate shall depend on scraping, browser/session automation, undocumented endpoints, or restricted partner APIs.", + "source": { + "line": 129, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-012", + "priority": "P0", + "requirement": "Connector adapters shall implement the reviewed transport contract, run with declared network and secret capabilities, and call published `IAE`, `DSM`, `JRA`, `DSO`, `IAM`, and `BUA` application contracts instead of accessing their databases, queues, object namespaces, or policy internals directly.", + "source": { + "line": 130, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-013", + "priority": "P0", + "requirement": "Provider access tokens, refresh tokens, passwords, signing secrets, and client secrets shall be held through encrypted secret references, redacted from logs and payloads, supplied just in time to one connection-scoped adapter, and atomically rotated or revoked without exposing plaintext.", + "source": { + "line": 131, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-014", + "priority": "P0", + "requirement": "Connector and API imports shall create ordinary immutable `IAE` artifact versions for bytes or `DSM` DatasetVersions for governed records, with exact `IAE` snapshot references, source external references, connector/schema/mapping versions, capture time, fingerprints, and lineage; retries shall not duplicate a committed source item.", + "source": { + "line": 132, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-015", + "priority": "P0", + "requirement": "API exports and connector exports or destination pushes shall require separate export and destination permissions, applicable `JRA` approval, current `BUA` admission, and the owning `IAE` artifact or `DSM` governed-data export manifest; they shall never silently broaden the source's data classification, project visibility, or data-mode policy.", + "source": { + "line": 133, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-016", + "priority": "P0", + "requirement": "A pull checkpoint shall advance only after all referenced domain commits and outbox records for that page are durable, and a push checkpoint shall advance only after the provider result is confirmed or reconciled; crash recovery shall replay unadvanced work without duplicate committed business records or external effects.", + "source": { + "line": 134, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-017", + "priority": "P1", + "requirement": "Outbound webhook failures shall expose safe attempt history and retry state, use bounded exponential backoff with jitter for a subscription-configured retry window no longer than 72 hours, retain replayable delivery metadata for at least 30 days, and support an authorized manual replay that does not create a new domain event.", + "source": { + "line": 135, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-018", + "priority": "P1", + "requirement": "Webhook documentation shall promise ordering only for an explicitly named ordering key; delivery and replay shall preserve the original event ID, and consumers shall be able to deduplicate without relying on arrival order.", + "source": { + "line": 136, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-019", + "priority": "P1", + "requirement": "After an ambiguous connector-push timeout or connection loss, the adapter shall reconcile through a documented provider read or idempotency mechanism before retrying, or stop for review when neither exists.", + "source": { + "line": 137, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-020", + "priority": "P1", + "requirement": "Provider scope reduction, credential expiry, authorization revocation, rate limiting, and partial access shall place the connection in an explicit degraded or reauthorization state, preserve prior imported artifacts and datasets, publish a content-minimized state event for `NCO`, and report affected capabilities without repeatedly retrying a permanent denial.", + "source": { + "line": 138, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-021", + "priority": "P1", + "requirement": "Public errors shall use stable machine codes, HTTP status, correlation ID, retryability, safe field details, and a localized message key; errors shall not reveal tenant existence, credentials, provider response bodies, or protected source values.", + "source": { + "line": 139, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-022", + "priority": "P1", + "requirement": "Bulk import and export requests shall execute as bounded `JRA` jobs with validated manifests, per-item outcomes, resumable transfer where supported, cancellation checkpoints, and explicit partial-result status instead of holding a synchronous API request open.", + "source": { + "line": 140, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-023", + "priority": "P2", + "requirement": "Official SDKs shall be generated from the published OpenAPI and event schemas, pin a supported major version, expose idempotency, pagination, rate-limit, and signature-verification helpers, and preserve underlying HTTP errors and correlation IDs.", + "source": { + "line": 141, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "INT-024", + "priority": "P2", + "requirement": "Any future third-party connector program shall require signed versioned manifests, publisher identity, declared capabilities, allowed data classifications, synchronization payload classes, isolated execution, contract and security review, revocation, compatibility fixtures, and removal behavior before code can run for a customer.", + "source": { + "line": 142, + "path": "docs/specs/foundation/integrations-api-webhooks.md" + } + }, + { + "id": "JRA-001", + "priority": "P0", + "requirement": "Every job and step state transition shall be durably committed in PostgreSQL; Redis Streams shall carry dispatch hints only and shall never be the source of truth.", + "source": { + "line": 97, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-002", + "priority": "P0", + "requirement": "Job creation, ready-step creation, any required BUA quota reservation, canonical AUD AuditEvent append, and delivery/dispatch outbox insertion shall occur through the `ExecutionAdmissionCoordinator` in one database transaction and be idempotent by workspace plus caller key; an outbox record shall never substitute for the AuditEvent.", + "source": { + "line": 98, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-003", + "priority": "P0", + "requirement": "Recipes and published recipe versions shall be immutable, content-hashed, and pinned by every job created from them.", + "source": { + "line": 99, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-004", + "priority": "P0", + "requirement": "Executors shall accept only registered typed actions whose versioned schema, capability requirements, side-effect class, and handler digest match the signed envelope.", + "source": { + "line": 100, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-005", + "priority": "P0", + "requirement": "DataBreeze shall not dispatch arbitrary scripts, shell commands, unrestricted URL navigation, arbitrary filesystem paths, or remote keyboard/mouse control.", + "source": { + "line": 101, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-006", + "priority": "P0", + "requirement": "Every job envelope shall be workspace-bound, job/step/attempt-bound, expiry-bound, nonce-protected, and signed by the control plane; devices shall verify it before input access or execution.", + "source": { + "line": 102, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-007", + "priority": "P0", + "requirement": "A worker or device shall claim a time-bounded lease before execution and use attempt-scoped heartbeats; stale completions from superseded attempts shall be rejected.", + "source": { + "line": 103, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-008", + "priority": "P0", + "requirement": "Retried jobs and steps shall not duplicate artifacts, imports, reports, notifications, exports, external actions, or usage charges; effecting handlers shall use stable idempotency keys and receipts.", + "source": { + "line": 104, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-009", + "priority": "P0", + "requirement": "Actions classified as destructive, external-sharing, platform-billing-provider-effecting, publication, or policy-sensitive shall require an unexpired approval when workspace policy says so.", + "source": { + "line": 105, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-010", + "priority": "P0", + "requirement": "Approval eligibility shall use `IAM` at decision time and support separation of duties that prohibits the requester or executor from approving their own action.", + "source": { + "line": 106, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-011", + "priority": "P0", + "requirement": "An approval shall bind the canonical hash of inputs, proposed effects, recipe version, action version, and policy version; any material change shall invalidate it.", + "source": { + "line": 107, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-012", + "priority": "P0", + "requirement": "Job results shall be immutable manifests containing source ArtifactVersion IDs, output IDs and hashes, evidence coverage, handler/engine versions, attempt, reviewer, and approval state.", + "source": { + "line": 108, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-013", + "priority": "P0", + "requirement": "The scheduler and dispatcher shall reconstruct all ready work from PostgreSQL after Redis loss, process restart, or outbox delay.", + "source": { + "line": 109, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-014", + "priority": "P1", + "requirement": "Job states shall be `CREATED`, `QUEUED`, `WAITING_FOR_DEVICE`, `DISPATCHED`, `RUNNING`, `NEEDS_REVIEW`, `AWAITING_APPROVAL`, `SUCCEEDED`, `PARTIALLY_SUCCEEDED`, `FAILED`, `CANCEL_REQUESTED`, `CANCELLED`, or `EXPIRED`, with a documented transition table enforced by the domain layer.", + "source": { + "line": 110, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-015", + "priority": "P1", + "requirement": "Recipe triggers shall include manual, schedule, artifact-created, folder-event, webhook, and approved API trigger types, each with deduplication and authorization context.", + "source": { + "line": 111, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-016", + "priority": "P1", + "requirement": "Review and approval queues shall support assignee, eligible group, due time, escalation rule, reason, and immutable decision history.", + "source": { + "line": 112, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-017", + "priority": "P1", + "requirement": "Progress events shall be monotonic per job, derive from committed state, and tolerate duplicate or out-of-order transport delivery.", + "source": { + "line": 113, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-018", + "priority": "P1", + "requirement": "Cancellation and compensation shall use registered typed handlers, preserve originals and prior results, and expose partial-effect receipts for manual recovery.", + "source": { + "line": 114, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-019", + "priority": "P1", + "requirement": "AI-assisted actions shall record provider/model/configuration versions and confidence, but deterministic validation and explicit approvals shall remain authoritative where required.", + "source": { + "line": 115, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-020", + "priority": "P1", + "requirement": "A direct typed-action job shall meet the same schema, authorization, data-mode, approval, idempotency, and audit rules as a recipe job.", + "source": { + "line": 116, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-021", + "priority": "P0", + "requirement": "Every executor shall isolate temporary source-derived data by tenant and job/attempt, encrypt it at rest, exclude it from telemetry and backup, enforce declared byte/retention limits, and verify cleanup or quarantine after success, cancellation, rejection, crash, and terminal failure.", + "source": { + "line": 117, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-022", + "priority": "P0", + "requirement": "Every typed action shall declare an action risk class of `READ_ONLY`, `LOW`, `CONSEQUENTIAL`, or `RESTRICTED`; policy shall require online authorization and approval for `RESTRICTED` actions and shall invalidate an existing approval after any material change to its bound subject or effect.", + "source": { + "line": 118, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-023", + "priority": "P0", + "requirement": "Processing workers shall claim leases, obtain inputs, send heartbeats, and commit results only through the authenticated internal worker API and job-bound object grants; they shall receive no PostgreSQL credential or workspace-enumeration capability.", + "source": { + "line": 119, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-024", + "priority": "P0", + "requirement": "Offline Desktop work shall be recorded as a `ProvisionalExecution`, never as a canonical Job; synchronization shall re-authorize and idempotently register an accepted execution as one PostgreSQL Job linked by client execution ID, while rejected work remains locally quarantined and exportable when policy permits.", + "source": { + "line": 120, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-025", + "priority": "P0", + "requirement": "`BILLING_PROVIDER_EFFECT` shall be reserved to the BUA adapter for DataBreeze's own subscription account; no feature, connector, or extension may register customer payment, funds-transfer, withholding, reversal, or settlement behavior without a separately approved product-boundary and safety specification.", + "source": { + "line": 121, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-026", + "priority": "P0", + "requirement": "JRA shall own one canonical actionable `Finding` envelope unique by full applicable TenantScope plus source subsystem, finding type, and fingerprint, including immutable diagnostic-detail reference, severity, workflow state, assignment, evidence references, disposition, and history; DSM and feature modules shall own diagnostic detail and link it by `sharedFindingId` rather than creating competing workflow authority.", + "source": { + "line": 122, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-027", + "priority": "P0", + "requirement": "JRA shall own the canonical `ReviewTask` envelope and state; resolution shall reference a versioned correction or disposition created through the subject-owning module contract, and no review completion shall count as an approval unless a distinct valid JRA ApprovalDecision also exists.", + "source": { + "line": 123, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-028", + "priority": "P0", + "requirement": "JRA shall be the only authority for ApprovalPolicy, ApprovalRequest, and ApprovalDecision; a feature may expose an authorized facade and persist a subject binding/projection containing the JRA request ID, exact resource version, and subject hash, but shall not persist an independent decision or weaken eligibility, separation of duties, MFA, expiry, or invalidation.", + "source": { + "line": 124, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-029", + "priority": "P0", + "requirement": "Every asynchronous feature run shall reference one canonical `jraJobId` and pinned JRA result manifest; JRA alone shall own dispatch, progress, cancellation, retry, and terminal execution state, while feature lifecycle state is an idempotent projection from committed JRA results/events with a documented mapping when states differ.", + "source": { + "line": 125, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "JRA-030", + "priority": "P0", + "requirement": "Offline execution shall accept a cached RecipeVersion only with a supported signed RecipePublicationEnvelope binding workspace, recipe ID/version/hash, action definitions and handler/schema hashes, referenced DSM definition hashes, policy references, issue/offline-validity time, schema version, and signer/key version; cache encryption alone shall not establish authenticity.", + "source": { + "line": 126, + "path": "docs/specs/foundation/jobs-recipes-approvals.md" + } + }, + { + "id": "MR-001", + "priority": "P0", + "requirement": "The system shall create migration projects scoped to one workspace and optionally one client/project, with owner, `dataModeConstraint`, `effectiveDataModePolicyRef`, locale, time zone, `retentionConstraint`, `effectiveRetentionPolicyRef`, and configurable capacity limits; module constraints shall never broaden workspace policy.", + "source": { + "line": 131, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-002", + "priority": "P0", + "requirement": "The system shall bind every run to immutable source artifact versions, `jraJobId`, and a pinned `resultManifestId` and shall verify each source checksum before processing.", + "source": { + "line": 132, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-003", + "priority": "P0", + "requirement": "The system shall ingest CSV, TSV, XLSX, JSON, JSONL, and Parquet sources with explicit encoding, delimiter, header, sheet, decimal, date, and null parsing settings.", + "source": { + "line": 133, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-004", + "priority": "P0", + "requirement": "Desktop shall register local sources through an explicit file or folder grant and shall not broaden that grant without user action.", + "source": { + "line": 134, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-005", + "priority": "P0", + "requirement": "The system shall profile row counts, column types, null rates, distinct counts, min/max values, length and format distributions, candidate keys, and parse failures.", + "source": { + "line": 135, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-006", + "priority": "P1", + "requirement": "The system shall compare fields and identifiers across sources and expose overlap and conflict statistics without merging records.", + "source": { + "line": 136, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-007", + "priority": "P0", + "requirement": "A migration target shall bind an immutable `DSM` `SchemaVersion` containing field identifiers, display names, types, requiredness, cardinality, constraints, and semantic references; new or imported schema drafts shall become canonical only through `DSM` publication.", + "source": { + "line": 137, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-008", + "priority": "P0", + "requirement": "A migration plan shall bind immutable `DSM` `MappingVersion`, `RuleDefinitionVersion`, and `RuleSetVersion` records for direct, constant, lookup, split, combine, parse, normalize, and allowlisted conditional field transformations.", + "source": { + "line": 138, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-009", + "priority": "P0", + "requirement": "Mapping suggestions shall show component signals and confidence; each suggestion shall remain a migration draft until an authorized user publishes it through `DSM` and explicitly binds the resulting immutable version.", + "source": { + "line": 139, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-010", + "priority": "P0", + "requirement": "Before activating a plan binding, the system shall validate mapping completeness, type compatibility, transformation order, cycles, referenced `DSM` versions, missing lookup dataset versions, and unreachable conditions without republishing those definitions.", + "source": { + "line": 140, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-011", + "priority": "P0", + "requirement": "Cleaning shall operate on derived working records and shall never modify an original artifact or its extracted source rows.", + "source": { + "line": 141, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-012", + "priority": "P0", + "requirement": "The system shall provide deterministic normalization for whitespace, Unicode, case, phone numbers, emails, dates, numbers, identifiers, and configured reference values.", + "source": { + "line": 142, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-013", + "priority": "P0", + "requirement": "Duplicate detection shall bind immutable `DSM` rule versions for blocking and matching, combine them with a versioned migration-specific threshold and survivorship policy, and preserve the contribution of every source record.", + "source": { + "line": 143, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-014", + "priority": "P0", + "requirement": "Ambiguous duplicate clusters shall create module review detail and a canonical `JRA` `ReviewTask` reference with side-by-side field evidence and shall not be auto-merged.", + "source": { + "line": 144, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-015", + "priority": "P1", + "requirement": "The system shall auto-resolve exact duplicate clusters only when a published policy identifies the exact fields, normalization version, and survivorship behavior.", + "source": { + "line": 145, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-016", + "priority": "P0", + "requirement": "A dry-run shall generate record-level output candidates and dispositions without external writes or source mutations.", + "source": { + "line": 146, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-017", + "priority": "P0", + "requirement": "Every rejected, excluded, warning, and merged record shall have one or more stable reason codes and evidence references.", + "source": { + "line": 147, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-018", + "priority": "P0", + "requirement": "Users shall filter module review details and assign, comment on, or bulk-resolve homogeneous exceptions through the canonical `JRA` review facade; bulk actions shall preview the affected count and require confirmation.", + "source": { + "line": 148, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-019", + "priority": "P1", + "requirement": "The system shall support scoped manual overrides that identify the record, field, prior value, replacement value, reason, author, and plan/run applicability.", + "source": { + "line": 149, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-020", + "priority": "P0", + "requirement": "The system shall compare two dry-runs by inputs, plan versions, rule results, dispositions, control totals, and changed output fields.", + "source": { + "line": 150, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-021", + "priority": "P0", + "requirement": "Reconciliation shall prove that each input record is ready, merged into a named survivor, rejected, or explicitly excluded and shall flag unexplained count differences.", + "source": { + "line": 151, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-022", + "priority": "P0", + "requirement": "Reconciliation shall calculate configured numeric control totals before and after transformation, with explicit rounding and tolerance rules.", + "source": { + "line": 152, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-023", + "priority": "P0", + "requirement": "A release policy shall block package generation when mandatory validations fail, unresolved required reviews exist, source versions changed, or reconciliation is outside tolerance.", + "source": { + "line": 153, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-024", + "priority": "P0", + "requirement": "Package release shall require a valid `JRA` `ApprovalDecision` for an `ApprovalRequest` bound to the exact requested action and subject type/ID/version/hash; `JRA` shall enforce that the approver is distinct from the last editor when separation-of-duties policy requires it, and the module shall store only `jraApprovalRequestId` plus the subject binding.", + "source": { + "line": 154, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-025", + "priority": "P0", + "requirement": "An export package shall contain versioned output files, rejected-record files, target schema, plan manifest, source and output checksums, reconciliation report, and machine-readable reason-code summary.", + "source": { + "line": 155, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-026", + "priority": "P0", + "requirement": "The default export formats shall be UTF-8 CSV plus JSON manifest; Parquet and JSONL may be enabled per workspace.", + "source": { + "line": 156, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-027", + "priority": "P0", + "requirement": "The system shall not send a destination write job unless a separately configured adapter, permission, release policy, and valid canonical `JRA` approval for the exact requested action and subject version/hash are all present.", + "source": { + "line": 157, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-028", + "priority": "P0", + "requirement": "Runs, module review details, `JRA` review/approval facade actions, downloads, and package staging shall emit or reference immutable audit events from their owning services.", + "source": { + "line": 158, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-029", + "priority": "P1", + "requirement": "An authorized user shall clone a published plan into a new draft while retaining references to its parent version.", + "source": { + "line": 159, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-030", + "priority": "P1", + "requirement": "The system shall support incremental source batches while preserving batch identity and cumulative reconciliation.", + "source": { + "line": 160, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-031", + "priority": "P1", + "requirement": "Desktop and cloud execution of the same plan and fixture shall produce equivalent normalized values, dispositions, reason codes, and control totals.", + "source": { + "line": 161, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-032", + "priority": "P1", + "requirement": "Users shall export a human-readable migration book covering sources, definitions, mappings, rules, exceptions, reconciliation, and approvals.", + "source": { + "line": 162, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "MR-033", + "priority": "P2", + "requirement": "The system shall permit a workspace to register a signed, declarative destination adapter whose capabilities and idempotency behavior are reviewed independently of the migration plan.", + "source": { + "line": 163, + "path": "docs/specs/features/migration-ready.md" + } + }, + { + "id": "NCO-001", + "priority": "P0", + "requirement": "Notifications shall be generated only from committed domain state through a transactional outbox and shall never be the authoritative record of the underlying action.", + "source": { + "line": 85, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-002", + "priority": "P0", + "requirement": "Each notification intent shall use a deterministic recipient-scoped deduplication key and category-specific window so retries do not create duplicate alerts.", + "source": { + "line": 86, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-003", + "priority": "P0", + "requirement": "Push, email, lock-screen, and Desktop notification payloads shall exclude sensitive source content, including file/client names, extracted values, evidence snippets, amounts, paths, and secrets.", + "source": { + "line": 87, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-004", + "priority": "P0", + "requirement": "Notification creation, external delivery, live-stream subscription, deep-link opening, and protected detail read shall each enforce active membership and resource authorization.", + "source": { + "line": 88, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-005", + "priority": "P0", + "requirement": "A mention shall not grant access; unauthorized or removed recipients shall receive no notification and shall not resolve the target resource.", + "source": { + "line": 89, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-006", + "priority": "P0", + "requirement": "User preferences shall be applied per organization/workspace, category, urgency, and channel, with locale-aware quiet hours and digest schedules.", + "source": { + "line": 90, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-007", + "priority": "P0", + "requirement": "Mandatory security, ownership, data-loss-risk, and access-suspension notices may not be disabled, but shall still use content-minimized templates.", + "source": { + "line": 91, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-008", + "priority": "P0", + "requirement": "Comments shall be tenant- and resource-scoped, versioned on edit, tombstoned on removal, and recorded with author, timestamps, and audit correlation.", + "source": { + "line": 92, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-009", + "priority": "P0", + "requirement": "Evidence-anchored comments shall reference a valid `IAE` EvidenceReference and preserve the referenced ArtifactVersion even when newer versions exist.", + "source": { + "line": 93, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-010", + "priority": "P0", + "requirement": "Notification actions such as approve, assign, or retry shall deep-link to the application; no external notification button shall perform the privileged action without authenticated re-authorization and applicable MFA.", + "source": { + "line": 94, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-011", + "priority": "P0", + "requirement": "Membership removal, device revocation, or resource-access loss shall suppress unsent deliveries and make prior deep links return a non-disclosing denial.", + "source": { + "line": 95, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-012", + "priority": "P1", + "requirement": "In-app notification states shall be `UNREAD`, `READ`, `ARCHIVED`, or `DISMISSED`; state changes shall synchronize idempotently per user without changing underlying work state.", + "source": { + "line": 96, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-013", + "priority": "P1", + "requirement": "Threads shall support reply, resolve/reopen, reaction, and assignment reference while preserving immutable event history.", + "source": { + "line": 97, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-014", + "priority": "P1", + "requirement": "Notification bundles shall update an existing notification count and last-occurrence time within the deduplication window instead of emitting one alert per event.", + "source": { + "line": 98, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-015", + "priority": "P1", + "requirement": "Approval/review reminders shall be preference-aware within policy bounds, stop after resolution or invalidation, and be limited to one initial, one due-soon, and one overdue delivery unless policy explicitly escalates.", + "source": { + "line": 99, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-016", + "priority": "P1", + "requirement": "Email and push provider webhooks shall be signature-verified, idempotent, and limited to delivery metadata; they shall not mutate domain decisions.", + "source": { + "line": 100, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-017", + "priority": "P1", + "requirement": "Vietnamese shall be the default notification locale with English fallback; templates shall use stable message keys and sanitized parameters.", + "source": { + "line": 101, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-018", + "priority": "P1", + "requirement": "Workspace administrators shall be able to configure allowed external channels and retention without reading private notification content beyond authorized resources.", + "source": { + "line": 102, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-019", + "priority": "P1", + "requirement": "Offline comment, read-state, and dismissal operations shall use stable operation IDs and explicit conflict rules from `DSO`.", + "source": { + "line": 103, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "NCO-020", + "priority": "P1", + "requirement": "The system shall provide accessible, filterable notification and thread views with pagination and no reliance on color or sound alone.", + "source": { + "line": 104, + "path": "docs/specs/foundation/notifications-collaboration.md" + } + }, + { + "id": "OC-001", + "priority": "P0", + "requirement": "Web shall create form definitions with stable machine field identifiers independent of labels, order, and translation.", + "source": { + "line": 127, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-002", + "priority": "P0", + "requirement": "Published form versions shall be immutable; edits create a draft child version with a machine-readable compatibility diff.", + "source": { + "line": 128, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-003", + "priority": "P0", + "requirement": "The field catalog shall include text, long text, integer, decimal, currency, date, time, date-time, duration, yes/no, single/multiple choice, identifier, barcode/QR, photo/document, file, voice, signature, and consented location.", + "source": { + "line": 129, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-004", + "priority": "P0", + "requirement": "Form logic shall use allowlisted declarative visibility, requiredness, default, validation, and display-calculation expressions and shall reject arbitrary code.", + "source": { + "line": 130, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-005", + "priority": "P0", + "requirement": "Form validation shall reject cycles, hidden required fields without satisfiable paths, incompatible types, unpinned reference data, prohibited field classes, and resource limits.", + "source": { + "line": 131, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-006", + "priority": "P0", + "requirement": "Designers shall preview Web and representative Android layouts and publish a test assignment before production publication.", + "source": { + "line": 132, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-007", + "priority": "P0", + "requirement": "Assignments shall bind form version, assignee/team, optional roster and record keys, availability window, reference versions, `dataModeConstraint`, `effectiveDataModePolicyRef`, and review policy; the module constraint shall never broaden workspace `DSO` policy.", + "source": { + "line": 133, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-008", + "priority": "P0", + "requirement": "Android shall verify form, assignment, and reference-data checksums before declaring an assignment ready offline.", + "source": { + "line": 134, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-009", + "priority": "P0", + "requirement": "Android shall store drafts, responses, media keys, sync journal, and cached definitions in encrypted app-private storage.", + "source": { + "line": 135, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-010", + "priority": "P0", + "requirement": "Every field change shall autosave locally with record ID, field ID, device revision, actor, device time, and monotonic capture sequence.", + "source": { + "line": 136, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-011", + "priority": "P0", + "requirement": "Camera, microphone, file, and location access shall require Android permission plus a visible in-app user action for each capture session.", + "source": { + "line": 137, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-012", + "priority": "P0", + "requirement": "Voice and video/audio capture shall show an unambiguous active-state indicator and stop control and shall obey configurable duration and size limits.", + "source": { + "line": 138, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-013", + "priority": "P0", + "requirement": "Barcode/QR capture shall retain decoded value, symbology, scan time, validation state, and optional image evidence when policy permits.", + "source": { + "line": 139, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-014", + "priority": "P0", + "requirement": "A drawn signature shall retain the immutable stroke/render artifact, signer-entered label, acknowledgement text/version, device timestamp, and evidence metadata without claiming identity verification.", + "source": { + "line": 140, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-015", + "priority": "P0", + "requirement": "Optional location capture shall disclose purpose, capture only on explicit action, record accuracy and provider state, and permit policy-defined unavailable handling.", + "source": { + "line": 141, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-016", + "priority": "P0", + "requirement": "On-device validation shall support requiredness, type, length, range, pattern, reference membership, uniqueness within the draft, and cross-field rules using the published definition.", + "source": { + "line": 142, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-017", + "priority": "P0", + "requirement": "Submission shall be blocked by mandatory validation failures and shall summarize warnings and missing evidence before confirmation.", + "source": { + "line": 143, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-018", + "priority": "P0", + "requirement": "Submitting shall freeze an immutable local submission version and generate a stable idempotency key before network transfer.", + "source": { + "line": 144, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-019", + "priority": "P0", + "requirement": "Sync retries shall create exactly one server submission version for one device submission idempotency key.", + "source": { + "line": 145, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-020", + "priority": "P0", + "requirement": "Media upload shall be resumable, chunk/checksum verified, and associated only after complete object verification.", + "source": { + "line": 146, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-021", + "priority": "P0", + "requirement": "Server acknowledgement shall include durable submission/version IDs and per-attachment status before Android marks local content safely synchronized.", + "source": { + "line": 147, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-022", + "priority": "P0", + "requirement": "Original photos, documents, voice, barcode image evidence, and signature artifacts shall be immutable; processing creates derived artifacts and candidate values.", + "source": { + "line": 148, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-023", + "priority": "P0", + "requirement": "OCR or transcription candidates shall retain adapter/version, language, confidence, and page/region or time-segment evidence.", + "source": { + "line": 149, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-024", + "priority": "P0", + "requirement": "Extracted candidates shall never silently overwrite operator-entered or submitted values; acceptance creates a correction/version event.", + "source": { + "line": 150, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-025", + "priority": "P0", + "requirement": "Review policy shall route validation warnings, low-confidence extraction, duplicates, reconciliation exceptions, and configured sensitive submissions to authorized queues.", + "source": { + "line": 151, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-026", + "priority": "P0", + "requirement": "Reviewers shall use an authorized facade over a canonical `JRA` `ReviewTask` to mark a capture review detail accepted, rejected, or returned, add comments, or create a correction draft with stable reason codes and field-level evidence; the module shall store `jraReviewTaskId` and a projection only, and review acceptance shall not satisfy or bypass a separate `JRA` approval.", + "source": { + "line": 152, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-027", + "priority": "P0", + "requirement": "Changes to an acknowledged submission shall create a new submission version linked to its parent and shall retain a field-level diff.", + "source": { + "line": 153, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-028", + "priority": "P0", + "requirement": "Form-version breaking changes shall not alter existing drafts; a user shall finish on the pinned version or explicitly migrate through a validated preview.", + "source": { + "line": 154, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-029", + "priority": "P0", + "requirement": "Desktop scanner intake shall operate only on explicitly granted folders, wait for stable files, fingerprint inputs, and avoid duplicate imports.", + "source": { + "line": 155, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-030", + "priority": "P0", + "requirement": "Desktop matching shall prioritize deterministic assignment, record, cover-sheet, barcode, and filename keys before confidence-based suggestions.", + "source": { + "line": 156, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-031", + "priority": "P0", + "requirement": "Ambiguous attachment matches and duplicate submissions shall require review and preserve every candidate and decision.", + "source": { + "line": 157, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-032", + "priority": "P0", + "requirement": "Reconciliation shall report expected, received, approved, missing, duplicate, rejected, returned, and waived records with no unexplained submissions.", + "source": { + "line": 158, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-033", + "priority": "P0", + "requirement": "All form publication, assignment, capture, submission, sync, extraction, review, correction, approval-facade, export, and evidence access actions shall be audited by their owning services.", + "source": { + "line": 159, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-034", + "priority": "P1", + "requirement": "Supervisors shall configure bounded bulk assignment, reassignment, return, approval-facade, and export actions with preview and permission checks; every approval item shall retain requested action, exact subject type/ID/version/hash, and `jraApprovalRequestId`.", + "source": { + "line": 160, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-035", + "priority": "P1", + "requirement": "Forms shall support repeatable groups with stable item IDs and configurable minimum/maximum occurrences.", + "source": { + "line": 161, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-036", + "priority": "P1", + "requirement": "Forms shall support offline reference-data search and dependent choice lists with pinned versions and explicit stale behavior.", + "source": { + "line": 162, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-037", + "priority": "P1", + "requirement": "Submissions backed by any required valid `JRA` `ApprovalDecision` for the exact requested action and subject type/ID/version/hash shall be exportable as UTF-8 CSV/JSON plus media/evidence manifest and available to other DataBreeze modules through typed intake.", + "source": { + "line": 163, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-038", + "priority": "P1", + "requirement": "Workspace admins shall configure per-form media quality, `retentionConstraint`, offline capacity, sync network, battery, roaming, and local-cache cleanup; `effectiveRetentionPolicyRef` and authoritative deletion shall remain owned by `IAE`.", + "source": { + "line": 164, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-039", + "priority": "P1", + "requirement": "Android shall display storage consumption, unsynchronized item count, oldest pending age, last successful sync, and actionable failure reasons.", + "source": { + "line": 165, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-040", + "priority": "P1", + "requirement": "Local Desktop and cloud processing of the same media fixture shall produce equivalent deterministic parsing and evidence coordinates within declared adapter tolerances.", + "source": { + "line": 166, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "OC-041", + "priority": "P2", + "requirement": "The system shall permit a form package to be imported/exported between workspaces only as an unsigned draft with assignments, responses, secrets, and restricted reference values removed.", + "source": { + "line": 167, + "path": "docs/specs/features/operations-capture.md" + } + }, + { + "id": "PDA-001", + "priority": "P0", + "requirement": "The system shall create an analysis-dataset binding to an existing `DSM` `Dataset` and exact immutable `DatasetVersion` records, then record module-specific locale, time zone, sensitivity projection, quality status, `dataModeConstraint`, `effectiveDataModePolicyRef`, `retentionConstraint`, and `effectiveRetentionPolicyRef` without registering a parallel dataset identity or broadening workspace policy.", + "source": { + "line": 125, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-002", + "priority": "P0", + "requirement": "An analysis-semantic binding shall reference exact immutable `DSM` schema, semantic, metric, relationship, calendar, and dataset versions and shall expose only the projections and analysis policies needed for entities, fields, dimensions, filters, aliases, and evidence.", + "source": { + "line": 126, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-003", + "priority": "P0", + "requirement": "Activated analysis-semantic binding versions shall be immutable; a referenced `DSM` definition change shall require a new binding version with a machine-readable compatibility diff rather than republishing the definition.", + "source": { + "line": 127, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-004", + "priority": "P0", + "requirement": "Every bound `DSM` `MetricDefinitionVersion` shall declare formula, source fields, base grain, aggregation behavior, unit/currency, null handling, rounding, default filters, allowed dimensions, and description before analysis use.", + "source": { + "line": 128, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-005", + "priority": "P0", + "requirement": "A relationship shall declare keys, cardinality, direction, optionality, effective-time behavior, and fan-out policy.", + "source": { + "line": 129, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-006", + "priority": "P0", + "requirement": "Analysis-binding validation shall reject missing or incompatible `DSM` versions, cycles, missing keys, ambiguous joins, incompatible units, non-additive aggregation misuse, unknown functions, and unbounded definitions without creating a parallel semantic publisher.", + "source": { + "line": 130, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-007", + "priority": "P0", + "requirement": "Users shall ask Vietnamese or English text questions within a selected authorized governed-data scope.", + "source": { + "line": 131, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-008", + "priority": "P0", + "requirement": "Android voice questions shall display a transcript for user confirmation before any plan is executed.", + "source": { + "line": 132, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-009", + "priority": "P0", + "requirement": "The planner shall represent analysis as a versioned typed intermediate plan, not executable free-form code.", + "source": { + "line": 133, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-010", + "priority": "P0", + "requirement": "The typed plan catalog shall support projection, filter, governed join, group, aggregate, sort, top/bottom, period comparison, share-of-total, bounded cohort, and allowlisted statistical operations.", + "source": { + "line": 134, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-011", + "priority": "P0", + "requirement": "When a question has multiple material interpretations, the system shall request clarification or present named alternatives and shall not silently select one.", + "source": { + "line": 135, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-012", + "priority": "P0", + "requirement": "Before execution, the system shall show selected `DSM` metric/semantic versions, dimensions, filters, date range, time grain, dataset versions or selectors, and material assumptions.", + "source": { + "line": 136, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-013", + "priority": "P0", + "requirement": "Plan validation shall enforce permissions, semantic types, join cardinality, grain, units, filter scope, output bounds, resource limits, and quality gates.", + "source": { + "line": 137, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-014", + "priority": "P0", + "requirement": "The deterministic engine shall calculate all displayed numeric results; an AI adapter shall not supply or alter numeric result values.", + "source": { + "line": 138, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-015", + "priority": "P0", + "requirement": "Every result shall bind exact `DSM` dataset and semantic/metric versions, analysis-semantic binding version, plan version, engine version, execution time, locale, time zone, `jraJobId`, and pinned `resultManifestId`.", + "source": { + "line": 139, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-016", + "priority": "P0", + "requirement": "Every table or chart value shall expose calculation provenance and, when permitted, drill-down evidence to contributing source rows/cells or an exact aggregate definition.", + "source": { + "line": 140, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-017", + "priority": "P0", + "requirement": "Answers shall disclose coverage period, source freshness, applied filters, exclusions, units, quality warnings, and whether results are complete, sampled, or truncated.", + "source": { + "line": 141, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-018", + "priority": "P0", + "requirement": "If authorized data cannot answer the question, the system shall return `INSUFFICIENT_DATA`, `AMBIGUOUS`, `UNAUTHORIZED_SCOPE`, or another stable non-answer reason rather than inventing a result.", + "source": { + "line": 142, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-019", + "priority": "P0", + "requirement": "Optional AI narrative shall be generated only from a bounded structured result and provenance package and shall label unsupported requested claims as unavailable.", + "source": { + "line": 143, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-020", + "priority": "P0", + "requirement": "Each material numeric narrative claim shall link to one or more result cells; qualitative claims shall link to result/evidence or be labeled as interpretation.", + "source": { + "line": 144, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-021", + "priority": "P0", + "requirement": "Users shall inspect and edit a typed plan through governed fields and operations before rerunning; arbitrary SQL or code execution shall not be exposed.", + "source": { + "line": 145, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-022", + "priority": "P0", + "requirement": "The system shall render accessible tables and allowlisted bar, line, area, scatter, and pie/donut charts only when the selected fields and grain are compatible.", + "source": { + "line": 146, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-023", + "priority": "P0", + "requirement": "Saved analysis versions shall retain question, plan, analysis-semantic binding and underlying `DSM` version references, parameter schema, display, result policy, owner, and parent version.", + "source": { + "line": 147, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-024", + "priority": "P0", + "requirement": "A saved analysis shall define whether readers see a frozen snapshot or execute a permission-checked rerun; it shall not silently switch behavior.", + "source": { + "line": 148, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-025", + "priority": "P0", + "requirement": "Sharing an analysis shall not grant underlying dataset permissions, raw-evidence access, or broader row/field visibility.", + "source": { + "line": 149, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-026", + "priority": "P0", + "requirement": "Certified analyses shall display the exact certified subject version, certification scope, data freshness policy, expiry/invalidation conditions, and permission-filtered canonical `JRA` approval projection; the module shall retain requested action, exact subject type/ID/version/hash, and `jraApprovalRequestId` but no independent certifier decision.", + "source": { + "line": 150, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-027", + "priority": "P0", + "requirement": "Breaking changes in referenced `DSM` definitions, failed required quality rules, expired certification, or unavailable input versions shall block a certified rerun until a new compatible binding is reviewed.", + "source": { + "line": 151, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-028", + "priority": "P0", + "requirement": "Schedules shall bind a saved analysis version, parameters, input selector, trigger, freshness policy, recipients, and idempotent occurrence key.", + "source": { + "line": 152, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-029", + "priority": "P0", + "requirement": "Scheduled answers shall not notify success when input freshness or required quality gates fail; they shall create a visible blocked occurrence.", + "source": { + "line": 153, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-030", + "priority": "P0", + "requirement": "All questions, plans, executions, analysis-binding activations, referenced `DSM` publications, certifications, shares, exports, and evidence access shall be audited by the owning services according to workspace policy.", + "source": { + "line": 154, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-031", + "priority": "P1", + "requirement": "Analysts shall compare two compatible result snapshots and show absolute, percentage, and contribution changes using declared zero and null behavior.", + "source": { + "line": 155, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-032", + "priority": "P1", + "requirement": "Users shall create reusable parameter controls with type, allowed values/range, default, sensitivity, and permission-aware options.", + "source": { + "line": 156, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-033", + "priority": "P1", + "requirement": "Result snapshots shall be embeddable in DataBreeze reports with immutable provenance and refresh policy.", + "source": { + "line": 157, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-034", + "priority": "P1", + "requirement": "Desktop shall offer a provider-neutral local AI adapter when installed and capable; deterministic plan execution shall work without any AI adapter.", + "source": { + "line": 158, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-035", + "priority": "P1", + "requirement": "Workspace admins shall configure which metadata, samples, result rows, and evidence may be sent to each approved AI adapter, with a previewable egress policy.", + "source": { + "line": 159, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-036", + "priority": "P1", + "requirement": "A user shall export result data, chart specification, permission-filtered projections of referenced `DSM` semantic definitions, and a provenance manifest in open formats subject to permissions.", + "source": { + "line": 160, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-037", + "priority": "P1", + "requirement": "Local and cloud execution of the same typed plan and fixture shall produce equivalent result values, row counts, units, reason codes, and evidence keys.", + "source": { + "line": 161, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "PDA-038", + "priority": "P2", + "requirement": "The system may recommend related certified analyses or follow-up questions using authorized metadata, but recommendations shall never imply a result before execution.", + "source": { + "line": 162, + "path": "docs/specs/features/private-data-analyst.md" + } + }, + { + "id": "QI-001", + "priority": "P0", + "requirement": "The system shall create a comparison under one workspace and project with a unique, tenant-scoped identifier.", + "source": { + "line": 99, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-002", + "priority": "P0", + "requirement": "The system shall version RFQ requirements and preserve previously used versions.", + "source": { + "line": 100, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-003", + "priority": "P0", + "requirement": "The system shall ingest PDF, common image formats, DOCX, XLSX, and CSV artifacts without modifying originals. An Android-held `ORIGINAL_CONTENT` source that cannot be processed on Android in strict Local mode shall remain on that device with its IAE InboxItem in `NEEDS_REVIEW` and reason `LOCAL_PROCESSOR_REQUIRED` until an explicit `DSO` user-mediated encrypted offline package is exported and imported on a registered Desktop; cloud upload and live relay are forbidden.", + "source": { + "line": 101, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-004", + "priority": "P0", + "requirement": "The system shall retain the supplier name extracted from each quote separately and bind the quote to an exact authorized DSM `BUSINESS_PARTY` ReferenceEntityVersion; QI shall not own supplier aliases, identifiers, visibility, or merge history.", + "source": { + "line": 102, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-005", + "priority": "P0", + "requirement": "The engine shall extract raw and normalized header terms and line items with field-level evidence references.", + "source": { + "line": 103, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-006", + "priority": "P0", + "requirement": "The system shall support Vietnamese and English labels, decimal conventions, dates, currencies, and unit aliases.", + "source": { + "line": 104, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-007", + "priority": "P0", + "requirement": "The system shall normalize units only through a versioned compatible-dimension conversion rule.", + "source": { + "line": 105, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-008", + "priority": "P0", + "requirement": "The system shall support exact, tolerance-based, many-to-one, one-to-many, partial, alternate, and unmatched line states.", + "source": { + "line": 106, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-009", + "priority": "P0", + "requirement": "A user shall be able to confirm, reject, split, merge, or remap a proposed line match without losing the proposal history.", + "source": { + "line": 107, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-010", + "priority": "P0", + "requirement": "The system shall calculate landed cost from a versioned formula and expose every intermediate component.", + "source": { + "line": 108, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-011", + "priority": "P0", + "requirement": "Required but absent cost inputs shall produce an incomplete result, not an assumed zero.", + "source": { + "line": 109, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-012", + "priority": "P0", + "requirement": "Currency conversion shall store source currency, target currency, rate, effective date, provenance, and rounding policy.", + "source": { + "line": 110, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-013", + "priority": "P0", + "requirement": "The system shall distinguish tax-inclusive, tax-exclusive, exempt, unknown, and not-applicable states.", + "source": { + "line": 111, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-014", + "priority": "P0", + "requirement": "Scoring shall use workspace-configurable weights totaling 100%, deterministic normalization functions, and mandatory eligibility gates.", + "source": { + "line": 112, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-015", + "priority": "P0", + "requirement": "The score view shall show raw value, normalized value, weight, contribution, gate result, and policy version.", + "source": { + "line": 113, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-016", + "priority": "P0", + "requirement": "Manual values and overrides shall require a reason and actor and shall remain visually distinct from extracted values.", + "source": { + "line": 114, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-017", + "priority": "P0", + "requirement": "Consequential compliance, cost, and eligibility findings shall be rule-derived and reproducible without an AI provider.", + "source": { + "line": 115, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-018", + "priority": "P0", + "requirement": "Submission to a `JRA` ApprovalRequest shall be blocked while required fields, invalid conversions, or unresolved blocking findings remain.", + "source": { + "line": 116, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-019", + "priority": "P0", + "requirement": "A version released by an accepted `JRA` ApprovalDecision shall be immutable and shall retain source, rule, rate, reviewer, exact subject type/ID/version/hash, and authoritative approval provenance.", + "source": { + "line": 117, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-020", + "priority": "P0", + "requirement": "All read, edit, export, submit, and approve-facade operations shall enforce workspace and project permissions plus the applicable `JRA` ApprovalPolicy; Quote Intelligence shall not persist an independent ApprovalDecision.", + "source": { + "line": 118, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-021", + "priority": "P1", + "requirement": "Users shall be able to compare multiple landed-cost scenarios without duplicating source artifacts.", + "source": { + "line": 119, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-022", + "priority": "P1", + "requirement": "The system shall detect duplicate quote artifacts and superseded supplier revisions while allowing an explicit override.", + "source": { + "line": 120, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-023", + "priority": "P1", + "requirement": "The system shall support configurable tolerances for quantity, price, delivery, and specification comparisons.", + "source": { + "line": 121, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-024", + "priority": "P1", + "requirement": "Users shall be able to assign extraction or matching questions and receive notifications.", + "source": { + "line": 122, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-025", + "priority": "P1", + "requirement": "The system shall export XLSX, PDF, and web decision packs with evidence identifiers and version metadata.", + "source": { + "line": 123, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-026", + "priority": "P1", + "requirement": "The system shall expose comparison history for a supplier without leaking data across projects lacking access.", + "source": { + "line": 124, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-027", + "priority": "P1", + "requirement": "A copied comparison shall reference its source template but shall receive new independent versions and, when required, new `JRA` ApprovalRequests.", + "source": { + "line": 125, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "QI-028", + "priority": "P2", + "requirement": "An AI adapter may suggest semantic matches or plain-language explanations, but suggestions shall require confirmation and never change deterministic calculations.", + "source": { + "line": 126, + "path": "docs/specs/features/quote-intelligence.md" + } + }, + { + "id": "SA-001", + "priority": "P0", + "requirement": "The system shall create each audit against an immutable `IAE` artifact version and recorded content hash and shall bind its asynchronous execution to `jraJobId` and a pinned `resultManifestId`.", + "source": { + "line": 84, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-002", + "priority": "P0", + "requirement": "The system shall inspect supported workbooks without executing macros, add-ins, queries, external links, or embedded scripts.", + "source": { + "line": 85, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-003", + "priority": "P0", + "requirement": "The audit shall inventory sheets, visibility, dimensions, tables, named ranges, formulas, validations, merged cells, links, macros, and calculation settings.", + "source": { + "line": 86, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-004", + "priority": "P0", + "requirement": "The parser shall preserve formula text, cached value when present, style identity, cell type, and sheet/cell evidence.", + "source": { + "line": 87, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-005", + "priority": "P0", + "requirement": "The engine shall detect parse errors, broken references, incompatible ranges, and unsupported formulas without inventing calculated values.", + "source": { + "line": 88, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-006", + "priority": "P0", + "requirement": "The engine shall group structurally equivalent formulas into formula families using relative-reference normalization.", + "source": { + "line": 89, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-007", + "priority": "P0", + "requirement": "The engine shall identify formula-family outliers, formula-to-constant overwrites, gaps, and inconsistent range boundaries.", + "source": { + "line": 90, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-008", + "priority": "P0", + "requirement": "Audit profiles shall bind immutable `DSM` `RuleDefinitionVersion` records, compatible registered `AuditRuleVersion` engine/plugin implementations, severities, parameters, scope selectors, and suppression policy.", + "source": { + "line": 91, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-009", + "priority": "P0", + "requirement": "Every `SpreadsheetFindingDetail` shall be immutable and include the bound `DSM` rule version, diagnostic severity/classification, evidence, affected scope, deterministic reproduction data, stable fingerprint, and `sharedFindingId` when actionable.", + "source": { + "line": 92, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-010", + "priority": "P0", + "requirement": "The system shall distinguish confirmed rule violations, heuristic warnings, unsupported checks, and informational observations.", + "source": { + "line": 93, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-011", + "priority": "P0", + "requirement": "Users shall be able to assign, comment on, resolve, reopen, or suppress actionable findings through an authorized facade over the canonical `JRA` `Finding` or `ReviewTask`, subject to permission and revision checks.", + "source": { + "line": 94, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-012", + "priority": "P0", + "requirement": "A suppression shall require scope, reason, actor, creation time, and optional expiry; broad suppressions require elevated permission.", + "source": { + "line": 95, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-013", + "priority": "P0", + "requirement": "Repair proposals shall state exact preconditions and exact cell or workbook-part changes.", + "source": { + "line": 96, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-014", + "priority": "P0", + "requirement": "Repairs shall be previewed as a before/after diff and validated on an isolated copy before application.", + "source": { + "line": 97, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-015", + "priority": "P0", + "requirement": "The system shall never mutate an original artifact or overwrite a user file in place.", + "source": { + "line": 98, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-016", + "priority": "P0", + "requirement": "Applying a repair shall create a new artifact version or separately named export with source lineage.", + "source": { + "line": 99, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-017", + "priority": "P0", + "requirement": "A repair plan shall be rejected as stale when its source hash or required preconditions differ.", + "source": { + "line": 100, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-018", + "priority": "P0", + "requirement": "Diagnostic details, `JRA` finding/review facades, and repair actions shall enforce tenant, project, artifact, and capability permissions.", + "source": { + "line": 101, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-019", + "priority": "P0", + "requirement": "Audit sign-off or repair approval shall use a canonical `JRA` `ApprovalRequest` whose exact subject type, ID, version, hash, and requested action bind the audit or repair plan; the module shall store only `jraApprovalRequestId` and that subject binding, and shall not persist an independent actor decision.", + "source": { + "line": 102, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-020", + "priority": "P0", + "requirement": "Reports shall disclose skipped sheets, unsupported features, truncated analysis, and calculation limitations.", + "source": { + "line": 103, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-021", + "priority": "P1", + "requirement": "The engine shall support configurable data rules for uniqueness, nulls, type, format, range, membership, pattern, and cross-column conditions.", + "source": { + "line": 104, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-022", + "priority": "P1", + "requirement": "The engine shall support deterministic reconciliation rules across cells, ranges, sheets, and imported reference datasets.", + "source": { + "line": 105, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-023", + "priority": "P1", + "requirement": "Users shall be able to compare immutable diagnostic-detail changes between two audit runs of the same logical workbook.", + "source": { + "line": 106, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-024", + "priority": "P1", + "requirement": "Desktop shall navigate to a finding in a safe read-only workbook view or launch the user's spreadsheet application at best-effort sheet/cell location.", + "source": { + "line": 107, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-025", + "priority": "P1", + "requirement": "Approved folders shall support debounced recurring audits with hash deduplication and per-folder policy.", + "source": { + "line": 108, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-026", + "priority": "P1", + "requirement": "The system shall export HTML, PDF, JSON, and XLSX finding reports with evidence identifiers.", + "source": { + "line": 109, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-027", + "priority": "P1", + "requirement": "Repair plans shall support selective acceptance and shall recompute plan validity after each selection change.", + "source": { + "line": 110, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "SA-028", + "priority": "P2", + "requirement": "An AI adapter may cluster or explain findings and suggest human-readable rule descriptions, but it shall not create or apply an executable repair without deterministic validation.", + "source": { + "line": 111, + "path": "docs/specs/features/spreadsheet-auditor.md" + } + }, + { + "id": "WEB-001", + "priority": "P0", + "requirement": "Web shall provide the complete management surface for organizations, workspaces, projects/clients, members, roles, policies, devices, billing, usage, and audit history.", + "source": { + "line": 94, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-002", + "priority": "P0", + "requirement": "Every route, query, mutation, download, SSE subscription, and action shall rely on server-side `IAM` authorization; client permission hints shall affect presentation only.", + "source": { + "line": 95, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-003", + "priority": "P0", + "requirement": "The application shall use generated OpenAPI types plus runtime validation and shall reject an incompatible control-plane contract before mutating state.", + "source": { + "line": 96, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-004", + "priority": "P0", + "requirement": "Browser refresh credentials shall remain in `HttpOnly`, `Secure`, `SameSite` cookies; long-lived bearer tokens, device secrets, and provider secrets shall not be stored in localStorage or JavaScript-readable persistence.", + "source": { + "line": 97, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-005", + "priority": "P0", + "requirement": "Original upload controls shall follow the server data-mode decision; a `LOCAL` workspace shall never send original bytes or reconstructable derived content such as previews, OCR/transcripts, row/cell values, thumbnails, source snippets, or chunks from Web.", + "source": { + "line": 98, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-006", + "priority": "P0", + "requirement": "Artifact originals shall be presented as immutable versions; corrections, transformations, redactions, and publications shall create explicit new versions.", + "source": { + "line": 99, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-007", + "priority": "P0", + "requirement": "Every material extraction, finding, and report value shall expose its evidence state and navigate to an exact authorized `IAE` EvidenceReference or an explicit resolution error.", + "source": { + "line": 100, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-008", + "priority": "P0", + "requirement": "Job and recipe controls shall create only registered typed actions through `JRA`; Web shall expose no arbitrary script, shell, filesystem, or remote-control input.", + "source": { + "line": 101, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-009", + "priority": "P0", + "requirement": "Approval decisions shall display the bound input/effect hash summary, policy, expiry, and evidence, and shall be re-authorized with MFA when required at submission time.", + "source": { + "line": 102, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-010", + "priority": "P0", + "requirement": "Live progress and notifications shall be derived from committed events, tolerate duplicate/out-of-order delivery, and reconcile from REST after reconnect or event-ID gap.", + "source": { + "line": 103, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-011", + "priority": "P0", + "requirement": "Billing entitlements, limits, grace, and suspension shall be enforced by the control plane; Web shall render stable denial/remediation states and preserve read/export/delete-request access.", + "source": { + "line": 104, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-012", + "priority": "P0", + "requirement": "Destructive actions shall show the exact scope and consequences, require explicit confirmation and recent MFA where specified, and never conflate billing cancellation with data deletion.", + "source": { + "line": 105, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-013", + "priority": "P1", + "requirement": "Vietnamese shall be the default complete locale, including dates, numbers, pluralization, validation, notification templates, and accessible names; English shall be a complete fallback.", + "source": { + "line": 106, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-014", + "priority": "P1", + "requirement": "Core workflows shall meet WCAG 2.2 AA, support keyboard-only use and screen readers, preserve focus across dialogs/routes, and not rely on color, pointer hover, sound, or motion alone.", + "source": { + "line": 107, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-015", + "priority": "P1", + "requirement": "All list and activity views shall use cursor pagination, stable filters encoded in the URL where safe, explicit empty/error states, and virtualized rendering for large result sets.", + "source": { + "line": 108, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-016", + "priority": "P1", + "requirement": "Mutations shall use idempotency keys and revision preconditions, show pending state, prevent accidental duplicate submit, and reconcile ambiguous network outcomes.", + "source": { + "line": 109, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-017", + "priority": "P1", + "requirement": "The recipe builder shall validate schemas, graph structure, capabilities, data mode, approval requirements, and entitlements before allowing publication.", + "source": { + "line": 110, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-018", + "priority": "P1", + "requirement": "Device management shall expose opaque capabilities, status, grants, revocation, conflicts, and data-mode migrations without revealing local paths or enabling filesystem browsing.", + "source": { + "line": 111, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-019", + "priority": "P1", + "requirement": "External notifications and shared links shall remain content-minimized; protected details shall load only after authenticated authorization.", + "source": { + "line": 112, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-020", + "priority": "P1", + "requirement": "The browser shall retain only content-minimized cached server state by default; source previews and original bytes shall not be available offline unless an explicit encrypted download/export completes.", + "source": { + "line": 113, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-021", + "priority": "P1", + "requirement": "Every error shall map to a stable problem code with a Vietnamese user message, correlation ID, safe retry guidance, and no stack trace, tenant existence leak, or source content.", + "source": { + "line": 114, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-022", + "priority": "P1", + "requirement": "Feature modules shall register routes, navigation, permissions, message keys, schemas, and telemetry at build time; arbitrary runtime third-party code shall not execute in the application origin.", + "source": { + "line": 115, + "path": "docs/specs/platforms/web.md" + } + }, + { + "id": "WEB-023", + "priority": "P0", + "requirement": "For `LOCAL` evidence, Web shall show an open-on-source-device action and explicit device availability; it shall not request or display a live source-derived relay unless the user first publishes a governed Hybrid/Cloud derivative.", + "source": { + "line": 116, + "path": "docs/specs/platforms/web.md" + } + } + ], + "version": 1 +} diff --git a/package.json b/package.json index 03b10429..312d917e 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,11 @@ "lint": "eslint . && node tools/repo-cli/src/check-dependency-boundaries.mjs", "repo:bootstrap": "corepack pnpm install --frozen-lockfile", "repo:build": "corepack pnpm build", - "repo:check": "corepack pnpm format:check && corepack pnpm lint && corepack pnpm typecheck && corepack pnpm test", + "repo:check": "corepack pnpm format:check && corepack pnpm lint && corepack pnpm typecheck && corepack pnpm requirements:check && corepack pnpm test", "repo:dev": "turbo run dev --parallel", "repo:test": "corepack pnpm test", + "requirements:check": "node tools/repo-cli/src/generate-requirement-index.mjs --check", + "requirements:generate": "node tools/repo-cli/src/generate-requirement-index.mjs", "test": "node --test tools/repo-cli/test/**/*.test.mjs", "typecheck": "tsc --noEmit --project tsconfig.json" }, diff --git a/tools/repo-cli/README.md b/tools/repo-cli/README.md index 99f744f5..cee47118 100644 --- a/tools/repo-cli/README.md +++ b/tools/repo-cli/README.md @@ -6,3 +6,8 @@ Cross-platform repository checks for Windows and CI. client-to-service implementation imports, cross-feature persistence imports, and workspace packages without public `exports` declarations or imports of their private subpaths. Root `pnpm lint` runs this checker after ESLint. + +`pnpm requirements:generate` reads the normative Markdown requirement tables in +`docs/specs/foundation`, `docs/specs/features`, and `docs/specs/platforms` and writes +`docs/specs/requirement-index.json`. `pnpm requirements:check` verifies that the +committed index has no drift; root `pnpm repo:check` includes that verification. diff --git a/tools/repo-cli/src/generate-requirement-index.mjs b/tools/repo-cli/src/generate-requirement-index.mjs new file mode 100644 index 00000000..ea1b62ec --- /dev/null +++ b/tools/repo-cli/src/generate-requirement-index.mjs @@ -0,0 +1,239 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const requirementHeader = ['ID', 'Priority', 'Requirement']; +const requirementIdPattern = /^([A-Z][A-Z0-9]*)-(\d{3})$/; +const normativeDirectories = ['foundation', 'features', 'platforms']; + +function parseOptions(argumentsList) { + const options = { + check: false, + output: undefined, + root: path.resolve(import.meta.dirname, '..', '..', '..'), + }; + + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + if (argument === '--check') { + options.check = true; + } else if (argument === '--root' || argument === '--output') { + const value = argumentsList[index + 1]; + if (value === undefined) { + throw new Error(`The ${argument} option requires a value.`); + } + options[argument.slice(2)] = path.resolve(value); + index += 1; + } else { + throw new Error(`Unknown option: ${argument}`); + } + } + + if (options.output === undefined) { + options.output = path.join(options.root, 'docs', 'specs', 'requirement-index.json'); + } + + return options; +} + +function listMarkdownFiles(directory) { + if (!existsSync(directory)) { + return []; + } + + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return listMarkdownFiles(entryPath); + } + return entry.isFile() && path.extname(entry.name) === '.md' ? [entryPath] : []; + }) + .sort((left, right) => left.localeCompare(right)); +} + +function isNormativeDocument(filePath) { + const fileName = path.basename(filePath); + return ( + fileName !== 'README.md' && fileName !== 'spec-template.md' && !fileName.endsWith('.index.md') + ); +} + +function sourcePath(repositoryRoot, filePath) { + return path.relative(repositoryRoot, filePath).split(path.sep).join('/'); +} + +function tableCells(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) { + return undefined; + } + return trimmed + .slice(1, -1) + .split('|') + .map((cell) => cell.trim()); +} + +function isRequirementHeader(cells) { + return ( + cells !== undefined && + cells.length === requirementHeader.length && + cells.every((cell, index) => cell === requirementHeader[index]) + ); +} + +function parseRequirements(repositoryRoot, filePath) { + const requirements = []; + const diagnostics = []; + const relativeFilePath = sourcePath(repositoryRoot, filePath); + const lines = readFileSync(filePath, 'utf8').split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + if (!isRequirementHeader(tableCells(lines[index]))) { + continue; + } + + index += 1; + while (index + 1 < lines.length && lines[index + 1].trim().startsWith('|')) { + index += 1; + const lineNumber = index + 1; + const cells = tableCells(lines[index]); + if (cells === undefined || cells.length !== 3 || cells.some((cell) => cell === '')) { + diagnostics.push( + `${relativeFilePath}:${lineNumber}: malformed requirement row; expected | ID | Priority | Requirement |`, + ); + continue; + } + + const [id, priority, requirement] = cells; + const idMatch = requirementIdPattern.exec(id); + if (idMatch === null) { + diagnostics.push( + `${relativeFilePath}:${lineNumber}: malformed requirement ID ${id}; expected PREFIX-NNN`, + ); + continue; + } + if (!['P0', 'P1', 'P2'].includes(priority)) { + diagnostics.push( + `${relativeFilePath}:${lineNumber}: malformed priority ${priority} for requirement ${id}; expected P0, P1, or P2`, + ); + continue; + } + + requirements.push({ + id, + prefix: idMatch[1], + priority, + requirement, + sequence: Number(idMatch[2]), + source: { line: lineNumber, path: relativeFilePath }, + }); + } + } + + return { diagnostics, requirements }; +} + +function compareSource(left, right) { + return left.source.path.localeCompare(right.source.path) || left.source.line - right.source.line; +} + +function validationDiagnostics(requirements) { + const diagnostics = []; + const requirementsById = new Map(); + + for (const requirement of requirements) { + const first = requirementsById.get(requirement.id); + if (first === undefined) { + requirementsById.set(requirement.id, requirement); + } else { + diagnostics.push( + `${requirement.source.path}:${requirement.source.line}: duplicate requirement ID ${requirement.id}; first declared at ${first.source.path}:${first.source.line}`, + ); + } + } + + const byPrefix = new Map(); + for (const requirement of requirementsById.values()) { + const entries = byPrefix.get(requirement.prefix) ?? []; + entries.push(requirement); + byPrefix.set(requirement.prefix, entries); + } + for (const [prefix, entries] of [...byPrefix.entries()].sort(([left], [right]) => + left.localeCompare(right), + )) { + let expectedSequence = 1; + for (const requirement of entries.sort( + (left, right) => left.sequence - right.sequence || compareSource(left, right), + )) { + if (requirement.sequence !== expectedSequence) { + diagnostics.push( + `${requirement.source.path}:${requirement.source.line}: prefix ${prefix} has a gap: expected ${prefix}-${String(expectedSequence).padStart(3, '0')} before ${requirement.id}`, + ); + expectedSequence = requirement.sequence + 1; + } else { + expectedSequence += 1; + } + } + } + + return diagnostics.sort((left, right) => left.localeCompare(right)); +} + +function buildIndex(repositoryRoot) { + const files = normativeDirectories + .flatMap((directory) => + listMarkdownFiles(path.join(repositoryRoot, 'docs', 'specs', directory)), + ) + .filter(isNormativeDocument); + const parsed = files.map((filePath) => parseRequirements(repositoryRoot, filePath)); + const diagnostics = [ + ...parsed.flatMap(({ diagnostics: documentDiagnostics }) => documentDiagnostics), + ...validationDiagnostics(parsed.flatMap(({ requirements }) => requirements)), + ].sort((left, right) => left.localeCompare(right)); + + if (diagnostics.length > 0) { + return { diagnostics }; + } + + return { + index: { + requirements: parsed + .flatMap(({ requirements }) => requirements) + .sort((left, right) => left.id.localeCompare(right.id)) + .map(({ id, priority, requirement, source }) => ({ id, priority, requirement, source })), + version: 1, + }, + }; +} + +function run(argumentsList) { + const options = parseOptions(argumentsList); + const result = buildIndex(options.root); + if (result.diagnostics !== undefined) { + process.stderr.write(`${result.diagnostics.join('\n')}\n`); + return 1; + } + + const contents = `${JSON.stringify(result.index, null, 2)}\n`; + if (options.check) { + if (!existsSync(options.output) || readFileSync(options.output, 'utf8') !== contents) { + process.stderr.write( + `requirement index drift: ${options.output} is missing or differs; run node tools/repo-cli/src/generate-requirement-index.mjs --output ${options.output}\n`, + ); + return 1; + } + return 0; + } + + mkdirSync(path.dirname(options.output), { recursive: true }); + writeFileSync(options.output, contents); + return 0; +} + +try { + process.exitCode = run(process.argv.slice(2)); +} catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; +} diff --git a/tools/repo-cli/test/fixtures/requirement-traceability/duplicate/docs/specs/features/duplicate.md b/tools/repo-cli/test/fixtures/requirement-traceability/duplicate/docs/specs/features/duplicate.md new file mode 100644 index 00000000..a0b6861c --- /dev/null +++ b/tools/repo-cli/test/fixtures/requirement-traceability/duplicate/docs/specs/features/duplicate.md @@ -0,0 +1,5 @@ +# Duplicate + +| ID | Priority | Requirement | +| --- | --- | --- | +| FOO-001 | P1 | This ID must be unique. | diff --git a/tools/repo-cli/test/fixtures/requirement-traceability/duplicate/docs/specs/foundation/foo.md b/tools/repo-cli/test/fixtures/requirement-traceability/duplicate/docs/specs/foundation/foo.md new file mode 100644 index 00000000..36f419b8 --- /dev/null +++ b/tools/repo-cli/test/fixtures/requirement-traceability/duplicate/docs/specs/foundation/foo.md @@ -0,0 +1,5 @@ +# Foo + +| ID | Priority | Requirement | +| --- | --- | --- | +| FOO-001 | P0 | Foo starts with a stable requirement. | diff --git a/tools/repo-cli/test/fixtures/requirement-traceability/gap/docs/specs/foundation/foo.md b/tools/repo-cli/test/fixtures/requirement-traceability/gap/docs/specs/foundation/foo.md new file mode 100644 index 00000000..17b4befc --- /dev/null +++ b/tools/repo-cli/test/fixtures/requirement-traceability/gap/docs/specs/foundation/foo.md @@ -0,0 +1,6 @@ +# Foo + +| ID | Priority | Requirement | +| --- | --- | --- | +| FOO-001 | P0 | Foo starts with a stable requirement. | +| FOO-003 | P1 | Foo skips a number. | diff --git a/tools/repo-cli/test/fixtures/requirement-traceability/malformed/docs/specs/foundation/foo.md b/tools/repo-cli/test/fixtures/requirement-traceability/malformed/docs/specs/foundation/foo.md new file mode 100644 index 00000000..9e8012f5 --- /dev/null +++ b/tools/repo-cli/test/fixtures/requirement-traceability/malformed/docs/specs/foundation/foo.md @@ -0,0 +1,6 @@ +# Foo + +| ID | Priority | Requirement | +| --- | --- | --- | +| FOO-001 | P3 | Foo uses an invalid priority. | +| FOO-002 | P1 | diff --git a/tools/repo-cli/test/fixtures/requirement-traceability/valid/docs/specs/features/bar.md b/tools/repo-cli/test/fixtures/requirement-traceability/valid/docs/specs/features/bar.md new file mode 100644 index 00000000..59bc005e --- /dev/null +++ b/tools/repo-cli/test/fixtures/requirement-traceability/valid/docs/specs/features/bar.md @@ -0,0 +1,5 @@ +# Bar + +| ID | Priority | Requirement | +| --- | --- | --- | +| BAR-001 | P1 | Bar keeps its first requirement. | diff --git a/tools/repo-cli/test/fixtures/requirement-traceability/valid/docs/specs/foundation/foo.md b/tools/repo-cli/test/fixtures/requirement-traceability/valid/docs/specs/foundation/foo.md new file mode 100644 index 00000000..75f17b32 --- /dev/null +++ b/tools/repo-cli/test/fixtures/requirement-traceability/valid/docs/specs/foundation/foo.md @@ -0,0 +1,6 @@ +# Foo + +| ID | Priority | Requirement | +| --- | --- | --- | +| FOO-001 | P0 | Foo starts with a stable requirement. | +| FOO-002 | P2 | Foo keeps sequential identifiers. | diff --git a/tools/repo-cli/test/requirement-traceability.test.mjs b/tools/repo-cli/test/requirement-traceability.test.mjs new file mode 100644 index 00000000..8099fadb --- /dev/null +++ b/tools/repo-cli/test/requirement-traceability.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const indexerPath = path.join(testDirectory, '..', 'src', 'generate-requirement-index.mjs'); +const fixturesDirectory = path.join(testDirectory, 'fixtures', 'requirement-traceability'); + +function runIndexer(fixtureName, extraArguments = []) { + const outputDirectory = mkdtempSync(path.join(os.tmpdir(), 'databreeze-requirements-')); + const outputPath = path.join(outputDirectory, 'requirements-index.json'); + const result = spawnSync( + process.execPath, + [ + indexerPath, + '--root', + path.join(fixturesDirectory, fixtureName), + '--output', + outputPath, + ...extraArguments, + ], + { encoding: 'utf8' }, + ); + + return { + ...result, + outputPath, + cleanup() { + rmSync(outputDirectory, { force: true, recursive: true }); + }, + }; +} + +test('generates a deterministic index with requirement text and source metadata', () => { + const first = runIndexer('valid'); + const second = runIndexer('valid'); + + try { + assert.equal(first.status, 0, first.stderr); + assert.equal(second.status, 0, second.stderr); + assert.equal(readFileSync(first.outputPath, 'utf8'), readFileSync(second.outputPath, 'utf8')); + assert.deepEqual(JSON.parse(readFileSync(first.outputPath, 'utf8')), { + requirements: [ + { + id: 'BAR-001', + priority: 'P1', + requirement: 'Bar keeps its first requirement.', + source: { line: 5, path: 'docs/specs/features/bar.md' }, + }, + { + id: 'FOO-001', + priority: 'P0', + requirement: 'Foo starts with a stable requirement.', + source: { line: 5, path: 'docs/specs/foundation/foo.md' }, + }, + { + id: 'FOO-002', + priority: 'P2', + requirement: 'Foo keeps sequential identifiers.', + source: { line: 6, path: 'docs/specs/foundation/foo.md' }, + }, + ], + version: 1, + }); + } finally { + first.cleanup(); + second.cleanup(); + } +}); + +test('rejects duplicate requirement IDs with deterministic source diagnostics', () => { + const result = runIndexer('duplicate'); + + try { + assert.equal(result.status, 1); + assert.equal( + result.stderr, + 'docs/specs/features/duplicate.md:5: duplicate requirement ID FOO-001; first declared at docs/specs/foundation/foo.md:5\n', + ); + } finally { + result.cleanup(); + } +}); + +test('rejects gaps in a requirement prefix', () => { + const result = runIndexer('gap'); + + try { + assert.equal(result.status, 1); + assert.equal( + result.stderr, + 'docs/specs/foundation/foo.md:6: prefix FOO has a gap: expected FOO-002 before FOO-003\n', + ); + } finally { + result.cleanup(); + } +}); + +test('rejects malformed priorities and malformed requirement rows', () => { + const result = runIndexer('malformed'); + + try { + assert.equal(result.status, 1); + assert.equal( + result.stderr, + 'docs/specs/foundation/foo.md:5: malformed priority P3 for requirement FOO-001; expected P0, P1, or P2\n' + + 'docs/specs/foundation/foo.md:6: malformed requirement row; expected | ID | Priority | Requirement |\n', + ); + } finally { + result.cleanup(); + } +}); + +test('reports output drift without rewriting the checked index', () => { + const result = runIndexer('valid', ['--check']); + + try { + assert.equal(result.status, 1); + assert.equal( + result.stderr, + `requirement index drift: ${result.outputPath} is missing or differs; run node tools/repo-cli/src/generate-requirement-index.mjs --output ${result.outputPath}\n`, + ); + } finally { + result.cleanup(); + } +}); From be5dd0d365333daa287406d81536672f31ce0afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 02:39:07 +0700 Subject: [PATCH 07/51] fix(traceability): validate requirement table separators --- .../src/generate-requirement-index.mjs | 14 +++++++ .../docs/specs/foundation/foo.md | 4 ++ .../malformed/docs/specs/foundation/foo.md | 1 + .../test/requirement-traceability.test.mjs | 40 +++++++++++++++++-- 4 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 tools/repo-cli/test/fixtures/requirement-traceability/malformed-separator/docs/specs/foundation/foo.md diff --git a/tools/repo-cli/src/generate-requirement-index.mjs b/tools/repo-cli/src/generate-requirement-index.mjs index ea1b62ec..665db3b0 100644 --- a/tools/repo-cli/src/generate-requirement-index.mjs +++ b/tools/repo-cli/src/generate-requirement-index.mjs @@ -82,6 +82,12 @@ function isRequirementHeader(cells) { ); } +function isTableSeparator(cells) { + return ( + cells !== undefined && cells.length === 3 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)) + ); +} + function parseRequirements(repositoryRoot, filePath) { const requirements = []; const diagnostics = []; @@ -93,6 +99,14 @@ function parseRequirements(repositoryRoot, filePath) { continue; } + const separatorLineNumber = index + 2; + if (!isTableSeparator(tableCells(lines[index + 1] ?? ''))) { + diagnostics.push( + `${relativeFilePath}:${separatorLineNumber}: malformed requirement table separator; expected | --- | --- | --- |`, + ); + continue; + } + index += 1; while (index + 1 < lines.length && lines[index + 1].trim().startsWith('|')) { index += 1; diff --git a/tools/repo-cli/test/fixtures/requirement-traceability/malformed-separator/docs/specs/foundation/foo.md b/tools/repo-cli/test/fixtures/requirement-traceability/malformed-separator/docs/specs/foundation/foo.md new file mode 100644 index 00000000..40d906d3 --- /dev/null +++ b/tools/repo-cli/test/fixtures/requirement-traceability/malformed-separator/docs/specs/foundation/foo.md @@ -0,0 +1,4 @@ +# Foo + +| ID | Priority | Requirement | +| FOO-001 | P0 | Foo must not be skipped when the separator is missing. | diff --git a/tools/repo-cli/test/fixtures/requirement-traceability/malformed/docs/specs/foundation/foo.md b/tools/repo-cli/test/fixtures/requirement-traceability/malformed/docs/specs/foundation/foo.md index 9e8012f5..3291f6b1 100644 --- a/tools/repo-cli/test/fixtures/requirement-traceability/malformed/docs/specs/foundation/foo.md +++ b/tools/repo-cli/test/fixtures/requirement-traceability/malformed/docs/specs/foundation/foo.md @@ -4,3 +4,4 @@ | --- | --- | --- | | FOO-001 | P3 | Foo uses an invalid priority. | | FOO-002 | P1 | +| FOO-01 | P1 | Foo uses a malformed identifier. | diff --git a/tools/repo-cli/test/requirement-traceability.test.mjs b/tools/repo-cli/test/requirement-traceability.test.mjs index 8099fadb..a2353925 100644 --- a/tools/repo-cli/test/requirement-traceability.test.mjs +++ b/tools/repo-cli/test/requirement-traceability.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; @@ -10,9 +10,10 @@ const testDirectory = path.dirname(fileURLToPath(import.meta.url)); const indexerPath = path.join(testDirectory, '..', 'src', 'generate-requirement-index.mjs'); const fixturesDirectory = path.join(testDirectory, 'fixtures', 'requirement-traceability'); -function runIndexer(fixtureName, extraArguments = []) { +function runIndexer(fixtureName, extraArguments = [], prepareOutput) { const outputDirectory = mkdtempSync(path.join(os.tmpdir(), 'databreeze-requirements-')); const outputPath = path.join(outputDirectory, 'requirements-index.json'); + prepareOutput?.(outputPath); const result = spawnSync( process.execPath, [ @@ -100,6 +101,20 @@ test('rejects gaps in a requirement prefix', () => { } }); +test('rejects a requirement table with a missing separator before any row is skipped', () => { + const result = runIndexer('malformed-separator'); + + try { + assert.equal(result.status, 1); + assert.equal( + result.stderr, + 'docs/specs/foundation/foo.md:4: malformed requirement table separator; expected | --- | --- | --- |\n', + ); + } finally { + result.cleanup(); + } +}); + test('rejects malformed priorities and malformed requirement rows', () => { const result = runIndexer('malformed'); @@ -108,7 +123,8 @@ test('rejects malformed priorities and malformed requirement rows', () => { assert.equal( result.stderr, 'docs/specs/foundation/foo.md:5: malformed priority P3 for requirement FOO-001; expected P0, P1, or P2\n' + - 'docs/specs/foundation/foo.md:6: malformed requirement row; expected | ID | Priority | Requirement |\n', + 'docs/specs/foundation/foo.md:6: malformed requirement row; expected | ID | Priority | Requirement |\n' + + 'docs/specs/foundation/foo.md:7: malformed requirement ID FOO-01; expected PREFIX-NNN\n', ); } finally { result.cleanup(); @@ -128,3 +144,21 @@ test('reports output drift without rewriting the checked index', () => { result.cleanup(); } }); + +test('reports stale index drift without rewriting the existing bytes', () => { + const staleContents = '{\n "version": 0\n}\n'; + const result = runIndexer('valid', ['--check'], (outputPath) => { + writeFileSync(outputPath, staleContents); + }); + + try { + assert.equal(result.status, 1); + assert.equal( + result.stderr, + `requirement index drift: ${result.outputPath} is missing or differs; run node tools/repo-cli/src/generate-requirement-index.mjs --output ${result.outputPath}\n`, + ); + assert.equal(readFileSync(result.outputPath, 'utf8'), staleContents); + } finally { + result.cleanup(); + } +}); From 9831109b59d7a82dcaa92adf586ed417a8a68bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 02:51:18 +0700 Subject: [PATCH 08/51] feat(contracts): define shared protocol envelopes --- package.json | 2 +- packages/contracts/README.md | 21 ++ packages/contracts/manifest.json | 56 ++++ packages/contracts/package.json | 27 ++ .../schemas/v1/actor-metadata.schema.json | 19 ++ .../schemas/v1/command-envelope.schema.json | 53 ++++ .../v1/correlation-metadata.schema.json | 21 ++ .../schemas/v1/cursor-page.schema.json | 57 +++++ .../schemas/v1/event-envelope.schema.json | 70 +++++ .../schemas/v1/identifier.schema.json | 9 + .../schemas/v1/problem-details.schema.json | 71 +++++ .../contracts/schemas/v1/revision.schema.json | 9 + .../schemas/v1/tenant-scope.schema.json | 68 +++++ .../schemas/v1/utc-timestamp.schema.json | 10 + packages/contracts/scripts/build.mjs | 27 ++ packages/contracts/test/schemas.test.mjs | 242 ++++++++++++++++++ packages/contracts/turbo.json | 13 + pnpm-lock.yaml | 47 ++++ 18 files changed, 821 insertions(+), 1 deletion(-) create mode 100644 packages/contracts/manifest.json create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/schemas/v1/actor-metadata.schema.json create mode 100644 packages/contracts/schemas/v1/command-envelope.schema.json create mode 100644 packages/contracts/schemas/v1/correlation-metadata.schema.json create mode 100644 packages/contracts/schemas/v1/cursor-page.schema.json create mode 100644 packages/contracts/schemas/v1/event-envelope.schema.json create mode 100644 packages/contracts/schemas/v1/identifier.schema.json create mode 100644 packages/contracts/schemas/v1/problem-details.schema.json create mode 100644 packages/contracts/schemas/v1/revision.schema.json create mode 100644 packages/contracts/schemas/v1/tenant-scope.schema.json create mode 100644 packages/contracts/schemas/v1/utc-timestamp.schema.json create mode 100644 packages/contracts/scripts/build.mjs create mode 100644 packages/contracts/test/schemas.test.mjs create mode 100644 packages/contracts/turbo.json diff --git a/package.json b/package.json index 312d917e..386686e3 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "repo:test": "corepack pnpm test", "requirements:check": "node tools/repo-cli/src/generate-requirement-index.mjs --check", "requirements:generate": "node tools/repo-cli/src/generate-requirement-index.mjs", - "test": "node --test tools/repo-cli/test/**/*.test.mjs", + "test": "node --test tools/repo-cli/test/**/*.test.mjs && turbo run test", "typecheck": "tsc --noEmit --project tsconfig.json" }, "devDependencies": { diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 65d19505..36bd1974 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -1,3 +1,24 @@ # Contracts Canonical OpenAPI, JSON Schema, event, typed-job, and compatibility definitions used to generate TypeScript, Kotlin, and Pydantic models. + +## Public interfaces + +- `manifest.json` is the deterministic registry for canonical source schemas. +- `schemas/v1/*.schema.json` contains closed JSON Schema 2020-12 definitions with stable absolute IDs and references. +- Consumers import only the entry points declared in `package.json#exports`. + +The v1 base schemas provide UUID identifiers and UTC timestamps (IAM-001), complete tenant ancestry (IAM-019), correlation and actor metadata (AUD-004), RFC-compatible public problems (INT-021), idempotent commands (INT-004), cursor pages (INT-005), and canonical events (AUD-004, AUD-006, and INT-008). This is partial foundation coverage; it does not implement those requirements' persistence or runtime behavior. + +## Local commands + +```text +corepack pnpm --filter @databreeze/contracts test +corepack pnpm --filter @databreeze/contracts build +``` + +`test` compiles the real schemas with Ajv's JSON Schema 2020-12 validator and exercises hand-authored protocol payloads. `build` compiles every manifest entry without generating language models; cross-language generation belongs to Task 5. + +## Forbidden dependencies + +This package contains protocol definitions only. It must not import application or service implementations, persistence adapters, framework code, or generated consumer models. diff --git a/packages/contracts/manifest.json b/packages/contracts/manifest.json new file mode 100644 index 00000000..e76ffe63 --- /dev/null +++ b/packages/contracts/manifest.json @@ -0,0 +1,56 @@ +{ + "draft": "https://json-schema.org/draft/2020-12/schema", + "version": 1, + "schemas": [ + { + "name": "actor-metadata", + "id": "https://schemas.databreeze.dev/contracts/v1/actor-metadata", + "path": "schemas/v1/actor-metadata.schema.json" + }, + { + "name": "command-envelope", + "id": "https://schemas.databreeze.dev/contracts/v1/command-envelope", + "path": "schemas/v1/command-envelope.schema.json" + }, + { + "name": "correlation-metadata", + "id": "https://schemas.databreeze.dev/contracts/v1/correlation-metadata", + "path": "schemas/v1/correlation-metadata.schema.json" + }, + { + "name": "cursor-page", + "id": "https://schemas.databreeze.dev/contracts/v1/cursor-page", + "path": "schemas/v1/cursor-page.schema.json" + }, + { + "name": "event-envelope", + "id": "https://schemas.databreeze.dev/contracts/v1/event-envelope", + "path": "schemas/v1/event-envelope.schema.json" + }, + { + "name": "identifier", + "id": "https://schemas.databreeze.dev/contracts/v1/identifier", + "path": "schemas/v1/identifier.schema.json" + }, + { + "name": "problem-details", + "id": "https://schemas.databreeze.dev/contracts/v1/problem-details", + "path": "schemas/v1/problem-details.schema.json" + }, + { + "name": "revision", + "id": "https://schemas.databreeze.dev/contracts/v1/revision", + "path": "schemas/v1/revision.schema.json" + }, + { + "name": "tenant-scope", + "id": "https://schemas.databreeze.dev/contracts/v1/tenant-scope", + "path": "schemas/v1/tenant-scope.schema.json" + }, + { + "name": "utc-timestamp", + "id": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp", + "path": "schemas/v1/utc-timestamp.schema.json" + } + ] +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 00000000..40e490e1 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,27 @@ +{ + "name": "@databreeze/contracts", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./manifest.json", + "./v1/actor-metadata": "./schemas/v1/actor-metadata.schema.json", + "./v1/command-envelope": "./schemas/v1/command-envelope.schema.json", + "./v1/correlation-metadata": "./schemas/v1/correlation-metadata.schema.json", + "./v1/cursor-page": "./schemas/v1/cursor-page.schema.json", + "./v1/event-envelope": "./schemas/v1/event-envelope.schema.json", + "./v1/identifier": "./schemas/v1/identifier.schema.json", + "./v1/problem-details": "./schemas/v1/problem-details.schema.json", + "./v1/revision": "./schemas/v1/revision.schema.json", + "./v1/tenant-scope": "./schemas/v1/tenant-scope.schema.json", + "./v1/utc-timestamp": "./schemas/v1/utc-timestamp.schema.json" + }, + "scripts": { + "build": "node scripts/build.mjs", + "test": "node --test test/**/*.test.mjs" + }, + "devDependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1" + } +} diff --git a/packages/contracts/schemas/v1/actor-metadata.schema.json b/packages/contracts/schemas/v1/actor-metadata.schema.json new file mode 100644 index 00000000..00848e63 --- /dev/null +++ b/packages/contracts/schemas/v1/actor-metadata.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/actor-metadata", + "$comment": "Shared actor identity metadata used by commands and events; supports AUD-004.", + "title": "Actor Metadata", + "description": "The stable type and identifier of the principal responsible for an action.", + "type": "object", + "additionalProperties": false, + "required": ["actorType", "actorId"], + "properties": { + "actorType": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,62}$" + }, + "actorId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + } + } +} diff --git a/packages/contracts/schemas/v1/command-envelope.schema.json b/packages/contracts/schemas/v1/command-envelope.schema.json new file mode 100644 index 00000000..63d8cf16 --- /dev/null +++ b/packages/contracts/schemas/v1/command-envelope.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/command-envelope", + "$comment": "Partial foundation coverage for INT-004 and IAM-019.", + "title": "Idempotent Command Envelope", + "description": "The shared closed envelope for an idempotent, tenant-scoped command.", + "type": "object", + "additionalProperties": false, + "required": [ + "commandId", + "commandType", + "schemaVersion", + "tenantScope", + "actor", + "correlation", + "issuedAt", + "idempotencyKey", + "data" + ], + "properties": { + "commandId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + }, + "commandType": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)+$" + }, + "schemaVersion": { + "type": "integer", + "minimum": 1 + }, + "tenantScope": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/tenant-scope" + }, + "actor": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/actor-metadata" + }, + "correlation": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/correlation-metadata" + }, + "issuedAt": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "data": { + "type": "object" + } + } +} diff --git a/packages/contracts/schemas/v1/correlation-metadata.schema.json b/packages/contracts/schemas/v1/correlation-metadata.schema.json new file mode 100644 index 00000000..36b260f7 --- /dev/null +++ b/packages/contracts/schemas/v1/correlation-metadata.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/correlation-metadata", + "$comment": "Partial foundation coverage for AUD-004 and INT-021.", + "title": "Correlation Metadata", + "description": "Content-safe identifiers used to join a request or event chain.", + "type": "object", + "additionalProperties": false, + "required": ["correlationId"], + "properties": { + "correlationId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + }, + "causationId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + }, + "requestId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + } + } +} diff --git a/packages/contracts/schemas/v1/cursor-page.schema.json b/packages/contracts/schemas/v1/cursor-page.schema.json new file mode 100644 index 00000000..585cf253 --- /dev/null +++ b/packages/contracts/schemas/v1/cursor-page.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/cursor-page", + "$comment": "Shared pagination shape supporting INT-005.", + "title": "Cursor Page Envelope", + "description": "A closed list envelope with an opaque continuation cursor.", + "type": "object", + "additionalProperties": false, + "required": ["items", "pageInfo"], + "properties": { + "items": { + "type": "array", + "items": {} + }, + "pageInfo": { + "type": "object", + "additionalProperties": false, + "required": ["hasNextPage", "nextCursor"], + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "nextCursor": { + "type": ["string", "null"] + } + }, + "allOf": [ + { + "if": { + "properties": { + "hasNextPage": { + "const": true + } + }, + "required": ["hasNextPage"] + }, + "then": { + "properties": { + "nextCursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + } + }, + "else": { + "properties": { + "nextCursor": { + "type": "null" + } + } + } + } + ] + } + } +} diff --git a/packages/contracts/schemas/v1/event-envelope.schema.json b/packages/contracts/schemas/v1/event-envelope.schema.json new file mode 100644 index 00000000..79cb0e8b --- /dev/null +++ b/packages/contracts/schemas/v1/event-envelope.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/event-envelope", + "$comment": "Canonical event base supporting AUD-004, AUD-006, IAM-019, and INT-008.", + "title": "Canonical Event Envelope", + "description": "The shared closed envelope for a versioned, tenant-scoped domain event.", + "type": "object", + "additionalProperties": false, + "required": [ + "eventId", + "eventType", + "schemaVersion", + "tenantScope", + "entity", + "actor", + "correlation", + "sourceComponent", + "occurredAt", + "data" + ], + "properties": { + "eventId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + }, + "eventType": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)+$" + }, + "schemaVersion": { + "type": "integer", + "minimum": 1 + }, + "tenantScope": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/tenant-scope" + }, + "entity": { + "type": "object", + "additionalProperties": false, + "required": ["entityType", "entityId", "revision"], + "properties": { + "entityType": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,62}$" + }, + "entityId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + }, + "revision": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/revision" + } + } + }, + "actor": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/actor-metadata" + }, + "correlation": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/correlation-metadata" + }, + "sourceComponent": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,62}$" + }, + "occurredAt": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" + }, + "data": { + "type": "object" + } + } +} diff --git a/packages/contracts/schemas/v1/identifier.schema.json b/packages/contracts/schemas/v1/identifier.schema.json new file mode 100644 index 00000000..aee9f95e --- /dev/null +++ b/packages/contracts/schemas/v1/identifier.schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/identifier", + "$comment": "Partial foundation coverage for IAM-001.", + "title": "Stable UUID Identifier", + "description": "An opaque stable UUID identifier.", + "type": "string", + "format": "uuid" +} diff --git a/packages/contracts/schemas/v1/problem-details.schema.json b/packages/contracts/schemas/v1/problem-details.schema.json new file mode 100644 index 00000000..601dae5b --- /dev/null +++ b/packages/contracts/schemas/v1/problem-details.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/problem-details", + "$comment": "RFC 7807-compatible base with the safe public error metadata required by INT-021 and WEB-021.", + "title": "Problem Details", + "description": "A closed RFC 7807-compatible problem document with DataBreeze public error extensions.", + "type": "object", + "additionalProperties": false, + "required": ["type", "title", "status", "code", "correlationId", "retryable", "messageKey"], + "properties": { + "type": { + "type": "string", + "format": "uri-reference" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "integer", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string", + "format": "uri-reference" + }, + "code": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,127}$" + }, + "correlationId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + }, + "retryable": { + "type": "boolean" + }, + "messageKey": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "fieldErrors": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["field", "code"], + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "code": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,127}$" + } + } + } + }, + "retryAfterSeconds": { + "type": "integer", + "minimum": 0 + } + } +} diff --git a/packages/contracts/schemas/v1/revision.schema.json b/packages/contracts/schemas/v1/revision.schema.json new file mode 100644 index 00000000..54ce1ab3 --- /dev/null +++ b/packages/contracts/schemas/v1/revision.schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/revision", + "$comment": "Supports optimistic-concurrency revisions described by the domain and data model.", + "title": "Entity Revision", + "description": "A positive, monotonically increasing entity revision.", + "type": "integer", + "minimum": 1 +} diff --git a/packages/contracts/schemas/v1/tenant-scope.schema.json b/packages/contracts/schemas/v1/tenant-scope.schema.json new file mode 100644 index 00000000..ecb25b97 --- /dev/null +++ b/packages/contracts/schemas/v1/tenant-scope.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/tenant-scope", + "$comment": "Partial foundation coverage for IAM-019.", + "title": "Tenant Scope", + "description": "A discriminated tenant scope containing the complete ancestry required at its level.", + "oneOf": [ + { + "$ref": "#/$defs/organizationScope" + }, + { + "$ref": "#/$defs/workspaceScope" + }, + { + "$ref": "#/$defs/projectScope" + } + ], + "$defs": { + "organizationScope": { + "type": "object", + "additionalProperties": false, + "required": ["scopeType", "organizationId"], + "properties": { + "scopeType": { + "const": "organization" + }, + "organizationId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + } + } + }, + "workspaceScope": { + "type": "object", + "additionalProperties": false, + "required": ["scopeType", "organizationId", "workspaceId"], + "properties": { + "scopeType": { + "const": "workspace" + }, + "organizationId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + }, + "workspaceId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + } + } + }, + "projectScope": { + "type": "object", + "additionalProperties": false, + "required": ["scopeType", "organizationId", "workspaceId", "projectId"], + "properties": { + "scopeType": { + "const": "project" + }, + "organizationId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + }, + "workspaceId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + }, + "projectId": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" + } + } + } + } +} diff --git a/packages/contracts/schemas/v1/utc-timestamp.schema.json b/packages/contracts/schemas/v1/utc-timestamp.schema.json new file mode 100644 index 00000000..61ac477b --- /dev/null +++ b/packages/contracts/schemas/v1/utc-timestamp.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp", + "$comment": "Partial foundation coverage for IAM-001 and INT-008.", + "title": "UTC Timestamp", + "description": "An RFC 3339 date-time normalized to UTC and terminated by uppercase Z.", + "type": "string", + "format": "date-time", + "pattern": "Z$" +} diff --git a/packages/contracts/scripts/build.mjs b/packages/contracts/scripts/build.mjs new file mode 100644 index 00000000..d029818d --- /dev/null +++ b/packages/contracts/scripts/build.mjs @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const manifest = JSON.parse(readFileSync(resolve(packageRoot, 'manifest.json'), 'utf8')); +const ajv = new Ajv2020({ allErrors: true, strict: true }); +addFormats(ajv); + +for (const entry of manifest.schemas) { + const schema = JSON.parse(readFileSync(resolve(packageRoot, entry.path), 'utf8')); + if (schema.$id !== entry.id) { + throw new Error(`Manifest ID does not match ${entry.path}`); + } + ajv.addSchema(schema); +} + +for (const entry of manifest.schemas) { + if (!ajv.getSchema(entry.id)) { + throw new Error(`Schema did not compile: ${entry.id}`); + } +} + +console.log(`Compiled ${manifest.schemas.length} canonical JSON Schemas.`); diff --git a/packages/contracts/test/schemas.test.mjs b/packages/contracts/test/schemas.test.mjs new file mode 100644 index 00000000..8c28848f --- /dev/null +++ b/packages/contracts/test/schemas.test.mjs @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; + +// Partial foundation coverage: IAM-001, IAM-019, AUD-004, AUD-006, +// INT-004, INT-005, INT-008, and INT-021. +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const manifestPath = resolve(packageRoot, 'manifest.json'); +const schemaBase = 'https://schemas.databreeze.dev/contracts/v1'; + +const ids = { + actorMetadata: `${schemaBase}/actor-metadata`, + commandEnvelope: `${schemaBase}/command-envelope`, + correlationMetadata: `${schemaBase}/correlation-metadata`, + cursorPage: `${schemaBase}/cursor-page`, + eventEnvelope: `${schemaBase}/event-envelope`, + identifier: `${schemaBase}/identifier`, + problemDetails: `${schemaBase}/problem-details`, + revision: `${schemaBase}/revision`, + tenantScope: `${schemaBase}/tenant-scope`, + utcTimestamp: `${schemaBase}/utc-timestamp`, +}; + +const organizationId = '018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01'; +const workspaceId = '018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02'; +const projectId = '018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc03'; +const actorId = '018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc04'; +const correlationId = '018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05'; + +function loadContracts() { + assert.equal(existsSync(manifestPath), true, 'canonical schema manifest must exist'); + + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + const schemas = manifest.schemas.map((entry) => { + const schemaPath = resolve(packageRoot, entry.path); + assert.equal(existsSync(schemaPath), true, `schema source must exist: ${entry.path}`); + return JSON.parse(readFileSync(schemaPath, 'utf8')); + }); + + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addFormats(ajv); + for (const schema of schemas) { + ajv.addSchema(schema); + } + + return { ajv, manifest, schemas }; +} + +function validatorFor(id) { + const { ajv } = loadContracts(); + const validate = ajv.getSchema(id); + assert.ok(validate, `manifest must register ${id}`); + return validate; +} + +test('publishes the complete deterministic v1 registry and compiles every real schema', () => { + const { ajv, manifest, schemas } = loadContracts(); + const expectedNames = [ + 'actor-metadata', + 'command-envelope', + 'correlation-metadata', + 'cursor-page', + 'event-envelope', + 'identifier', + 'problem-details', + 'revision', + 'tenant-scope', + 'utc-timestamp', + ]; + + assert.equal(manifest.draft, 'https://json-schema.org/draft/2020-12/schema'); + assert.equal(manifest.version, 1); + assert.deepEqual( + manifest.schemas.map((entry) => entry.name), + expectedNames, + ); + assert.deepEqual( + manifest.schemas.map((entry) => entry.id), + expectedNames.map((name) => `${schemaBase}/${name}`), + ); + assert.deepEqual( + schemas.map((schema) => schema.$id), + manifest.schemas.map((entry) => entry.id), + ); + + for (const entry of manifest.schemas) { + assert.ok(ajv.getSchema(entry.id), `schema must compile: ${entry.name}`); + } +}); + +test('exports only declared registry and versioned schema entry points', () => { + const packageJson = JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf8')); + + assert.deepEqual(Object.keys(packageJson.exports), [ + '.', + './v1/actor-metadata', + './v1/command-envelope', + './v1/correlation-metadata', + './v1/cursor-page', + './v1/event-envelope', + './v1/identifier', + './v1/problem-details', + './v1/revision', + './v1/tenant-scope', + './v1/utc-timestamp', + ]); + for (const target of Object.values(packageJson.exports)) { + assert.equal( + existsSync(resolve(packageRoot, target)), + true, + `export target must exist: ${target}`, + ); + } +}); + +test('rejects a malformed UUID identifier', () => { + const validate = validatorFor(ids.identifier); + + assert.equal(validate('not-a-uuid'), false); + assert.equal(validate(organizationId), true); +}); + +test('rejects a timestamp that is not expressed with UTC Z', () => { + const validate = validatorFor(ids.utcTimestamp); + + assert.equal(validate('2026-08-01T08:30:00+07:00'), false); + assert.equal(validate('2026-08-01T01:30:00.125Z'), true); +}); + +test('accepts only positive entity revisions', () => { + const validate = validatorFor(ids.revision); + + assert.equal(validate(0), false); + assert.equal(validate(1), true); +}); + +test('accepts explicit organization, workspace, and project tenant ancestry', () => { + const validate = validatorFor(ids.tenantScope); + + assert.equal(validate({ scopeType: 'organization', organizationId }), true); + assert.equal(validate({ scopeType: 'workspace', organizationId, workspaceId }), true); + assert.equal(validate({ scopeType: 'project', organizationId, workspaceId, projectId }), true); +}); + +test('rejects incomplete or discriminator-mismatched tenant ancestry', () => { + const validate = validatorFor(ids.tenantScope); + + assert.equal(validate({ scopeType: 'project', organizationId, projectId }), false); + assert.equal(validate({ scopeType: 'workspace', organizationId, workspaceId, projectId }), false); +}); + +test('accepts closed correlation metadata and rejects undeclared context', () => { + const validate = validatorFor(ids.correlationMetadata); + + assert.equal(validate({ correlationId }), true); + assert.equal(validate({ correlationId, customerEmail: 'sensitive@example.test' }), false); +}); + +test('rejects RFC problem details with an invalid HTTP status', () => { + const validate = validatorFor(ids.problemDetails); + const problem = { + type: 'https://api.databreeze.dev/problems/validation-failed', + title: 'Request validation failed', + status: 422, + detail: 'One or more request fields are invalid.', + instance: '/requests/018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc09', + code: 'VALIDATION_FAILED', + correlationId, + retryable: false, + messageKey: 'errors.validationFailed', + fieldErrors: [{ field: 'name', code: 'REQUIRED' }], + }; + + assert.equal(validate(problem), true); + assert.equal(validate({ ...problem, status: 99 }), false); + assert.equal(validate({ ...problem, status: 600 }), false); +}); + +test('rejects a command envelope with no idempotency key', () => { + const validate = validatorFor(ids.commandEnvelope); + const command = { + commandId: '018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc06', + commandType: 'iam.workspace.rename', + schemaVersion: 1, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + actor: { actorType: 'user', actorId }, + correlation: { correlationId }, + issuedAt: '2026-08-01T01:30:00.125Z', + idempotencyKey: 'rename-workspace-018f47f2', + data: { displayName: 'Operations' }, + }; + + assert.equal(validate(command), true); + const withoutIdempotency = { ...command }; + delete withoutIdempotency.idempotencyKey; + assert.equal(validate(withoutIdempotency), false); +}); + +test('rejects invalid and internally inconsistent cursor page shapes', () => { + const validate = validatorFor(ids.cursorPage); + + assert.equal( + validate({ + items: [{ id: workspaceId }], + pageInfo: { hasNextPage: true, nextCursor: 'opaque-cursor' }, + }), + true, + ); + assert.equal(validate({ items: [], pageInfo: { hasNextPage: false, nextCursor: null } }), true); + assert.equal(validate({ items: [], pageInfo: { hasNextPage: true, nextCursor: null } }), false); + assert.equal(validate({ items: [], pageInfo: { hasNextPage: false, nextCursor: 42 } }), false); +}); + +test('requires event type and positive entity revision in the canonical event envelope', () => { + const validate = validatorFor(ids.eventEnvelope); + const event = { + eventId: '018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc07', + eventType: 'iam.workspace.renamed', + schemaVersion: 1, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + entity: { entityType: 'workspace', entityId: workspaceId, revision: 2 }, + actor: { actorType: 'user', actorId }, + correlation: { correlationId }, + sourceComponent: 'iam', + occurredAt: '2026-08-01T01:30:00.125Z', + data: { changedFields: ['displayName'] }, + }; + + assert.equal(validate(event), true); + const withoutEventType = { ...event }; + delete withoutEventType.eventType; + assert.equal(validate(withoutEventType), false); + assert.equal( + validate({ ...event, entity: { entityType: 'workspace', entityId: workspaceId } }), + false, + ); +}); diff --git a/packages/contracts/turbo.json b/packages/contracts/turbo.json new file mode 100644 index 00000000..7409b09e --- /dev/null +++ b/packages/contracts/turbo.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": [] + }, + "test": { + "dependsOn": ["^build"], + "outputs": [] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9743ac49..eac82865 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,15 @@ importers: specifier: 8.43.0 version: 8.43.0(eslint@9.36.0)(typescript@5.9.2) + packages/contracts: + devDependencies: + ajv: + specifier: 8.17.1 + version: 8.17.1 + ajv-formats: + specifier: 3.0.1 + version: 3.0.1(ajv@8.17.1) + packages: '@eslint-community/eslint-utils@4.10.1': @@ -174,9 +183,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -290,6 +310,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -372,6 +395,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -454,6 +480,10 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -739,6 +769,10 @@ snapshots: acorn@8.18.0: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -746,6 +780,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -877,6 +918,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.4: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -944,6 +987,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} keyv@4.5.4: @@ -1015,6 +1060,8 @@ snapshots: queue-microtask@1.2.3: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} reusify@1.1.0: {} From 648a47bf9100f9d523675b7d136b3ae4318efae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 03:00:50 +0700 Subject: [PATCH 09/51] fix(contracts): align pages and problems with specs --- .../schemas/v1/cursor-page.schema.json | 76 +++++------ .../schemas/v1/problem-details.schema.json | 52 +++++++- packages/contracts/test/schemas.test.mjs | 125 +++++++++++++++++- 3 files changed, 206 insertions(+), 47 deletions(-) diff --git a/packages/contracts/schemas/v1/cursor-page.schema.json b/packages/contracts/schemas/v1/cursor-page.schema.json index 585cf253..59c9ed72 100644 --- a/packages/contracts/schemas/v1/cursor-page.schema.json +++ b/packages/contracts/schemas/v1/cursor-page.schema.json @@ -3,55 +3,51 @@ "$id": "https://schemas.databreeze.dev/contracts/v1/cursor-page", "$comment": "Shared pagination shape supporting INT-005.", "title": "Cursor Page Envelope", - "description": "A closed list envelope with an opaque continuation cursor.", + "description": "The canonical closed page envelope with a UTC snapshot and opaque continuation cursor.", "type": "object", "additionalProperties": false, - "required": ["items", "pageInfo"], + "required": ["data", "snapshotAt", "hasMore"], "properties": { - "items": { + "data": { "type": "array", "items": {} }, - "pageInfo": { - "type": "object", - "additionalProperties": false, - "required": ["hasNextPage", "nextCursor"], - "properties": { - "hasNextPage": { - "type": "boolean" + "nextCursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "snapshotAt": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" + }, + "hasMore": { + "type": "boolean" + } + }, + "allOf": [ + { + "if": { + "properties": { + "hasMore": { + "const": true + } }, - "nextCursor": { - "type": ["string", "null"] - } + "required": ["hasMore"] }, - "allOf": [ - { - "if": { - "properties": { - "hasNextPage": { - "const": true - } - }, - "required": ["hasNextPage"] - }, - "then": { - "properties": { - "nextCursor": { - "type": "string", - "minLength": 1, - "maxLength": 4096 - } - } + "then": { + "properties": { + "nextCursor": true + }, + "required": ["nextCursor"] + }, + "else": { + "not": { + "properties": { + "nextCursor": true }, - "else": { - "properties": { - "nextCursor": { - "type": "null" - } - } - } + "required": ["nextCursor"] } - ] + } } - } + ] } diff --git a/packages/contracts/schemas/v1/problem-details.schema.json b/packages/contracts/schemas/v1/problem-details.schema.json index 601dae5b..9d921ef1 100644 --- a/packages/contracts/schemas/v1/problem-details.schema.json +++ b/packages/contracts/schemas/v1/problem-details.schema.json @@ -6,7 +6,21 @@ "description": "A closed RFC 7807-compatible problem document with DataBreeze public error extensions.", "type": "object", "additionalProperties": false, - "required": ["type", "title", "status", "code", "correlationId", "retryable", "messageKey"], + "required": ["type", "status", "code", "correlationId", "retryable"], + "anyOf": [ + { + "properties": { + "titleKey": true + }, + "required": ["titleKey"] + }, + { + "properties": { + "messageKey": true + }, + "required": ["messageKey"] + } + ], "properties": { "type": { "type": "string", @@ -16,6 +30,11 @@ "type": "string", "minLength": 1 }, + "titleKey": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, "status": { "type": "integer", "minimum": 100, @@ -66,6 +85,37 @@ "retryAfterSeconds": { "type": "integer", "minimum": 0 + }, + "currentRevision": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/revision" + }, + "remediationAction": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "rateLimit": { + "type": "object", + "additionalProperties": false, + "required": ["scope", "resetAt"], + "properties": { + "scope": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "limit": { + "type": "integer", + "minimum": 0 + }, + "remaining": { + "type": "integer", + "minimum": 0 + }, + "resetAt": { + "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" + } + } } } } diff --git a/packages/contracts/test/schemas.test.mjs b/packages/contracts/test/schemas.test.mjs index 8c28848f..00ecfa2f 100644 --- a/packages/contracts/test/schemas.test.mjs +++ b/packages/contracts/test/schemas.test.mjs @@ -181,6 +181,81 @@ test('rejects RFC problem details with an invalid HTTP status', () => { assert.equal(validate({ ...problem, status: 600 }), false); }); +test('accepts the documented Web problem shape with title localization and revision recovery', () => { + const validate = validatorFor(ids.problemDetails); + const webProblem = { + type: 'https://api.databreeze.dev/problems/revision-conflict', + titleKey: 'errors.revisionConflict.title', + status: 409, + code: 'REVISION_CONFLICT', + correlationId, + retryable: false, + currentRevision: 7, + remediationAction: 'refresh-and-retry', + }; + + assert.equal(validate(webProblem), true); +}); + +test('accepts the documented rate-limit problem shape with message localization', () => { + const validate = validatorFor(ids.problemDetails); + const rateLimitProblem = { + type: 'https://api.databreeze.dev/problems/rate-limit-exceeded', + messageKey: 'errors.rateLimitExceeded', + status: 429, + code: 'RATE_LIMIT_EXCEEDED', + correlationId, + retryable: true, + retryAfterSeconds: 30, + rateLimit: { + scope: 'principal', + limit: 100, + remaining: 0, + resetAt: '2026-08-01T01:31:00Z', + }, + }; + + assert.equal(validate(rateLimitProblem), true); +}); + +test('requires at least one problem localization key', () => { + const validate = validatorFor(ids.problemDetails); + const problem = { + type: 'https://api.databreeze.dev/problems/access-denied', + status: 403, + code: 'ACCESS_DENIED', + correlationId, + retryable: false, + }; + + assert.equal(validate(problem), false); +}); + +test('rejects unknown outer and nested problem fields', () => { + const validate = validatorFor(ids.problemDetails); + const problem = { + type: 'https://api.databreeze.dev/problems/access-denied', + status: 403, + code: 'ACCESS_DENIED', + correlationId, + retryable: false, + }; + + assert.equal(validate({ ...problem, titleKey: 'errors.accessDenied', stack: 'secret' }), false); + assert.equal( + validate({ + ...problem, + messageKey: 'errors.accessDenied', + rateLimit: { + scope: 'principal', + resetAt: '2026-08-01T01:31:00Z', + credential: 'secret', + }, + }), + false, + ); +}); + test('rejects a command envelope with no idempotency key', () => { const validate = validatorFor(ids.commandEnvelope); const command = { @@ -201,19 +276,57 @@ test('rejects a command envelope with no idempotency key', () => { assert.equal(validate(withoutIdempotency), false); }); -test('rejects invalid and internally inconsistent cursor page shapes', () => { +test('accepts the authoritative continuing and terminal cursor page shapes', () => { const validate = validatorFor(ids.cursorPage); assert.equal( validate({ - items: [{ id: workspaceId }], - pageInfo: { hasNextPage: true, nextCursor: 'opaque-cursor' }, + data: [{ id: workspaceId }], + nextCursor: 'opaque-cursor', + snapshotAt: '2026-08-01T01:30:00Z', + hasMore: true, }), true, ); - assert.equal(validate({ items: [], pageInfo: { hasNextPage: false, nextCursor: null } }), true); - assert.equal(validate({ items: [], pageInfo: { hasNextPage: true, nextCursor: null } }), false); - assert.equal(validate({ items: [], pageInfo: { hasNextPage: false, nextCursor: 42 } }), false); + assert.equal(validate({ data: [], snapshotAt: '2026-08-01T01:30:00Z', hasMore: false }), true); +}); + +test('requires a continuation cursor only while more data exists', () => { + const validate = validatorFor(ids.cursorPage); + + assert.equal(validate({ data: [], snapshotAt: '2026-08-01T01:30:00Z', hasMore: true }), false); + assert.equal( + validate({ + data: [], + nextCursor: 'stale-cursor', + snapshotAt: '2026-08-01T01:30:00Z', + hasMore: false, + }), + false, + ); +}); + +test('rejects a cursor page with a non-UTC snapshot', () => { + const validate = validatorFor(ids.cursorPage); + + assert.equal( + validate({ data: [], snapshotAt: '2026-08-01T08:30:00+07:00', hasMore: false }), + false, + ); +}); + +test('rejects unknown cursor page fields', () => { + const validate = validatorFor(ids.cursorPage); + + assert.equal( + validate({ + data: [], + snapshotAt: '2026-08-01T01:30:00Z', + hasMore: false, + pageInfo: {}, + }), + false, + ); }); test('requires event type and positive entity revision in the canonical event envelope', () => { From 0dfe9f135f95767171d43f7642768f682dfd3f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 03:23:59 +0700 Subject: [PATCH 10/51] feat(contracts): generate typescript kotlin and python models --- .prettierignore | 1 + eslint.config.mjs | 1 + package.json | 3 +- packages/contracts/README.md | 7 +- .../com/databreeze/contracts/v1/Models.kt | 115 +++ .../python/databreeze_contracts/__init__.py | 5 + .../python/databreeze_contracts/py.typed | 0 .../databreeze_contracts/v1/__init__.py | 39 + .../python/databreeze_contracts/v1/models.py | 136 +++ .../generated/typescript/v1/index.ts | 117 +++ packages/contracts/package.json | 7 +- .../contracts/scripts/contract-generator.mjs | 823 ++++++++++++++++++ .../contracts/scripts/generate-models.mjs | 42 + .../test/fixtures/generator/manifest.json | 16 + .../generator/schemas/v1/alpha.schema.json | 7 + .../schemas/v1/sample-envelope.schema.json | 25 + packages/contracts/test/generation.test.mjs | 263 ++++++ packages/contracts/test/schemas.test.mjs | 16 +- 18 files changed, 1614 insertions(+), 9 deletions(-) create mode 100644 packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt create mode 100644 packages/contracts/generated/python/databreeze_contracts/__init__.py create mode 100644 packages/contracts/generated/python/databreeze_contracts/py.typed create mode 100644 packages/contracts/generated/python/databreeze_contracts/v1/__init__.py create mode 100644 packages/contracts/generated/python/databreeze_contracts/v1/models.py create mode 100644 packages/contracts/generated/typescript/v1/index.ts create mode 100644 packages/contracts/scripts/contract-generator.mjs create mode 100644 packages/contracts/scripts/generate-models.mjs create mode 100644 packages/contracts/test/fixtures/generator/manifest.json create mode 100644 packages/contracts/test/fixtures/generator/schemas/v1/alpha.schema.json create mode 100644 packages/contracts/test/fixtures/generator/schemas/v1/sample-envelope.schema.json create mode 100644 packages/contracts/test/generation.test.mjs diff --git a/.prettierignore b/.prettierignore index 06fe426b..680dcaab 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ README.md docs/ **/README.md pnpm-lock.yaml +packages/contracts/generated/ diff --git a/eslint.config.mjs b/eslint.config.mjs index 35427d09..d1aa84ad 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,6 +9,7 @@ export default tseslint.config( '**/dist/**', '**/node_modules/**', '**/out/**', + 'packages/contracts/generated/**', 'tools/repo-cli/test/fixtures/**', ], }, diff --git a/package.json b/package.json index 386686e3..bc08cfa1 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,13 @@ }, "scripts": { "build": "turbo run build", + "contracts:check": "corepack pnpm --filter @databreeze/contracts generate:check", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint . && node tools/repo-cli/src/check-dependency-boundaries.mjs", "repo:bootstrap": "corepack pnpm install --frozen-lockfile", "repo:build": "corepack pnpm build", - "repo:check": "corepack pnpm format:check && corepack pnpm lint && corepack pnpm typecheck && corepack pnpm requirements:check && corepack pnpm test", + "repo:check": "corepack pnpm format:check && corepack pnpm lint && corepack pnpm typecheck && corepack pnpm requirements:check && corepack pnpm contracts:check && corepack pnpm test", "repo:dev": "turbo run dev --parallel", "repo:test": "corepack pnpm test", "requirements:check": "node tools/repo-cli/src/generate-requirement-index.mjs --check", diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 36bd1974..e17f310e 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -6,6 +6,9 @@ Canonical OpenAPI, JSON Schema, event, typed-job, and compatibility definitions - `manifest.json` is the deterministic registry for canonical source schemas. - `schemas/v1/*.schema.json` contains closed JSON Schema 2020-12 definitions with stable absolute IDs and references. +- `generated/typescript/v1/index.ts` exports structural TypeScript contracts for Web, Desktop, and API consumers. +- `generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt` provides standard Kotlin models in `com.databreeze.contracts.v1`. +- `generated/python/databreeze_contracts/v1` is the Pydantic v2 model package for Python consumers. - Consumers import only the entry points declared in `package.json#exports`. The v1 base schemas provide UUID identifiers and UTC timestamps (IAM-001), complete tenant ancestry (IAM-019), correlation and actor metadata (AUD-004), RFC-compatible public problems (INT-021), idempotent commands (INT-004), cursor pages (INT-005), and canonical events (AUD-004, AUD-006, and INT-008). This is partial foundation coverage; it does not implement those requirements' persistence or runtime behavior. @@ -15,9 +18,11 @@ The v1 base schemas provide UUID identifiers and UTC timestamps (IAM-001), compl ```text corepack pnpm --filter @databreeze/contracts test corepack pnpm --filter @databreeze/contracts build +corepack pnpm --filter @databreeze/contracts generate +corepack pnpm --filter @databreeze/contracts generate:check ``` -`test` compiles the real schemas with Ajv's JSON Schema 2020-12 validator and exercises hand-authored protocol payloads. `build` compiles every manifest entry without generating language models; cross-language generation belongs to Task 5. +`generate` is the only supported way to update checked-in language models. Do not edit files below `generated/` by hand. `generate:check` regenerates into a temporary directory, byte-compares the complete expected file set, and reports missing, stale, or unexpected files without changing checked-in output. `test` compiles the real schemas with Ajv's JSON Schema 2020-12 validator and exercises generator behavior plus hand-authored protocol payloads. `build` compiles every manifest entry and checks generated-file drift. ## Forbidden dependencies diff --git a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt new file mode 100644 index 00000000..cfdc793f --- /dev/null +++ b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt @@ -0,0 +1,115 @@ +// Generated by @databreeze/contracts. DO NOT EDIT. + +package com.databreeze.contracts.v1 + +public typealias JsonObject = Map + +public typealias Identifier = String + +public typealias Revision = Long + +public typealias UtcTimestamp = String + +public sealed interface TenantScope { + public val scopeType: String +} + +public data class ActorMetadata( + public val actorId: Identifier, + public val actorType: String, +) + +public data class CommandEnvelope( + public val actor: ActorMetadata, + public val commandId: Identifier, + public val commandType: String, + public val correlation: CorrelationMetadata, + public val data: TData, + public val idempotencyKey: String, + public val issuedAt: UtcTimestamp, + public val schemaVersion: Long, + public val tenantScope: TenantScope, +) + +public data class CorrelationMetadata( + public val causationId: Identifier? = null, + public val correlationId: Identifier, + public val requestId: Identifier? = null, +) + +public data class CursorPage( + public val data: List, + public val hasMore: Boolean, + public val nextCursor: String? = null, + public val snapshotAt: UtcTimestamp, +) + +public data class EventEnvelope( + public val actor: ActorMetadata, + public val correlation: CorrelationMetadata, + public val data: TData, + public val entity: EventEnvelopeEntity, + public val eventId: Identifier, + public val eventType: String, + public val occurredAt: UtcTimestamp, + public val schemaVersion: Long, + public val sourceComponent: String, + public val tenantScope: TenantScope, +) + +public data class EventEnvelopeEntity( + public val entityId: Identifier, + public val entityType: String, + public val revision: Revision, +) + +public data class OrganizationScope( + public val organizationId: Identifier, +) : TenantScope { + public override val scopeType: String = "organization" +} + +public data class ProblemDetails( + public val code: String, + public val correlationId: Identifier, + public val currentRevision: Revision? = null, + public val detail: String? = null, + public val fieldErrors: List? = null, + public val instance: String? = null, + public val messageKey: String? = null, + public val rateLimit: ProblemDetailsRateLimit? = null, + public val remediationAction: String? = null, + public val retryAfterSeconds: Long? = null, + public val retryable: Boolean, + public val status: Long, + public val title: String? = null, + public val titleKey: String? = null, + public val type: String, +) + +public data class ProblemDetailsFieldErrorsItem( + public val code: String, + public val field: String, +) + +public data class ProblemDetailsRateLimit( + public val limit: Long? = null, + public val remaining: Long? = null, + public val resetAt: UtcTimestamp, + public val scope: String, +) + +public data class ProjectScope( + public val organizationId: Identifier, + public val projectId: Identifier, + public val workspaceId: Identifier, +) : TenantScope { + public override val scopeType: String = "project" +} + +public data class WorkspaceScope( + public val organizationId: Identifier, + public val workspaceId: Identifier, +) : TenantScope { + public override val scopeType: String = "workspace" +} diff --git a/packages/contracts/generated/python/databreeze_contracts/__init__.py b/packages/contracts/generated/python/databreeze_contracts/__init__.py new file mode 100644 index 00000000..c11cb5fe --- /dev/null +++ b/packages/contracts/generated/python/databreeze_contracts/__init__.py @@ -0,0 +1,5 @@ +# Generated by @databreeze/contracts. DO NOT EDIT. + +from . import v1 + +__all__ = ["v1"] diff --git a/packages/contracts/generated/python/databreeze_contracts/py.typed b/packages/contracts/generated/python/databreeze_contracts/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/contracts/generated/python/databreeze_contracts/v1/__init__.py b/packages/contracts/generated/python/databreeze_contracts/v1/__init__.py new file mode 100644 index 00000000..4d609bc1 --- /dev/null +++ b/packages/contracts/generated/python/databreeze_contracts/v1/__init__.py @@ -0,0 +1,39 @@ +# Generated by @databreeze/contracts. DO NOT EDIT. + +from .models import ( + ActorMetadata, + CommandEnvelope, + CorrelationMetadata, + CursorPage, + EventEnvelope, + EventEnvelopeEntity, + Identifier, + OrganizationScope, + ProblemDetails, + ProblemDetailsFieldErrorsItem, + ProblemDetailsRateLimit, + ProjectScope, + Revision, + TenantScope, + UtcTimestamp, + WorkspaceScope, +) + +__all__ = [ + "ActorMetadata", + "CommandEnvelope", + "CorrelationMetadata", + "CursorPage", + "EventEnvelope", + "EventEnvelopeEntity", + "Identifier", + "OrganizationScope", + "ProblemDetails", + "ProblemDetailsFieldErrorsItem", + "ProblemDetailsRateLimit", + "ProjectScope", + "Revision", + "TenantScope", + "UtcTimestamp", + "WorkspaceScope", +] diff --git a/packages/contracts/generated/python/databreeze_contracts/v1/models.py b/packages/contracts/generated/python/databreeze_contracts/v1/models.py new file mode 100644 index 00000000..f47dfeaa --- /dev/null +++ b/packages/contracts/generated/python/databreeze_contracts/v1/models.py @@ -0,0 +1,136 @@ +# Generated by @databreeze/contracts. DO NOT EDIT. + +from __future__ import annotations + +from typing import Annotated, Any, Generic, Literal, Self, TypeAlias, TypeVar +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator + +JsonObject: TypeAlias = dict[str, Any] +TData = TypeVar("TData") +TItem = TypeVar("TItem") + + +class ClosedModel(BaseModel): + model_config = ConfigDict(extra="forbid") + +Identifier: TypeAlias = UUID + +Revision: TypeAlias = Annotated[int, Field(ge=1)] + +UtcTimestamp: TypeAlias = Annotated[str, StringConstraints(pattern=r"Z$")] + +class ActorMetadata(ClosedModel): + actorId: Identifier + actorType: Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] + +class CommandEnvelope(ClosedModel, Generic[TData]): + actor: ActorMetadata + commandId: Identifier + commandType: Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$")] + correlation: CorrelationMetadata + data: TData + idempotencyKey: Annotated[str, StringConstraints(min_length=1, max_length=255)] + issuedAt: UtcTimestamp + schemaVersion: Annotated[int, Field(ge=1)] + tenantScope: TenantScope + +class CorrelationMetadata(ClosedModel): + causationId: Identifier | None = None + correlationId: Identifier + requestId: Identifier | None = None + +class CursorPage(ClosedModel, Generic[TItem]): + data: list[TItem] + hasMore: bool + nextCursor: Annotated[str, StringConstraints(min_length=1, max_length=4096)] | None = None + snapshotAt: UtcTimestamp + + @model_validator(mode="after") + def enforce_conditional_fields(self) -> Self: + if self.hasMore is True and self.nextCursor is None: + raise ValueError("nextCursor is required for this hasMore value") + if self.hasMore is False and self.nextCursor is not None: + raise ValueError("nextCursor is forbidden for this hasMore value") + return self + +class EventEnvelope(ClosedModel, Generic[TData]): + actor: ActorMetadata + correlation: CorrelationMetadata + data: TData + entity: EventEnvelopeEntity + eventId: Identifier + eventType: Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$")] + occurredAt: UtcTimestamp + schemaVersion: Annotated[int, Field(ge=1)] + sourceComponent: Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] + tenantScope: TenantScope + +class EventEnvelopeEntity(ClosedModel): + entityId: Identifier + entityType: Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] + revision: Revision + +class OrganizationScope(ClosedModel): + organizationId: Identifier + scopeType: Literal["organization"] = "organization" + +class ProblemDetails(ClosedModel): + code: Annotated[str, StringConstraints(pattern=r"^[A-Z][A-Z0-9_]{0,127}$")] + correlationId: Identifier + currentRevision: Revision | None = None + detail: str | None = None + fieldErrors: Annotated[list[ProblemDetailsFieldErrorsItem], Field(max_length=100)] | None = None + instance: str | None = None + messageKey: Annotated[str, StringConstraints(min_length=1, max_length=255)] | None = None + rateLimit: ProblemDetailsRateLimit | None = None + remediationAction: Annotated[str, StringConstraints(min_length=1, max_length=255)] | None = None + retryAfterSeconds: Annotated[int, Field(ge=0)] | None = None + retryable: bool + status: Annotated[int, Field(ge=100, le=599)] + title: Annotated[str, StringConstraints(min_length=1)] | None = None + titleKey: Annotated[str, StringConstraints(min_length=1, max_length=255)] | None = None + type: str + + @model_validator(mode="after") + def require_schema_alternative(self) -> Self: + if not ((self.titleKey is not None) or (self.messageKey is not None)): + raise ValueError("at least one required schema alternative must be present") + return self + +class ProblemDetailsFieldErrorsItem(ClosedModel): + code: Annotated[str, StringConstraints(pattern=r"^[A-Z][A-Z0-9_]{0,127}$")] + field: Annotated[str, StringConstraints(min_length=1, max_length=255)] + +class ProblemDetailsRateLimit(ClosedModel): + limit: Annotated[int, Field(ge=0)] | None = None + remaining: Annotated[int, Field(ge=0)] | None = None + resetAt: UtcTimestamp + scope: Annotated[str, StringConstraints(min_length=1, max_length=255)] + +class ProjectScope(ClosedModel): + organizationId: Identifier + projectId: Identifier + scopeType: Literal["project"] = "project" + workspaceId: Identifier + +class WorkspaceScope(ClosedModel): + organizationId: Identifier + scopeType: Literal["workspace"] = "workspace" + workspaceId: Identifier + +TenantScope: TypeAlias = Annotated[OrganizationScope | WorkspaceScope | ProjectScope, Field(discriminator="scopeType")] + +ActorMetadata.model_rebuild() +CommandEnvelope.model_rebuild() +CorrelationMetadata.model_rebuild() +CursorPage.model_rebuild() +EventEnvelope.model_rebuild() +EventEnvelopeEntity.model_rebuild() +OrganizationScope.model_rebuild() +ProblemDetails.model_rebuild() +ProblemDetailsFieldErrorsItem.model_rebuild() +ProblemDetailsRateLimit.model_rebuild() +ProjectScope.model_rebuild() +WorkspaceScope.model_rebuild() diff --git a/packages/contracts/generated/typescript/v1/index.ts b/packages/contracts/generated/typescript/v1/index.ts new file mode 100644 index 00000000..f3354a72 --- /dev/null +++ b/packages/contracts/generated/typescript/v1/index.ts @@ -0,0 +1,117 @@ +// Generated by @databreeze/contracts. DO NOT EDIT. + +export type JsonPrimitive = boolean | number | string | null; +export type JsonValue = JsonPrimitive | readonly JsonValue[] | JsonObject; +export type JsonObject = { readonly [key: string]: JsonValue }; + +export interface ActorMetadata { + readonly actorId: Identifier; + readonly actorType: string; +} + +export interface CommandEnvelope { + readonly actor: ActorMetadata; + readonly commandId: Identifier; + readonly commandType: string; + readonly correlation: CorrelationMetadata; + readonly data: TData; + readonly idempotencyKey: string; + readonly issuedAt: UtcTimestamp; + readonly schemaVersion: number; + readonly tenantScope: TenantScope; +} + +export interface CorrelationMetadata { + readonly causationId?: Identifier; + readonly correlationId: Identifier; + readonly requestId?: Identifier; +} + +export interface CursorPageFields { + readonly data: readonly TItem[]; + readonly hasMore: boolean; + readonly nextCursor?: string; + readonly snapshotAt: UtcTimestamp; +} + +export type CursorPage = Omit, "hasMore" | "nextCursor"> & ( + | { readonly hasMore: true; readonly nextCursor: string } + | { readonly hasMore: false; readonly nextCursor?: never } +); + +export interface EventEnvelope { + readonly actor: ActorMetadata; + readonly correlation: CorrelationMetadata; + readonly data: TData; + readonly entity: EventEnvelopeEntity; + readonly eventId: Identifier; + readonly eventType: string; + readonly occurredAt: UtcTimestamp; + readonly schemaVersion: number; + readonly sourceComponent: string; + readonly tenantScope: TenantScope; +} + +export interface EventEnvelopeEntity { + readonly entityId: Identifier; + readonly entityType: string; + readonly revision: Revision; +} + +export type Identifier = string; + +export interface OrganizationScope { + readonly organizationId: Identifier; + readonly scopeType: "organization"; +} + +export interface ProblemDetailsFields { + readonly code: string; + readonly correlationId: Identifier; + readonly currentRevision?: Revision; + readonly detail?: string; + readonly fieldErrors?: readonly ProblemDetailsFieldErrorsItem[]; + readonly instance?: string; + readonly messageKey?: string; + readonly rateLimit?: ProblemDetailsRateLimit; + readonly remediationAction?: string; + readonly retryAfterSeconds?: number; + readonly retryable: boolean; + readonly status: number; + readonly title?: string; + readonly titleKey?: string; + readonly type: string; +} + +export type ProblemDetails = ProblemDetailsFields & ({ readonly titleKey: string } | { readonly messageKey: string }); + +export interface ProblemDetailsFieldErrorsItem { + readonly code: string; + readonly field: string; +} + +export interface ProblemDetailsRateLimit { + readonly limit?: number; + readonly remaining?: number; + readonly resetAt: UtcTimestamp; + readonly scope: string; +} + +export interface ProjectScope { + readonly organizationId: Identifier; + readonly projectId: Identifier; + readonly scopeType: "project"; + readonly workspaceId: Identifier; +} + +export type Revision = number; + +export type TenantScope = OrganizationScope | WorkspaceScope | ProjectScope; + +export type UtcTimestamp = string; + +export interface WorkspaceScope { + readonly organizationId: Identifier; + readonly scopeType: "workspace"; + readonly workspaceId: Identifier; +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 40e490e1..f4e8f545 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -5,6 +5,9 @@ "type": "module", "exports": { ".": "./manifest.json", + "./v1": { + "types": "./generated/typescript/v1/index.ts" + }, "./v1/actor-metadata": "./schemas/v1/actor-metadata.schema.json", "./v1/command-envelope": "./schemas/v1/command-envelope.schema.json", "./v1/correlation-metadata": "./schemas/v1/correlation-metadata.schema.json", @@ -17,7 +20,9 @@ "./v1/utc-timestamp": "./schemas/v1/utc-timestamp.schema.json" }, "scripts": { - "build": "node scripts/build.mjs", + "build": "node scripts/build.mjs && node scripts/generate-models.mjs --check", + "generate": "node scripts/generate-models.mjs", + "generate:check": "node scripts/generate-models.mjs --check", "test": "node --test test/**/*.test.mjs" }, "devDependencies": { diff --git a/packages/contracts/scripts/contract-generator.mjs b/packages/contracts/scripts/contract-generator.mjs new file mode 100644 index 00000000..a2086eea --- /dev/null +++ b/packages/contracts/scripts/contract-generator.mjs @@ -0,0 +1,823 @@ +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; + +const DRAFT = 'https://json-schema.org/draft/2020-12/schema'; +const HEADER = 'Generated by @databreeze/contracts. DO NOT EDIT.'; +const SUPPORTED_KEYWORDS = new Set([ + '$comment', + '$defs', + '$id', + '$ref', + '$schema', + 'additionalProperties', + 'allOf', + 'anyOf', + 'const', + 'description', + 'else', + 'format', + 'if', + 'items', + 'maxItems', + 'maxLength', + 'maximum', + 'minLength', + 'minimum', + 'not', + 'oneOf', + 'pattern', + 'properties', + 'required', + 'then', + 'title', + 'type', +]); +const SUPPORTED_TYPES = new Set(['array', 'boolean', 'integer', 'object', 'string']); +const SUPPORTED_FORMATS = new Set(['date-time', 'uri-reference', 'uuid']); + +function fail(message) { + throw new Error(message); +} + +function parseJson(path) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + fail(`Could not parse ${path}: ${error.message}`); + } +} + +function assertPlainObject(value, label) { + if (value === null || Array.isArray(value) || typeof value !== 'object') { + fail(`${label} must be an object`); + } +} + +function validateSchemaNode(node, sourcePath, location = '#') { + if (typeof node === 'boolean') return; + assertPlainObject(node, `${sourcePath}${location}`); + + for (const keyword of Object.keys(node)) { + if (!SUPPORTED_KEYWORDS.has(keyword)) { + fail(`Unsupported JSON Schema keyword "${keyword}" in ${sourcePath}${location}`); + } + } + + if ('type' in node && !SUPPORTED_TYPES.has(node.type)) { + fail(`Unsupported JSON Schema type ${JSON.stringify(node.type)} in ${sourcePath}${location}`); + } + if ('format' in node && !SUPPORTED_FORMATS.has(node.format)) { + fail( + `Unsupported JSON Schema format ${JSON.stringify(node.format)} in ${sourcePath}${location}`, + ); + } + if ('additionalProperties' in node && node.additionalProperties !== false) { + fail(`Unsupported additionalProperties value in ${sourcePath}${location}`); + } + if ('properties' in node) { + assertPlainObject(node.properties, `${sourcePath}${location}/properties`); + for (const [name, property] of Object.entries(node.properties)) { + validateSchemaNode(property, sourcePath, `${location}/properties/${name}`); + } + } + if ('$defs' in node) { + assertPlainObject(node.$defs, `${sourcePath}${location}/$defs`); + for (const [name, definition] of Object.entries(node.$defs)) { + validateSchemaNode(definition, sourcePath, `${location}/$defs/${name}`); + } + } + if ('items' in node) validateSchemaNode(node.items, sourcePath, `${location}/items`); + for (const keyword of ['oneOf', 'anyOf', 'allOf']) { + if (!(keyword in node)) continue; + if (!Array.isArray(node[keyword]) || node[keyword].length === 0) { + fail(`${sourcePath}${location}/${keyword} must be a non-empty array`); + } + node[keyword].forEach((child, index) => + validateSchemaNode(child, sourcePath, `${location}/${keyword}/${index}`), + ); + } + for (const keyword of ['if', 'then', 'else', 'not']) { + if (keyword in node) validateSchemaNode(node[keyword], sourcePath, `${location}/${keyword}`); + } + if ('required' in node && (!Array.isArray(node.required) || !node.required.every(isString))) { + fail(`${sourcePath}${location}/required must be an array of strings`); + } + if ('const' in node && !['boolean', 'number', 'string'].includes(typeof node.const)) { + fail(`Unsupported const value in ${sourcePath}${location}`); + } +} + +function isString(value) { + return typeof value === 'string'; +} + +function pascalCase(value) { + const result = value + .split(/[^A-Za-z0-9]+/u) + .filter(Boolean) + .map((part) => `${part[0].toUpperCase()}${part.slice(1)}`) + .join(''); + if (!result || !/^[A-Za-z][A-Za-z0-9]*$/u.test(result)) { + fail(`Cannot derive a model name from ${JSON.stringify(value)}`); + } + return result; +} + +function loadRegistry(sourceRoot) { + const root = resolve(sourceRoot); + const manifestPath = resolve(root, 'manifest.json'); + const manifest = parseJson(manifestPath); + assertPlainObject(manifest, manifestPath); + if (manifest.draft !== DRAFT || manifest.version !== 1 || !Array.isArray(manifest.schemas)) { + fail(`${manifestPath} must declare the supported JSON Schema draft and v1 schema registry`); + } + + const names = new Set(); + const ids = new Set(); + const entries = manifest.schemas.map((entry, index) => { + assertPlainObject(entry, `${manifestPath}#/schemas/${index}`); + if (!isString(entry.name) || !isString(entry.id) || !isString(entry.path)) { + fail(`${manifestPath}#/schemas/${index} must contain string name, id, and path values`); + } + if (names.has(entry.name) || ids.has(entry.id)) + fail(`Duplicate schema registry entry: ${entry.name}`); + names.add(entry.name); + ids.add(entry.id); + const schemaPath = resolve(root, entry.path); + const relativePath = relative(root, schemaPath); + if (relativePath.startsWith(`..${sep}`) || relativePath === '..' || isAbsolute(relativePath)) { + fail(`Schema path escapes the source root: ${entry.path}`); + } + const schema = parseJson(schemaPath); + validateSchemaNode(schema, entry.path); + if (schema.$schema !== manifest.draft) fail(`Schema draft does not match ${entry.path}`); + if (schema.$id !== entry.id) fail(`Manifest ID does not match ${entry.path}`); + return { ...entry, modelName: pascalCase(entry.name), schema, schemaPath: entry.path }; + }); + entries.sort((left, right) => compareStrings(left.name, right.name)); + + const byId = new Map(entries.map((entry) => [entry.id, entry])); + for (const entry of entries) validateReferences(entry.schema, entry, byId); + return { entries, byId, root }; +} + +function validateReferences(node, entry, byId, location = '#') { + if (typeof node === 'boolean') return; + if (node.$ref) { + if (node.$ref.startsWith('#/$defs/')) { + const name = node.$ref.slice('#/$defs/'.length); + if (!entry.schema.$defs?.[name]) + fail(`Unresolved schema reference ${node.$ref} in ${entry.path}`); + } else if (!byId.has(node.$ref)) { + fail(`Unresolved schema reference ${node.$ref} in ${entry.path}`); + } + } + for (const [name, child] of Object.entries(node.properties ?? {})) { + validateReferences(child, entry, byId, `${location}/properties/${name}`); + } + for (const [name, child] of Object.entries(node.$defs ?? {})) { + validateReferences(child, entry, byId, `${location}/$defs/${name}`); + } + if (node.items) validateReferences(node.items, entry, byId, `${location}/items`); + for (const keyword of ['oneOf', 'anyOf', 'allOf']) { + (node[keyword] ?? []).forEach((child, index) => + validateReferences(child, entry, byId, `${location}/${keyword}/${index}`), + ); + } + for (const keyword of ['if', 'then', 'else', 'not']) { + if (node[keyword]) validateReferences(node[keyword], entry, byId, `${location}/${keyword}`); + } +} + +function buildModelContext(registry) { + const nameByNode = new Map(); + const entryByNode = new Map(); + const nodesByName = new Map(); + const unionMembership = new Map(); + + function register(node, name, entry) { + const existing = nodesByName.get(name); + if (existing && existing !== node) fail(`Generated model name collision: ${name}`); + nameByNode.set(node, name); + entryByNode.set(node, entry); + nodesByName.set(name, node); + } + + for (const entry of registry.entries) { + register(entry.schema, entry.modelName, entry); + for (const [definitionName, definition] of Object.entries(entry.schema.$defs ?? {}).sort( + compareEntries, + )) { + register(definition, pascalCase(definitionName), entry); + } + } + + for (const entry of registry.entries) { + const root = entry.schema; + if (root.oneOf) { + for (const alternative of root.oneOf) { + const target = resolveReference(alternative.$ref, entry, registry, nameByNode); + unionMembership.set(target.node, entry.modelName); + } + } + } + + function collect(node, ownerName, entry) { + if (typeof node === 'boolean') return; + for (const [propertyName, property] of Object.entries(node.properties ?? {}).sort( + compareEntries, + )) { + if (isNamedObjectCandidate(property) && !nameByNode.has(property)) { + register(property, `${ownerName}${pascalCase(propertyName)}`, entry); + } + if ( + property.type === 'array' && + isNamedObjectCandidate(property.items) && + !nameByNode.has(property.items) + ) { + register(property.items, `${ownerName}${pascalCase(propertyName)}Item`, entry); + } + collect(property, nameByNode.get(property) ?? ownerName, entry); + } + for (const [definitionName, definition] of Object.entries(node.$defs ?? {}).sort( + compareEntries, + )) { + collect(definition, nameByNode.get(definition) ?? pascalCase(definitionName), entry); + } + if (node.items && typeof node.items === 'object') collect(node.items, ownerName, entry); + } + for (const entry of registry.entries) collect(entry.schema, entry.modelName, entry); + + return { + ...registry, + entryByNode, + nameByNode, + nodesByName, + unionMembership, + }; +} + +function compareEntries([left], [right]) { + return compareStrings(left, right); +} + +function compareStrings(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function isNamedObjectCandidate(schema) { + return schema && typeof schema === 'object' && schema.type === 'object' && schema.properties; +} + +function resolveReference(reference, currentEntry, context, nameByNode = context.nameByNode) { + if (!reference) fail(`Expected a schema reference in ${currentEntry.path}`); + if (reference.startsWith('#/$defs/')) { + const node = currentEntry.schema.$defs[reference.slice('#/$defs/'.length)]; + return { node, name: nameByNode.get(node), entry: currentEntry }; + } + const entry = context.byId.get(reference); + if (!entry) fail(`Unresolved schema reference ${reference} in ${currentEntry.path}`); + return { node: entry.schema, name: entry.modelName, entry }; +} + +function genericParameters(node) { + const parameters = []; + for (const [propertyName, property] of Object.entries(node.properties ?? {}).sort( + compareEntries, + )) { + if (isOpenObject(property)) { + parameters.push({ child: property, kind: 'object', name: `T${pascalCase(propertyName)}` }); + } else if (property.type === 'array' && isEmptySchema(property.items)) { + parameters.push({ child: property.items, kind: 'item', name: 'TItem' }); + } + } + const seen = new Set(); + return parameters.filter(({ name }) => !seen.has(name) && seen.add(name)); +} + +function isOpenObject(schema) { + return ( + schema && + typeof schema === 'object' && + schema.type === 'object' && + !schema.properties && + !schema.$ref + ); +} + +function isEmptySchema(schema) { + return schema && typeof schema === 'object' && Object.keys(schema).length === 0; +} + +function quoted(value) { + return JSON.stringify(value); +} + +function renderTypeScript(context) { + const lines = [ + `// ${HEADER}`, + '', + 'export type JsonPrimitive = boolean | number | string | null;', + 'export type JsonValue = JsonPrimitive | readonly JsonValue[] | JsonObject;', + 'export type JsonObject = { readonly [key: string]: JsonValue };', + '', + ]; + + for (const [name, node] of [...context.nodesByName.entries()].sort(compareEntries)) { + const entry = context.entryByNode.get(node); + if (node.oneOf) { + const alternatives = node.oneOf.map( + (alternative) => resolveReference(alternative.$ref, entry, context).name, + ); + lines.push(`export type ${name} = ${alternatives.join(' | ')};`, ''); + continue; + } + if (node.type !== 'object') { + lines.push(`export type ${name} = ${typescriptType(node, entry, context, new Map())};`, ''); + continue; + } + + const parameters = genericParameters(node); + const parameterMap = new Map(parameters.map((parameter) => [parameter.child, parameter.name])); + const declaration = renderTypeScriptGenericDeclaration(parameters); + if (node.anyOf) { + const fieldsName = `${name}Fields`; + lines.push( + ...renderTypeScriptInterface(fieldsName, node, entry, context, parameters, parameterMap), + ); + const genericUse = parameters.length + ? `<${parameters.map(({ name: parameter }) => parameter).join(', ')}>` + : ''; + const alternatives = requiredAlternatives(node, entry.path).map( + (required) => + `{ ${required + .map( + (property) => + `readonly ${property}: ${typescriptType(node.properties[property], entry, context, parameterMap)}`, + ) + .join('; ')} }`, + ); + lines.push( + `export type ${name}${declaration} = ${fieldsName}${genericUse} & (${alternatives.join(' | ')});`, + '', + ); + } else if (node.allOf) { + const fieldsName = `${name}Fields`; + lines.push( + ...renderTypeScriptInterface(fieldsName, node, entry, context, parameters, parameterMap), + ); + const genericUse = parameters.length + ? `<${parameters.map(({ name: parameter }) => parameter).join(', ')}>` + : ''; + const conditional = conditionalConstraint(node, entry.path); + const base = `Omit<${fieldsName}${genericUse}, ${quoted(conditional.discriminator)} | ${quoted(conditional.dependent)}>`; + const dependentType = typescriptType( + node.properties[conditional.dependent], + entry, + context, + parameterMap, + ); + lines.push( + `export type ${name}${declaration} = ${base} & (`, + ` | { readonly ${conditional.discriminator}: ${quoted(conditional.value)}; readonly ${conditional.dependent}: ${dependentType} }`, + ` | { readonly ${conditional.discriminator}: ${quoted(!conditional.value)}; readonly ${conditional.dependent}?: never }`, + ');', + '', + ); + } else { + lines.push( + ...renderTypeScriptInterface(name, node, entry, context, parameters, parameterMap), + ); + } + } + return `${lines.join('\n').trimEnd()}\n`; +} + +function renderTypeScriptGenericDeclaration(parameters) { + if (!parameters.length) return ''; + return `<${parameters + .map(({ kind, name }) => + kind === 'object' ? `${name} extends object = JsonObject` : `${name} = unknown`, + ) + .join(', ')}>`; +} + +function renderTypeScriptInterface(name, node, entry, context, parameters, parameterMap) { + const lines = [`export interface ${name}${renderTypeScriptGenericDeclaration(parameters)} {`]; + const required = new Set(node.required ?? []); + for (const [propertyName, property] of Object.entries(node.properties ?? {}).sort( + compareEntries, + )) { + const optional = required.has(propertyName) ? '' : '?'; + lines.push( + ` readonly ${propertyName}${optional}: ${typescriptType(property, entry, context, parameterMap)};`, + ); + } + lines.push('}', ''); + return lines; +} + +function typescriptType(node, entry, context, parameters) { + if (parameters.has(node)) return parameters.get(node); + if (typeof node === 'boolean' || isEmptySchema(node)) return 'unknown'; + if (node.$ref) return resolveReference(node.$ref, entry, context).name; + if ('const' in node) return quoted(node.const); + if (node.oneOf) { + return node.oneOf + .map((alternative) => typescriptType(alternative, entry, context, parameters)) + .join(' | '); + } + if (node.type === 'string') return 'string'; + if (node.type === 'integer') return 'number'; + if (node.type === 'boolean') return 'boolean'; + if (node.type === 'array') { + const item = typescriptType(node.items, entry, context, parameters); + return `readonly ${item.includes(' | ') ? `(${item})` : item}[]`; + } + if (node.type === 'object') { + if (context.nameByNode.has(node)) return context.nameByNode.get(node); + return 'JsonObject'; + } + fail(`Cannot render TypeScript type from ${entry.path}`); +} + +function renderKotlin(context) { + const lines = [ + `// ${HEADER}`, + '', + 'package com.databreeze.contracts.v1', + '', + 'public typealias JsonObject = Map', + '', + ]; + const models = [...context.nodesByName.entries()].sort(compareEntries); + + for (const [name, node] of models) { + if (node.type !== 'object' && !node.oneOf) { + const entry = context.entryByNode.get(node); + lines.push(`public typealias ${name} = ${kotlinType(node, entry, context, new Map())}`, ''); + } + } + for (const [name, node] of models) { + if (!node.oneOf) continue; + const entry = context.entryByNode.get(node); + const discriminator = unionDiscriminator(node, entry, context); + lines.push( + `public sealed interface ${name} {`, + ` public val ${discriminator}: String`, + '}', + '', + ); + } + for (const [name, node] of models) { + if (node.type !== 'object') continue; + const entry = context.entryByNode.get(node); + const parameters = genericParameters(node); + const parameterMap = new Map(parameters.map((parameter) => [parameter.child, parameter.name])); + const generic = parameters.length + ? `<${parameters.map(({ name: parameter }) => parameter).join(', ')}>` + : ''; + const parent = context.unionMembership.get(node); + const discriminator = parent + ? unionDiscriminator(context.nodesByName.get(parent), entry, context) + : undefined; + const properties = Object.entries(node.properties ?? {}).sort(compareEntries); + const constructorProperties = properties.filter( + ([propertyName]) => propertyName !== discriminator, + ); + const required = new Set(node.required ?? []); + lines.push(`public data class ${name}${generic}(`); + for (const [propertyName, property] of constructorProperties) { + const optional = required.has(propertyName) ? '' : '?'; + const defaultValue = required.has(propertyName) ? '' : ' = null'; + lines.push( + ` public val ${propertyName}: ${kotlinType(property, entry, context, parameterMap)}${optional}${defaultValue},`, + ); + } + if (parent) { + lines.push(`) : ${parent} {`); + const literal = node.properties[discriminator].const; + lines.push(` public override val ${discriminator}: String = ${quoted(literal)}`, '}', ''); + } else { + lines.push(')', ''); + } + } + return `${lines.join('\n').trimEnd()}\n`; +} + +function kotlinType(node, entry, context, parameters) { + if (parameters.has(node)) return parameters.get(node); + if (typeof node === 'boolean' || isEmptySchema(node)) return 'Any?'; + if (node.$ref) return resolveReference(node.$ref, entry, context).name; + if ('const' in node) { + if (typeof node.const === 'boolean') return 'Boolean'; + if (typeof node.const === 'number') return 'Long'; + return 'String'; + } + if (node.oneOf) return context.nameByNode.get(node); + if (node.type === 'string') return 'String'; + if (node.type === 'integer') return 'Long'; + if (node.type === 'boolean') return 'Boolean'; + if (node.type === 'array') return `List<${kotlinType(node.items, entry, context, parameters)}>`; + if (node.type === 'object') return context.nameByNode.get(node) ?? 'JsonObject'; + fail(`Cannot render Kotlin type from ${entry.path}`); +} + +function unionDiscriminator(node, entry, context) { + const alternatives = node.oneOf.map( + (alternative) => resolveReference(alternative.$ref, entry, context).node, + ); + const candidates = Object.keys(alternatives[0].properties ?? {}).filter((property) => + alternatives.every((alternative) => 'const' in (alternative.properties?.[property] ?? {})), + ); + if (candidates.length !== 1) fail(`A closed union must have one discriminator in ${entry.path}`); + return candidates[0]; +} + +function renderPython(context) { + const models = [...context.nodesByName.entries()].sort(compareEntries); + const genericNames = [ + ...new Set( + models.flatMap(([, node]) => + genericParameters(node).map(({ kind, name }) => `${name}:${kind}`), + ), + ), + ].sort(); + const lines = [ + `# ${HEADER}`, + '', + 'from __future__ import annotations', + '', + 'from typing import Annotated, Any, Generic, Literal, Self, TypeAlias, TypeVar', + 'from uuid import UUID', + '', + 'from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator', + '', + 'JsonObject: TypeAlias = dict[str, Any]', + ]; + for (const generic of genericNames) { + const [name] = generic.split(':'); + lines.push(`${name} = TypeVar("${name}")`); + } + lines.push( + '', + '', + 'class ClosedModel(BaseModel):', + ' model_config = ConfigDict(extra="forbid")', + '', + ); + + for (const [name, node] of models) { + if (node.type !== 'object' && !node.oneOf) { + const entry = context.entryByNode.get(node); + lines.push(`${name}: TypeAlias = ${pythonType(node, entry, context, new Map())}`, ''); + } + } + for (const [name, node] of models) { + if (node.type !== 'object') continue; + const entry = context.entryByNode.get(node); + const parameters = genericParameters(node); + const parameterMap = new Map(parameters.map((parameter) => [parameter.child, parameter.name])); + const bases = ['ClosedModel']; + if (parameters.length) + bases.push(`Generic[${parameters.map(({ name: parameter }) => parameter).join(', ')}]`); + lines.push(`class ${name}(${bases.join(', ')}):`); + const properties = Object.entries(node.properties ?? {}).sort(compareEntries); + const required = new Set(node.required ?? []); + if (!properties.length) { + lines.push(' pass'); + } else { + for (const [propertyName, property] of properties) { + const optional = required.has(propertyName) ? '' : ' | None'; + let defaultValue = required.has(propertyName) ? '' : ' = None'; + if ('const' in property && required.has(propertyName)) + defaultValue = ` = ${quoted(property.const)}`; + lines.push( + ` ${propertyName}: ${pythonType(property, entry, context, parameterMap)}${optional}${defaultValue}`, + ); + } + } + if (node.anyOf) { + const alternatives = requiredAlternatives(node, entry.path); + lines.push( + '', + ' @model_validator(mode="after")', + ' def require_schema_alternative(self) -> Self:', + ` if not (${alternatives + .map((requiredProperties) => + requiredProperties.map((property) => `self.${property} is not None`).join(' and '), + ) + .map((condition) => `(${condition})`) + .join(' or ')}):`, + ' raise ValueError("at least one required schema alternative must be present")', + ' return self', + ); + } + if (node.allOf) { + const conditional = conditionalConstraint(node, entry.path); + lines.push( + '', + ' @model_validator(mode="after")', + ' def enforce_conditional_fields(self) -> Self:', + ` if self.${conditional.discriminator} is ${conditional.value ? 'True' : 'False'} and self.${conditional.dependent} is None:`, + ` raise ValueError("${conditional.dependent} is required for this ${conditional.discriminator} value")`, + ` if self.${conditional.discriminator} is ${conditional.value ? 'False' : 'True'} and self.${conditional.dependent} is not None:`, + ` raise ValueError("${conditional.dependent} is forbidden for this ${conditional.discriminator} value")`, + ' return self', + ); + } + lines.push(''); + } + for (const [name, node] of models) { + if (!node.oneOf) continue; + const entry = context.entryByNode.get(node); + const discriminator = unionDiscriminator(node, entry, context); + const alternatives = node.oneOf.map( + (alternative) => resolveReference(alternative.$ref, entry, context).name, + ); + lines.push( + `${name}: TypeAlias = Annotated[${alternatives.join(' | ')}, Field(discriminator=${quoted(discriminator)})]`, + '', + ); + } + for (const [name, node] of models) { + if (node.type === 'object') lines.push(`${name}.model_rebuild()`); + } + return `${lines.join('\n').trimEnd()}\n`; +} + +function pythonType(node, entry, context, parameters) { + if (parameters.has(node)) return parameters.get(node); + if (typeof node === 'boolean' || isEmptySchema(node)) return 'Any'; + if (node.$ref) return resolveReference(node.$ref, entry, context).name; + if ('const' in node) return `Literal[${quoted(node.const)}]`; + if (node.oneOf) return context.nameByNode.get(node); + if (node.type === 'string') { + if (node.format === 'uuid') return 'UUID'; + const argumentsList = []; + if (node.minLength !== undefined) argumentsList.push(`min_length=${node.minLength}`); + if (node.maxLength !== undefined) argumentsList.push(`max_length=${node.maxLength}`); + if (node.pattern !== undefined) argumentsList.push(`pattern=${pythonRawString(node.pattern)}`); + return argumentsList.length + ? `Annotated[str, StringConstraints(${argumentsList.join(', ')})]` + : 'str'; + } + if (node.type === 'integer') { + const argumentsList = []; + if (node.minimum !== undefined) argumentsList.push(`ge=${node.minimum}`); + if (node.maximum !== undefined) argumentsList.push(`le=${node.maximum}`); + return argumentsList.length ? `Annotated[int, Field(${argumentsList.join(', ')})]` : 'int'; + } + if (node.type === 'boolean') return 'bool'; + if (node.type === 'array') { + const list = `list[${pythonType(node.items, entry, context, parameters)}]`; + return node.maxItems !== undefined + ? `Annotated[${list}, Field(max_length=${node.maxItems})]` + : list; + } + if (node.type === 'object') return context.nameByNode.get(node) ?? 'JsonObject'; + fail(`Cannot render Python type from ${entry.path}`); +} + +function pythonRawString(value) { + return `r${quoted(value).replaceAll('\\\\', '\\')}`; +} + +function requiredAlternatives(node, sourcePath) { + return node.anyOf.map((alternative) => { + const keys = Object.keys(alternative); + if ( + !keys.every((key) => key === 'properties' || key === 'required') || + !alternative.required?.length || + !alternative.required.every((property) => alternative.properties?.[property] === true) + ) { + fail(`Unsupported anyOf shape in ${sourcePath}`); + } + return [...alternative.required].sort(); + }); +} + +function conditionalConstraint(node, sourcePath) { + if (node.allOf.length !== 1) fail(`Unsupported allOf shape in ${sourcePath}`); + const conditional = node.allOf[0]; + const discriminator = conditional.if?.required?.[0]; + const dependent = conditional.then?.required?.[0]; + const value = conditional.if?.properties?.[discriminator]?.const; + const forbidden = conditional.else?.not?.required?.[0]; + if ( + conditional.if?.required?.length !== 1 || + conditional.then?.required?.length !== 1 || + conditional.else?.not?.required?.length !== 1 || + typeof value !== 'boolean' || + dependent !== forbidden || + !node.properties?.[discriminator] || + !node.properties?.[dependent] + ) { + fail(`Unsupported allOf shape in ${sourcePath}`); + } + return { dependent, discriminator, value }; +} + +function renderPythonPackage(context) { + const publicNames = [...context.nodesByName.keys()].sort(compareStrings); + const versionInit = [ + `# ${HEADER}`, + '', + 'from .models import (', + ...publicNames.map((name) => ` ${name},`), + ')', + '', + '__all__ = [', + ...publicNames.map((name) => ` ${quoted(name)},`), + ']', + '', + ].join('\n'); + const packageInit = [`# ${HEADER}`, '', 'from . import v1', '', '__all__ = ["v1"]', ''].join( + '\n', + ); + return { packageInit, versionInit }; +} + +export function generateContractFiles(sourceRoot) { + const context = buildModelContext(loadRegistry(sourceRoot)); + const { packageInit, versionInit } = renderPythonPackage(context); + return new Map([ + ['kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt', renderKotlin(context)], + ['python/databreeze_contracts/__init__.py', packageInit], + ['python/databreeze_contracts/py.typed', ''], + ['python/databreeze_contracts/v1/__init__.py', versionInit], + ['python/databreeze_contracts/v1/models.py', renderPython(context)], + ['typescript/v1/index.ts', renderTypeScript(context)], + ]); +} + +export function writeContractFiles(sourceRoot, outputRoot) { + const source = resolve(sourceRoot); + const output = resolve(outputRoot); + const sourceFromOutput = relative(output, source); + if ( + sourceFromOutput === '' || + (!sourceFromOutput.startsWith(`..${sep}`) && + sourceFromOutput !== '..' && + !isAbsolute(sourceFromOutput)) + ) { + fail(`Refusing to replace a source tree with generated output: ${output}`); + } + const files = generateContractFiles(sourceRoot); + rmSync(output, { recursive: true, force: true }); + for (const [path, content] of files) { + const destination = resolve(output, ...path.split('/')); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, content, 'utf8'); + } + return files.size; +} + +export function checkContractDrift(sourceRoot, outputRoot) { + const temporaryRoot = mkdtempSync(resolve(tmpdir(), 'databreeze-contracts-check-')); + try { + writeContractFiles(sourceRoot, temporaryRoot); + const expected = listRelativeFiles(temporaryRoot); + const actual = listRelativeFiles(outputRoot); + const expectedSet = new Set(expected); + const actualSet = new Set(actual); + const missing = expected.filter((path) => !actualSet.has(path)); + const unexpected = actual.filter((path) => !expectedSet.has(path)); + const stale = expected.filter( + (path) => + actualSet.has(path) && + !readFileSync(resolve(temporaryRoot, ...path.split('/'))).equals( + readFileSync(resolve(outputRoot, ...path.split('/'))), + ), + ); + if (missing.length || stale.length || unexpected.length) { + const details = [ + ...missing.map((path) => `missing: ${path}`), + ...stale.map((path) => `stale: ${path}`), + ...unexpected.map((path) => `unexpected: ${path}`), + ]; + fail(`Generated contract drift detected:\n${details.join('\n')}`); + } + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function listRelativeFiles(root, directory = resolve(root)) { + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT') return []; + throw error; + } + return entries + .flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() + ? listRelativeFiles(root, path) + : [relative(resolve(root), path).replaceAll('\\', '/')]; + }) + .sort(compareStrings); +} diff --git a/packages/contracts/scripts/generate-models.mjs b/packages/contracts/scripts/generate-models.mjs new file mode 100644 index 00000000..b344e0bf --- /dev/null +++ b/packages/contracts/scripts/generate-models.mjs @@ -0,0 +1,42 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { checkContractDrift, writeContractFiles } from './contract-generator.mjs'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function readArguments(argumentsList) { + const options = { + check: false, + output: resolve(packageRoot, 'generated'), + source: packageRoot, + }; + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + if (argument === '--check') { + options.check = true; + } else if (argument === '--source' || argument === '--output') { + const value = argumentsList[index + 1]; + if (!value) throw new Error(`${argument} requires a path`); + options[argument.slice(2)] = resolve(value); + index += 1; + } else { + throw new Error(`Unknown argument: ${argument}`); + } + } + return options; +} + +try { + const options = readArguments(process.argv.slice(2)); + if (options.check) { + checkContractDrift(options.source, options.output); + console.log('Generated contract files are up to date.'); + } else { + const count = writeContractFiles(options.source, options.output); + console.log(`Generated ${count} contract files.`); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/packages/contracts/test/fixtures/generator/manifest.json b/packages/contracts/test/fixtures/generator/manifest.json new file mode 100644 index 00000000..8445a2d6 --- /dev/null +++ b/packages/contracts/test/fixtures/generator/manifest.json @@ -0,0 +1,16 @@ +{ + "draft": "https://json-schema.org/draft/2020-12/schema", + "version": 1, + "schemas": [ + { + "name": "sample-envelope", + "id": "https://schemas.example.test/contracts/v1/sample-envelope", + "path": "schemas/v1/sample-envelope.schema.json" + }, + { + "name": "alpha", + "id": "https://schemas.example.test/contracts/v1/alpha", + "path": "schemas/v1/alpha.schema.json" + } + ] +} diff --git a/packages/contracts/test/fixtures/generator/schemas/v1/alpha.schema.json b/packages/contracts/test/fixtures/generator/schemas/v1/alpha.schema.json new file mode 100644 index 00000000..088f2170 --- /dev/null +++ b/packages/contracts/test/fixtures/generator/schemas/v1/alpha.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.example.test/contracts/v1/alpha", + "title": "Alpha Identifier", + "type": "string", + "pattern": "^[a-z]+$" +} diff --git a/packages/contracts/test/fixtures/generator/schemas/v1/sample-envelope.schema.json b/packages/contracts/test/fixtures/generator/schemas/v1/sample-envelope.schema.json new file mode 100644 index 00000000..580b52ee --- /dev/null +++ b/packages/contracts/test/fixtures/generator/schemas/v1/sample-envelope.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.example.test/contracts/v1/sample-envelope", + "title": "Sample Envelope", + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "data"], + "properties": { + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "kind": { + "const": "sample" + }, + "id": { + "$ref": "https://schemas.example.test/contracts/v1/alpha" + }, + "data": { + "type": "object" + } + } +} diff --git a/packages/contracts/test/generation.test.mjs b/packages/contracts/test/generation.test.mjs new file mode 100644 index 00000000..fe68e8ec --- /dev/null +++ b/packages/contracts/test/generation.test.mjs @@ -0,0 +1,263 @@ +import assert from 'node:assert/strict'; +import { + appendFileSync, + cpSync, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const generatorPath = resolve(packageRoot, 'scripts/generate-models.mjs'); +const fixtureRoot = resolve(packageRoot, 'test/fixtures/generator'); +const generatedRoot = resolve(packageRoot, 'generated'); +const expectedFiles = [ + 'kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt', + 'python/databreeze_contracts/__init__.py', + 'python/databreeze_contracts/py.typed', + 'python/databreeze_contracts/v1/__init__.py', + 'python/databreeze_contracts/v1/models.py', + 'typescript/v1/index.ts', +]; + +function runGenerator(...args) { + return spawnSync(process.execPath, [generatorPath, ...args], { + cwd: packageRoot, + encoding: 'utf8', + }); +} + +function listFiles(root, directory = root) { + if (!existsSync(directory)) return []; + + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() + ? listFiles(root, path) + : [relative(root, path).replaceAll('\\', '/')]; + }) + .sort(); +} + +function snapshot(root) { + return listFiles(root).map((path) => [ + path, + readFileSync(resolve(root, path)).toString('base64'), + ]); +} + +function withTemporaryDirectory(run) { + const directory = mkdtempSync(join(tmpdir(), 'databreeze-contracts-test-')); + try { + return run(directory); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test('generates byte-stable language packages from a controlled registry', () => { + withTemporaryDirectory((directory) => { + const first = resolve(directory, 'first'); + const second = resolve(directory, 'second'); + const firstRun = runGenerator('--source', fixtureRoot, '--output', first); + const secondRun = runGenerator('--source', fixtureRoot, '--output', second); + + assert.equal(firstRun.status, 0, firstRun.stderr); + assert.equal(secondRun.status, 0, secondRun.stderr); + assert.deepEqual(listFiles(first), expectedFiles); + assert.deepEqual(snapshot(first), snapshot(second)); + + for (const path of expectedFiles.filter((entry) => !entry.endsWith('py.typed'))) { + const content = readFileSync(resolve(first, path), 'utf8'); + assert.match(content, /^[/#].*Generated by @databreeze\/contracts\. DO NOT EDIT\./); + assert.equal(content.includes('\r'), false, `${path} must use LF newlines`); + assert.equal(content.endsWith('\n'), true, `${path} must end with a newline`); + } + + const typescript = readFileSync(resolve(first, 'typescript/v1/index.ts'), 'utf8'); + assert.match(typescript, /export type Alpha = string;/); + assert.match(typescript, /export interface SampleEnvelope/); + assert.ok( + typescript.indexOf('export type Alpha') < + typescript.indexOf('export interface SampleEnvelope'), + ); + assert.ok( + typescript.indexOf('readonly data: TData;') < typescript.indexOf('readonly id: Alpha;'), + ); + assert.match(typescript, /readonly kind: "sample";/); + assert.match(typescript, /readonly labels\?: readonly string\[\];/); + + const kotlin = readFileSync( + resolve(first, 'kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt'), + 'utf8', + ); + assert.match(kotlin, /package com\.databreeze\.contracts\.v1/); + assert.match(kotlin, /public typealias Alpha = String/); + assert.match(kotlin, /public data class SampleEnvelope/); + + const python = readFileSync(resolve(first, 'python/databreeze_contracts/v1/models.py'), 'utf8'); + assert.match(python, /class ClosedModel\(BaseModel\):/); + assert.match(python, /model_config = ConfigDict\(extra="forbid"\)/); + assert.match(python, /TData = TypeVar\("TData"\)/); + assert.match(python, /class SampleEnvelope\(ClosedModel, Generic\[TData\]\):/); + + const check = runGenerator('--check', '--source', fixtureRoot, '--output', first); + assert.equal(check.status, 0, check.stderr); + assert.match(check.stdout, /Generated contract files are up to date\./); + }); +}); + +test('fails loudly when a schema uses an unsupported construct', () => { + withTemporaryDirectory((directory) => { + const source = resolve(directory, 'source'); + cpSync(fixtureRoot, source, { recursive: true }); + const schemaPath = resolve(source, 'schemas/v1/alpha.schema.json'); + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + schema.enum = ['alpha']; + writeFileSync(schemaPath, `${JSON.stringify(schema, null, 2)}\n`); + + const result = runGenerator('--source', source, '--output', resolve(directory, 'output')); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Unsupported JSON Schema keyword "enum"/); + assert.match(result.stderr, /alpha\.schema\.json/); + }); +}); + +test('drift check reports missing stale and unexpected files without mutation', () => { + withTemporaryDirectory((directory) => { + const output = resolve(directory, 'output'); + const generate = runGenerator('--source', fixtureRoot, '--output', output); + assert.equal(generate.status, 0, generate.stderr); + + const missing = 'kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt'; + const stale = 'typescript/v1/index.ts'; + const unexpected = 'typescript/v1/obsolete.ts'; + unlinkSync(resolve(output, missing)); + appendFileSync(resolve(output, stale), '// stale\n'); + writeFileSync(resolve(output, unexpected), '// unexpected\n'); + const before = snapshot(output); + + const check = runGenerator('--check', '--source', fixtureRoot, '--output', output); + + assert.notEqual(check.status, 0); + assert.match(check.stderr, new RegExp(`missing: ${missing.replaceAll('/', '\\/')}`)); + assert.match(check.stderr, new RegExp(`stale: ${stale.replaceAll('/', '\\/')}`)); + assert.match(check.stderr, new RegExp(`unexpected: ${unexpected.replaceAll('/', '\\/')}`)); + assert.deepEqual(snapshot(output), before); + }); +}); + +test('generation refuses to replace its schema source tree', () => { + withTemporaryDirectory((directory) => { + const source = resolve(directory, 'source'); + cpSync(fixtureRoot, source, { recursive: true }); + const before = snapshot(source); + + const generate = runGenerator('--source', source, '--output', source); + + assert.notEqual(generate.status, 0); + assert.match(generate.stderr, /Refusing to replace a source tree with generated output/); + assert.deepEqual(snapshot(source), before); + }); +}); + +test('the checked-in TypeScript contracts compile in strict mode', () => { + const source = resolve(generatedRoot, 'typescript/v1/index.ts'); + assert.equal(existsSync(source), true, 'generated TypeScript entry point is missing'); + withTemporaryDirectory((directory) => { + let importPath = relative(directory, source).replaceAll('\\', '/').replace(/\.ts$/u, '.js'); + if (!importPath.startsWith('.')) importPath = `./${importPath}`; + const consumer = resolve(directory, 'consumer.ts'); + writeFileSync( + consumer, + [ + `import type { CommandEnvelope, EventEnvelope } from ${JSON.stringify(importPath)};`, + '', + 'interface DomainPayload {', + ' readonly displayName: string;', + '}', + '', + 'declare const command: CommandEnvelope;', + 'declare const event: EventEnvelope;', + 'const commandName: string = command.data.displayName;', + 'const eventName: string = event.data.displayName;', + 'void commandName;', + 'void eventName;', + '', + ].join('\n'), + ); + const compiler = resolve(packageRoot, '../../node_modules/typescript/bin/tsc'); + const result = spawnSync( + process.execPath, + [ + compiler, + '--noEmit', + '--strict', + '--target', + 'ES2024', + '--module', + 'NodeNext', + '--moduleResolution', + 'NodeNext', + '--skipLibCheck', + consumer, + ], + { cwd: packageRoot, encoding: 'utf8' }, + ); + + assert.equal(result.status, 0, `${result.error ?? ''}\n${result.stdout}\n${result.stderr}`); + }); +}); + +test('the checked-in Python package compiles with the available interpreter', () => { + const files = expectedFiles + .filter((path) => path.endsWith('.py')) + .map((path) => resolve(generatedRoot, path)); + for (const path of files) assert.equal(existsSync(path), true, `${path} is missing`); + const program = [ + 'from pathlib import Path', + 'import sys', + 'for name in sys.argv[1:]:', + ' source = Path(name).read_text(encoding="utf-8")', + ' compile(source, name, "exec")', + ].join('\n'); + const result = spawnSync('python', ['-c', program, ...files], { + cwd: packageRoot, + encoding: 'utf8', + }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); +}); + +test('the checked-in Kotlin models have a deterministic standard-Kotlin structure', () => { + const source = resolve( + generatedRoot, + 'kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt', + ); + assert.equal(existsSync(source), true, 'generated Kotlin source is missing'); + const kotlin = readFileSync(source, 'utf8'); + + assert.match(kotlin, /^\/\/ Generated by @databreeze\/contracts\. DO NOT EDIT\.\n/); + assert.match(kotlin, /package com\.databreeze\.contracts\.v1/); + assert.match(kotlin, /public sealed interface TenantScope/); + assert.match(kotlin, /public data class OrganizationScope/); + assert.match(kotlin, /public data class WorkspaceScope/); + assert.match(kotlin, /public data class ProjectScope/); + assert.match(kotlin, /public data class CommandEnvelope/); + assert.match(kotlin, /public data class CursorPage/); + assert.match(kotlin, /public data class EventEnvelope/); + assert.doesNotMatch(kotlin, /android\.|androidx\.|kotlinx\./); + assert.equal((kotlin.match(/\{/g) ?? []).length, (kotlin.match(/\}/g) ?? []).length); + assert.equal((kotlin.match(/\(/g) ?? []).length, (kotlin.match(/\)/g) ?? []).length); +}); diff --git a/packages/contracts/test/schemas.test.mjs b/packages/contracts/test/schemas.test.mjs index 00ecfa2f..a3e6f633 100644 --- a/packages/contracts/test/schemas.test.mjs +++ b/packages/contracts/test/schemas.test.mjs @@ -93,11 +93,12 @@ test('publishes the complete deterministic v1 registry and compiles every real s } }); -test('exports only declared registry and versioned schema entry points', () => { +test('exports only declared registry schema and generated TypeScript entry points', () => { const packageJson = JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf8')); assert.deepEqual(Object.keys(packageJson.exports), [ '.', + './v1', './v1/actor-metadata', './v1/command-envelope', './v1/correlation-metadata', @@ -110,11 +111,14 @@ test('exports only declared registry and versioned schema entry points', () => { './v1/utc-timestamp', ]); for (const target of Object.values(packageJson.exports)) { - assert.equal( - existsSync(resolve(packageRoot, target)), - true, - `export target must exist: ${target}`, - ); + const paths = typeof target === 'string' ? [target] : Object.values(target); + for (const path of paths) { + assert.equal( + existsSync(resolve(packageRoot, path)), + true, + `export target must exist: ${path}`, + ); + } } }); From 1926b30ff0df23cf36073421e541d74f90e93f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 03:47:36 +0700 Subject: [PATCH 11/51] fix(contracts): preserve generated model semantics --- .../com/databreeze/contracts/v1/Models.kt | 16 +- .../databreeze_contracts/v1/_validation.py | 69 +++++++ .../python/databreeze_contracts/v1/models.py | 95 ++++++---- .../contracts/scripts/contract-generator.mjs | 175 ++++++++++++++++-- packages/contracts/test/generation.test.mjs | 152 +++++++++++++++ 5 files changed, 457 insertions(+), 50 deletions(-) create mode 100644 packages/contracts/generated/python/databreeze_contracts/v1/_validation.py diff --git a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt index cfdc793f..c6ed86da 100644 --- a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt +++ b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt @@ -42,7 +42,13 @@ public data class CursorPage( public val hasMore: Boolean, public val nextCursor: String? = null, public val snapshotAt: UtcTimestamp, -) +) { + init { + require(if (hasMore) !nextCursor.isNullOrBlank() else nextCursor == null) { + "nextCursor must match hasMore" + } + } +} public data class EventEnvelope( public val actor: ActorMetadata, @@ -85,7 +91,13 @@ public data class ProblemDetails( public val title: String? = null, public val titleKey: String? = null, public val type: String, -) +) { + init { + require(titleKey != null || messageKey != null) { + "at least one required schema alternative must be present" + } + } +} public data class ProblemDetailsFieldErrorsItem( public val code: String, diff --git a/packages/contracts/generated/python/databreeze_contracts/v1/_validation.py b/packages/contracts/generated/python/databreeze_contracts/v1/_validation.py new file mode 100644 index 00000000..18a60cf5 --- /dev/null +++ b/packages/contracts/generated/python/databreeze_contracts/v1/_validation.py @@ -0,0 +1,69 @@ +# Generated by @databreeze/contracts. DO NOT EDIT. + +from __future__ import annotations + +import re +from collections.abc import Mapping +from datetime import datetime +from typing import Any +from urllib.parse import urlsplit + +_UTC_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$") +_UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") +_INVALID_PERCENT_ENCODING = re.compile(r"%(?![0-9a-fA-F]{2})") +_INVALID_URI_CHARACTER = re.compile(r'[\x00-\x20\x7f<>"{}|\\^`]') + + +def _require_string(value: Any, format_name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{format_name} must be a string") + return value + + +def validate_uuid(value: Any) -> str: + value = _require_string(value, "uuid") + if _UUID.fullmatch(value) is None: + raise ValueError("value must be a UUID string") + return value + + +def validate_utc_timestamp(value: Any) -> str: + value = _require_string(value, "date-time") + if _UTC_TIMESTAMP.fullmatch(value) is None: + raise ValueError("value must be an RFC 3339 date-time ending in uppercase Z") + try: + datetime.fromisoformat(f"{value[:-1]}+00:00") + except ValueError as error: + raise ValueError("value must be a valid RFC 3339 date-time") from error + return value + + +def validate_uri_reference(value: Any) -> str: + value = _require_string(value, "uri-reference") + if _INVALID_URI_CHARACTER.search(value) or _INVALID_PERCENT_ENCODING.search(value): + raise ValueError("value must be an RFC 3986 URI reference") + try: + urlsplit(value) + except ValueError as error: + raise ValueError("value must be an RFC 3986 URI reference") from error + return value + + +def reject_explicit_null_properties( + value: Any, field_names: frozenset[str] +) -> Any: + if isinstance(value, Mapping): + null_fields = sorted( + field_name + for field_name in field_names + if field_name in value and value[field_name] is None + ) + if null_fields: + raise ValueError(f"null is not allowed for: {', '.join(null_fields)}") + return value + + +def serialization_options(options: dict[str, Any]) -> dict[str, Any]: + result = dict(options) + result.setdefault("exclude_unset", True) + return result diff --git a/packages/contracts/generated/python/databreeze_contracts/v1/models.py b/packages/contracts/generated/python/databreeze_contracts/v1/models.py index f47dfeaa..00438505 100644 --- a/packages/contracts/generated/python/databreeze_contracts/v1/models.py +++ b/packages/contracts/generated/python/databreeze_contracts/v1/models.py @@ -3,9 +3,25 @@ from __future__ import annotations from typing import Annotated, Any, Generic, Literal, Self, TypeAlias, TypeVar -from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + Field, + StrictBool, + StrictStr, + StringConstraints, + model_validator, +) + +from ._validation import ( + reject_explicit_null_properties, + serialization_options, + validate_uri_reference, + validate_utc_timestamp, + validate_uuid, +) JsonObject: TypeAlias = dict[str, Any] TData = TypeVar("TData") @@ -15,25 +31,36 @@ class ClosedModel(BaseModel): model_config = ConfigDict(extra="forbid") -Identifier: TypeAlias = UUID + @model_validator(mode="before") + @classmethod + def reject_explicit_nulls(cls, value: Any) -> Any: + return reject_explicit_null_properties(value, frozenset(cls.model_fields)) -Revision: TypeAlias = Annotated[int, Field(ge=1)] + def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + return super().model_dump(*args, **serialization_options(kwargs)) -UtcTimestamp: TypeAlias = Annotated[str, StringConstraints(pattern=r"Z$")] + def model_dump_json(self, *args: Any, **kwargs: Any) -> str: + return super().model_dump_json(*args, **serialization_options(kwargs)) + +Identifier: TypeAlias = Annotated[StrictStr, AfterValidator(validate_uuid)] + +Revision: TypeAlias = Annotated[int, Field(strict=True, ge=1)] + +UtcTimestamp: TypeAlias = Annotated[StrictStr, AfterValidator(validate_utc_timestamp)] class ActorMetadata(ClosedModel): actorId: Identifier - actorType: Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] + actorType: Annotated[StrictStr, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] class CommandEnvelope(ClosedModel, Generic[TData]): actor: ActorMetadata commandId: Identifier - commandType: Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$")] + commandType: Annotated[StrictStr, StringConstraints(pattern=r"^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$")] correlation: CorrelationMetadata data: TData - idempotencyKey: Annotated[str, StringConstraints(min_length=1, max_length=255)] + idempotencyKey: Annotated[StrictStr, StringConstraints(min_length=1, max_length=255)] issuedAt: UtcTimestamp - schemaVersion: Annotated[int, Field(ge=1)] + schemaVersion: Annotated[int, Field(strict=True, ge=1)] tenantScope: TenantScope class CorrelationMetadata(ClosedModel): @@ -43,8 +70,8 @@ class CorrelationMetadata(ClosedModel): class CursorPage(ClosedModel, Generic[TItem]): data: list[TItem] - hasMore: bool - nextCursor: Annotated[str, StringConstraints(min_length=1, max_length=4096)] | None = None + hasMore: StrictBool + nextCursor: Annotated[StrictStr, StringConstraints(min_length=1, max_length=4096)] | None = None snapshotAt: UtcTimestamp @model_validator(mode="after") @@ -61,37 +88,37 @@ class EventEnvelope(ClosedModel, Generic[TData]): data: TData entity: EventEnvelopeEntity eventId: Identifier - eventType: Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$")] + eventType: Annotated[StrictStr, StringConstraints(pattern=r"^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$")] occurredAt: UtcTimestamp - schemaVersion: Annotated[int, Field(ge=1)] - sourceComponent: Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] + schemaVersion: Annotated[int, Field(strict=True, ge=1)] + sourceComponent: Annotated[StrictStr, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] tenantScope: TenantScope class EventEnvelopeEntity(ClosedModel): entityId: Identifier - entityType: Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] + entityType: Annotated[StrictStr, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] revision: Revision class OrganizationScope(ClosedModel): organizationId: Identifier - scopeType: Literal["organization"] = "organization" + scopeType: Literal["organization"] class ProblemDetails(ClosedModel): - code: Annotated[str, StringConstraints(pattern=r"^[A-Z][A-Z0-9_]{0,127}$")] + code: Annotated[StrictStr, StringConstraints(pattern=r"^[A-Z][A-Z0-9_]{0,127}$")] correlationId: Identifier currentRevision: Revision | None = None - detail: str | None = None + detail: StrictStr | None = None fieldErrors: Annotated[list[ProblemDetailsFieldErrorsItem], Field(max_length=100)] | None = None - instance: str | None = None - messageKey: Annotated[str, StringConstraints(min_length=1, max_length=255)] | None = None + instance: Annotated[StrictStr, AfterValidator(validate_uri_reference)] | None = None + messageKey: Annotated[StrictStr, StringConstraints(min_length=1, max_length=255)] | None = None rateLimit: ProblemDetailsRateLimit | None = None - remediationAction: Annotated[str, StringConstraints(min_length=1, max_length=255)] | None = None - retryAfterSeconds: Annotated[int, Field(ge=0)] | None = None - retryable: bool - status: Annotated[int, Field(ge=100, le=599)] - title: Annotated[str, StringConstraints(min_length=1)] | None = None - titleKey: Annotated[str, StringConstraints(min_length=1, max_length=255)] | None = None - type: str + remediationAction: Annotated[StrictStr, StringConstraints(min_length=1, max_length=255)] | None = None + retryAfterSeconds: Annotated[int, Field(strict=True, ge=0)] | None = None + retryable: StrictBool + status: Annotated[int, Field(strict=True, ge=100, le=599)] + title: Annotated[StrictStr, StringConstraints(min_length=1)] | None = None + titleKey: Annotated[StrictStr, StringConstraints(min_length=1, max_length=255)] | None = None + type: Annotated[StrictStr, AfterValidator(validate_uri_reference)] @model_validator(mode="after") def require_schema_alternative(self) -> Self: @@ -100,24 +127,24 @@ def require_schema_alternative(self) -> Self: return self class ProblemDetailsFieldErrorsItem(ClosedModel): - code: Annotated[str, StringConstraints(pattern=r"^[A-Z][A-Z0-9_]{0,127}$")] - field: Annotated[str, StringConstraints(min_length=1, max_length=255)] + code: Annotated[StrictStr, StringConstraints(pattern=r"^[A-Z][A-Z0-9_]{0,127}$")] + field: Annotated[StrictStr, StringConstraints(min_length=1, max_length=255)] class ProblemDetailsRateLimit(ClosedModel): - limit: Annotated[int, Field(ge=0)] | None = None - remaining: Annotated[int, Field(ge=0)] | None = None + limit: Annotated[int, Field(strict=True, ge=0)] | None = None + remaining: Annotated[int, Field(strict=True, ge=0)] | None = None resetAt: UtcTimestamp - scope: Annotated[str, StringConstraints(min_length=1, max_length=255)] + scope: Annotated[StrictStr, StringConstraints(min_length=1, max_length=255)] class ProjectScope(ClosedModel): organizationId: Identifier projectId: Identifier - scopeType: Literal["project"] = "project" + scopeType: Literal["project"] workspaceId: Identifier class WorkspaceScope(ClosedModel): organizationId: Identifier - scopeType: Literal["workspace"] = "workspace" + scopeType: Literal["workspace"] workspaceId: Identifier TenantScope: TypeAlias = Annotated[OrganizationScope | WorkspaceScope | ProjectScope, Field(discriminator="scopeType")] diff --git a/packages/contracts/scripts/contract-generator.mjs b/packages/contracts/scripts/contract-generator.mjs index a2086eea..d5a51be8 100644 --- a/packages/contracts/scripts/contract-generator.mjs +++ b/packages/contracts/scripts/contract-generator.mjs @@ -55,7 +55,10 @@ function assertPlainObject(value, label) { } function validateSchemaNode(node, sourcePath, location = '#') { - if (typeof node === 'boolean') return; + if (node === true) return; + if (node === false) { + fail(`Unsupported boolean JSON Schema false in ${sourcePath}${location}`); + } assertPlainObject(node, `${sourcePath}${location}`); for (const keyword of Object.keys(node)) { @@ -495,10 +498,44 @@ function renderKotlin(context) { ` public val ${propertyName}: ${kotlinType(property, entry, context, parameterMap)}${optional}${defaultValue},`, ); } + const body = []; if (parent) { - lines.push(`) : ${parent} {`); const literal = node.properties[discriminator].const; - lines.push(` public override val ${discriminator}: String = ${quoted(literal)}`, '}', ''); + body.push(` public override val ${discriminator}: String = ${quoted(literal)}`); + } + if (node.anyOf) { + const alternatives = requiredAlternatives(node, entry.path).map((requiredProperties) => + requiredProperties.map((property) => `${property} != null`).join(' && '), + ); + if (body.length) body.push(''); + body.push( + ' init {', + ` require(${alternatives.join(' || ')}) {`, + ' "at least one required schema alternative must be present"', + ' }', + ' }', + ); + } + if (node.allOf) { + const conditional = conditionalConstraint(node, entry.path); + const present = + node.properties[conditional.dependent].type === 'string' + ? `!${conditional.dependent}.isNullOrBlank()` + : `${conditional.dependent} != null`; + const discriminator = conditional.value + ? conditional.discriminator + : `!${conditional.discriminator}`; + if (body.length) body.push(''); + body.push( + ' init {', + ` require(if (${discriminator}) ${present} else ${conditional.dependent} == null) {`, + ` "${conditional.dependent} must match ${conditional.discriminator}"`, + ' }', + ' }', + ); + } + if (body.length) { + lines.push(parent ? `) : ${parent} {` : ') {', ...body, '}', ''); } else { lines.push(')', ''); } @@ -550,9 +587,25 @@ function renderPython(context) { 'from __future__ import annotations', '', 'from typing import Annotated, Any, Generic, Literal, Self, TypeAlias, TypeVar', - 'from uuid import UUID', '', - 'from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator', + 'from pydantic import (', + ' AfterValidator,', + ' BaseModel,', + ' ConfigDict,', + ' Field,', + ' StrictBool,', + ' StrictStr,', + ' StringConstraints,', + ' model_validator,', + ')', + '', + 'from ._validation import (', + ' reject_explicit_null_properties,', + ' serialization_options,', + ' validate_uri_reference,', + ' validate_utc_timestamp,', + ' validate_uuid,', + ')', '', 'JsonObject: TypeAlias = dict[str, Any]', ]; @@ -566,6 +619,17 @@ function renderPython(context) { 'class ClosedModel(BaseModel):', ' model_config = ConfigDict(extra="forbid")', '', + ' @model_validator(mode="before")', + ' @classmethod', + ' def reject_explicit_nulls(cls, value: Any) -> Any:', + ' return reject_explicit_null_properties(value, frozenset(cls.model_fields))', + '', + ' def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]:', + ' return super().model_dump(*args, **serialization_options(kwargs))', + '', + ' def model_dump_json(self, *args: Any, **kwargs: Any) -> str:', + ' return super().model_dump_json(*args, **serialization_options(kwargs))', + '', ); for (const [name, node] of models) { @@ -590,9 +654,7 @@ function renderPython(context) { } else { for (const [propertyName, property] of properties) { const optional = required.has(propertyName) ? '' : ' | None'; - let defaultValue = required.has(propertyName) ? '' : ' = None'; - if ('const' in property && required.has(propertyName)) - defaultValue = ` = ${quoted(property.const)}`; + const defaultValue = required.has(propertyName) ? '' : ' = None'; lines.push( ` ${propertyName}: ${pythonType(property, entry, context, parameterMap)}${optional}${defaultValue}`, ); @@ -654,22 +716,30 @@ function pythonType(node, entry, context, parameters) { if ('const' in node) return `Literal[${quoted(node.const)}]`; if (node.oneOf) return context.nameByNode.get(node); if (node.type === 'string') { - if (node.format === 'uuid') return 'UUID'; + if (node.format === 'uuid') { + return 'Annotated[StrictStr, AfterValidator(validate_uuid)]'; + } + if (node.format === 'date-time') { + return 'Annotated[StrictStr, AfterValidator(validate_utc_timestamp)]'; + } + if (node.format === 'uri-reference') { + return 'Annotated[StrictStr, AfterValidator(validate_uri_reference)]'; + } const argumentsList = []; if (node.minLength !== undefined) argumentsList.push(`min_length=${node.minLength}`); if (node.maxLength !== undefined) argumentsList.push(`max_length=${node.maxLength}`); if (node.pattern !== undefined) argumentsList.push(`pattern=${pythonRawString(node.pattern)}`); return argumentsList.length - ? `Annotated[str, StringConstraints(${argumentsList.join(', ')})]` - : 'str'; + ? `Annotated[StrictStr, StringConstraints(${argumentsList.join(', ')})]` + : 'StrictStr'; } if (node.type === 'integer') { - const argumentsList = []; + const argumentsList = ['strict=True']; if (node.minimum !== undefined) argumentsList.push(`ge=${node.minimum}`); if (node.maximum !== undefined) argumentsList.push(`le=${node.maximum}`); - return argumentsList.length ? `Annotated[int, Field(${argumentsList.join(', ')})]` : 'int'; + return `Annotated[int, Field(${argumentsList.join(', ')})]`; } - if (node.type === 'boolean') return 'bool'; + if (node.type === 'boolean') return 'StrictBool'; if (node.type === 'array') { const list = `list[${pythonType(node.items, entry, context, parameters)}]`; return node.maxItems !== undefined @@ -739,6 +809,82 @@ function renderPythonPackage(context) { return { packageInit, versionInit }; } +function renderPythonValidation() { + return `${[ + `# ${HEADER}`, + '', + 'from __future__ import annotations', + '', + 'import re', + 'from collections.abc import Mapping', + 'from datetime import datetime', + 'from typing import Any', + 'from urllib.parse import urlsplit', + '', + '_UTC_TIMESTAMP = re.compile(r"^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z$")', + '_UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")', + '_INVALID_PERCENT_ENCODING = re.compile(r"%(?![0-9a-fA-F]{2})")', + "_INVALID_URI_CHARACTER = re.compile(r'[\\x00-\\x20\\x7f<>\"{}|\\\\^`]')", + '', + '', + 'def _require_string(value: Any, format_name: str) -> str:', + ' if not isinstance(value, str):', + ' raise TypeError(f"{format_name} must be a string")', + ' return value', + '', + '', + 'def validate_uuid(value: Any) -> str:', + ' value = _require_string(value, "uuid")', + ' if _UUID.fullmatch(value) is None:', + ' raise ValueError("value must be a UUID string")', + ' return value', + '', + '', + 'def validate_utc_timestamp(value: Any) -> str:', + ' value = _require_string(value, "date-time")', + ' if _UTC_TIMESTAMP.fullmatch(value) is None:', + ' raise ValueError("value must be an RFC 3339 date-time ending in uppercase Z")', + ' try:', + ' datetime.fromisoformat(f"{value[:-1]}+00:00")', + ' except ValueError as error:', + ' raise ValueError("value must be a valid RFC 3339 date-time") from error', + ' return value', + '', + '', + 'def validate_uri_reference(value: Any) -> str:', + ' value = _require_string(value, "uri-reference")', + ' if _INVALID_URI_CHARACTER.search(value) or _INVALID_PERCENT_ENCODING.search(value):', + ' raise ValueError("value must be an RFC 3986 URI reference")', + ' try:', + ' urlsplit(value)', + ' except ValueError as error:', + ' raise ValueError("value must be an RFC 3986 URI reference") from error', + ' return value', + '', + '', + 'def reject_explicit_null_properties(', + ' value: Any, field_names: frozenset[str]', + ') -> Any:', + ' if isinstance(value, Mapping):', + ' null_fields = sorted(', + ' field_name', + ' for field_name in field_names', + ' if field_name in value and value[field_name] is None', + ' )', + ' if null_fields:', + ' raise ValueError(f"null is not allowed for: {\', \'.join(null_fields)}")', + ' return value', + '', + '', + 'def serialization_options(options: dict[str, Any]) -> dict[str, Any]:', + ' result = dict(options)', + ' result.setdefault("exclude_unset", True)', + ' return result', + ] + .join('\n') + .trimEnd()}\n`; +} + export function generateContractFiles(sourceRoot) { const context = buildModelContext(loadRegistry(sourceRoot)); const { packageInit, versionInit } = renderPythonPackage(context); @@ -747,6 +893,7 @@ export function generateContractFiles(sourceRoot) { ['python/databreeze_contracts/__init__.py', packageInit], ['python/databreeze_contracts/py.typed', ''], ['python/databreeze_contracts/v1/__init__.py', versionInit], + ['python/databreeze_contracts/v1/_validation.py', renderPythonValidation()], ['python/databreeze_contracts/v1/models.py', renderPython(context)], ['typescript/v1/index.ts', renderTypeScript(context)], ]); diff --git a/packages/contracts/test/generation.test.mjs b/packages/contracts/test/generation.test.mjs index fe68e8ec..3a6ddc05 100644 --- a/packages/contracts/test/generation.test.mjs +++ b/packages/contracts/test/generation.test.mjs @@ -25,6 +25,7 @@ const expectedFiles = [ 'python/databreeze_contracts/__init__.py', 'python/databreeze_contracts/py.typed', 'python/databreeze_contracts/v1/__init__.py', + 'python/databreeze_contracts/v1/_validation.py', 'python/databreeze_contracts/v1/models.py', 'typescript/v1/index.ts', ]; @@ -65,6 +66,15 @@ function withTemporaryDirectory(run) { } } +function runPythonValidationProgram(program) { + const source = resolve(generatedRoot, 'python/databreeze_contracts/v1/_validation.py'); + assert.equal(existsSync(source), true, 'generated Python validation helpers are missing'); + return spawnSync('python', ['-c', program, source], { + cwd: packageRoot, + encoding: 'utf8', + }); +} + test('generates byte-stable language packages from a controlled registry', () => { withTemporaryDirectory((directory) => { const first = resolve(directory, 'first'); @@ -134,6 +144,23 @@ test('fails loudly when a schema uses an unsupported construct', () => { }); }); +test('fails loudly when a schema contains the unsatisfiable false schema', () => { + withTemporaryDirectory((directory) => { + const source = resolve(directory, 'source'); + cpSync(fixtureRoot, source, { recursive: true }); + const schemaPath = resolve(source, 'schemas/v1/sample-envelope.schema.json'); + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + schema.properties.labels.items = false; + writeFileSync(schemaPath, `${JSON.stringify(schema, null, 2)}\n`); + + const result = runGenerator('--source', source, '--output', resolve(directory, 'output')); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Unsupported boolean JSON Schema false/); + assert.match(result.stderr, /sample-envelope\.schema\.json#\/properties\/labels\/items/); + }); +}); + test('drift check reports missing stale and unexpected files without mutation', () => { withTemporaryDirectory((directory) => { const output = resolve(directory, 'output'); @@ -240,6 +267,131 @@ test('the checked-in Python package compiles with the available interpreter', () assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); }); +test('generated Pydantic models reject explicit nulls and omit absent properties by default', () => { + const models = readFileSync( + resolve(generatedRoot, 'python/databreeze_contracts/v1/models.py'), + 'utf8', + ); + assert.match( + models, + /return reject_explicit_null_properties\(value, frozenset\(cls\.model_fields\)\)/, + ); + assert.match( + models, + /return super\(\)\.model_dump\(\*args, \*\*serialization_options\(kwargs\)\)/, + ); + assert.match( + models, + /return super\(\)\.model_dump_json\(\*args, \*\*serialization_options\(kwargs\)\)/, + ); + + const program = [ + 'import json', + 'import runpy', + 'import sys', + 'helpers = runpy.run_path(sys.argv[1])', + 'reject = helpers["reject_explicit_null_properties"]', + 'options = helpers["serialization_options"]', + 'missing = reject({"status": 400}, frozenset({"status", "detail"}))', + 'try:', + ' reject({"status": 400, "detail": None}, frozenset({"status", "detail"}))', + ' explicit_null_rejected = False', + 'except ValueError:', + ' explicit_null_rejected = True', + 'print(json.dumps([missing, explicit_null_rejected, options({}), options({"exclude_unset": False})], sort_keys=True))', + ].join('\n'); + const result = runPythonValidationProgram(program); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal( + result.stdout.trim(), + '[{"status": 400}, true, {"exclude_unset": true}, {"exclude_unset": false}]', + ); +}); + +test('generated Python helpers preserve formats and strict primitive declarations', () => { + const models = readFileSync( + resolve(generatedRoot, 'python/databreeze_contracts/v1/models.py'), + 'utf8', + ); + assert.match( + models, + /Identifier: TypeAlias = Annotated\[StrictStr, AfterValidator\(validate_uuid\)\]/, + ); + assert.match( + models, + /UtcTimestamp: TypeAlias = Annotated\[StrictStr, AfterValidator\(validate_utc_timestamp\)\]/, + ); + assert.match(models, /status: Annotated\[int, Field\(strict=True, ge=100, le=599\)\]/); + assert.match(models, /retryable: StrictBool/); + assert.match(models, /type: Annotated\[StrictStr, AfterValidator\(validate_uri_reference\)\]/); + assert.match( + models, + /instance: Annotated\[StrictStr, AfterValidator\(validate_uri_reference\)\] \| None = None/, + ); + + const program = [ + 'import json', + 'import runpy', + 'import sys', + 'helpers = runpy.run_path(sys.argv[1])', + 'utc = helpers["validate_utc_timestamp"]', + 'uri = helpers["validate_uri_reference"]', + 'uuid = helpers["validate_uuid"]', + 'def rejects(function, value):', + ' try:', + ' function(value)', + ' return False', + ' except (TypeError, ValueError):', + ' return True', + 'results = [', + ' utc("2026-08-01T01:30:00.125Z"),', + ' rejects(utc, "2026-08-01T08:30:00+07:00"),', + ' rejects(utc, "2026-02-30T01:30:00Z"),', + ' rejects(utc, 123),', + ' uri("about:blank"),', + ' uri("/problems/conflict?source=api#field"),', + ' rejects(uri, "/problems/has space"),', + ' rejects(uri, "/problems/%GG"),', + ' uuid("018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01"),', + ' rejects(uuid, "not-a-uuid"),', + ']', + 'print(json.dumps(results))', + ].join('\n'); + const result = runPythonValidationProgram(program); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal( + result.stdout.trim(), + '["2026-08-01T01:30:00.125Z", true, true, true, "about:blank", "/problems/conflict?source=api#field", true, true, "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", true]', + ); +}); + +test('generated Pydantic tenant discriminators remain required', () => { + const models = readFileSync( + resolve(generatedRoot, 'python/databreeze_contracts/v1/models.py'), + 'utf8', + ); + + assert.match(models, /scopeType: Literal\["organization"\]\n/); + assert.match(models, /scopeType: Literal\["workspace"\]\n/); + assert.match(models, /scopeType: Literal\["project"\]\n/); + assert.doesNotMatch(models, /scopeType: Literal\[[^\]]+\] =/); +}); + +test('generated Kotlin models preserve cursor and problem constructor invariants', () => { + const kotlin = readFileSync( + resolve(generatedRoot, 'kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt'), + 'utf8', + ); + + assert.match( + kotlin, + /require\(if \(hasMore\) !nextCursor\.isNullOrBlank\(\) else nextCursor == null\)/, + ); + assert.match(kotlin, /require\(titleKey != null \|\| messageKey != null\)/); +}); + test('the checked-in Kotlin models have a deterministic standard-Kotlin structure', () => { const source = resolve( generatedRoot, From f7359b3eada190b0cd69d3cf6f1cafc0f1553513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 04:04:34 +0700 Subject: [PATCH 12/51] fix(contracts): align generated format validation --- .../com/databreeze/contracts/v1/Models.kt | 2 +- .../databreeze_contracts/v1/_validation.py | 41 +++---- .../contracts/generated/python/pyproject.toml | 11 ++ packages/contracts/package.json | 3 +- .../contracts/scripts/contract-generator.mjs | 60 ++++++---- packages/contracts/test/generation.test.mjs | 79 ++++++++------ .../test/python-format-runtime-probe.mjs | 103 ++++++++++++++++++ 7 files changed, 222 insertions(+), 77 deletions(-) create mode 100644 packages/contracts/generated/python/pyproject.toml create mode 100644 packages/contracts/test/python-format-runtime-probe.mjs diff --git a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt index c6ed86da..295949ab 100644 --- a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt +++ b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt @@ -44,7 +44,7 @@ public data class CursorPage( public val snapshotAt: UtcTimestamp, ) { init { - require(if (hasMore) !nextCursor.isNullOrBlank() else nextCursor == null) { + require(if (hasMore) !nextCursor.isNullOrEmpty() else nextCursor == null) { "nextCursor must match hasMore" } } diff --git a/packages/contracts/generated/python/databreeze_contracts/v1/_validation.py b/packages/contracts/generated/python/databreeze_contracts/v1/_validation.py index 18a60cf5..50aaf4f2 100644 --- a/packages/contracts/generated/python/databreeze_contracts/v1/_validation.py +++ b/packages/contracts/generated/python/databreeze_contracts/v1/_validation.py @@ -2,16 +2,8 @@ from __future__ import annotations -import re from collections.abc import Mapping -from datetime import datetime from typing import Any -from urllib.parse import urlsplit - -_UTC_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$") -_UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") -_INVALID_PERCENT_ENCODING = re.compile(r"%(?![0-9a-fA-F]{2})") -_INVALID_URI_CHARACTER = re.compile(r'[\x00-\x20\x7f<>"{}|\\^`]') def _require_string(value: Any, format_name: str) -> str: @@ -21,31 +13,42 @@ def _require_string(value: Any, format_name: str) -> str: def validate_uuid(value: Any) -> str: + from uuid import UUID + value = _require_string(value, "uuid") - if _UUID.fullmatch(value) is None: + candidate = value[9:] if value.lower().startswith("urn:uuid:") else value + try: + parsed = UUID(candidate) + except ValueError as error: + raise ValueError("value must be a UUID string") from error + if str(parsed) != candidate.lower(): raise ValueError("value must be a UUID string") return value def validate_utc_timestamp(value: Any) -> str: + from rfc3339_validator import validate_rfc3339 + value = _require_string(value, "date-time") - if _UTC_TIMESTAMP.fullmatch(value) is None: + valid = bool(validate_rfc3339(value)) + if not valid: + prefix, separator, seconds = value.rpartition(":") + valid = bool( + separator + and seconds.startswith("60") + and validate_rfc3339(f"{prefix}:59{seconds[2:]}") + ) + if not value.endswith("Z") or not valid: raise ValueError("value must be an RFC 3339 date-time ending in uppercase Z") - try: - datetime.fromisoformat(f"{value[:-1]}+00:00") - except ValueError as error: - raise ValueError("value must be a valid RFC 3339 date-time") from error return value def validate_uri_reference(value: Any) -> str: + from rfc3986_validator import validate_rfc3986 + value = _require_string(value, "uri-reference") - if _INVALID_URI_CHARACTER.search(value) or _INVALID_PERCENT_ENCODING.search(value): + if validate_rfc3986(value, rule="URI_reference") is None: raise ValueError("value must be an RFC 3986 URI reference") - try: - urlsplit(value) - except ValueError as error: - raise ValueError("value must be an RFC 3986 URI reference") from error return value diff --git a/packages/contracts/generated/python/pyproject.toml b/packages/contracts/generated/python/pyproject.toml new file mode 100644 index 00000000..9abdceb1 --- /dev/null +++ b/packages/contracts/generated/python/pyproject.toml @@ -0,0 +1,11 @@ +# Generated by @databreeze/contracts. DO NOT EDIT. + +[project] +name = "databreeze-contracts" +version = "1.0.0" +requires-python = ">=3.13" +dependencies = [ + "pydantic==2.13.4", + "rfc3339-validator==0.1.4", + "rfc3986-validator==0.1.1", +] diff --git a/packages/contracts/package.json b/packages/contracts/package.json index f4e8f545..be856b63 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -23,7 +23,8 @@ "build": "node scripts/build.mjs && node scripts/generate-models.mjs --check", "generate": "node scripts/generate-models.mjs", "generate:check": "node scripts/generate-models.mjs --check", - "test": "node --test test/**/*.test.mjs" + "test": "node --test test/**/*.test.mjs", + "test:python-formats": "node test/python-format-runtime-probe.mjs" }, "devDependencies": { "ajv": "8.17.1", diff --git a/packages/contracts/scripts/contract-generator.mjs b/packages/contracts/scripts/contract-generator.mjs index d5a51be8..504c4f4f 100644 --- a/packages/contracts/scripts/contract-generator.mjs +++ b/packages/contracts/scripts/contract-generator.mjs @@ -520,7 +520,7 @@ function renderKotlin(context) { const conditional = conditionalConstraint(node, entry.path); const present = node.properties[conditional.dependent].type === 'string' - ? `!${conditional.dependent}.isNullOrBlank()` + ? `!${conditional.dependent}.isNullOrEmpty()` : `${conditional.dependent} != null`; const discriminator = conditional.value ? conditional.discriminator @@ -815,16 +815,8 @@ function renderPythonValidation() { '', 'from __future__ import annotations', '', - 'import re', 'from collections.abc import Mapping', - 'from datetime import datetime', 'from typing import Any', - 'from urllib.parse import urlsplit', - '', - '_UTC_TIMESTAMP = re.compile(r"^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z$")', - '_UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")', - '_INVALID_PERCENT_ENCODING = re.compile(r"%(?![0-9a-fA-F]{2})")', - "_INVALID_URI_CHARACTER = re.compile(r'[\\x00-\\x20\\x7f<>\"{}|\\\\^`]')", '', '', 'def _require_string(value: Any, format_name: str) -> str:', @@ -834,31 +826,42 @@ function renderPythonValidation() { '', '', 'def validate_uuid(value: Any) -> str:', + ' from uuid import UUID', + '', ' value = _require_string(value, "uuid")', - ' if _UUID.fullmatch(value) is None:', + ' candidate = value[9:] if value.lower().startswith("urn:uuid:") else value', + ' try:', + ' parsed = UUID(candidate)', + ' except ValueError as error:', + ' raise ValueError("value must be a UUID string") from error', + ' if str(parsed) != candidate.lower():', ' raise ValueError("value must be a UUID string")', ' return value', '', '', 'def validate_utc_timestamp(value: Any) -> str:', + ' from rfc3339_validator import validate_rfc3339', + '', ' value = _require_string(value, "date-time")', - ' if _UTC_TIMESTAMP.fullmatch(value) is None:', + ' valid = bool(validate_rfc3339(value))', + ' if not valid:', + ' prefix, separator, seconds = value.rpartition(":")', + ' valid = bool(', + ' separator', + ' and seconds.startswith("60")', + ' and validate_rfc3339(f"{prefix}:59{seconds[2:]}")', + ' )', + ' if not value.endswith("Z") or not valid:', ' raise ValueError("value must be an RFC 3339 date-time ending in uppercase Z")', - ' try:', - ' datetime.fromisoformat(f"{value[:-1]}+00:00")', - ' except ValueError as error:', - ' raise ValueError("value must be a valid RFC 3339 date-time") from error', ' return value', '', '', 'def validate_uri_reference(value: Any) -> str:', + ' from rfc3986_validator import validate_rfc3986', + '', ' value = _require_string(value, "uri-reference")', - ' if _INVALID_URI_CHARACTER.search(value) or _INVALID_PERCENT_ENCODING.search(value):', + ' if validate_rfc3986(value, rule="URI_reference") is None:', ' raise ValueError("value must be an RFC 3986 URI reference")', - ' try:', - ' urlsplit(value)', - ' except ValueError as error:', - ' raise ValueError("value must be an RFC 3986 URI reference") from error', ' return value', '', '', @@ -885,6 +888,22 @@ function renderPythonValidation() { .trimEnd()}\n`; } +function renderPythonProject() { + return `${[ + `# ${HEADER}`, + '', + '[project]', + 'name = "databreeze-contracts"', + 'version = "1.0.0"', + 'requires-python = ">=3.13"', + 'dependencies = [', + ' "pydantic==2.13.4",', + ' "rfc3339-validator==0.1.4",', + ' "rfc3986-validator==0.1.1",', + ']', + ].join('\n')}\n`; +} + export function generateContractFiles(sourceRoot) { const context = buildModelContext(loadRegistry(sourceRoot)); const { packageInit, versionInit } = renderPythonPackage(context); @@ -895,6 +914,7 @@ export function generateContractFiles(sourceRoot) { ['python/databreeze_contracts/v1/__init__.py', versionInit], ['python/databreeze_contracts/v1/_validation.py', renderPythonValidation()], ['python/databreeze_contracts/v1/models.py', renderPython(context)], + ['python/pyproject.toml', renderPythonProject()], ['typescript/v1/index.ts', renderTypeScript(context)], ]); } diff --git a/packages/contracts/test/generation.test.mjs b/packages/contracts/test/generation.test.mjs index 3a6ddc05..a1bbf3ba 100644 --- a/packages/contracts/test/generation.test.mjs +++ b/packages/contracts/test/generation.test.mjs @@ -15,6 +15,8 @@ import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import test from 'node:test'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const generatorPath = resolve(packageRoot, 'scripts/generate-models.mjs'); @@ -27,6 +29,7 @@ const expectedFiles = [ 'python/databreeze_contracts/v1/__init__.py', 'python/databreeze_contracts/v1/_validation.py', 'python/databreeze_contracts/v1/models.py', + 'python/pyproject.toml', 'typescript/v1/index.ts', ]; @@ -329,41 +332,43 @@ test('generated Python helpers preserve formats and strict primitive declaration models, /instance: Annotated\[StrictStr, AfterValidator\(validate_uri_reference\)\] \| None = None/, ); - - const program = [ - 'import json', - 'import runpy', - 'import sys', - 'helpers = runpy.run_path(sys.argv[1])', - 'utc = helpers["validate_utc_timestamp"]', - 'uri = helpers["validate_uri_reference"]', - 'uuid = helpers["validate_uuid"]', - 'def rejects(function, value):', - ' try:', - ' function(value)', - ' return False', - ' except (TypeError, ValueError):', - ' return True', - 'results = [', - ' utc("2026-08-01T01:30:00.125Z"),', - ' rejects(utc, "2026-08-01T08:30:00+07:00"),', - ' rejects(utc, "2026-02-30T01:30:00Z"),', - ' rejects(utc, 123),', - ' uri("about:blank"),', - ' uri("/problems/conflict?source=api#field"),', - ' rejects(uri, "/problems/has space"),', - ' rejects(uri, "/problems/%GG"),', - ' uuid("018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01"),', - ' rejects(uuid, "not-a-uuid"),', - ']', - 'print(json.dumps(results))', - ].join('\n'); - const result = runPythonValidationProgram(program); - - assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); - assert.equal( - result.stdout.trim(), - '["2026-08-01T01:30:00.125Z", true, true, true, "about:blank", "/problems/conflict?source=api#field", true, true, "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", true]', + const pyprojectPath = resolve(generatedRoot, 'python/pyproject.toml'); + assert.equal(existsSync(pyprojectPath), true, 'generated Python dependency manifest is missing'); + const pyproject = readFileSync(pyprojectPath, 'utf8'); + assert.match(pyproject, /"pydantic==2\.13\.4"/); + assert.match(pyproject, /"rfc3339-validator==0\.1\.4"/); + assert.match(pyproject, /"rfc3986-validator==0\.1\.1"/); + + const helpers = readFileSync( + resolve(generatedRoot, 'python/databreeze_contracts/v1/_validation.py'), + 'utf8', + ); + assert.match(helpers, /from uuid import UUID/); + assert.match(helpers, /from rfc3339_validator import validate_rfc3339/); + assert.match(helpers, /from rfc3986_validator import validate_rfc3986/); + assert.match(helpers, /UUID\(candidate\)/); + assert.match(helpers, /validate_rfc3339\(value\)/); + assert.match(helpers, /validate_rfc3986\(value, rule="URI_reference"\)/); + assert.doesNotMatch(helpers, /datetime\.fromisoformat|urlsplit|_UTC_TIMESTAMP|_UUID =/); + + const ajv = new Ajv2020({ strict: true }); + addFormats(ajv); + const validators = { + 'date-time': ajv.compile({ type: 'string', format: 'date-time', pattern: 'Z$' }), + 'uri-reference': ajv.compile({ type: 'string', format: 'uri-reference' }), + uuid: ajv.compile({ type: 'string', format: 'uuid' }), + }; + const cases = [ + ['uri-reference', 'abc]'], + ['uri-reference', 'http://example.com/[]'], + ['uri-reference', 'foo#bar#baz'], + ['date-time', '2016-12-31T23:59:60Z'], + ['date-time', '2016-12-31T23:60:00Z'], + ['uuid', 'urn:uuid:018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01'], + ]; + assert.deepEqual( + cases.map(([format, value]) => validators[format](value)), + [false, false, false, true, false, true], ); }); @@ -387,8 +392,10 @@ test('generated Kotlin models preserve cursor and problem constructor invariants assert.match( kotlin, - /require\(if \(hasMore\) !nextCursor\.isNullOrBlank\(\) else nextCursor == null\)/, + /require\(if \(hasMore\) !nextCursor\.isNullOrEmpty\(\) else nextCursor == null\)/, ); + assert.doesNotMatch(kotlin, /nextCursor\.isNullOrBlank\(\)/); + assert.equal(' '.length >= 1, true, 'JSON Schema minLength: 1 accepts a whitespace cursor'); assert.match(kotlin, /require\(titleKey != null \|\| messageKey != null\)/); }); diff --git a/packages/contracts/test/python-format-runtime-probe.mjs b/packages/contracts/test/python-format-runtime-probe.mjs new file mode 100644 index 00000000..4b814aa9 --- /dev/null +++ b/packages/contracts/test/python-format-runtime-probe.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const generatedPython = resolve(packageRoot, 'generated/python'); +const pyproject = readFileSync(resolve(generatedPython, 'pyproject.toml'), 'utf8'); +const dependenciesSection = /dependencies\s*=\s*\[([\s\S]*?)\]/u.exec(pyproject); +assert.ok(dependenciesSection, 'generated pyproject.toml must declare project dependencies'); +const dependencies = [...dependenciesSection[1].matchAll(/"([^"]+)"/gu)].map((match) => match[1]); +assert.deepEqual(dependencies, [ + 'pydantic==2.13.4', + 'rfc3339-validator==0.1.4', + 'rfc3986-validator==0.1.1', +]); + +const cases = [ + { format: 'uri-reference', value: 'abc]' }, + { format: 'uri-reference', value: 'http://example.com/[]' }, + { format: 'uri-reference', value: 'foo#bar#baz' }, + { format: 'date-time', value: '2016-12-31T23:59:60Z' }, + { format: 'date-time', value: '2016-12-31T23:60:00Z' }, + { format: 'uuid', value: 'urn:uuid:018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01' }, +]; +const ajv = new Ajv2020({ strict: true }); +addFormats(ajv); +const ajvValidators = { + 'date-time': ajv.compile({ type: 'string', format: 'date-time', pattern: 'Z$' }), + 'uri-reference': ajv.compile({ type: 'string', format: 'uri-reference' }), + uuid: ajv.compile({ type: 'string', format: 'uuid' }), +}; +const expected = cases.map(({ format, value }) => ({ + accepted: ajvValidators[format](value), + returnedOriginal: ajvValidators[format](value) ? true : null, +})); +assert.deepEqual( + expected.map(({ accepted }) => accepted), + [false, false, false, true, false, true], + 'canonical Ajv expectations changed', +); + +const pythonProgram = [ + 'import importlib.util', + 'import json', + 'import sys', + 'sys.dont_write_bytecode = True', + 'sys.path.insert(0, sys.argv[1])', + 'spec = importlib.util.spec_from_file_location("generated_validation", sys.argv[2])', + 'module = importlib.util.module_from_spec(spec)', + 'spec.loader.exec_module(module)', + 'cases = json.loads(sys.argv[3])', + 'function_names = {"date-time": "validate_utc_timestamp", "uri-reference": "validate_uri_reference", "uuid": "validate_uuid"}', + 'results = []', + 'for case in cases:', + ' function = getattr(module, function_names[case["format"]])', + ' try:', + ' result = function(case["value"])', + ' results.append({"accepted": True, "returnedOriginal": result == case["value"]})', + ' except (TypeError, ValueError):', + ' results.append({"accepted": False, "returnedOriginal": None})', + 'print(json.dumps(results))', +].join('\n'); + +const temporaryRoot = mkdtempSync(resolve(tmpdir(), 'databreeze-python-formats-')); +try { + const dependenciesRoot = resolve(temporaryRoot, 'site-packages'); + const install = spawnSync( + 'python', + [ + '-m', + 'pip', + 'install', + '--disable-pip-version-check', + '--quiet', + '--target', + dependenciesRoot, + ...dependencies, + ], + { cwd: packageRoot, encoding: 'utf8' }, + ); + assert.equal(install.status, 0, `${install.stdout}\n${install.stderr}`); + + const validationPath = resolve(generatedPython, 'databreeze_contracts/v1/_validation.py'); + const probe = spawnSync( + 'python', + ['-c', pythonProgram, dependenciesRoot, validationPath, JSON.stringify(cases)], + { + cwd: packageRoot, + encoding: 'utf8', + env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' }, + }, + ); + assert.equal(probe.status, 0, `${probe.stdout}\n${probe.stderr}`); + assert.deepEqual(JSON.parse(probe.stdout), expected); + console.log(`Python format runtime parity probe passed for ${cases.length} cases.`); +} finally { + rmSync(temporaryRoot, { recursive: true, force: true }); +} From 399e5aa80d819e205accc0dcc7b546aca647ac14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 04:11:13 +0700 Subject: [PATCH 13/51] fix(contracts): constrain leap second validation --- .../generated/python/databreeze_contracts/v1/_validation.py | 1 + packages/contracts/scripts/contract-generator.mjs | 1 + packages/contracts/test/python-format-runtime-probe.mjs | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/contracts/generated/python/databreeze_contracts/v1/_validation.py b/packages/contracts/generated/python/databreeze_contracts/v1/_validation.py index 50aaf4f2..84db6c31 100644 --- a/packages/contracts/generated/python/databreeze_contracts/v1/_validation.py +++ b/packages/contracts/generated/python/databreeze_contracts/v1/_validation.py @@ -35,6 +35,7 @@ def validate_utc_timestamp(value: Any) -> str: prefix, separator, seconds = value.rpartition(":") valid = bool( separator + and prefix.endswith(("T23:59", "t23:59")) and seconds.startswith("60") and validate_rfc3339(f"{prefix}:59{seconds[2:]}") ) diff --git a/packages/contracts/scripts/contract-generator.mjs b/packages/contracts/scripts/contract-generator.mjs index 504c4f4f..25bc9c34 100644 --- a/packages/contracts/scripts/contract-generator.mjs +++ b/packages/contracts/scripts/contract-generator.mjs @@ -848,6 +848,7 @@ function renderPythonValidation() { ' prefix, separator, seconds = value.rpartition(":")', ' valid = bool(', ' separator', + ' and prefix.endswith(("T23:59", "t23:59"))', ' and seconds.startswith("60")', ' and validate_rfc3339(f"{prefix}:59{seconds[2:]}")', ' )', diff --git a/packages/contracts/test/python-format-runtime-probe.mjs b/packages/contracts/test/python-format-runtime-probe.mjs index 4b814aa9..7d5d08e0 100644 --- a/packages/contracts/test/python-format-runtime-probe.mjs +++ b/packages/contracts/test/python-format-runtime-probe.mjs @@ -24,6 +24,7 @@ const cases = [ { format: 'uri-reference', value: 'http://example.com/[]' }, { format: 'uri-reference', value: 'foo#bar#baz' }, { format: 'date-time', value: '2016-12-31T23:59:60Z' }, + { format: 'date-time', value: '2016-12-31T12:34:60Z' }, { format: 'date-time', value: '2016-12-31T23:60:00Z' }, { format: 'uuid', value: 'urn:uuid:018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01' }, ]; @@ -40,7 +41,7 @@ const expected = cases.map(({ format, value }) => ({ })); assert.deepEqual( expected.map(({ accepted }) => accepted), - [false, false, false, true, false, true], + [false, false, false, true, false, false, true], 'canonical Ajv expectations changed', ); From 08184ea343f6041f58cf3e9bee61de65a5d82a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 05:03:14 +0700 Subject: [PATCH 14/51] test(contracts): enforce cross-language parity --- .gitignore | 1 + package.json | 2 +- packages/contracts/COMPATIBILITY.md | 34 ++ packages/contracts/README.md | 8 + .../contracts/compatibility/published.json | 10 + .../contracts/compatibility/v1/baseline.json | 89 ++++++ packages/contracts/package.json | 4 + .../scripts/contract-compatibility.mjs | 299 ++++++++++++++++++ .../contracts/test/compatibility.test.mjs | 128 ++++++++ packages/test-fixtures/README.md | 8 +- .../test-fixtures/contracts/v1/manifest.json | 205 ++++++++++++ .../actor-metadata/missing-identity.json | 3 + .../payloads/actor-metadata/valid-user.json | 4 + .../missing-idempotency-key.json | 21 ++ .../command-envelope/valid-idempotent.json | 22 ++ .../undeclared-context.json | 4 + .../valid-full-chain.json | 5 + .../continuing-without-cursor.json | 5 + .../cursor-page/terminal-with-cursor.json | 6 + .../cursor-page/valid-continuing.json | 10 + .../payloads/cursor-page/valid-terminal.json | 5 + .../event-envelope/missing-entity-id.json | 24 ++ .../missing-entity-revision.json | 24 ++ .../event-envelope/missing-event-id.json | 24 ++ .../event-envelope/missing-event-type.json | 24 ++ .../valid-workspace-revision.json | 27 ++ .../v1/payloads/identifier/malformed.json | 1 + .../v1/payloads/identifier/valid-uuid.json | 1 + .../problem-details/missing-localization.json | 7 + ...valid-message-localization-rate-limit.json | 15 + .../valid-title-localization.json | 10 + .../v1/payloads/revision/valid-positive.json | 1 + .../contracts/v1/payloads/revision/zero.json | 1 + .../tenant-scope/incomplete-project.json | 5 + .../tenant-scope/valid-organization.json | 4 + .../payloads/tenant-scope/valid-project.json | 6 + .../tenant-scope/valid-workspace.json | 5 + .../v1/payloads/utc-timestamp/offset.json | 1 + .../v1/payloads/utc-timestamp/valid-zulu.json | 1 + packages/test-fixtures/package.json | 12 + .../test-fixtures/test/contracts-v1.test.mjs | 83 +++++ pnpm-lock.yaml | 15 + tools/fixture-validation/README.md | 18 +- .../kotlin/build.gradle.kts | 40 +++ .../fixture-validation/kotlin/gradle.lockfile | 31 ++ .../kotlin/gradle.properties | 2 + .../kotlin/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + tools/fixture-validation/kotlin/gradlew | 251 +++++++++++++++ tools/fixture-validation/kotlin/gradlew.bat | 94 ++++++ .../kotlin/settings.gradle.kts | 17 + .../ContractFixtureRunner.kt | 174 ++++++++++ tools/fixture-validation/package.json | 17 + .../fixture-validation/python/.python-version | 1 + .../fixture-validation/python/pyproject.toml | 13 + .../fixture-validation/python/run_fixtures.py | 83 +++++ tools/fixture-validation/python/uv.lock | 121 +++++++ .../src/compare-contract-results.mjs | 110 +++++++ .../src/run-contract-parity.mjs | 156 +++++++++ .../test/contract-parity.test.mjs | 145 +++++++++ .../typescript/run-fixtures.mjs | 71 +++++ .../typescript/tsconfig.json | 8 + .../typescript/valid-fixture-consumer.ts | 73 +++++ turbo.json | 1 + 64 files changed, 2595 insertions(+), 3 deletions(-) create mode 100644 packages/contracts/COMPATIBILITY.md create mode 100644 packages/contracts/compatibility/published.json create mode 100644 packages/contracts/compatibility/v1/baseline.json create mode 100644 packages/contracts/scripts/contract-compatibility.mjs create mode 100644 packages/contracts/test/compatibility.test.mjs create mode 100644 packages/test-fixtures/contracts/v1/manifest.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/actor-metadata/missing-identity.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/actor-metadata/valid-user.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/command-envelope/missing-idempotency-key.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/command-envelope/valid-idempotent.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/correlation-metadata/undeclared-context.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/correlation-metadata/valid-full-chain.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/cursor-page/continuing-without-cursor.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/cursor-page/terminal-with-cursor.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/cursor-page/valid-continuing.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/cursor-page/valid-terminal.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-entity-id.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-entity-revision.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-event-id.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-event-type.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/event-envelope/valid-workspace-revision.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/identifier/malformed.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/identifier/valid-uuid.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/problem-details/missing-localization.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/problem-details/valid-message-localization-rate-limit.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/problem-details/valid-title-localization.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/revision/valid-positive.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/revision/zero.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/tenant-scope/incomplete-project.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-organization.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-project.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-workspace.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/utc-timestamp/offset.json create mode 100644 packages/test-fixtures/contracts/v1/payloads/utc-timestamp/valid-zulu.json create mode 100644 packages/test-fixtures/package.json create mode 100644 packages/test-fixtures/test/contracts-v1.test.mjs create mode 100644 tools/fixture-validation/kotlin/build.gradle.kts create mode 100644 tools/fixture-validation/kotlin/gradle.lockfile create mode 100644 tools/fixture-validation/kotlin/gradle.properties create mode 100644 tools/fixture-validation/kotlin/gradle/wrapper/gradle-wrapper.jar create mode 100644 tools/fixture-validation/kotlin/gradle/wrapper/gradle-wrapper.properties create mode 100644 tools/fixture-validation/kotlin/gradlew create mode 100644 tools/fixture-validation/kotlin/gradlew.bat create mode 100644 tools/fixture-validation/kotlin/settings.gradle.kts create mode 100644 tools/fixture-validation/kotlin/src/main/kotlin/com/databreeze/fixturevalidation/ContractFixtureRunner.kt create mode 100644 tools/fixture-validation/package.json create mode 100644 tools/fixture-validation/python/.python-version create mode 100644 tools/fixture-validation/python/pyproject.toml create mode 100644 tools/fixture-validation/python/run_fixtures.py create mode 100644 tools/fixture-validation/python/uv.lock create mode 100644 tools/fixture-validation/src/compare-contract-results.mjs create mode 100644 tools/fixture-validation/src/run-contract-parity.mjs create mode 100644 tools/fixture-validation/test/contract-parity.test.mjs create mode 100644 tools/fixture-validation/typescript/run-fixtures.mjs create mode 100644 tools/fixture-validation/typescript/tsconfig.json create mode 100644 tools/fixture-validation/typescript/valid-fixture-consumer.ts diff --git a/.gitignore b/.gitignore index d827e7f5..f90e6541 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ htmlcov/ # Gradle and Android .gradle/ +.kotlin/ **/build/ local.properties *.apk diff --git a/package.json b/package.json index bc08cfa1..aa4611c5 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "build": "turbo run build", - "contracts:check": "corepack pnpm --filter @databreeze/contracts generate:check", + "contracts:check": "corepack pnpm --filter @databreeze/contracts contract:check", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint . && node tools/repo-cli/src/check-dependency-boundaries.mjs", diff --git a/packages/contracts/COMPATIBILITY.md b/packages/contracts/COMPATIBILITY.md new file mode 100644 index 00000000..835582a3 --- /dev/null +++ b/packages/contracts/COMPATIBILITY.md @@ -0,0 +1,34 @@ +# Contract compatibility policy + +DataBreeze v1 contracts are published, conservative, and immutable. A schema's absolute `$id`, +registry entry, path, and exact source bytes cannot change in place. Version-specific generated +TypeScript, Python, and Kotlin public outputs are immutable for the same reason. This rule also +applies to changes that would normally be described as additive or backward compatible: after +publication, they require a new contract version and new absolute schema IDs. + +`compatibility/published.json` locks the SHA-256 digest of each reviewed version baseline. Each +`compatibility/vN/baseline.json` locks every schema ID/path/byte digest and each version-specific +generated public file. The check fails for removed or added v1 schemas, changed IDs or bytes, +missing or changed generated outputs, missing baselines, and baseline edits that do not match the +published registry. + +Run the read-only checks with: + +```sh +corepack pnpm contracts:check +``` + +That root gate checks generated drift, published compatibility, compile-time TypeScript fixture +consumption, and TypeScript/Python/Kotlin runtime fixture parity. + +To publish a reviewed new version after its new schema IDs, generated outputs, and fixtures exist: + +```sh +corepack pnpm --filter @databreeze/contracts compatibility:baseline -- --version 2 --approve-new-version +``` + +The update is deterministic and is a no-op when the same version is already current. It refuses to +rewrite a published version. `--approve-new-version` records the caller's intent; it is not a +substitute for repository review. An incompatible change therefore requires a new version/ID, +consumer migration evidence, an updated shared fixture suite, and review of the new immutable +baseline. diff --git a/packages/contracts/README.md b/packages/contracts/README.md index e17f310e..f4d8dd22 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -9,6 +9,8 @@ Canonical OpenAPI, JSON Schema, event, typed-job, and compatibility definitions - `generated/typescript/v1/index.ts` exports structural TypeScript contracts for Web, Desktop, and API consumers. - `generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt` provides standard Kotlin models in `com.databreeze.contracts.v1`. - `generated/python/databreeze_contracts/v1` is the Pydantic v2 model package for Python consumers. +- `compatibility/` contains immutable reviewed hashes for published schema bytes and generated + version-specific public outputs; see [COMPATIBILITY.md](COMPATIBILITY.md). - Consumers import only the entry points declared in `package.json#exports`. The v1 base schemas provide UUID identifiers and UTC timestamps (IAM-001), complete tenant ancestry (IAM-019), correlation and actor metadata (AUD-004), RFC-compatible public problems (INT-021), idempotent commands (INT-004), cursor pages (INT-005), and canonical events (AUD-004, AUD-006, and INT-008). This is partial foundation coverage; it does not implement those requirements' persistence or runtime behavior. @@ -20,10 +22,16 @@ corepack pnpm --filter @databreeze/contracts test corepack pnpm --filter @databreeze/contracts build corepack pnpm --filter @databreeze/contracts generate corepack pnpm --filter @databreeze/contracts generate:check +corepack pnpm --filter @databreeze/contracts compatibility:check +corepack pnpm --filter @databreeze/contracts fixtures:check ``` `generate` is the only supported way to update checked-in language models. Do not edit files below `generated/` by hand. `generate:check` regenerates into a temporary directory, byte-compares the complete expected file set, and reports missing, stale, or unexpected files without changing checked-in output. `test` compiles the real schemas with Ajv's JSON Schema 2020-12 validator and exercises generator behavior plus hand-authored protocol payloads. `build` compiles every manifest entry and checks generated-file drift. +`compatibility:check` rejects in-place changes to a published contract baseline. +`fixtures:check` runs the shared payloads through the real TypeScript, Python, and Kotlin consumers. +Root `pnpm contracts:check` runs generation drift, compatibility, and cross-runtime fixture parity. + ## Forbidden dependencies This package contains protocol definitions only. It must not import application or service implementations, persistence adapters, framework code, or generated consumer models. diff --git a/packages/contracts/compatibility/published.json b/packages/contracts/compatibility/published.json new file mode 100644 index 00000000..855b3070 --- /dev/null +++ b/packages/contracts/compatibility/published.json @@ -0,0 +1,10 @@ +{ + "policyVersion": 1, + "versions": [ + { + "contractVersion": 1, + "baseline": "compatibility/v1/baseline.json", + "sha256": "5307743b430a27ff7aa894a09c15b3920f36459a04ae9bb29100f2b164c9dcfe" + } + ] +} diff --git a/packages/contracts/compatibility/v1/baseline.json b/packages/contracts/compatibility/v1/baseline.json new file mode 100644 index 00000000..c1016999 --- /dev/null +++ b/packages/contracts/compatibility/v1/baseline.json @@ -0,0 +1,89 @@ +{ + "baselineFormat": 1, + "contractVersion": 1, + "schemaIdPrefix": "https://schemas.databreeze.dev/contracts/v1/", + "schemas": [ + { + "name": "actor-metadata", + "id": "https://schemas.databreeze.dev/contracts/v1/actor-metadata", + "path": "schemas/v1/actor-metadata.schema.json", + "sha256": "fb9d12675478ae805bbe0163866c1cf8e4bb810dbe32ac780b1c1bf4975c856c" + }, + { + "name": "command-envelope", + "id": "https://schemas.databreeze.dev/contracts/v1/command-envelope", + "path": "schemas/v1/command-envelope.schema.json", + "sha256": "4bf310647800d038bfdaae8f6d89722862f89200589fe4e5e08a764b16b80002" + }, + { + "name": "correlation-metadata", + "id": "https://schemas.databreeze.dev/contracts/v1/correlation-metadata", + "path": "schemas/v1/correlation-metadata.schema.json", + "sha256": "3e0b490036d2c709b0398cb7d3dcb2618d8e67bc6c64e90f62650b8b79d87b79" + }, + { + "name": "cursor-page", + "id": "https://schemas.databreeze.dev/contracts/v1/cursor-page", + "path": "schemas/v1/cursor-page.schema.json", + "sha256": "8d009cb3b0e2232e5efdc297dfdd7b89f697410438d610c92414c014a5d44b19" + }, + { + "name": "event-envelope", + "id": "https://schemas.databreeze.dev/contracts/v1/event-envelope", + "path": "schemas/v1/event-envelope.schema.json", + "sha256": "54780e954b80a07de08d428a03c972cb9fcfe6682691521fe6d6c357b45753dd" + }, + { + "name": "identifier", + "id": "https://schemas.databreeze.dev/contracts/v1/identifier", + "path": "schemas/v1/identifier.schema.json", + "sha256": "a4892d0fd11473356f53638758ae7017ff4b816765de65b2463ce8483fc699bb" + }, + { + "name": "problem-details", + "id": "https://schemas.databreeze.dev/contracts/v1/problem-details", + "path": "schemas/v1/problem-details.schema.json", + "sha256": "c1209d3d234e75b13a84e7cfbbd2bec9f6d9f1daa3602ec2443f682770892272" + }, + { + "name": "revision", + "id": "https://schemas.databreeze.dev/contracts/v1/revision", + "path": "schemas/v1/revision.schema.json", + "sha256": "6319ea8d21627cf73eb8d49b93e7529b87018e9e8ae5cd982fd70b419e0c7bb1" + }, + { + "name": "tenant-scope", + "id": "https://schemas.databreeze.dev/contracts/v1/tenant-scope", + "path": "schemas/v1/tenant-scope.schema.json", + "sha256": "a08f7d59f2fcd9c675298dc11f1fb05ba1c1f18757040980c6b214c3d9fff327" + }, + { + "name": "utc-timestamp", + "id": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp", + "path": "schemas/v1/utc-timestamp.schema.json", + "sha256": "904b8736592d6c1e527f084daf0fa452447ada75dbfee8e9395ea55cf7c07dc5" + } + ], + "generatedPublicOutputs": [ + { + "path": "kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt", + "sha256": "cd60b56f750e382ada6a9bbaba1baefef3a43e8fdb41ee6a12c89e1b8e1f9487" + }, + { + "path": "python/databreeze_contracts/v1/__init__.py", + "sha256": "785ff1b0fde763730070345f43b494203f149b10879683364fcbec0fcc35cb7e" + }, + { + "path": "python/databreeze_contracts/v1/_validation.py", + "sha256": "843bf675e577d3e9197e3e263a95bd483dac2d2d93c0987dc1b0785cb6a111cf" + }, + { + "path": "python/databreeze_contracts/v1/models.py", + "sha256": "435a3af5a513fd14fbf232c0f289b901f4b3722a12b9f3a559d59df404fee4ab" + }, + { + "path": "typescript/v1/index.ts", + "sha256": "5794700e965202973a0056d68368d090a83ad04e6157d17e59a91432653d446d" + } + ] +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index be856b63..dbde9a13 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -21,6 +21,10 @@ }, "scripts": { "build": "node scripts/build.mjs && node scripts/generate-models.mjs --check", + "compatibility:baseline": "node scripts/contract-compatibility.mjs update", + "compatibility:check": "node scripts/contract-compatibility.mjs check", + "contract:check": "node scripts/generate-models.mjs --check && node scripts/contract-compatibility.mjs check && node ../../tools/fixture-validation/src/run-contract-parity.mjs", + "fixtures:check": "node ../../tools/fixture-validation/src/run-contract-parity.mjs", "generate": "node scripts/generate-models.mjs", "generate:check": "node scripts/generate-models.mjs --check", "test": "node --test test/**/*.test.mjs", diff --git a/packages/contracts/scripts/contract-compatibility.mjs b/packages/contracts/scripts/contract-compatibility.mjs new file mode 100644 index 00000000..e459d9b0 --- /dev/null +++ b/packages/contracts/scripts/contract-compatibility.mjs @@ -0,0 +1,299 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const publishedRegistryPath = 'compatibility/published.json'; + +function fail(message) { + throw new Error(message); +} + +function compareStrings(left, right) { + return left.localeCompare(right, 'en'); +} + +function toPosix(path) { + return path.replaceAll('\\', '/'); +} + +function parseJson(path, label) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + fail(`${label} is not valid JSON: ${error.message}`); + } +} + +function formatJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function sha256Bytes(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function sha256File(path) { + return sha256Bytes(readFileSync(path)); +} + +function listFiles(root, directory = root) { + if (!existsSync(directory)) return []; + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? listFiles(root, path) : [toPosix(relative(root, path))]; + }) + .sort(compareStrings); +} + +function versionSchemaEntries(root, version) { + const manifestPath = resolve(root, 'manifest.json'); + if (!existsSync(manifestPath)) fail('Canonical contract manifest is missing: manifest.json'); + const manifest = parseJson(manifestPath, 'Canonical contract manifest'); + if (!Array.isArray(manifest.schemas)) + fail('Canonical contract manifest schemas must be an array'); + + const versionPrefix = `schemas/v${version}/`; + const entries = manifest.schemas + .filter((entry) => typeof entry.path === 'string' && entry.path.startsWith(versionPrefix)) + .sort((left, right) => compareStrings(left.name, right.name)); + if (entries.length === 0) fail(`No canonical schemas found for v${version}`); + return entries; +} + +function versionGeneratedOutputs(root, version) { + const generatedRoot = resolve(root, 'generated'); + const versionSegment = `/v${version}/`; + return listFiles(generatedRoot).filter((path) => `/${path}`.includes(versionSegment)); +} + +function buildBaseline(root, version) { + const expectedIdPrefix = `https://schemas.databreeze.dev/contracts/v${version}/`; + const schemaEntries = versionSchemaEntries(root, version); + const registeredPaths = new Set(schemaEntries.map((entry) => entry.path)); + const schemaDirectory = resolve(root, `schemas/v${version}`); + const unregisteredSchemas = listFiles(schemaDirectory) + .map((path) => `schemas/v${version}/${path}`) + .filter((path) => !registeredPaths.has(path)); + if (unregisteredSchemas.length > 0) { + fail(`Unregistered schema in published v${version}: ${unregisteredSchemas[0]}`); + } + + const schemas = schemaEntries.map((entry) => { + if (!entry.id.startsWith(expectedIdPrefix)) { + fail(`Schema ID must use the v${version} namespace: ${entry.id}`); + } + const schemaPath = resolve(root, entry.path); + if (!existsSync(schemaPath)) fail(`Published schema is missing: ${entry.path}`); + const schema = parseJson(schemaPath, `Schema ${entry.name}`); + if (schema.$id !== entry.id) fail(`Manifest ID does not match ${entry.path}`); + return { + name: entry.name, + id: entry.id, + path: entry.path, + sha256: sha256File(schemaPath), + }; + }); + + const outputPaths = versionGeneratedOutputs(root, version); + if (outputPaths.length === 0) fail(`No generated public outputs found for v${version}`); + const generatedPublicOutputs = outputPaths.map((path) => ({ + path, + sha256: sha256File(resolve(root, 'generated', ...path.split('/'))), + })); + + return { + baselineFormat: 1, + contractVersion: version, + schemaIdPrefix: expectedIdPrefix, + schemas, + generatedPublicOutputs, + }; +} + +function readPublishedRegistry(root) { + const path = resolve(root, publishedRegistryPath); + if (!existsSync(path)) { + fail(`Published compatibility registry is missing: ${publishedRegistryPath}`); + } + const registry = parseJson(path, 'Published compatibility registry'); + if (registry.policyVersion !== 1 || !Array.isArray(registry.versions)) { + fail('Published compatibility registry has an unsupported shape'); + } + return registry; +} + +function verifySchemaBaseline(root, version, baseline) { + const currentEntries = versionSchemaEntries(root, version); + const currentByName = new Map(currentEntries.map((entry) => [entry.name, entry])); + const baselineNames = new Set(baseline.schemas.map((entry) => entry.name)); + + for (const expected of baseline.schemas) { + const current = currentByName.get(expected.name); + if (!current) fail(`Published schema was removed from v${version}: ${expected.name}`); + if (current.id !== expected.id) { + fail(`Published schema ID changed in place: ${expected.name}`); + } + if (current.path !== expected.path) { + fail(`Published schema path changed in place: ${expected.name}`); + } + const sourcePath = resolve(root, expected.path); + if (!existsSync(sourcePath)) fail(`Published schema is missing: ${expected.path}`); + if (sha256File(sourcePath) !== expected.sha256) { + fail(`Published schema bytes changed in place: ${expected.name}`); + } + } + + const added = currentEntries.find((entry) => !baselineNames.has(entry.name)); + if (added) fail(`Schema added to published v${version}: ${added.name}`); + + const registeredPaths = new Set(currentEntries.map((entry) => entry.path)); + const schemaRoot = resolve(root, `schemas/v${version}`); + const unregistered = listFiles(schemaRoot) + .map((path) => `schemas/v${version}/${path}`) + .find((path) => !registeredPaths.has(path)); + if (unregistered) fail(`Unregistered schema in published v${version}: ${unregistered}`); +} + +function verifyGeneratedBaseline(root, version, baseline) { + const baselinePaths = new Set(baseline.generatedPublicOutputs.map((entry) => entry.path)); + for (const expected of baseline.generatedPublicOutputs) { + const outputPath = resolve(root, 'generated', ...expected.path.split('/')); + if (!existsSync(outputPath)) { + fail(`Published generated output is missing: ${expected.path}`); + } + if (sha256File(outputPath) !== expected.sha256) { + fail(`Published generated output changed in place: ${expected.path}`); + } + } + const added = versionGeneratedOutputs(root, version).find((path) => !baselinePaths.has(path)); + if (added) fail(`Generated public output added to published v${version}: ${added}`); +} + +function checkCompatibility(root) { + const registry = readPublishedRegistry(root); + if (registry.versions.length === 0) fail('Published compatibility registry has no versions'); + + for (const published of [...registry.versions].sort( + (left, right) => left.contractVersion - right.contractVersion, + )) { + const version = published.contractVersion; + const baselinePath = resolve(root, ...published.baseline.split('/')); + if (!existsSync(baselinePath)) { + fail(`Published baseline is missing: ${published.baseline}`); + } + if (sha256File(baselinePath) !== published.sha256) { + fail(`Published baseline drift detected for v${version}`); + } + const baseline = parseJson(baselinePath, `Published v${version} baseline`); + if (baseline.contractVersion !== version) { + fail(`Published baseline version mismatch for v${version}`); + } + verifySchemaBaseline(root, version, baseline); + verifyGeneratedBaseline(root, version, baseline); + } +} + +function updateBaseline(root, version, approved) { + const baseline = buildBaseline(root, version); + const baselineContent = formatJson(baseline); + const registryPath = resolve(root, publishedRegistryPath); + const registry = existsSync(registryPath) + ? readPublishedRegistry(root) + : { policyVersion: 1, versions: [] }; + const published = registry.versions.find((entry) => entry.contractVersion === version); + + if (published) { + const baselinePath = resolve(root, ...published.baseline.split('/')); + if (!existsSync(baselinePath)) { + fail(`Published baseline is missing: ${published.baseline}`); + } + if ( + sha256File(baselinePath) !== published.sha256 || + readFileSync(baselinePath, 'utf8') !== baselineContent + ) { + fail(`Refusing to rewrite published v${version}; publish new schema IDs and a new version`); + } + return false; + } + + if (!approved) { + fail(`Creating v${version} requires --approve-new-version after review`); + } + + const relativeBaselinePath = `compatibility/v${version}/baseline.json`; + const baselinePath = resolve(root, ...relativeBaselinePath.split('/')); + if (existsSync(baselinePath)) { + fail(`Unregistered baseline already exists: ${relativeBaselinePath}`); + } + mkdirSync(dirname(baselinePath), { recursive: true }); + writeFileSync(baselinePath, baselineContent, 'utf8'); + + const nextRegistry = { + policyVersion: 1, + versions: [ + ...registry.versions, + { + contractVersion: version, + baseline: relativeBaselinePath, + sha256: sha256Bytes(baselineContent), + }, + ].sort((left, right) => left.contractVersion - right.contractVersion), + }; + mkdirSync(dirname(registryPath), { recursive: true }); + writeFileSync(registryPath, formatJson(nextRegistry), 'utf8'); + return true; +} + +function readArguments(argumentsList) { + const command = argumentsList[0]; + if (!['check', 'update'].includes(command)) { + fail('Usage: contract-compatibility.mjs [--root PATH] [--version N]'); + } + const options = { + approved: false, + command, + root: defaultRoot, + version: undefined, + }; + for (let index = 1; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + if (argument === '--approve-new-version') { + options.approved = true; + } else if (argument === '--root' || argument === '--version') { + const value = argumentsList[index + 1]; + if (!value) fail(`${argument} requires a value`); + if (argument === '--root') options.root = resolve(value); + else options.version = Number(value); + index += 1; + } else { + fail(`Unknown argument: ${argument}`); + } + } + if (options.command === 'update') { + if (!Number.isSafeInteger(options.version) || options.version < 1) { + fail('update requires a positive integer --version'); + } + } + return options; +} + +try { + const options = readArguments(process.argv.slice(2)); + if (options.command === 'check') { + checkCompatibility(options.root); + console.log('Published contract compatibility baseline is unchanged.'); + } else { + const created = updateBaseline(options.root, options.version, options.approved); + console.log( + created + ? `Created reviewed compatibility baseline for v${options.version}.` + : `Published v${options.version} baseline is already up to date.`, + ); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/packages/contracts/test/compatibility.test.mjs b/packages/contracts/test/compatibility.test.mjs new file mode 100644 index 00000000..7c70cd24 --- /dev/null +++ b/packages/contracts/test/compatibility.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { appendFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const compatibilityScript = resolve(packageRoot, 'scripts/contract-compatibility.mjs'); + +function runCompatibility(root, command, extraArguments = []) { + return spawnSync( + process.execPath, + [compatibilityScript, command, '--root', root, ...extraArguments], + { cwd: packageRoot, encoding: 'utf8' }, + ); +} + +function withPackageCopy(callback) { + const temporaryRoot = mkdtempSync(resolve(tmpdir(), 'databreeze-contract-compatibility-')); + const copyRoot = resolve(temporaryRoot, 'contracts'); + cpSync(packageRoot, copyRoot, { recursive: true }); + try { + callback(copyRoot); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +test('the checked-in published v1 compatibility baseline accepts unchanged contracts', () => { + const run = runCompatibility(packageRoot, 'check'); + assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`); + assert.match(run.stdout, /Published contract compatibility baseline is unchanged/u); +}); + +test('compatibility check rejects a missing published schema', () => { + withPackageCopy((copyRoot) => { + rmSync(resolve(copyRoot, 'schemas/v1/identifier.schema.json')); + + const run = runCompatibility(copyRoot, 'check'); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, /Published schema is missing: schemas\/v1\/identifier\.schema\.json/u); + }); +}); + +test('compatibility check rejects changed schema bytes in place', () => { + withPackageCopy((copyRoot) => { + appendFileSync(resolve(copyRoot, 'schemas/v1/revision.schema.json'), '\n', 'utf8'); + + const run = runCompatibility(copyRoot, 'check'); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, /Published schema bytes changed in place: revision/u); + }); +}); + +test('compatibility check rejects changed generated public output in place', () => { + withPackageCopy((copyRoot) => { + appendFileSync(resolve(copyRoot, 'generated/typescript/v1/index.ts'), '\n', 'utf8'); + + const run = runCompatibility(copyRoot, 'check'); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match( + run.stderr, + /Published generated output changed in place: typescript\/v1\/index\.ts/u, + ); + }); +}); + +test('compatibility check rejects a missing baseline', () => { + withPackageCopy((copyRoot) => { + rmSync(resolve(copyRoot, 'compatibility/v1/baseline.json'), { force: true }); + + const run = runCompatibility(copyRoot, 'check'); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, /Published baseline is missing: compatibility\/v1\/baseline\.json/u); + }); +}); + +test('compatibility check rejects unauthorized baseline drift', () => { + withPackageCopy((copyRoot) => { + const baselinePath = resolve(copyRoot, 'compatibility/v1/baseline.json'); + if (existsSync(baselinePath)) appendFileSync(baselinePath, '\n', 'utf8'); + + const run = runCompatibility(copyRoot, 'check'); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, /Published baseline drift detected for v1/u); + }); +}); + +test('baseline update is deterministic for a reviewed unpublished version', () => { + withPackageCopy((copyRoot) => { + rmSync(resolve(copyRoot, 'compatibility'), { recursive: true, force: true }); + + const first = runCompatibility(copyRoot, 'update', ['--version', '1', '--approve-new-version']); + assert.equal(first.status, 0, `${first.stdout}\n${first.stderr}`); + const firstPublished = readFileSync(resolve(copyRoot, 'compatibility/published.json'), 'utf8'); + const firstBaseline = readFileSync(resolve(copyRoot, 'compatibility/v1/baseline.json'), 'utf8'); + + const second = runCompatibility(copyRoot, 'update', [ + '--version', + '1', + '--approve-new-version', + ]); + assert.equal(second.status, 0, `${second.stdout}\n${second.stderr}`); + assert.equal( + readFileSync(resolve(copyRoot, 'compatibility/published.json'), 'utf8'), + firstPublished, + ); + assert.equal( + readFileSync(resolve(copyRoot, 'compatibility/v1/baseline.json'), 'utf8'), + firstBaseline, + ); + }); +}); + +test('baseline update refuses to rewrite an already published v1 contract', () => { + withPackageCopy((copyRoot) => { + appendFileSync(resolve(copyRoot, 'schemas/v1/revision.schema.json'), '\n', 'utf8'); + + const run = runCompatibility(copyRoot, 'update', ['--version', '1', '--approve-new-version']); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match( + run.stderr, + /Refusing to rewrite published v1; publish new schema IDs and a new version/u, + ); + }); +}); diff --git a/packages/test-fixtures/README.md b/packages/test-fixtures/README.md index 137d680f..f017b69e 100644 --- a/packages/test-fixtures/README.md +++ b/packages/test-fixtures/README.md @@ -1,3 +1,9 @@ # Test Fixtures -Synthetic, non-sensitive fixtures used to prove contract and local/cloud processing parity across TypeScript, Kotlin, and Python. +Synthetic, non-sensitive fixtures used to prove contract and local/cloud processing parity across +TypeScript, Kotlin, and Python. + +`contracts/v1/manifest.json` is the versioned shared contract fixture registry. Every case has a +stable ID, canonical schema ID, expected acceptance result, and a dedicated hand-authored JSON +source. Consumers use the manifest result instead of deriving an expectation from their own +validator. diff --git a/packages/test-fixtures/contracts/v1/manifest.json b/packages/test-fixtures/contracts/v1/manifest.json new file mode 100644 index 00000000..8cc90bd2 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/manifest.json @@ -0,0 +1,205 @@ +{ + "package": "@databreeze/test-fixtures", + "fixtureVersion": 1, + "contractVersion": 1, + "synthetic": true, + "schemaManifest": "../../../contracts/manifest.json", + "cases": [ + { + "id": "v1.actor-metadata.valid-user", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/actor-metadata", + "expectedAcceptance": true, + "source": "payloads/actor-metadata/valid-user.json", + "covers": ["actor.identity"] + }, + { + "id": "v1.actor-metadata.missing-identity", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/actor-metadata", + "expectedAcceptance": false, + "source": "payloads/actor-metadata/missing-identity.json", + "covers": ["actor.identity"] + }, + { + "id": "v1.command-envelope.valid-idempotent", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/command-envelope", + "expectedAcceptance": true, + "source": "payloads/command-envelope/valid-idempotent.json", + "covers": ["command.idempotency"] + }, + { + "id": "v1.command-envelope.missing-idempotency-key", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/command-envelope", + "expectedAcceptance": false, + "source": "payloads/command-envelope/missing-idempotency-key.json", + "covers": ["command.idempotency"] + }, + { + "id": "v1.correlation-metadata.valid-full-chain", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/correlation-metadata", + "expectedAcceptance": true, + "source": "payloads/correlation-metadata/valid-full-chain.json", + "covers": ["correlation.safe-context"] + }, + { + "id": "v1.correlation-metadata.undeclared-context", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/correlation-metadata", + "expectedAcceptance": false, + "source": "payloads/correlation-metadata/undeclared-context.json", + "covers": ["correlation.safe-context"] + }, + { + "id": "v1.cursor-page.valid-continuing", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/cursor-page", + "expectedAcceptance": true, + "source": "payloads/cursor-page/valid-continuing.json", + "covers": ["cursor.continuing"] + }, + { + "id": "v1.cursor-page.valid-terminal", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/cursor-page", + "expectedAcceptance": true, + "source": "payloads/cursor-page/valid-terminal.json", + "covers": ["cursor.terminal"] + }, + { + "id": "v1.cursor-page.continuing-without-cursor", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/cursor-page", + "expectedAcceptance": false, + "source": "payloads/cursor-page/continuing-without-cursor.json", + "covers": ["cursor.continuing"] + }, + { + "id": "v1.cursor-page.terminal-with-cursor", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/cursor-page", + "expectedAcceptance": false, + "source": "payloads/cursor-page/terminal-with-cursor.json", + "covers": ["cursor.terminal"] + }, + { + "id": "v1.event-envelope.valid-workspace-revision", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/event-envelope", + "expectedAcceptance": true, + "source": "payloads/event-envelope/valid-workspace-revision.json", + "covers": ["event.identity", "event.revision"] + }, + { + "id": "v1.event-envelope.missing-event-id", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/event-envelope", + "expectedAcceptance": false, + "source": "payloads/event-envelope/missing-event-id.json", + "covers": ["event.identity"] + }, + { + "id": "v1.event-envelope.missing-event-type", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/event-envelope", + "expectedAcceptance": false, + "source": "payloads/event-envelope/missing-event-type.json", + "covers": ["event.identity"] + }, + { + "id": "v1.event-envelope.missing-entity-id", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/event-envelope", + "expectedAcceptance": false, + "source": "payloads/event-envelope/missing-entity-id.json", + "covers": ["event.identity"] + }, + { + "id": "v1.event-envelope.missing-entity-revision", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/event-envelope", + "expectedAcceptance": false, + "source": "payloads/event-envelope/missing-entity-revision.json", + "covers": ["event.revision"] + }, + { + "id": "v1.identifier.valid-uuid", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/identifier", + "expectedAcceptance": true, + "source": "payloads/identifier/valid-uuid.json", + "covers": ["identifier.uuid"] + }, + { + "id": "v1.identifier.malformed", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/identifier", + "expectedAcceptance": false, + "source": "payloads/identifier/malformed.json", + "covers": ["identifier.uuid"] + }, + { + "id": "v1.problem-details.valid-title-localization", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/problem-details", + "expectedAcceptance": true, + "source": "payloads/problem-details/valid-title-localization.json", + "covers": ["problem.title-localization"] + }, + { + "id": "v1.problem-details.valid-message-localization-rate-limit", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/problem-details", + "expectedAcceptance": true, + "source": "payloads/problem-details/valid-message-localization-rate-limit.json", + "covers": ["problem.message-localization", "problem.rate-limit"] + }, + { + "id": "v1.problem-details.missing-localization", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/problem-details", + "expectedAcceptance": false, + "source": "payloads/problem-details/missing-localization.json", + "covers": ["problem.title-localization", "problem.message-localization"] + }, + { + "id": "v1.revision.valid-positive", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/revision", + "expectedAcceptance": true, + "source": "payloads/revision/valid-positive.json", + "covers": ["revision.positive"] + }, + { + "id": "v1.revision.zero", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/revision", + "expectedAcceptance": false, + "source": "payloads/revision/zero.json", + "covers": ["revision.positive"] + }, + { + "id": "v1.tenant-scope.valid-organization", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/tenant-scope", + "expectedAcceptance": true, + "source": "payloads/tenant-scope/valid-organization.json", + "covers": ["tenant.organization"] + }, + { + "id": "v1.tenant-scope.valid-workspace", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/tenant-scope", + "expectedAcceptance": true, + "source": "payloads/tenant-scope/valid-workspace.json", + "covers": ["tenant.workspace"] + }, + { + "id": "v1.tenant-scope.valid-project", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/tenant-scope", + "expectedAcceptance": true, + "source": "payloads/tenant-scope/valid-project.json", + "covers": ["tenant.project"] + }, + { + "id": "v1.tenant-scope.incomplete-project", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/tenant-scope", + "expectedAcceptance": false, + "source": "payloads/tenant-scope/incomplete-project.json", + "covers": ["tenant.project"] + }, + { + "id": "v1.utc-timestamp.valid-zulu", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp", + "expectedAcceptance": true, + "source": "payloads/utc-timestamp/valid-zulu.json", + "covers": ["timestamp.utc-z"] + }, + { + "id": "v1.utc-timestamp.offset", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp", + "expectedAcceptance": false, + "source": "payloads/utc-timestamp/offset.json", + "covers": ["timestamp.utc-z"] + } + ] +} diff --git a/packages/test-fixtures/contracts/v1/payloads/actor-metadata/missing-identity.json b/packages/test-fixtures/contracts/v1/payloads/actor-metadata/missing-identity.json new file mode 100644 index 00000000..0ae52ab6 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/actor-metadata/missing-identity.json @@ -0,0 +1,3 @@ +{ + "actorType": "user" +} diff --git a/packages/test-fixtures/contracts/v1/payloads/actor-metadata/valid-user.json b/packages/test-fixtures/contracts/v1/payloads/actor-metadata/valid-user.json new file mode 100644 index 00000000..5bce06f9 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/actor-metadata/valid-user.json @@ -0,0 +1,4 @@ +{ + "actorType": "user", + "actorId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc04" +} diff --git a/packages/test-fixtures/contracts/v1/payloads/command-envelope/missing-idempotency-key.json b/packages/test-fixtures/contracts/v1/payloads/command-envelope/missing-idempotency-key.json new file mode 100644 index 00000000..5f0267e8 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/command-envelope/missing-idempotency-key.json @@ -0,0 +1,21 @@ +{ + "commandId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc06", + "commandType": "iam.workspace.rename", + "schemaVersion": 1, + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "actor": { + "actorType": "user", + "actorId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc04" + }, + "correlation": { + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05" + }, + "issuedAt": "2026-08-01T01:30:00.125Z", + "data": { + "displayName": "Điều hành" + } +} diff --git a/packages/test-fixtures/contracts/v1/payloads/command-envelope/valid-idempotent.json b/packages/test-fixtures/contracts/v1/payloads/command-envelope/valid-idempotent.json new file mode 100644 index 00000000..e990043f --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/command-envelope/valid-idempotent.json @@ -0,0 +1,22 @@ +{ + "commandId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc06", + "commandType": "iam.workspace.rename", + "schemaVersion": 1, + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "actor": { + "actorType": "user", + "actorId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc04" + }, + "correlation": { + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05" + }, + "issuedAt": "2026-08-01T01:30:00.125Z", + "idempotencyKey": "rename-workspace-018f47f2", + "data": { + "displayName": "Điều hành" + } +} diff --git a/packages/test-fixtures/contracts/v1/payloads/correlation-metadata/undeclared-context.json b/packages/test-fixtures/contracts/v1/payloads/correlation-metadata/undeclared-context.json new file mode 100644 index 00000000..1ab37f1a --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/correlation-metadata/undeclared-context.json @@ -0,0 +1,4 @@ +{ + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05", + "customerEmail": "synthetic@example.test" +} diff --git a/packages/test-fixtures/contracts/v1/payloads/correlation-metadata/valid-full-chain.json b/packages/test-fixtures/contracts/v1/payloads/correlation-metadata/valid-full-chain.json new file mode 100644 index 00000000..4bdf549b --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/correlation-metadata/valid-full-chain.json @@ -0,0 +1,5 @@ +{ + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05", + "causationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc08", + "requestId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc09" +} diff --git a/packages/test-fixtures/contracts/v1/payloads/cursor-page/continuing-without-cursor.json b/packages/test-fixtures/contracts/v1/payloads/cursor-page/continuing-without-cursor.json new file mode 100644 index 00000000..c4a9a99e --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/cursor-page/continuing-without-cursor.json @@ -0,0 +1,5 @@ +{ + "data": [], + "snapshotAt": "2026-08-01T01:30:00Z", + "hasMore": true +} diff --git a/packages/test-fixtures/contracts/v1/payloads/cursor-page/terminal-with-cursor.json b/packages/test-fixtures/contracts/v1/payloads/cursor-page/terminal-with-cursor.json new file mode 100644 index 00000000..4b1de79a --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/cursor-page/terminal-with-cursor.json @@ -0,0 +1,6 @@ +{ + "data": [], + "nextCursor": "stale-cursor", + "snapshotAt": "2026-08-01T01:30:00Z", + "hasMore": false +} diff --git a/packages/test-fixtures/contracts/v1/payloads/cursor-page/valid-continuing.json b/packages/test-fixtures/contracts/v1/payloads/cursor-page/valid-continuing.json new file mode 100644 index 00000000..9f4b6ede --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/cursor-page/valid-continuing.json @@ -0,0 +1,10 @@ +{ + "data": [ + { + "id": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + } + ], + "nextCursor": "opaque-cursor-v1-page-2", + "snapshotAt": "2026-08-01T01:30:00Z", + "hasMore": true +} diff --git a/packages/test-fixtures/contracts/v1/payloads/cursor-page/valid-terminal.json b/packages/test-fixtures/contracts/v1/payloads/cursor-page/valid-terminal.json new file mode 100644 index 00000000..5295572c --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/cursor-page/valid-terminal.json @@ -0,0 +1,5 @@ +{ + "data": [], + "snapshotAt": "2026-08-01T01:30:00Z", + "hasMore": false +} diff --git a/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-entity-id.json b/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-entity-id.json new file mode 100644 index 00000000..59bf5643 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-entity-id.json @@ -0,0 +1,24 @@ +{ + "eventId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc07", + "eventType": "iam.workspace.renamed", + "schemaVersion": 1, + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "entity": { + "entityType": "workspace", + "revision": 2 + }, + "actor": { + "actorType": "user", + "actorId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc04" + }, + "correlation": { + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05" + }, + "sourceComponent": "iam", + "occurredAt": "2026-08-01T01:30:00.125Z", + "data": {} +} diff --git a/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-entity-revision.json b/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-entity-revision.json new file mode 100644 index 00000000..634b62cc --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-entity-revision.json @@ -0,0 +1,24 @@ +{ + "eventId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc07", + "eventType": "iam.workspace.renamed", + "schemaVersion": 1, + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "entity": { + "entityType": "workspace", + "entityId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "actor": { + "actorType": "user", + "actorId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc04" + }, + "correlation": { + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05" + }, + "sourceComponent": "iam", + "occurredAt": "2026-08-01T01:30:00.125Z", + "data": {} +} diff --git a/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-event-id.json b/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-event-id.json new file mode 100644 index 00000000..d8a7911a --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-event-id.json @@ -0,0 +1,24 @@ +{ + "eventType": "iam.workspace.renamed", + "schemaVersion": 1, + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "entity": { + "entityType": "workspace", + "entityId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02", + "revision": 2 + }, + "actor": { + "actorType": "user", + "actorId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc04" + }, + "correlation": { + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05" + }, + "sourceComponent": "iam", + "occurredAt": "2026-08-01T01:30:00.125Z", + "data": {} +} diff --git a/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-event-type.json b/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-event-type.json new file mode 100644 index 00000000..fd59a6b5 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/event-envelope/missing-event-type.json @@ -0,0 +1,24 @@ +{ + "eventId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc07", + "schemaVersion": 1, + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "entity": { + "entityType": "workspace", + "entityId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02", + "revision": 2 + }, + "actor": { + "actorType": "user", + "actorId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc04" + }, + "correlation": { + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05" + }, + "sourceComponent": "iam", + "occurredAt": "2026-08-01T01:30:00.125Z", + "data": {} +} diff --git a/packages/test-fixtures/contracts/v1/payloads/event-envelope/valid-workspace-revision.json b/packages/test-fixtures/contracts/v1/payloads/event-envelope/valid-workspace-revision.json new file mode 100644 index 00000000..267b0b8c --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/event-envelope/valid-workspace-revision.json @@ -0,0 +1,27 @@ +{ + "eventId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc07", + "eventType": "iam.workspace.renamed", + "schemaVersion": 1, + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "entity": { + "entityType": "workspace", + "entityId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02", + "revision": 2 + }, + "actor": { + "actorType": "user", + "actorId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc04" + }, + "correlation": { + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05" + }, + "sourceComponent": "iam", + "occurredAt": "2026-08-01T01:30:00.125Z", + "data": { + "changedFields": ["displayName"] + } +} diff --git a/packages/test-fixtures/contracts/v1/payloads/identifier/malformed.json b/packages/test-fixtures/contracts/v1/payloads/identifier/malformed.json new file mode 100644 index 00000000..8848edc2 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/identifier/malformed.json @@ -0,0 +1 @@ +"not-a-uuid" diff --git a/packages/test-fixtures/contracts/v1/payloads/identifier/valid-uuid.json b/packages/test-fixtures/contracts/v1/payloads/identifier/valid-uuid.json new file mode 100644 index 00000000..902a0123 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/identifier/valid-uuid.json @@ -0,0 +1 @@ +"018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01" diff --git a/packages/test-fixtures/contracts/v1/payloads/problem-details/missing-localization.json b/packages/test-fixtures/contracts/v1/payloads/problem-details/missing-localization.json new file mode 100644 index 00000000..cce96f81 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/problem-details/missing-localization.json @@ -0,0 +1,7 @@ +{ + "type": "https://api.databreeze.dev/problems/access-denied", + "status": 403, + "code": "ACCESS_DENIED", + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05", + "retryable": false +} diff --git a/packages/test-fixtures/contracts/v1/payloads/problem-details/valid-message-localization-rate-limit.json b/packages/test-fixtures/contracts/v1/payloads/problem-details/valid-message-localization-rate-limit.json new file mode 100644 index 00000000..62a99096 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/problem-details/valid-message-localization-rate-limit.json @@ -0,0 +1,15 @@ +{ + "type": "https://api.databreeze.dev/problems/rate-limit-exceeded", + "messageKey": "errors.rateLimitExceeded", + "status": 429, + "code": "RATE_LIMIT_EXCEEDED", + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05", + "retryable": true, + "retryAfterSeconds": 30, + "rateLimit": { + "scope": "principal", + "limit": 100, + "remaining": 0, + "resetAt": "2026-08-01T01:31:00Z" + } +} diff --git a/packages/test-fixtures/contracts/v1/payloads/problem-details/valid-title-localization.json b/packages/test-fixtures/contracts/v1/payloads/problem-details/valid-title-localization.json new file mode 100644 index 00000000..a6744922 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/problem-details/valid-title-localization.json @@ -0,0 +1,10 @@ +{ + "type": "https://api.databreeze.dev/problems/revision-conflict", + "titleKey": "errors.revisionConflict.title", + "status": 409, + "code": "REVISION_CONFLICT", + "correlationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc05", + "retryable": false, + "currentRevision": 7, + "remediationAction": "refresh-and-retry" +} diff --git a/packages/test-fixtures/contracts/v1/payloads/revision/valid-positive.json b/packages/test-fixtures/contracts/v1/payloads/revision/valid-positive.json new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/revision/valid-positive.json @@ -0,0 +1 @@ +1 diff --git a/packages/test-fixtures/contracts/v1/payloads/revision/zero.json b/packages/test-fixtures/contracts/v1/payloads/revision/zero.json new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/revision/zero.json @@ -0,0 +1 @@ +0 diff --git a/packages/test-fixtures/contracts/v1/payloads/tenant-scope/incomplete-project.json b/packages/test-fixtures/contracts/v1/payloads/tenant-scope/incomplete-project.json new file mode 100644 index 00000000..23796be6 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/tenant-scope/incomplete-project.json @@ -0,0 +1,5 @@ +{ + "scopeType": "project", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "projectId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc03" +} diff --git a/packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-organization.json b/packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-organization.json new file mode 100644 index 00000000..2b3e648d --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-organization.json @@ -0,0 +1,4 @@ +{ + "scopeType": "organization", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01" +} diff --git a/packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-project.json b/packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-project.json new file mode 100644 index 00000000..dfc224d1 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-project.json @@ -0,0 +1,6 @@ +{ + "scopeType": "project", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02", + "projectId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc03" +} diff --git a/packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-workspace.json b/packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-workspace.json new file mode 100644 index 00000000..6258390b --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-workspace.json @@ -0,0 +1,5 @@ +{ + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" +} diff --git a/packages/test-fixtures/contracts/v1/payloads/utc-timestamp/offset.json b/packages/test-fixtures/contracts/v1/payloads/utc-timestamp/offset.json new file mode 100644 index 00000000..2bc234f0 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/utc-timestamp/offset.json @@ -0,0 +1 @@ +"2026-08-01T08:30:00.125+07:00" diff --git a/packages/test-fixtures/contracts/v1/payloads/utc-timestamp/valid-zulu.json b/packages/test-fixtures/contracts/v1/payloads/utc-timestamp/valid-zulu.json new file mode 100644 index 00000000..1892b519 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/utc-timestamp/valid-zulu.json @@ -0,0 +1 @@ +"2026-08-01T01:30:00.125Z" diff --git a/packages/test-fixtures/package.json b/packages/test-fixtures/package.json new file mode 100644 index 00000000..9634eaab --- /dev/null +++ b/packages/test-fixtures/package.json @@ -0,0 +1,12 @@ +{ + "name": "@databreeze/test-fixtures", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + "./contracts/v1": "./contracts/v1/manifest.json" + }, + "scripts": { + "test": "node --test test/**/*.test.mjs" + } +} diff --git a/packages/test-fixtures/test/contracts-v1.test.mjs b/packages/test-fixtures/test/contracts-v1.test.mjs new file mode 100644 index 00000000..b83e1081 --- /dev/null +++ b/packages/test-fixtures/test/contracts-v1.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const fixtureRoot = resolve(packageRoot, 'contracts/v1'); +const fixtureManifestPath = resolve(fixtureRoot, 'manifest.json'); +const fixtureManifest = JSON.parse(readFileSync(fixtureManifestPath, 'utf8')); +const schemaManifestPath = resolve(fixtureRoot, fixtureManifest.schemaManifest); +const schemaManifest = JSON.parse(readFileSync(schemaManifestPath, 'utf8')); + +test('publishes a deterministic synthetic v1 contract fixture registry', () => { + assert.equal(fixtureManifest.package, '@databreeze/test-fixtures'); + assert.equal(fixtureManifest.fixtureVersion, 1); + assert.equal(fixtureManifest.contractVersion, 1); + assert.equal(fixtureManifest.synthetic, true); + assert.equal(fixtureManifest.cases.length, 28); + + const caseIds = fixtureManifest.cases.map((fixtureCase) => fixtureCase.id); + assert.equal(new Set(caseIds).size, caseIds.length, 'fixture case IDs must be unique'); + for (const caseId of caseIds) { + assert.match(caseId, /^v1\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/u); + } +}); + +test('covers every canonical schema with accepted and rejected payloads', () => { + const canonicalIds = schemaManifest.schemas.map((schema) => schema.id); + const fixtureIds = new Set(fixtureManifest.cases.map((fixtureCase) => fixtureCase.schemaId)); + assert.deepEqual([...fixtureIds].sort(), [...canonicalIds].sort()); + + for (const schemaId of canonicalIds) { + const expectations = new Set( + fixtureManifest.cases + .filter((fixtureCase) => fixtureCase.schemaId === schemaId) + .map((fixtureCase) => fixtureCase.expectedAcceptance), + ); + assert.deepEqual( + [...expectations].sort(), + [false, true], + `${schemaId} must have accepted and rejected fixtures`, + ); + } +}); + +test('loads every source as a hand-authored JSON payload inside the versioned package', () => { + const sources = new Set(); + for (const fixtureCase of fixtureManifest.cases) { + assert.equal(typeof fixtureCase.expectedAcceptance, 'boolean'); + assert.equal(sources.has(fixtureCase.source), false, `duplicate source: ${fixtureCase.source}`); + sources.add(fixtureCase.source); + + const sourcePath = resolve(fixtureRoot, fixtureCase.source); + assert.equal( + sourcePath.startsWith(`${fixtureRoot}${sep}`), + true, + `fixture source escapes v1 root: ${fixtureCase.source}`, + ); + assert.equal(existsSync(sourcePath), true, `fixture source is missing: ${fixtureCase.source}`); + assert.doesNotThrow(() => JSON.parse(readFileSync(sourcePath, 'utf8'))); + } +}); + +test('includes the required composition coverage', () => { + const coverage = new Set(fixtureManifest.cases.flatMap((fixtureCase) => fixtureCase.covers)); + assert.deepEqual( + [ + 'command.idempotency', + 'cursor.continuing', + 'cursor.terminal', + 'event.identity', + 'event.revision', + 'problem.message-localization', + 'problem.rate-limit', + 'problem.title-localization', + 'tenant.organization', + 'tenant.project', + 'tenant.workspace', + ].filter((requirement) => !coverage.has(requirement)), + [], + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eac82865..20214b9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,21 @@ importers: specifier: 3.0.1 version: 3.0.1(ajv@8.17.1) + packages/test-fixtures: {} + + tools/fixture-validation: + dependencies: + ajv: + specifier: 8.17.1 + version: 8.17.1 + ajv-formats: + specifier: 3.0.1 + version: 3.0.1(ajv@8.17.1) + devDependencies: + typescript: + specifier: 5.9.2 + version: 5.9.2 + packages: '@eslint-community/eslint-utils@4.10.1': diff --git a/tools/fixture-validation/README.md b/tools/fixture-validation/README.md index 79d82cc2..977b0920 100644 --- a/tools/fixture-validation/README.md +++ b/tools/fixture-validation/README.md @@ -1,3 +1,19 @@ # Fixture Validation -Future cross-runtime fixture and golden-result parity validation for API consumers and processing engines. +Cross-runtime fixture parity for generated contract consumers. + +`src/run-contract-parity.mjs` type-checks the valid fixtures against generated TypeScript types, +validates every shared payload with the canonical Ajv registry, runs the generated Pydantic v2 +models under the frozen uv environment, and compiles/runs the generated standard-Kotlin models +under the checksummed Gradle/JDK 21 harness. Kotlin rejects invalid JSON with NetworkNT JSON Schema +2020-12 validation before generated model construction. + +Run from the repository root: + +```sh +corepack pnpm --filter @databreeze/fixture-validation parity +``` + +The command emits one deterministic JSON summary and fails if a runtime disagrees with the fixture +manifest or another runtime. It requires uv 0.11.32 and JDK 21 on `PATH`; `DATABREEZE_UV` and +`DATABREEZE_JAVA` may point to those executables without committing machine-specific paths. diff --git a/tools/fixture-validation/kotlin/build.gradle.kts b/tools/fixture-validation/kotlin/build.gradle.kts new file mode 100644 index 00000000..8dd13f41 --- /dev/null +++ b/tools/fixture-validation/kotlin/build.gradle.kts @@ -0,0 +1,40 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") version "2.2.20" + application +} + +group = "com.databreeze.fixturevalidation" +version = "1.0.0" + +dependencies { + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.21.0") + implementation("com.networknt:json-schema-validator:2.0.4") + runtimeOnly("org.slf4j:slf4j-nop:2.0.17") +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + allWarningsAsErrors.set(true) + jvmTarget.set(JvmTarget.JVM_21) + } + sourceSets.named("main") { + kotlin.srcDir("../../../packages/contracts/generated/kotlin/src/main/kotlin") + } +} + +application { + mainClass.set("com.databreeze.fixturevalidation.ContractFixtureRunnerKt") +} + +dependencyLocking { + lockAllConfigurations() +} diff --git a/tools/fixture-validation/kotlin/gradle.lockfile b/tools/fixture-validation/kotlin/gradle.lockfile new file mode 100644 index 00000000..df54c56c --- /dev/null +++ b/tools/fixture-validation/kotlin/gradle.lockfile @@ -0,0 +1,31 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath +com.fasterxml.jackson.core:jackson-core:2.21.0=compileClasspath,runtimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.21.0=compileClasspath,runtimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.0=compileClasspath,runtimeClasspath +com.fasterxml.jackson.module:jackson-module-kotlin:2.21.0=compileClasspath,runtimeClasspath +com.fasterxml.jackson:jackson-bom:2.21.0=compileClasspath,runtimeClasspath +com.networknt:json-schema-validator:2.0.4=compileClasspath,runtimeClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.2.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.2.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.2.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.2.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-reflect:2.1.21=compileClasspath,runtimeClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain +org.jetbrains.kotlin:kotlin-scripting-common:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain +org.jetbrains.kotlin:kotlin-scripting-jvm:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain +org.jetbrains.kotlin:kotlin-stdlib:2.2.20=compileClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,runtimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains:annotations:13.0=compileClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,runtimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath +org.slf4j:slf4j-nop:2.0.17=runtimeClasspath +org.yaml:snakeyaml:2.4=compileClasspath,runtimeClasspath +empty=kotlinScriptDefExtensions diff --git a/tools/fixture-validation/kotlin/gradle.properties b/tools/fixture-validation/kotlin/gradle.properties new file mode 100644 index 00000000..7d41230e --- /dev/null +++ b/tools/fixture-validation/kotlin/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.caching=false +org.gradle.daemon=false diff --git a/tools/fixture-validation/kotlin/gradle/wrapper/gradle-wrapper.jar b/tools/fixture-validation/kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/tools/fixture-validation/kotlin/gradlew.bat b/tools/fixture-validation/kotlin/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/tools/fixture-validation/kotlin/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/tools/fixture-validation/kotlin/settings.gradle.kts b/tools/fixture-validation/kotlin/settings.gradle.kts new file mode 100644 index 00000000..3f8adba4 --- /dev/null +++ b/tools/fixture-validation/kotlin/settings.gradle.kts @@ -0,0 +1,17 @@ +import org.gradle.api.initialization.resolve.RepositoriesMode + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "databreeze-contract-fixture-consumer" diff --git a/tools/fixture-validation/kotlin/src/main/kotlin/com/databreeze/fixturevalidation/ContractFixtureRunner.kt b/tools/fixture-validation/kotlin/src/main/kotlin/com/databreeze/fixturevalidation/ContractFixtureRunner.kt new file mode 100644 index 00000000..72fdfdea --- /dev/null +++ b/tools/fixture-validation/kotlin/src/main/kotlin/com/databreeze/fixturevalidation/ContractFixtureRunner.kt @@ -0,0 +1,174 @@ +package com.databreeze.fixturevalidation + +import com.databreeze.contracts.v1.ActorMetadata +import com.databreeze.contracts.v1.CommandEnvelope +import com.databreeze.contracts.v1.CorrelationMetadata +import com.databreeze.contracts.v1.CursorPage +import com.databreeze.contracts.v1.EventEnvelope +import com.databreeze.contracts.v1.Identifier +import com.databreeze.contracts.v1.OrganizationScope +import com.databreeze.contracts.v1.ProblemDetails +import com.databreeze.contracts.v1.ProjectScope +import com.databreeze.contracts.v1.Revision +import com.databreeze.contracts.v1.TenantScope +import com.databreeze.contracts.v1.UtcTimestamp +import com.databreeze.contracts.v1.WorkspaceScope +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.networknt.schema.InputFormat +import com.networknt.schema.SchemaLocation +import com.networknt.schema.SchemaRegistry +import com.networknt.schema.SpecificationVersion +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.absolute +import kotlin.io.path.readText + +private const val SCHEMA_BASE = "https://schemas.databreeze.dev/contracts/v1" + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "scopeType", + visible = false, +) +@JsonSubTypes( + JsonSubTypes.Type(value = OrganizationScope::class, name = "organization"), + JsonSubTypes.Type(value = WorkspaceScope::class, name = "workspace"), + JsonSubTypes.Type(value = ProjectScope::class, name = "project"), +) +private interface TenantScopeMixin + +private val mapper: ObjectMapper = jacksonObjectMapper() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addMixIn(TenantScope::class.java, TenantScopeMixin::class.java) + +private data class Arguments( + val fixtureManifest: Path, + val output: Path, +) + +private fun parseArguments(arguments: Array): Arguments { + var fixtureManifest: Path? = null + var output: Path? = null + var index = 0 + while (index < arguments.size) { + val value = arguments.getOrNull(index + 1) + ?: error("${arguments[index]} requires a path") + when (arguments[index]) { + "--fixture-manifest" -> fixtureManifest = Path.of(value).absolute().normalize() + "--output" -> output = Path.of(value).absolute().normalize() + else -> error("Unknown argument: ${arguments[index]}") + } + index += 2 + } + return Arguments( + fixtureManifest = requireNotNull(fixtureManifest) { "--fixture-manifest is required" }, + output = requireNotNull(output) { "--output is required" }, + ) +} + +private fun schemaRegistry(fixtureManifest: Path, manifest: JsonNode): SchemaRegistry { + val fixtureRoot = requireNotNull(fixtureManifest.parent) + val schemaManifestPath = fixtureRoot.resolve(manifest.required("schemaManifest").asText()).normalize() + val contractRoot = requireNotNull(schemaManifestPath.parent) + val schemaManifest = mapper.readTree(schemaManifestPath.toFile()) + val schemas = schemaManifest.required("schemas").associate { entry -> + val id = entry.required("id").asText() + val source = contractRoot.resolve(entry.required("path").asText()).normalize().readText() + id to source + } + return SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12) { builder -> + builder.schemas(schemas) + } +} + +private fun identifier(payload: JsonNode): Identifier = + mapper.treeToValue(payload, String::class.java) + +private fun revision(payload: JsonNode): Revision = payload.longValue() + +private fun utcTimestamp(payload: JsonNode): UtcTimestamp = + mapper.treeToValue(payload, String::class.java) + +private fun constructGeneratedModel(schemaId: String, payload: JsonNode): Any = when (schemaId) { + "$SCHEMA_BASE/actor-metadata" -> mapper.treeToValue(payload, ActorMetadata::class.java) + "$SCHEMA_BASE/command-envelope" -> mapper.convertValue( + payload, + object : TypeReference>>() {}, + ) + "$SCHEMA_BASE/correlation-metadata" -> mapper.treeToValue( + payload, + CorrelationMetadata::class.java, + ) + "$SCHEMA_BASE/cursor-page" -> mapper.convertValue( + payload, + object : TypeReference>() {}, + ) + "$SCHEMA_BASE/event-envelope" -> mapper.convertValue( + payload, + object : TypeReference>>() {}, + ) + "$SCHEMA_BASE/identifier" -> identifier(payload) + "$SCHEMA_BASE/problem-details" -> mapper.treeToValue(payload, ProblemDetails::class.java) + "$SCHEMA_BASE/revision" -> revision(payload) + "$SCHEMA_BASE/tenant-scope" -> mapper.treeToValue(payload, TenantScope::class.java) + "$SCHEMA_BASE/utc-timestamp" -> utcTimestamp(payload) + else -> error("No generated Kotlin model for $schemaId") +} + +private fun acceptsFixture( + registry: SchemaRegistry, + schemaId: String, + payloadSource: String, + payload: JsonNode, +): Boolean { + val schema = registry.getSchema(SchemaLocation.of(schemaId)) + val errors = schema.validate(payloadSource, InputFormat.JSON) { executionContext -> + executionContext.executionConfig { configuration -> + configuration.formatAssertionsEnabled(true) + } + } + if (errors.isNotEmpty()) return false + return try { + constructGeneratedModel(schemaId, payload) + true + } catch (_: Exception) { + false + } +} + +private fun runFixtures(arguments: Arguments) { + val manifest = mapper.readTree(arguments.fixtureManifest.toFile()) + val registry = schemaRegistry(arguments.fixtureManifest, manifest) + val fixtureRoot = requireNotNull(arguments.fixtureManifest.parent) + val output = mapper.createObjectNode() + output.put("runtime", "kotlin") + val results = output.putArray("results") + for (fixtureCase in manifest.required("cases")) { + val source = fixtureRoot.resolve(fixtureCase.required("source").asText()).normalize() + val payloadSource = source.readText() + val payload = mapper.readTree(payloadSource) + results.addObject() + .put("caseId", fixtureCase.required("id").asText()) + .put( + "accepted", + acceptsFixture( + registry, + fixtureCase.required("schemaId").asText(), + payloadSource, + payload, + ), + ) + } + Files.writeString(arguments.output, mapper.writeValueAsString(output) + "\n") +} + +public fun main(arguments: Array) { + runFixtures(parseArguments(arguments)) +} diff --git a/tools/fixture-validation/package.json b/tools/fixture-validation/package.json new file mode 100644 index 00000000..93625db0 --- /dev/null +++ b/tools/fixture-validation/package.json @@ -0,0 +1,17 @@ +{ + "name": "@databreeze/fixture-validation", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "parity": "node src/run-contract-parity.mjs", + "test": "node --test test/**/*.test.mjs" + }, + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1" + }, + "devDependencies": { + "typescript": "5.9.2" + } +} diff --git a/tools/fixture-validation/python/.python-version b/tools/fixture-validation/python/.python-version new file mode 100644 index 00000000..4eba2a62 --- /dev/null +++ b/tools/fixture-validation/python/.python-version @@ -0,0 +1 @@ +3.13.0 diff --git a/tools/fixture-validation/python/pyproject.toml b/tools/fixture-validation/python/pyproject.toml new file mode 100644 index 00000000..959cb217 --- /dev/null +++ b/tools/fixture-validation/python/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "databreeze-contract-fixture-consumer" +version = "1.0.0" +requires-python = "==3.13.*" +dependencies = [ + "pydantic==2.13.4", + "rfc3339-validator==0.1.4", + "rfc3986-validator==0.1.1", +] + +[tool.uv] +package = false +required-version = "==0.11.32" diff --git a/tools/fixture-validation/python/run_fixtures.py b/tools/fixture-validation/python/run_fixtures.py new file mode 100644 index 00000000..4a970075 --- /dev/null +++ b/tools/fixture-validation/python/run_fixtures.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from pydantic import TypeAdapter, ValidationError + +from databreeze_contracts.v1 import ( + ActorMetadata, + CommandEnvelope, + CorrelationMetadata, + CursorPage, + EventEnvelope, + Identifier, + ProblemDetails, + Revision, + TenantScope, + UtcTimestamp, +) + +SCHEMA_BASE = "https://schemas.databreeze.dev/contracts/v1" +ADAPTERS: dict[str, TypeAdapter[Any]] = { + f"{SCHEMA_BASE}/actor-metadata": TypeAdapter(ActorMetadata), + f"{SCHEMA_BASE}/command-envelope": TypeAdapter(CommandEnvelope[dict[str, Any]]), + f"{SCHEMA_BASE}/correlation-metadata": TypeAdapter(CorrelationMetadata), + f"{SCHEMA_BASE}/cursor-page": TypeAdapter(CursorPage[Any]), + f"{SCHEMA_BASE}/event-envelope": TypeAdapter(EventEnvelope[dict[str, Any]]), + f"{SCHEMA_BASE}/identifier": TypeAdapter(Identifier), + f"{SCHEMA_BASE}/problem-details": TypeAdapter(ProblemDetails), + f"{SCHEMA_BASE}/revision": TypeAdapter(Revision), + f"{SCHEMA_BASE}/tenant-scope": TypeAdapter(TenantScope), + f"{SCHEMA_BASE}/utc-timestamp": TypeAdapter(UtcTimestamp), +} + + +def read_json(path: Path) -> Any: + with path.open(encoding="utf-8") as source: + return json.load(source) + + +def accepts_fixture(schema_id: str, payload: Any) -> bool: + adapter = ADAPTERS.get(schema_id) + if adapter is None: + raise ValueError(f"No generated Pydantic model for {schema_id}") + try: + adapter.validate_python(payload) + except ValidationError: + return False + return True + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--fixture-manifest", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + return parser.parse_args() + + +def main() -> None: + arguments = parse_arguments() + fixture_manifest_path = arguments.fixture_manifest.resolve() + fixture_root = fixture_manifest_path.parent + manifest = read_json(fixture_manifest_path) + results = [] + for fixture_case in manifest["cases"]: + payload = read_json(fixture_root / fixture_case["source"]) + results.append( + { + "caseId": fixture_case["id"], + "accepted": accepts_fixture(fixture_case["schemaId"], payload), + } + ) + document = {"runtime": "python", "results": results} + arguments.output.write_text( + json.dumps(document, ensure_ascii=False, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/tools/fixture-validation/python/uv.lock b/tools/fixture-validation/python/uv.lock new file mode 100644 index 00000000..18c932ed --- /dev/null +++ b/tools/fixture-validation/python/uv.lock @@ -0,0 +1,121 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "databreeze-contract-fixture-consumer" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "pydantic" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = "==2.13.4" }, + { name = "rfc3339-validator", specifier = "==0.1.4" }, + { name = "rfc3986-validator", specifier = "==0.1.1" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/tools/fixture-validation/src/compare-contract-results.mjs b/tools/fixture-validation/src/compare-contract-results.mjs new file mode 100644 index 00000000..d1548c48 --- /dev/null +++ b/tools/fixture-validation/src/compare-contract-results.mjs @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const runtimeOrder = ['typescript', 'python', 'kotlin']; + +function fail(message) { + throw new Error(message); +} + +function parseJson(path, label) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + fail(`${label} is not valid JSON: ${error.message}`); + } +} + +export function compareContractResults(fixtureManifestPath, resultPaths) { + const manifest = parseJson(fixtureManifestPath, 'Fixture manifest'); + if (!Array.isArray(manifest.cases)) fail('Fixture manifest cases must be an array'); + + const resultDocuments = resultPaths.map((path) => parseJson(path, `Runtime result ${path}`)); + const byRuntime = new Map(); + for (const document of resultDocuments) { + if (!runtimeOrder.includes(document.runtime)) fail(`Unexpected runtime: ${document.runtime}`); + if (byRuntime.has(document.runtime)) fail(`Duplicate runtime result: ${document.runtime}`); + if (!Array.isArray(document.results)) fail(`${document.runtime} results must be an array`); + byRuntime.set(document.runtime, document.results); + } + for (const runtime of runtimeOrder) { + if (!byRuntime.has(runtime)) fail(`Missing runtime result: ${runtime}`); + } + + for (const runtime of runtimeOrder) { + const results = byRuntime.get(runtime); + if (results.length !== manifest.cases.length) { + fail(`${runtime} emitted ${results.length} results for ${manifest.cases.length} cases`); + } + for (let index = 0; index < manifest.cases.length; index += 1) { + const fixtureCase = manifest.cases[index]; + const result = results[index]; + if (result.caseId !== fixtureCase.id) { + fail(`${runtime} result order changed at ${fixtureCase.id}`); + } + if (typeof result.accepted !== 'boolean') { + fail(`${runtime} emitted a non-boolean result for ${fixtureCase.id}`); + } + } + } + + for (let index = 0; index < manifest.cases.length; index += 1) { + const fixtureCase = manifest.cases[index]; + const runtimeValues = runtimeOrder.map((runtime) => byRuntime.get(runtime)[index].accepted); + if (!runtimeValues.every((accepted) => accepted === runtimeValues[0])) { + const details = runtimeOrder + .map((runtime, runtimeIndex) => `${runtime}=${runtimeValues[runtimeIndex]}`) + .join(', '); + fail(`Runtime disagreement for ${fixtureCase.id}: ${details}`); + } + if (runtimeValues[0] !== fixtureCase.expectedAcceptance) { + fail( + `Manifest disagreement for ${fixtureCase.id}: expected ${ + fixtureCase.expectedAcceptance ? 'accepted' : 'rejected' + }, received ${runtimeValues[0] ? 'accepted' : 'rejected'}`, + ); + } + } + + const expectedAccepted = manifest.cases.filter( + (fixtureCase) => fixtureCase.expectedAcceptance, + ).length; + return { + caseCount: manifest.cases.length, + expectedAccepted, + expectedRejected: manifest.cases.length - expectedAccepted, + runtimes: runtimeOrder, + }; +} + +function readArguments(argumentsList) { + const options = { fixtureManifest: undefined, results: [] }; + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + if (argument === '--fixture-manifest' || argument === '--result') { + const value = argumentsList[index + 1]; + if (!value) fail(`${argument} requires a path`); + if (argument === '--fixture-manifest') options.fixtureManifest = resolve(value); + else options.results.push(resolve(value)); + index += 1; + } else { + fail(`Unknown argument: ${argument}`); + } + } + if (!options.fixtureManifest) fail('--fixture-manifest is required'); + return options; +} + +function runCli() { + try { + const options = readArguments(process.argv.slice(2)); + const summary = compareContractResults(options.fixtureManifest, options.results); + process.stdout.write(`${JSON.stringify(summary)}\n`); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) runCli(); diff --git a/tools/fixture-validation/src/run-contract-parity.mjs b/tools/fixture-validation/src/run-contract-parity.mjs new file mode 100644 index 00000000..ed9eba71 --- /dev/null +++ b/tools/fixture-validation/src/run-contract-parity.mjs @@ -0,0 +1,156 @@ +import { createRequire } from 'node:module'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +import { compareContractResults } from './compare-contract-results.mjs'; + +const toolRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repositoryRoot = resolve(toolRoot, '../..'); +const require = createRequire(import.meta.url); + +function fail(message) { + throw new Error(message); +} + +function readArguments(argumentsList) { + const options = { + fixtureManifest: resolve(repositoryRoot, 'packages/test-fixtures/contracts/v1/manifest.json'), + }; + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + if (argument !== '--fixture-manifest') fail(`Unknown argument: ${argument}`); + const value = argumentsList[index + 1]; + if (!value) fail('--fixture-manifest requires a path'); + options.fixtureManifest = resolve(value); + index += 1; + } + return options; +} + +function runCommand(command, argumentsList, options = {}) { + const run = spawnSync(command, argumentsList, { + cwd: options.cwd ?? repositoryRoot, + encoding: 'utf8', + env: options.env ?? process.env, + maxBuffer: 10 * 1024 * 1024, + timeout: 300_000, + windowsHide: true, + }); + if (run.error) fail(`${command} could not start: ${run.error.message}`); + if (run.status !== 0) { + const output = [run.stdout, run.stderr].filter(Boolean).join('\n').trim(); + fail(`${command} exited with status ${run.status}${output ? `:\n${output}` : ''}`); + } + return run; +} + +function quoteApplicationArgument(value) { + return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; +} + +function runTypeScript(fixtureManifest, output) { + const typeScriptCompiler = require.resolve('typescript/bin/tsc'); + runCommand(process.execPath, [ + typeScriptCompiler, + '--project', + resolve(toolRoot, 'typescript/tsconfig.json'), + ]); + runCommand(process.execPath, [ + resolve(toolRoot, 'typescript/run-fixtures.mjs'), + '--fixture-manifest', + fixtureManifest, + '--output', + output, + ]); +} + +function runPython(fixtureManifest, output, temporaryRoot) { + const uvCommand = process.env.DATABREEZE_UV ?? 'uv'; + const version = runCommand(uvCommand, ['--version']).stdout.trim(); + if (!/^uv 0\.11\.32(?:\s|$)/u.test(version)) { + fail(`Expected uv 0.11.32, received ${version}`); + } + const pythonRoot = resolve(toolRoot, 'python'); + runCommand( + uvCommand, + [ + 'run', + '--frozen', + '--project', + pythonRoot, + 'python', + resolve(pythonRoot, 'run_fixtures.py'), + '--fixture-manifest', + fixtureManifest, + '--output', + output, + ], + { + env: { + ...process.env, + PYTHONPATH: [ + resolve(repositoryRoot, 'packages/contracts/generated/python'), + process.env.PYTHONPATH, + ] + .filter(Boolean) + .join(delimiter), + PYTHONDONTWRITEBYTECODE: '1', + UV_PROJECT_ENVIRONMENT: resolve(temporaryRoot, 'python-environment'), + }, + }, + ); +} + +function runKotlin(fixtureManifest, output) { + const javaCommand = process.env.DATABREEZE_JAVA ?? 'java'; + const kotlinRoot = resolve(toolRoot, 'kotlin'); + const wrapperJar = resolve(kotlinRoot, 'gradle/wrapper/gradle-wrapper.jar'); + const applicationArguments = [ + '--fixture-manifest', + quoteApplicationArgument(fixtureManifest), + '--output', + quoteApplicationArgument(output), + ].join(' '); + runCommand( + javaCommand, + [ + '-classpath', + wrapperJar, + 'org.gradle.wrapper.GradleWrapperMain', + '--no-daemon', + '--quiet', + 'run', + `--args=${applicationArguments}`, + ], + { cwd: kotlinRoot }, + ); +} + +try { + const options = readArguments(process.argv.slice(2)); + const temporaryRoot = mkdtempSync(resolve(tmpdir(), 'databreeze-contract-parity-')); + try { + const results = { + typescript: resolve(temporaryRoot, 'typescript.json'), + python: resolve(temporaryRoot, 'python.json'), + kotlin: resolve(temporaryRoot, 'kotlin.json'), + }; + runTypeScript(options.fixtureManifest, results.typescript); + runPython(options.fixtureManifest, results.python, temporaryRoot); + runKotlin(options.fixtureManifest, results.kotlin); + const summary = compareContractResults(options.fixtureManifest, [ + results.typescript, + results.python, + results.kotlin, + ]); + process.stdout.write(`${JSON.stringify(summary)}\n`); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/tools/fixture-validation/test/contract-parity.test.mjs b/tools/fixture-validation/test/contract-parity.test.mjs new file mode 100644 index 00000000..8347b2be --- /dev/null +++ b/tools/fixture-validation/test/contract-parity.test.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const toolRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repositoryRoot = resolve(toolRoot, '../..'); +const comparatorPath = resolve(toolRoot, 'src/compare-contract-results.mjs'); +const orchestratorPath = resolve(toolRoot, 'src/run-contract-parity.mjs'); +const fixtureManifestPath = resolve( + repositoryRoot, + 'packages/test-fixtures/contracts/v1/manifest.json', +); +const generatedContractsRoot = resolve(repositoryRoot, 'packages/contracts/generated'); + +function snapshotDirectory(root, directory = root) { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() + ? snapshotDirectory(root, path) + : [ + [ + path.slice(root.length + 1).replaceAll('\\', '/'), + createHash('sha256').update(readFileSync(path)).digest('hex'), + ], + ]; + }) + .sort(([left], [right]) => left.localeCompare(right, 'en')); +} + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function runComparator(manifestPath, resultPaths) { + return spawnSync( + process.execPath, + [ + comparatorPath, + '--fixture-manifest', + manifestPath, + ...resultPaths.flatMap((resultPath) => ['--result', resultPath]), + ], + { cwd: repositoryRoot, encoding: 'utf8' }, + ); +} + +function withComparisonFixture(callback) { + const root = mkdtempSync(resolve(tmpdir(), 'databreeze-parity-comparison-')); + try { + const manifestPath = resolve(root, 'manifest.json'); + writeJson(manifestPath, { + fixtureVersion: 1, + cases: [ + { + id: 'v1.identifier.valid-uuid', + schemaId: 'https://schemas.databreeze.dev/contracts/v1/identifier', + expectedAcceptance: true, + source: 'payload.json', + }, + ], + }); + callback(root, manifestPath); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test('fails when a runtime disagrees with another consumer', () => { + withComparisonFixture((root, manifestPath) => { + const results = [ + ['typescript', true], + ['python', false], + ['kotlin', true], + ].map(([runtime, accepted]) => { + const path = resolve(root, `${runtime}.json`); + writeJson(path, { + runtime, + results: [{ caseId: 'v1.identifier.valid-uuid', accepted }], + }); + return path; + }); + + const run = runComparator(manifestPath, results); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, /Runtime disagreement for v1\.identifier\.valid-uuid/u); + }); +}); + +test('fails when every runtime disagrees with the fixture manifest', () => { + withComparisonFixture((root, manifestPath) => { + const results = ['typescript', 'python', 'kotlin'].map((runtime) => { + const path = resolve(root, `${runtime}.json`); + writeJson(path, { + runtime, + results: [{ caseId: 'v1.identifier.valid-uuid', accepted: false }], + }); + return path; + }); + + const run = runComparator(manifestPath, results); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match( + run.stderr, + /Manifest disagreement for v1\.identifier\.valid-uuid: expected accepted/u, + ); + }); +}); + +test('the real TypeScript Python and Kotlin consumers agree on every shared fixture', () => { + const generatedBefore = snapshotDirectory(generatedContractsRoot); + const run = spawnSync( + process.execPath, + [orchestratorPath, '--fixture-manifest', fixtureManifestPath], + { + cwd: repositoryRoot, + encoding: 'utf8', + env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', UV_NO_CACHE: '1' }, + timeout: 300_000, + }, + ); + assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`); + assert.deepEqual(JSON.parse(run.stdout), { + caseCount: 28, + expectedAccepted: 14, + expectedRejected: 14, + runtimes: ['typescript', 'python', 'kotlin'], + }); + assert.deepEqual( + snapshotDirectory(generatedContractsRoot), + generatedBefore, + 'consumer parity must not mutate checked-in generated contracts', + ); +}); + +test('fixture expectations stay independently balanced', () => { + const manifest = JSON.parse(readFileSync(fixtureManifestPath, 'utf8')); + assert.equal(manifest.cases.filter((fixtureCase) => fixtureCase.expectedAcceptance).length, 14); + assert.equal(manifest.cases.filter((fixtureCase) => !fixtureCase.expectedAcceptance).length, 14); +}); diff --git a/tools/fixture-validation/typescript/run-fixtures.mjs b/tools/fixture-validation/typescript/run-fixtures.mjs new file mode 100644 index 00000000..94633018 --- /dev/null +++ b/tools/fixture-validation/typescript/run-fixtures.mjs @@ -0,0 +1,71 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; + +function fail(message) { + throw new Error(message); +} + +function parseJson(path, label) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + fail(`${label} is not valid JSON: ${error.message}`); + } +} + +function readArguments(argumentsList) { + const options = { fixtureManifest: undefined, output: undefined }; + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + if (argument === '--fixture-manifest' || argument === '--output') { + const value = argumentsList[index + 1]; + if (!value) fail(`${argument} requires a path`); + options[argument === '--output' ? 'output' : 'fixtureManifest'] = resolve(value); + index += 1; + } else { + fail(`Unknown argument: ${argument}`); + } + } + if (!options.fixtureManifest || !options.output) { + fail('--fixture-manifest and --output are required'); + } + return options; +} + +function buildCanonicalRegistry(fixtureManifestPath, fixtureManifest) { + const fixtureRoot = dirname(fixtureManifestPath); + const schemaManifestPath = resolve(fixtureRoot, fixtureManifest.schemaManifest); + const contractRoot = dirname(schemaManifestPath); + const schemaManifest = parseJson(schemaManifestPath, 'Canonical schema manifest'); + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addFormats(ajv); + for (const entry of schemaManifest.schemas) { + const schema = parseJson(resolve(contractRoot, entry.path), `Canonical schema ${entry.name}`); + if (schema.$id !== entry.id) fail(`Manifest ID does not match ${entry.path}`); + ajv.addSchema(schema); + } + return ajv; +} + +try { + const options = readArguments(process.argv.slice(2)); + const fixtureManifest = parseJson(options.fixtureManifest, 'Fixture manifest'); + const fixtureRoot = dirname(options.fixtureManifest); + const ajv = buildCanonicalRegistry(options.fixtureManifest, fixtureManifest); + const results = fixtureManifest.cases.map((fixtureCase) => { + const validate = ajv.getSchema(fixtureCase.schemaId); + if (!validate) fail(`Canonical registry has no schema for ${fixtureCase.schemaId}`); + const payload = parseJson( + resolve(fixtureRoot, fixtureCase.source), + `Fixture ${fixtureCase.id}`, + ); + return { caseId: fixtureCase.id, accepted: validate(payload) }; + }); + writeFileSync(options.output, `${JSON.stringify({ runtime: 'typescript', results })}\n`, 'utf8'); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/tools/fixture-validation/typescript/tsconfig.json b/tools/fixture-validation/typescript/tsconfig.json new file mode 100644 index 00000000..928f82c5 --- /dev/null +++ b/tools/fixture-validation/typescript/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "files": ["valid-fixture-consumer.ts"] +} diff --git a/tools/fixture-validation/typescript/valid-fixture-consumer.ts b/tools/fixture-validation/typescript/valid-fixture-consumer.ts new file mode 100644 index 00000000..bbafad79 --- /dev/null +++ b/tools/fixture-validation/typescript/valid-fixture-consumer.ts @@ -0,0 +1,73 @@ +import type { + ActorMetadata, + CommandEnvelope, + CorrelationMetadata, + CursorPage, + EventEnvelope, + Identifier, + OrganizationScope, + ProblemDetails, + ProjectScope, + Revision, + UtcTimestamp, + WorkspaceScope, +} from '../../../packages/contracts/generated/typescript/v1/index.js'; + +import actorPayload from '../../../packages/test-fixtures/contracts/v1/payloads/actor-metadata/valid-user.json' with { type: 'json' }; +import commandPayload from '../../../packages/test-fixtures/contracts/v1/payloads/command-envelope/valid-idempotent.json' with { type: 'json' }; +import correlationPayload from '../../../packages/test-fixtures/contracts/v1/payloads/correlation-metadata/valid-full-chain.json' with { type: 'json' }; +import continuingPayload from '../../../packages/test-fixtures/contracts/v1/payloads/cursor-page/valid-continuing.json' with { type: 'json' }; +import terminalPayload from '../../../packages/test-fixtures/contracts/v1/payloads/cursor-page/valid-terminal.json' with { type: 'json' }; +import eventPayload from '../../../packages/test-fixtures/contracts/v1/payloads/event-envelope/valid-workspace-revision.json' with { type: 'json' }; +import identifierPayload from '../../../packages/test-fixtures/contracts/v1/payloads/identifier/valid-uuid.json' with { type: 'json' }; +import messageProblemPayload from '../../../packages/test-fixtures/contracts/v1/payloads/problem-details/valid-message-localization-rate-limit.json' with { type: 'json' }; +import titleProblemPayload from '../../../packages/test-fixtures/contracts/v1/payloads/problem-details/valid-title-localization.json' with { type: 'json' }; +import revisionPayload from '../../../packages/test-fixtures/contracts/v1/payloads/revision/valid-positive.json' with { type: 'json' }; +import organizationPayload from '../../../packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-organization.json' with { type: 'json' }; +import projectPayload from '../../../packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-project.json' with { type: 'json' }; +import workspacePayload from '../../../packages/test-fixtures/contracts/v1/payloads/tenant-scope/valid-workspace.json' with { type: 'json' }; +import timestampPayload from '../../../packages/test-fixtures/contracts/v1/payloads/utc-timestamp/valid-zulu.json' with { type: 'json' }; + +const actor: ActorMetadata = actorPayload; +const command: CommandEnvelope<{ readonly displayName: string }> = { + ...commandPayload, + tenantScope: { ...commandPayload.tenantScope, scopeType: 'workspace' }, +}; +const correlation: CorrelationMetadata = correlationPayload; +const continuing: CursorPage<{ readonly id: string }> = { + ...continuingPayload, + hasMore: true, +}; +const terminal: CursorPage = { ...terminalPayload, hasMore: false }; +const event: EventEnvelope<{ readonly changedFields: readonly string[] }> = { + ...eventPayload, + tenantScope: { ...eventPayload.tenantScope, scopeType: 'workspace' }, +}; +const identifier: Identifier = identifierPayload; +const messageProblem: ProblemDetails = messageProblemPayload; +const titleProblem: ProblemDetails = titleProblemPayload; +const revision: Revision = revisionPayload; +const organization: OrganizationScope = { + ...organizationPayload, + scopeType: 'organization', +}; +const project: ProjectScope = { ...projectPayload, scopeType: 'project' }; +const workspace: WorkspaceScope = { ...workspacePayload, scopeType: 'workspace' }; +const timestamp: UtcTimestamp = timestampPayload; + +export const validGeneratedTypeConsumers = [ + actor, + command, + correlation, + continuing, + terminal, + event, + identifier, + messageProblem, + titleProblem, + revision, + organization, + project, + workspace, + timestamp, +] as const; diff --git a/turbo.json b/turbo.json index 5b49bbc6..7e4c1b0a 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,7 @@ { "$schema": "https://turbo.build/schema.json", "globalDependencies": [".node-version", ".npmrc", ".tool-versions"], + "globalPassThroughEnv": ["DATABREEZE_JAVA", "DATABREEZE_UV", "JAVA_HOME"], "tasks": { "build": { "dependsOn": ["^build"], From 4d2d9ba0ac2aa23d802751ae6cde67c38ee64be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 05:46:08 +0700 Subject: [PATCH 15/51] fix(contracts): close parity and compatibility gaps --- packages/contracts/COMPATIBILITY.md | 19 +- packages/contracts/README.md | 14 +- .../contracts/compatibility/published.json | 2 +- .../contracts/compatibility/v1/baseline.json | 76 ++++++- .../com/databreeze/contracts/v1/Validation.kt | 111 +++++++++ .../generated/typescript/v1/index.ts | 11 + .../generated/typescript/v1/validation.mjs | 35 +++ packages/contracts/package.json | 5 +- packages/contracts/public-outputs.json | 26 +++ .../scripts/contract-compatibility.mjs | 179 +++++++++++++-- .../contracts/scripts/contract-generator.mjs | 212 ++++++++++++++++++ .../contracts/test/compatibility.test.mjs | 77 ++++++- packages/contracts/test/generation.test.mjs | 2 + pnpm-lock.yaml | 11 +- tools/fixture-validation/README.md | 15 +- .../ContractFixtureRunner.kt | 118 +--------- tools/fixture-validation/package.json | 3 +- .../test/contract-parity.test.mjs | 60 +++++ .../typescript/run-fixtures.mjs | 26 +-- .../typescript/valid-fixture-consumer.ts | 2 +- 20 files changed, 817 insertions(+), 187 deletions(-) create mode 100644 packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt create mode 100644 packages/contracts/generated/typescript/v1/validation.mjs create mode 100644 packages/contracts/public-outputs.json diff --git a/packages/contracts/COMPATIBILITY.md b/packages/contracts/COMPATIBILITY.md index 835582a3..5d33953a 100644 --- a/packages/contracts/COMPATIBILITY.md +++ b/packages/contracts/COMPATIBILITY.md @@ -6,11 +6,18 @@ TypeScript, Python, and Kotlin public outputs are immutable for the same reason. applies to changes that would normally be described as additive or backward compatible: after publication, they require a new contract version and new absolute schema IDs. +`public-outputs.json` is the explicit versioned inventory of generated public files and selected +package JSON surfaces. It includes language-package metadata outside `/vN/` directories, such as the +Python `pyproject.toml`, package-root `__init__.py`, and `py.typed`, plus the JavaScript package +name, exports, and runtime dependency map. Every file below `generated/` must appear in the +inventory. + `compatibility/published.json` locks the SHA-256 digest of each reviewed version baseline. Each -`compatibility/vN/baseline.json` locks every schema ID/path/byte digest and each version-specific -generated public file. The check fails for removed or added v1 schemas, changed IDs or bytes, -missing or changed generated outputs, missing baselines, and baseline edits that do not match the -published registry. +`compatibility/vN/baseline.json` locks every schema ID/path/byte digest, the version's exact +public-output inventory entry, every inventoried generated public file, and each selected package +surface. The check fails for removed or added v1 schemas, changed IDs or bytes, missing, changed, +added, or unlisted generated outputs, public export drift, missing baselines, and baseline edits +that do not match the published registry. Run the read-only checks with: @@ -32,3 +39,7 @@ rewrite a published version. `--approve-new-version` records the caller's intent substitute for repository review. An incompatible change therefore requires a new version/ID, consumer migration evidence, an updated shared fixture suite, and review of the new immutable baseline. + +There is deliberately no command that rewrites a published baseline or its registry digest. An +exceptional coordinated repair remains a repository-review trust boundary; Task 21 must protect the +inventory, baseline, and registry together in CI and review policy. diff --git a/packages/contracts/README.md b/packages/contracts/README.md index f4d8dd22..8196ab6c 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -6,9 +6,13 @@ Canonical OpenAPI, JSON Schema, event, typed-job, and compatibility definitions - `manifest.json` is the deterministic registry for canonical source schemas. - `schemas/v1/*.schema.json` contains closed JSON Schema 2020-12 definitions with stable absolute IDs and references. -- `generated/typescript/v1/index.ts` exports structural TypeScript contracts for Web, Desktop, and API consumers. -- `generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt` provides standard Kotlin models in `com.databreeze.contracts.v1`. +- `@databreeze/contracts/v1` exports structural TypeScript contracts plus the generated + `parseV1Contract` runtime validator backed by the canonical schema registry. +- `generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1` provides standard Kotlin models + and a public `parseV1Contract` model/validation entry point. - `generated/python/databreeze_contracts/v1` is the Pydantic v2 model package for Python consumers. +- `public-outputs.json` explicitly inventories every version-relevant generated file and selected + package export surface. - `compatibility/` contains immutable reviewed hashes for published schema bytes and generated version-specific public outputs; see [COMPATIBILITY.md](COMPATIBILITY.md). - Consumers import only the entry points declared in `package.json#exports`. @@ -28,8 +32,10 @@ corepack pnpm --filter @databreeze/contracts fixtures:check `generate` is the only supported way to update checked-in language models. Do not edit files below `generated/` by hand. `generate:check` regenerates into a temporary directory, byte-compares the complete expected file set, and reports missing, stale, or unexpected files without changing checked-in output. `test` compiles the real schemas with Ajv's JSON Schema 2020-12 validator and exercises generator behavior plus hand-authored protocol payloads. `build` compiles every manifest entry and checks generated-file drift. -`compatibility:check` rejects in-place changes to a published contract baseline. -`fixtures:check` runs the shared payloads through the real TypeScript, Python, and Kotlin consumers. +`compatibility:check` rejects in-place changes to a published contract baseline, public-output +inventory, generated package metadata, or package export surface. `fixtures:check` runs every +shared payload through the public generated TypeScript and Kotlin parsers and the generated +Pydantic models. Root `pnpm contracts:check` runs generation drift, compatibility, and cross-runtime fixture parity. ## Forbidden dependencies diff --git a/packages/contracts/compatibility/published.json b/packages/contracts/compatibility/published.json index 855b3070..fbdd0098 100644 --- a/packages/contracts/compatibility/published.json +++ b/packages/contracts/compatibility/published.json @@ -4,7 +4,7 @@ { "contractVersion": 1, "baseline": "compatibility/v1/baseline.json", - "sha256": "5307743b430a27ff7aa894a09c15b3920f36459a04ae9bb29100f2b164c9dcfe" + "sha256": "cb1e4833e517b96eaa2ca6c0583838619bfa494e3b634b7d855b60eb2fb242ff" } ] } diff --git a/packages/contracts/compatibility/v1/baseline.json b/packages/contracts/compatibility/v1/baseline.json index c1016999..6d257209 100644 --- a/packages/contracts/compatibility/v1/baseline.json +++ b/packages/contracts/compatibility/v1/baseline.json @@ -1,5 +1,5 @@ { - "baselineFormat": 1, + "baselineFormat": 2, "contractVersion": 1, "schemaIdPrefix": "https://schemas.databreeze.dev/contracts/v1/", "schemas": [ @@ -64,26 +64,88 @@ "sha256": "904b8736592d6c1e527f084daf0fa452447ada75dbfee8e9395ea55cf7c07dc5" } ], + "publicOutputInventory": { + "path": "public-outputs.json", + "versionEntrySha256": "786630d0f58b04485efedf643f4d9ffce09c2d0e4a3a5bf61f74f7046a1f731a" + }, "generatedPublicOutputs": [ { - "path": "kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt", + "path": "generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt", "sha256": "cd60b56f750e382ada6a9bbaba1baefef3a43e8fdb41ee6a12c89e1b8e1f9487" }, { - "path": "python/databreeze_contracts/v1/__init__.py", + "path": "generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt", + "sha256": "f4c98e5f568968160687ffaf9ddbba4db51e25266c5baf927cde8b24e6a2442a" + }, + { + "path": "generated/python/databreeze_contracts/__init__.py", + "sha256": "36705a639b307e118392bd5000315e13aeecc0d605d5afab10c13f35cd79787b" + }, + { + "path": "generated/python/databreeze_contracts/py.typed", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "generated/python/databreeze_contracts/v1/__init__.py", "sha256": "785ff1b0fde763730070345f43b494203f149b10879683364fcbec0fcc35cb7e" }, { - "path": "python/databreeze_contracts/v1/_validation.py", + "path": "generated/python/databreeze_contracts/v1/_validation.py", "sha256": "843bf675e577d3e9197e3e263a95bd483dac2d2d93c0987dc1b0785cb6a111cf" }, { - "path": "python/databreeze_contracts/v1/models.py", + "path": "generated/python/databreeze_contracts/v1/models.py", "sha256": "435a3af5a513fd14fbf232c0f289b901f4b3722a12b9f3a559d59df404fee4ab" }, { - "path": "typescript/v1/index.ts", - "sha256": "5794700e965202973a0056d68368d090a83ad04e6157d17e59a91432653d446d" + "path": "generated/python/pyproject.toml", + "sha256": "1492e96a4ccb035f17ed777c853cbdbc0bca427bc808e269bbe3917c67c83c0f" + }, + { + "path": "generated/typescript/v1/index.ts", + "sha256": "59e3b40f806d91a6d82b81a59e8937e6e0716d4eab44dbd5d2740e8eeb14912b" + }, + { + "path": "generated/typescript/v1/validation.mjs", + "sha256": "0d48072b7dbc919e7ac1d9a3fbd8b864137594a98cf33e900d239564851fda7e" + } + ], + "publicPackageSurfaces": [ + { + "path": "package.json", + "values": [ + { + "pointer": "/dependencies", + "value": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1" + } + }, + { + "pointer": "/exports", + "value": { + ".": "./manifest.json", + "./v1": { + "types": "./generated/typescript/v1/index.ts", + "import": "./generated/typescript/v1/validation.mjs" + }, + "./v1/actor-metadata": "./schemas/v1/actor-metadata.schema.json", + "./v1/command-envelope": "./schemas/v1/command-envelope.schema.json", + "./v1/correlation-metadata": "./schemas/v1/correlation-metadata.schema.json", + "./v1/cursor-page": "./schemas/v1/cursor-page.schema.json", + "./v1/event-envelope": "./schemas/v1/event-envelope.schema.json", + "./v1/identifier": "./schemas/v1/identifier.schema.json", + "./v1/problem-details": "./schemas/v1/problem-details.schema.json", + "./v1/revision": "./schemas/v1/revision.schema.json", + "./v1/tenant-scope": "./schemas/v1/tenant-scope.schema.json", + "./v1/utc-timestamp": "./schemas/v1/utc-timestamp.schema.json" + } + }, + { + "pointer": "/name", + "value": "@databreeze/contracts" + } + ] } ] } diff --git a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt new file mode 100644 index 00000000..9422343e --- /dev/null +++ b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt @@ -0,0 +1,111 @@ +// Generated by @databreeze/contracts. DO NOT EDIT. + +package com.databreeze.contracts.v1 + +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.networknt.schema.InputFormat +import com.networknt.schema.SchemaLocation +import com.networknt.schema.SchemaRegistry +import com.networknt.schema.SpecificationVersion +import java.util.Base64 + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "scopeType", + visible = false, +) +@JsonSubTypes( + JsonSubTypes.Type(value = OrganizationScope::class, name = "organization"), + JsonSubTypes.Type(value = WorkspaceScope::class, name = "workspace"), + JsonSubTypes.Type(value = ProjectScope::class, name = "project"), +) +private interface TenantScopeMixin + +public sealed interface ContractV1ParseResult { + public val accepted: Boolean +} + +public data class AcceptedV1Contract(public val value: Any) : ContractV1ParseResult { + public override val accepted: Boolean = true +} + +public data object RejectedV1Contract : ContractV1ParseResult { + public override val accepted: Boolean = false +} + +private val mapper: ObjectMapper = jacksonObjectMapper() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addMixIn(TenantScope::class.java, TenantScopeMixin::class.java) + +private fun decodeSchema(encoded: String): String = + String(Base64.getDecoder().decode(encoded), Charsets.UTF_8) + +private val schemaSources: Map = mapOf( + "https://schemas.databreeze.dev/contracts/v1/actor-metadata" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2FjdG9yLW1ldGFkYXRhIiwiJGNvbW1lbnQiOiJTaGFyZWQgYWN0b3IgaWRlbnRpdHkgbWV0YWRhdGEgdXNlZCBieSBjb21tYW5kcyBhbmQgZXZlbnRzOyBzdXBwb3J0cyBBVUQtMDA0LiIsInRpdGxlIjoiQWN0b3IgTWV0YWRhdGEiLCJkZXNjcmlwdGlvbiI6IlRoZSBzdGFibGUgdHlwZSBhbmQgaWRlbnRpZmllciBvZiB0aGUgcHJpbmNpcGFsIHJlc3BvbnNpYmxlIGZvciBhbiBhY3Rpb24uIiwidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiYWN0b3JUeXBlIiwiYWN0b3JJZCJdLCJwcm9wZXJ0aWVzIjp7ImFjdG9yVHlwZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bYS16XVthLXowLTlfLV17MCw2Mn0kIn0sImFjdG9ySWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9fX0="), + "https://schemas.databreeze.dev/contracts/v1/command-envelope" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2NvbW1hbmQtZW52ZWxvcGUiLCIkY29tbWVudCI6IlBhcnRpYWwgZm91bmRhdGlvbiBjb3ZlcmFnZSBmb3IgSU5ULTAwNCBhbmQgSUFNLTAxOS4iLCJ0aXRsZSI6IklkZW1wb3RlbnQgQ29tbWFuZCBFbnZlbG9wZSIsImRlc2NyaXB0aW9uIjoiVGhlIHNoYXJlZCBjbG9zZWQgZW52ZWxvcGUgZm9yIGFuIGlkZW1wb3RlbnQsIHRlbmFudC1zY29wZWQgY29tbWFuZC4iLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJjb21tYW5kSWQiLCJjb21tYW5kVHlwZSIsInNjaGVtYVZlcnNpb24iLCJ0ZW5hbnRTY29wZSIsImFjdG9yIiwiY29ycmVsYXRpb24iLCJpc3N1ZWRBdCIsImlkZW1wb3RlbmN5S2V5IiwiZGF0YSJdLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmRJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sImNvbW1hbmRUeXBlIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlthLXpdW2EtejAtOV8tXSooXFwuW2Etel1bYS16MC05Xy1dKikrJCJ9LCJzY2hlbWFWZXJzaW9uIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MX0sInRlbmFudFNjb3BlIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSJ9LCJhY3RvciI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9hY3Rvci1tZXRhZGF0YSJ9LCJjb3JyZWxhdGlvbiI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9jb3JyZWxhdGlvbi1tZXRhZGF0YSJ9LCJpc3N1ZWRBdCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS91dGMtdGltZXN0YW1wIn0sImlkZW1wb3RlbmN5S2V5Ijp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjEsIm1heExlbmd0aCI6MjU1fSwiZGF0YSI6eyJ0eXBlIjoib2JqZWN0In19fQ=="), + "https://schemas.databreeze.dev/contracts/v1/correlation-metadata" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2NvcnJlbGF0aW9uLW1ldGFkYXRhIiwiJGNvbW1lbnQiOiJQYXJ0aWFsIGZvdW5kYXRpb24gY292ZXJhZ2UgZm9yIEFVRC0wMDQgYW5kIElOVC0wMjEuIiwidGl0bGUiOiJDb3JyZWxhdGlvbiBNZXRhZGF0YSIsImRlc2NyaXB0aW9uIjoiQ29udGVudC1zYWZlIGlkZW50aWZpZXJzIHVzZWQgdG8gam9pbiBhIHJlcXVlc3Qgb3IgZXZlbnQgY2hhaW4uIiwidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiY29ycmVsYXRpb25JZCJdLCJwcm9wZXJ0aWVzIjp7ImNvcnJlbGF0aW9uSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJjYXVzYXRpb25JZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sInJlcXVlc3RJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn19fQ=="), + "https://schemas.databreeze.dev/contracts/v1/cursor-page" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2N1cnNvci1wYWdlIiwiJGNvbW1lbnQiOiJTaGFyZWQgcGFnaW5hdGlvbiBzaGFwZSBzdXBwb3J0aW5nIElOVC0wMDUuIiwidGl0bGUiOiJDdXJzb3IgUGFnZSBFbnZlbG9wZSIsImRlc2NyaXB0aW9uIjoiVGhlIGNhbm9uaWNhbCBjbG9zZWQgcGFnZSBlbnZlbG9wZSB3aXRoIGEgVVRDIHNuYXBzaG90IGFuZCBvcGFxdWUgY29udGludWF0aW9uIGN1cnNvci4iLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJkYXRhIiwic25hcHNob3RBdCIsImhhc01vcmUiXSwicHJvcGVydGllcyI6eyJkYXRhIjp7InR5cGUiOiJhcnJheSIsIml0ZW1zIjp7fX0sIm5leHRDdXJzb3IiOnsidHlwZSI6InN0cmluZyIsIm1pbkxlbmd0aCI6MSwibWF4TGVuZ3RoIjo0MDk2fSwic25hcHNob3RBdCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS91dGMtdGltZXN0YW1wIn0sImhhc01vcmUiOnsidHlwZSI6ImJvb2xlYW4ifX0sImFsbE9mIjpbeyJpZiI6eyJwcm9wZXJ0aWVzIjp7Imhhc01vcmUiOnsiY29uc3QiOnRydWV9fSwicmVxdWlyZWQiOlsiaGFzTW9yZSJdfSwidGhlbiI6eyJwcm9wZXJ0aWVzIjp7Im5leHRDdXJzb3IiOnRydWV9LCJyZXF1aXJlZCI6WyJuZXh0Q3Vyc29yIl19LCJlbHNlIjp7Im5vdCI6eyJwcm9wZXJ0aWVzIjp7Im5leHRDdXJzb3IiOnRydWV9LCJyZXF1aXJlZCI6WyJuZXh0Q3Vyc29yIl19fX1dfQ=="), + "https://schemas.databreeze.dev/contracts/v1/event-envelope" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2V2ZW50LWVudmVsb3BlIiwiJGNvbW1lbnQiOiJDYW5vbmljYWwgZXZlbnQgYmFzZSBzdXBwb3J0aW5nIEFVRC0wMDQsIEFVRC0wMDYsIElBTS0wMTksIGFuZCBJTlQtMDA4LiIsInRpdGxlIjoiQ2Fub25pY2FsIEV2ZW50IEVudmVsb3BlIiwiZGVzY3JpcHRpb24iOiJUaGUgc2hhcmVkIGNsb3NlZCBlbnZlbG9wZSBmb3IgYSB2ZXJzaW9uZWQsIHRlbmFudC1zY29wZWQgZG9tYWluIGV2ZW50LiIsInR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbImV2ZW50SWQiLCJldmVudFR5cGUiLCJzY2hlbWFWZXJzaW9uIiwidGVuYW50U2NvcGUiLCJlbnRpdHkiLCJhY3RvciIsImNvcnJlbGF0aW9uIiwic291cmNlQ29tcG9uZW50Iiwib2NjdXJyZWRBdCIsImRhdGEiXSwicHJvcGVydGllcyI6eyJldmVudElkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwiZXZlbnRUeXBlIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlthLXpdW2EtejAtOV8tXSooXFwuW2Etel1bYS16MC05Xy1dKikrJCJ9LCJzY2hlbWFWZXJzaW9uIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MX0sInRlbmFudFNjb3BlIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSJ9LCJlbnRpdHkiOnsidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiZW50aXR5VHlwZSIsImVudGl0eUlkIiwicmV2aXNpb24iXSwicHJvcGVydGllcyI6eyJlbnRpdHlUeXBlIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlthLXpdW2EtejAtOV8tXXswLDYyfSQifSwiZW50aXR5SWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJyZXZpc2lvbiI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9yZXZpc2lvbiJ9fX0sImFjdG9yIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2FjdG9yLW1ldGFkYXRhIn0sImNvcnJlbGF0aW9uIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2NvcnJlbGF0aW9uLW1ldGFkYXRhIn0sInNvdXJjZUNvbXBvbmVudCI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bYS16XVthLXowLTlfLV17MCw2Mn0kIn0sIm9jY3VycmVkQXQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvdXRjLXRpbWVzdGFtcCJ9LCJkYXRhIjp7InR5cGUiOiJvYmplY3QifX19"), + "https://schemas.databreeze.dev/contracts/v1/identifier" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIiLCIkY29tbWVudCI6IlBhcnRpYWwgZm91bmRhdGlvbiBjb3ZlcmFnZSBmb3IgSUFNLTAwMS4iLCJ0aXRsZSI6IlN0YWJsZSBVVUlEIElkZW50aWZpZXIiLCJkZXNjcmlwdGlvbiI6IkFuIG9wYXF1ZSBzdGFibGUgVVVJRCBpZGVudGlmaWVyLiIsInR5cGUiOiJzdHJpbmciLCJmb3JtYXQiOiJ1dWlkIn0="), + "https://schemas.databreeze.dev/contracts/v1/problem-details" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3Byb2JsZW0tZGV0YWlscyIsIiRjb21tZW50IjoiUkZDIDc4MDctY29tcGF0aWJsZSBiYXNlIHdpdGggdGhlIHNhZmUgcHVibGljIGVycm9yIG1ldGFkYXRhIHJlcXVpcmVkIGJ5IElOVC0wMjEgYW5kIFdFQi0wMjEuIiwidGl0bGUiOiJQcm9ibGVtIERldGFpbHMiLCJkZXNjcmlwdGlvbiI6IkEgY2xvc2VkIFJGQyA3ODA3LWNvbXBhdGlibGUgcHJvYmxlbSBkb2N1bWVudCB3aXRoIERhdGFCcmVlemUgcHVibGljIGVycm9yIGV4dGVuc2lvbnMuIiwidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsidHlwZSIsInN0YXR1cyIsImNvZGUiLCJjb3JyZWxhdGlvbklkIiwicmV0cnlhYmxlIl0sImFueU9mIjpbeyJwcm9wZXJ0aWVzIjp7InRpdGxlS2V5Ijp0cnVlfSwicmVxdWlyZWQiOlsidGl0bGVLZXkiXX0seyJwcm9wZXJ0aWVzIjp7Im1lc3NhZ2VLZXkiOnRydWV9LCJyZXF1aXJlZCI6WyJtZXNzYWdlS2V5Il19XSwicHJvcGVydGllcyI6eyJ0eXBlIjp7InR5cGUiOiJzdHJpbmciLCJmb3JtYXQiOiJ1cmktcmVmZXJlbmNlIn0sInRpdGxlIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjF9LCJ0aXRsZUtleSI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sInN0YXR1cyI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjEwMCwibWF4aW11bSI6NTk5fSwiZGV0YWlsIjp7InR5cGUiOiJzdHJpbmcifSwiaW5zdGFuY2UiOnsidHlwZSI6InN0cmluZyIsImZvcm1hdCI6InVyaS1yZWZlcmVuY2UifSwiY29kZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bQS1aXVtBLVowLTlfXXswLDEyN30kIn0sImNvcnJlbGF0aW9uSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJyZXRyeWFibGUiOnsidHlwZSI6ImJvb2xlYW4ifSwibWVzc2FnZUtleSI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sImZpZWxkRXJyb3JzIjp7InR5cGUiOiJhcnJheSIsIm1heEl0ZW1zIjoxMDAsIml0ZW1zIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbImZpZWxkIiwiY29kZSJdLCJwcm9wZXJ0aWVzIjp7ImZpZWxkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjEsIm1heExlbmd0aCI6MjU1fSwiY29kZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bQS1aXVtBLVowLTlfXXswLDEyN30kIn19fX0sInJldHJ5QWZ0ZXJTZWNvbmRzIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MH0sImN1cnJlbnRSZXZpc2lvbiI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9yZXZpc2lvbiJ9LCJyZW1lZGlhdGlvbkFjdGlvbiI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sInJhdGVMaW1pdCI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY29wZSIsInJlc2V0QXQiXSwicHJvcGVydGllcyI6eyJzY29wZSI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sImxpbWl0Ijp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MH0sInJlbWFpbmluZyI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjB9LCJyZXNldEF0Ijp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAifX19fX0="), + "https://schemas.databreeze.dev/contracts/v1/revision" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3JldmlzaW9uIiwiJGNvbW1lbnQiOiJTdXBwb3J0cyBvcHRpbWlzdGljLWNvbmN1cnJlbmN5IHJldmlzaW9ucyBkZXNjcmliZWQgYnkgdGhlIGRvbWFpbiBhbmQgZGF0YSBtb2RlbC4iLCJ0aXRsZSI6IkVudGl0eSBSZXZpc2lvbiIsImRlc2NyaXB0aW9uIjoiQSBwb3NpdGl2ZSwgbW9ub3RvbmljYWxseSBpbmNyZWFzaW5nIGVudGl0eSByZXZpc2lvbi4iLCJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjF9"), + "https://schemas.databreeze.dev/contracts/v1/tenant-scope" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSIsIiRjb21tZW50IjoiUGFydGlhbCBmb3VuZGF0aW9uIGNvdmVyYWdlIGZvciBJQU0tMDE5LiIsInRpdGxlIjoiVGVuYW50IFNjb3BlIiwiZGVzY3JpcHRpb24iOiJBIGRpc2NyaW1pbmF0ZWQgdGVuYW50IHNjb3BlIGNvbnRhaW5pbmcgdGhlIGNvbXBsZXRlIGFuY2VzdHJ5IHJlcXVpcmVkIGF0IGl0cyBsZXZlbC4iLCJvbmVPZiI6W3siJHJlZiI6IiMvJGRlZnMvb3JnYW5pemF0aW9uU2NvcGUifSx7IiRyZWYiOiIjLyRkZWZzL3dvcmtzcGFjZVNjb3BlIn0seyIkcmVmIjoiIy8kZGVmcy9wcm9qZWN0U2NvcGUifV0sIiRkZWZzIjp7Im9yZ2FuaXphdGlvblNjb3BlIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbInNjb3BlVHlwZSIsIm9yZ2FuaXphdGlvbklkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0Ijoib3JnYW5pemF0aW9uIn0sIm9yZ2FuaXphdGlvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifX19LCJ3b3Jrc3BhY2VTY29wZSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY29wZVR5cGUiLCJvcmdhbml6YXRpb25JZCIsIndvcmtzcGFjZUlkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0Ijoid29ya3NwYWNlIn0sIm9yZ2FuaXphdGlvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwid29ya3NwYWNlSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9fX0sInByb2plY3RTY29wZSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY29wZVR5cGUiLCJvcmdhbml6YXRpb25JZCIsIndvcmtzcGFjZUlkIiwicHJvamVjdElkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0IjoicHJvamVjdCJ9LCJvcmdhbml6YXRpb25JZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sIndvcmtzcGFjZUlkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwicHJvamVjdElkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifX19fX0="), + "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAiLCIkY29tbWVudCI6IlBhcnRpYWwgZm91bmRhdGlvbiBjb3ZlcmFnZSBmb3IgSUFNLTAwMSBhbmQgSU5ULTAwOC4iLCJ0aXRsZSI6IlVUQyBUaW1lc3RhbXAiLCJkZXNjcmlwdGlvbiI6IkFuIFJGQyAzMzM5IGRhdGUtdGltZSBub3JtYWxpemVkIHRvIFVUQyBhbmQgdGVybWluYXRlZCBieSB1cHBlcmNhc2UgWi4iLCJ0eXBlIjoic3RyaW5nIiwiZm9ybWF0IjoiZGF0ZS10aW1lIiwicGF0dGVybiI6IlokIn0="), +) + +private val schemaRegistry: SchemaRegistry = + SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12) { builder -> + builder.schemas(schemaSources) + } + +private fun constructGeneratedModel(schemaId: String, payload: JsonNode): Any = when (schemaId) { + "https://schemas.databreeze.dev/contracts/v1/actor-metadata" -> mapper.treeToValue(payload, ActorMetadata::class.java) + "https://schemas.databreeze.dev/contracts/v1/command-envelope" -> mapper.convertValue( + payload, + object : TypeReference>() {}, + ) + "https://schemas.databreeze.dev/contracts/v1/correlation-metadata" -> mapper.treeToValue(payload, CorrelationMetadata::class.java) + "https://schemas.databreeze.dev/contracts/v1/cursor-page" -> mapper.convertValue( + payload, + object : TypeReference>() {}, + ) + "https://schemas.databreeze.dev/contracts/v1/event-envelope" -> mapper.convertValue( + payload, + object : TypeReference>() {}, + ) + "https://schemas.databreeze.dev/contracts/v1/identifier" -> mapper.treeToValue(payload, String::class.java) + "https://schemas.databreeze.dev/contracts/v1/problem-details" -> mapper.treeToValue(payload, ProblemDetails::class.java) + "https://schemas.databreeze.dev/contracts/v1/revision" -> payload.longValue() + "https://schemas.databreeze.dev/contracts/v1/tenant-scope" -> when (payload.required("scopeType").asText()) { + "organization" -> mapper.treeToValue(payload, OrganizationScope::class.java) + "workspace" -> mapper.treeToValue(payload, WorkspaceScope::class.java) + "project" -> mapper.treeToValue(payload, ProjectScope::class.java) + else -> error("Unknown scopeType discriminator") + } + "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" -> mapper.treeToValue(payload, String::class.java) + else -> error("No generated Kotlin model for $schemaId") +} + +public fun parseV1Contract(schemaId: String, payloadSource: String): ContractV1ParseResult { + require(schemaId in schemaSources) { "Unknown v1 contract schema: $schemaId" } + return try { + val payload = mapper.readTree(payloadSource) + val model = constructGeneratedModel(schemaId, payload) + val schema = schemaRegistry.getSchema(SchemaLocation.of(schemaId)) + val errors = schema.validate(payloadSource, InputFormat.JSON) { executionContext -> + executionContext.executionConfig { configuration -> + configuration.formatAssertionsEnabled(true) + } + } + if (errors.isEmpty()) AcceptedV1Contract(model) else RejectedV1Contract + } catch (_: Exception) { + RejectedV1Contract + } +} diff --git a/packages/contracts/generated/typescript/v1/index.ts b/packages/contracts/generated/typescript/v1/index.ts index f3354a72..17114417 100644 --- a/packages/contracts/generated/typescript/v1/index.ts +++ b/packages/contracts/generated/typescript/v1/index.ts @@ -115,3 +115,14 @@ export interface WorkspaceScope { readonly scopeType: "workspace"; readonly workspaceId: Identifier; } + +export type ContractV1SchemaId = "https://schemas.databreeze.dev/contracts/v1/actor-metadata" | "https://schemas.databreeze.dev/contracts/v1/command-envelope" | "https://schemas.databreeze.dev/contracts/v1/correlation-metadata" | "https://schemas.databreeze.dev/contracts/v1/cursor-page" | "https://schemas.databreeze.dev/contracts/v1/event-envelope" | "https://schemas.databreeze.dev/contracts/v1/identifier" | "https://schemas.databreeze.dev/contracts/v1/problem-details" | "https://schemas.databreeze.dev/contracts/v1/revision" | "https://schemas.databreeze.dev/contracts/v1/tenant-scope" | "https://schemas.databreeze.dev/contracts/v1/utc-timestamp"; + +export type ContractV1ParseResult = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false }; + +export declare function parseV1Contract( + schemaId: ContractV1SchemaId, + payload: unknown, +): ContractV1ParseResult; diff --git a/packages/contracts/generated/typescript/v1/validation.mjs b/packages/contracts/generated/typescript/v1/validation.mjs new file mode 100644 index 00000000..b4cc1597 --- /dev/null +++ b/packages/contracts/generated/typescript/v1/validation.mjs @@ -0,0 +1,35 @@ +// Generated by @databreeze/contracts. DO NOT EDIT. + +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; + +const schemas = [ + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/actor-metadata","$comment":"Shared actor identity metadata used by commands and events; supports AUD-004.","title":"Actor Metadata","description":"The stable type and identifier of the principal responsible for an action.","type":"object","additionalProperties":false,"required":["actorType","actorId"],"properties":{"actorType":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,62}$"},"actorId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/command-envelope","$comment":"Partial foundation coverage for INT-004 and IAM-019.","title":"Idempotent Command Envelope","description":"The shared closed envelope for an idempotent, tenant-scoped command.","type":"object","additionalProperties":false,"required":["commandId","commandType","schemaVersion","tenantScope","actor","correlation","issuedAt","idempotencyKey","data"],"properties":{"commandId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"commandType":{"type":"string","pattern":"^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)+$"},"schemaVersion":{"type":"integer","minimum":1},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"actor":{"$ref":"https://schemas.databreeze.dev/contracts/v1/actor-metadata"},"correlation":{"$ref":"https://schemas.databreeze.dev/contracts/v1/correlation-metadata"},"issuedAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"idempotencyKey":{"type":"string","minLength":1,"maxLength":255},"data":{"type":"object"}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/correlation-metadata","$comment":"Partial foundation coverage for AUD-004 and INT-021.","title":"Correlation Metadata","description":"Content-safe identifiers used to join a request or event chain.","type":"object","additionalProperties":false,"required":["correlationId"],"properties":{"correlationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"causationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"requestId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/cursor-page","$comment":"Shared pagination shape supporting INT-005.","title":"Cursor Page Envelope","description":"The canonical closed page envelope with a UTC snapshot and opaque continuation cursor.","type":"object","additionalProperties":false,"required":["data","snapshotAt","hasMore"],"properties":{"data":{"type":"array","items":{}},"nextCursor":{"type":"string","minLength":1,"maxLength":4096},"snapshotAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"hasMore":{"type":"boolean"}},"allOf":[{"if":{"properties":{"hasMore":{"const":true}},"required":["hasMore"]},"then":{"properties":{"nextCursor":true},"required":["nextCursor"]},"else":{"not":{"properties":{"nextCursor":true},"required":["nextCursor"]}}}]}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/event-envelope","$comment":"Canonical event base supporting AUD-004, AUD-006, IAM-019, and INT-008.","title":"Canonical Event Envelope","description":"The shared closed envelope for a versioned, tenant-scoped domain event.","type":"object","additionalProperties":false,"required":["eventId","eventType","schemaVersion","tenantScope","entity","actor","correlation","sourceComponent","occurredAt","data"],"properties":{"eventId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"eventType":{"type":"string","pattern":"^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)+$"},"schemaVersion":{"type":"integer","minimum":1},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"entity":{"type":"object","additionalProperties":false,"required":["entityType","entityId","revision"],"properties":{"entityType":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,62}$"},"entityId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"revision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"}}},"actor":{"$ref":"https://schemas.databreeze.dev/contracts/v1/actor-metadata"},"correlation":{"$ref":"https://schemas.databreeze.dev/contracts/v1/correlation-metadata"},"sourceComponent":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,62}$"},"occurredAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"data":{"type":"object"}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/identifier","$comment":"Partial foundation coverage for IAM-001.","title":"Stable UUID Identifier","description":"An opaque stable UUID identifier.","type":"string","format":"uuid"}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/problem-details","$comment":"RFC 7807-compatible base with the safe public error metadata required by INT-021 and WEB-021.","title":"Problem Details","description":"A closed RFC 7807-compatible problem document with DataBreeze public error extensions.","type":"object","additionalProperties":false,"required":["type","status","code","correlationId","retryable"],"anyOf":[{"properties":{"titleKey":true},"required":["titleKey"]},{"properties":{"messageKey":true},"required":["messageKey"]}],"properties":{"type":{"type":"string","format":"uri-reference"},"title":{"type":"string","minLength":1},"titleKey":{"type":"string","minLength":1,"maxLength":255},"status":{"type":"integer","minimum":100,"maximum":599},"detail":{"type":"string"},"instance":{"type":"string","format":"uri-reference"},"code":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"},"correlationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"retryable":{"type":"boolean"},"messageKey":{"type":"string","minLength":1,"maxLength":255},"fieldErrors":{"type":"array","maxItems":100,"items":{"type":"object","additionalProperties":false,"required":["field","code"],"properties":{"field":{"type":"string","minLength":1,"maxLength":255},"code":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}}}},"retryAfterSeconds":{"type":"integer","minimum":0},"currentRevision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"},"remediationAction":{"type":"string","minLength":1,"maxLength":255},"rateLimit":{"type":"object","additionalProperties":false,"required":["scope","resetAt"],"properties":{"scope":{"type":"string","minLength":1,"maxLength":255},"limit":{"type":"integer","minimum":0},"remaining":{"type":"integer","minimum":0},"resetAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"}}}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/revision","$comment":"Supports optimistic-concurrency revisions described by the domain and data model.","title":"Entity Revision","description":"A positive, monotonically increasing entity revision.","type":"integer","minimum":1}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/tenant-scope","$comment":"Partial foundation coverage for IAM-019.","title":"Tenant Scope","description":"A discriminated tenant scope containing the complete ancestry required at its level.","oneOf":[{"$ref":"#/$defs/organizationScope"},{"$ref":"#/$defs/workspaceScope"},{"$ref":"#/$defs/projectScope"}],"$defs":{"organizationScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId"],"properties":{"scopeType":{"const":"organization"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}},"workspaceScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId","workspaceId"],"properties":{"scopeType":{"const":"workspace"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}},"projectScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId","workspaceId","projectId"],"properties":{"scopeType":{"const":"project"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"projectId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp","$comment":"Partial foundation coverage for IAM-001 and INT-008.","title":"UTC Timestamp","description":"An RFC 3339 date-time normalized to UTC and terminated by uppercase Z.","type":"string","format":"date-time","pattern":"Z$"}, +]; + +const ajv = new Ajv2020({ allErrors: true, strict: true }); +addFormats(ajv); +for (const schema of schemas) ajv.addSchema(schema); + +const validators = new Map( + schemas.map((schema) => { + const validate = ajv.getSchema(schema.$id); + if (!validate) throw new Error(`No generated validator for ${schema.$id}`); + return [schema.$id, validate]; + }), +); + +export function parseV1Contract(schemaId, payload) { + const validate = validators.get(schemaId); + if (!validate) throw new TypeError(`Unknown v1 contract schema: ${schemaId}`); + return validate(payload) ? { accepted: true, value: payload } : { accepted: false }; +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index dbde9a13..dbf1139a 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./manifest.json", "./v1": { - "types": "./generated/typescript/v1/index.ts" + "types": "./generated/typescript/v1/index.ts", + "import": "./generated/typescript/v1/validation.mjs" }, "./v1/actor-metadata": "./schemas/v1/actor-metadata.schema.json", "./v1/command-envelope": "./schemas/v1/command-envelope.schema.json", @@ -30,7 +31,7 @@ "test": "node --test test/**/*.test.mjs", "test:python-formats": "node test/python-format-runtime-probe.mjs" }, - "devDependencies": { + "dependencies": { "ajv": "8.17.1", "ajv-formats": "3.0.1" } diff --git a/packages/contracts/public-outputs.json b/packages/contracts/public-outputs.json new file mode 100644 index 00000000..723bbe51 --- /dev/null +++ b/packages/contracts/public-outputs.json @@ -0,0 +1,26 @@ +{ + "inventoryFormat": 1, + "versions": [ + { + "contractVersion": 1, + "generatedFiles": [ + "generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt", + "generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt", + "generated/python/databreeze_contracts/__init__.py", + "generated/python/databreeze_contracts/py.typed", + "generated/python/databreeze_contracts/v1/__init__.py", + "generated/python/databreeze_contracts/v1/_validation.py", + "generated/python/databreeze_contracts/v1/models.py", + "generated/python/pyproject.toml", + "generated/typescript/v1/index.ts", + "generated/typescript/v1/validation.mjs" + ], + "jsonSurfaces": [ + { + "path": "package.json", + "pointers": ["/dependencies", "/exports", "/name"] + } + ] + } + ] +} diff --git a/packages/contracts/scripts/contract-compatibility.mjs b/packages/contracts/scripts/contract-compatibility.mjs index e459d9b0..8fe94fa6 100644 --- a/packages/contracts/scripts/contract-compatibility.mjs +++ b/packages/contracts/scripts/contract-compatibility.mjs @@ -1,10 +1,11 @@ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; -import { dirname, relative, resolve } from 'node:path'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const publishedRegistryPath = 'compatibility/published.json'; +const publicOutputInventoryPath = 'public-outputs.json'; function fail(message) { throw new Error(message); @@ -48,6 +49,132 @@ function listFiles(root, directory = root) { .sort(compareStrings); } +function resolveInventoryPath(root, path, label) { + if (typeof path !== 'string' || !path || path.includes('\\')) { + fail(`${label} must be a non-empty POSIX path`); + } + const packageRoot = resolve(root); + const destination = resolve(packageRoot, ...path.split('/')); + const relativePath = relative(packageRoot, destination); + if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + fail(`${label} escapes the contract package: ${path}`); + } + return destination; +} + +function assertSortedUniqueStrings(values, label) { + if ( + !Array.isArray(values) || + values.length === 0 || + !values.every((value) => typeof value === 'string') + ) { + fail(`${label} must be a non-empty array of strings`); + } + const sorted = [...values].sort(compareStrings); + if (new Set(values).size !== values.length || JSON.stringify(values) !== JSON.stringify(sorted)) { + fail(`${label} must contain unique values in stable order`); + } +} + +function readPublicOutputInventory(root) { + const path = resolve(root, publicOutputInventoryPath); + if (!existsSync(path)) fail(`Public-output inventory is missing: ${publicOutputInventoryPath}`); + const inventory = parseJson(path, 'Public-output inventory'); + if (inventory.inventoryFormat !== 1 || !Array.isArray(inventory.versions)) { + fail('Public-output inventory has an unsupported shape'); + } + const versions = new Set(); + for (const entry of inventory.versions) { + if (!Number.isInteger(entry.contractVersion) || entry.contractVersion < 1) { + fail('Public-output inventory contractVersion must be a positive integer'); + } + if (versions.has(entry.contractVersion)) { + fail(`Duplicate public-output inventory entry for v${entry.contractVersion}`); + } + versions.add(entry.contractVersion); + assertSortedUniqueStrings( + entry.generatedFiles, + `Public-output inventory v${entry.contractVersion} generatedFiles`, + ); + for (const generatedFile of entry.generatedFiles) { + if (!generatedFile.startsWith('generated/')) { + fail(`Generated public output must be below generated/: ${generatedFile}`); + } + resolveInventoryPath(root, generatedFile, 'Generated public output'); + } + if (!Array.isArray(entry.jsonSurfaces) || entry.jsonSurfaces.length === 0) { + fail(`Public-output inventory v${entry.contractVersion} jsonSurfaces must be non-empty`); + } + const surfacePaths = entry.jsonSurfaces.map((surface) => surface.path); + assertSortedUniqueStrings( + surfacePaths, + `Public-output inventory v${entry.contractVersion} json surface paths`, + ); + for (const surface of entry.jsonSurfaces) { + resolveInventoryPath(root, surface.path, 'Public JSON surface'); + assertSortedUniqueStrings( + surface.pointers, + `Public-output inventory ${surface.path} pointers`, + ); + if (!surface.pointers.every((pointer) => pointer.startsWith('/'))) { + fail(`Public JSON surface pointers must use JSON Pointer syntax: ${surface.path}`); + } + } + } + return inventory; +} + +function versionPublicOutputEntry(root, version, published = false) { + const inventory = readPublicOutputInventory(root); + const entry = inventory.versions.find((candidate) => candidate.contractVersion === version); + if (!entry) { + fail( + published + ? `Published public-output inventory changed in place for v${version}` + : `Public-output inventory has no v${version} entry`, + ); + } + return { entry, inventory }; +} + +function verifyAllGeneratedFilesAreDeclared(root, inventory) { + const declared = new Set(inventory.versions.flatMap((entry) => entry.generatedFiles)); + const generatedRoot = resolve(root, 'generated'); + const undeclared = listFiles(generatedRoot) + .map((path) => `generated/${path}`) + .find((path) => !declared.has(path)); + if (undeclared) { + fail(`Generated output is not declared in public-output inventory: ${undeclared}`); + } +} + +function jsonPointerValue(document, pointer, label) { + let current = document; + for (const token of pointer.slice(1).split('/')) { + const key = token.replaceAll('~1', '/').replaceAll('~0', '~'); + if (current === null || typeof current !== 'object' || !Object.hasOwn(current, key)) { + fail(`${label} is missing JSON pointer ${pointer}`); + } + current = current[key]; + } + return current; +} + +function buildPublicPackageSurfaces(root, entry) { + return entry.jsonSurfaces.map((surface) => { + const path = resolveInventoryPath(root, surface.path, 'Public JSON surface'); + if (!existsSync(path)) fail(`Published package surface is missing: ${surface.path}`); + const document = parseJson(path, `Public package surface ${surface.path}`); + return { + path: surface.path, + values: surface.pointers.map((pointer) => ({ + pointer, + value: jsonPointerValue(document, pointer, surface.path), + })), + }; + }); +} + function versionSchemaEntries(root, version) { const manifestPath = resolve(root, 'manifest.json'); if (!existsSync(manifestPath)) fail('Canonical contract manifest is missing: manifest.json'); @@ -63,12 +190,6 @@ function versionSchemaEntries(root, version) { return entries; } -function versionGeneratedOutputs(root, version) { - const generatedRoot = resolve(root, 'generated'); - const versionSegment = `/v${version}/`; - return listFiles(generatedRoot).filter((path) => `/${path}`.includes(versionSegment)); -} - function buildBaseline(root, version) { const expectedIdPrefix = `https://schemas.databreeze.dev/contracts/v${version}/`; const schemaEntries = versionSchemaEntries(root, version); @@ -97,19 +218,26 @@ function buildBaseline(root, version) { }; }); - const outputPaths = versionGeneratedOutputs(root, version); + const { entry: publicOutputEntry, inventory } = versionPublicOutputEntry(root, version); + verifyAllGeneratedFilesAreDeclared(root, inventory); + const outputPaths = publicOutputEntry.generatedFiles; if (outputPaths.length === 0) fail(`No generated public outputs found for v${version}`); const generatedPublicOutputs = outputPaths.map((path) => ({ path, - sha256: sha256File(resolve(root, 'generated', ...path.split('/'))), + sha256: sha256File(resolveInventoryPath(root, path, 'Generated public output')), })); return { - baselineFormat: 1, + baselineFormat: 2, contractVersion: version, schemaIdPrefix: expectedIdPrefix, schemas, + publicOutputInventory: { + path: publicOutputInventoryPath, + versionEntrySha256: sha256Bytes(formatJson(publicOutputEntry)), + }, generatedPublicOutputs, + publicPackageSurfaces: buildPublicPackageSurfaces(root, publicOutputEntry), }; } @@ -158,18 +286,37 @@ function verifySchemaBaseline(root, version, baseline) { } function verifyGeneratedBaseline(root, version, baseline) { + const { entry: publicOutputEntry, inventory } = versionPublicOutputEntry(root, version, true); + verifyAllGeneratedFilesAreDeclared(root, inventory); + if ( + baseline.publicOutputInventory?.path !== publicOutputInventoryPath || + baseline.publicOutputInventory?.versionEntrySha256 !== + sha256Bytes(formatJson(publicOutputEntry)) + ) { + fail(`Published public-output inventory changed in place for v${version}`); + } + const baselinePaths = new Set(baseline.generatedPublicOutputs.map((entry) => entry.path)); for (const expected of baseline.generatedPublicOutputs) { - const outputPath = resolve(root, 'generated', ...expected.path.split('/')); + const outputPath = resolveInventoryPath(root, expected.path, 'Generated public output'); if (!existsSync(outputPath)) { - fail(`Published generated output is missing: ${expected.path}`); + fail(`Published public output is missing: ${expected.path}`); } if (sha256File(outputPath) !== expected.sha256) { - fail(`Published generated output changed in place: ${expected.path}`); + fail(`Published public output changed in place: ${expected.path}`); } } - const added = versionGeneratedOutputs(root, version).find((path) => !baselinePaths.has(path)); - if (added) fail(`Generated public output added to published v${version}: ${added}`); + const added = publicOutputEntry.generatedFiles.find((path) => !baselinePaths.has(path)); + if (added) fail(`Public output added to published v${version}: ${added}`); + const removed = baseline.generatedPublicOutputs.find( + ({ path }) => !publicOutputEntry.generatedFiles.includes(path), + ); + if (removed) fail(`Public output removed from published v${version}: ${removed.path}`); + + const currentSurfaces = buildPublicPackageSurfaces(root, publicOutputEntry); + if (JSON.stringify(currentSurfaces) !== JSON.stringify(baseline.publicPackageSurfaces)) { + fail(`Published package surface changed in place for v${version}`); + } } function checkCompatibility(root) { @@ -188,7 +335,7 @@ function checkCompatibility(root) { fail(`Published baseline drift detected for v${version}`); } const baseline = parseJson(baselinePath, `Published v${version} baseline`); - if (baseline.contractVersion !== version) { + if (baseline.baselineFormat !== 2 || baseline.contractVersion !== version) { fail(`Published baseline version mismatch for v${version}`); } verifySchemaBaseline(root, version, baseline); diff --git a/packages/contracts/scripts/contract-generator.mjs b/packages/contracts/scripts/contract-generator.mjs index 25bc9c34..18f1ee60 100644 --- a/packages/contracts/scripts/contract-generator.mjs +++ b/packages/contracts/scripts/contract-generator.mjs @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer'; import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; @@ -394,9 +395,55 @@ function renderTypeScript(context) { ); } } + const schemaIds = context.entries.map(({ id }) => quoted(id)); + lines.push( + `export type ContractV1SchemaId = ${schemaIds.join(' | ')};`, + '', + 'export type ContractV1ParseResult =', + ' | { readonly accepted: true; readonly value: TValue }', + ' | { readonly accepted: false };', + '', + 'export declare function parseV1Contract(', + ' schemaId: ContractV1SchemaId,', + ' payload: unknown,', + '): ContractV1ParseResult;', + '', + ); return `${lines.join('\n').trimEnd()}\n`; } +function renderTypeScriptValidation(context) { + const schemas = context.entries.map(({ schema }) => ` ${JSON.stringify(schema)},`); + return `${[ + `// ${HEADER}`, + '', + "import Ajv2020 from 'ajv/dist/2020.js';", + "import addFormats from 'ajv-formats';", + '', + 'const schemas = [', + ...schemas, + '];', + '', + 'const ajv = new Ajv2020({ allErrors: true, strict: true });', + 'addFormats(ajv);', + 'for (const schema of schemas) ajv.addSchema(schema);', + '', + 'const validators = new Map(', + ' schemas.map((schema) => {', + ' const validate = ajv.getSchema(schema.$id);', + ' if (!validate) throw new Error(`No generated validator for ${schema.$id}`);', + ' return [schema.$id, validate];', + ' }),', + ');', + '', + 'export function parseV1Contract(schemaId, payload) {', + ' const validate = validators.get(schemaId);', + ' if (!validate) throw new TypeError(`Unknown v1 contract schema: ${schemaId}`);', + ' return validate(payload) ? { accepted: true, value: payload } : { accepted: false };', + '}', + ].join('\n')}\n`; +} + function renderTypeScriptGenericDeclaration(parameters) { if (!parameters.length) return ''; return `<${parameters @@ -543,6 +590,166 @@ function renderKotlin(context) { return `${lines.join('\n').trimEnd()}\n`; } +function renderKotlinModelExpression(entry, context) { + const node = entry.schema; + if (node.oneOf) { + const discriminator = unionDiscriminator(node, entry, context); + const alternatives = node.oneOf.map((alternative) => { + const target = resolveReference(alternative.$ref, entry, context); + return { + literal: target.node.properties[discriminator].const, + modelName: target.name, + }; + }); + return [ + `when (payload.required(${quoted(discriminator)}).asText()) {`, + ...alternatives.map( + ({ literal, modelName }) => + ` ${quoted(literal)} -> mapper.treeToValue(payload, ${modelName}::class.java)`, + ), + ` else -> error("Unknown ${discriminator} discriminator")`, + ' }', + ]; + } + if (node.type === 'string') return ['mapper.treeToValue(payload, String::class.java)']; + if (node.type === 'integer') return ['payload.longValue()']; + if (node.type === 'boolean') return ['payload.booleanValue()']; + if (node.type !== 'object') fail(`Cannot render Kotlin parser for ${entry.path}`); + + const parameters = genericParameters(node); + if (!parameters.length) { + return [`mapper.treeToValue(payload, ${entry.modelName}::class.java)`]; + } + const argumentsList = parameters.map(({ kind }) => + kind === 'object' ? 'JsonObject' : 'JsonNode', + ); + return [ + 'mapper.convertValue(', + ' payload,', + ` object : TypeReference<${entry.modelName}<${argumentsList.join(', ')}>>() {},`, + ' )', + ]; +} + +function renderKotlinValidation(context) { + const schemaEntries = context.entries.map(({ id, schema }) => { + const encoded = Buffer.from(JSON.stringify(schema), 'utf8').toString('base64'); + return ` ${quoted(id)} to decodeSchema(${quoted(encoded)}),`; + }); + const modelCases = context.entries.flatMap((entry) => { + const [first, ...rest] = renderKotlinModelExpression(entry, context); + return [` ${quoted(entry.id)} -> ${first}`, ...rest.map((line) => ` ${line}`)]; + }); + const unions = [...context.nodesByName.entries()] + .filter(([, node]) => node.oneOf) + .sort(compareEntries) + .map(([name, node]) => { + const entry = context.entryByNode.get(node); + const discriminator = unionDiscriminator(node, entry, context); + const alternatives = node.oneOf.map((alternative) => { + const target = resolveReference(alternative.$ref, entry, context); + return { + literal: target.node.properties[discriminator].const, + modelName: target.name, + }; + }); + return { alternatives, discriminator, name }; + }); + const unionImports = unions.length + ? [ + 'import com.fasterxml.jackson.annotation.JsonSubTypes', + 'import com.fasterxml.jackson.annotation.JsonTypeInfo', + ] + : []; + const unionMixins = unions.flatMap(({ alternatives, discriminator, name }) => [ + '@JsonTypeInfo(', + ' use = JsonTypeInfo.Id.NAME,', + ' include = JsonTypeInfo.As.EXISTING_PROPERTY,', + ` property = ${quoted(discriminator)},`, + ' visible = false,', + ')', + '@JsonSubTypes(', + ...alternatives.map( + ({ literal, modelName }) => + ` JsonSubTypes.Type(value = ${modelName}::class, name = ${quoted(literal)}),`, + ), + ')', + `private interface ${name}Mixin`, + '', + ]); + const mapperMixins = unions.map( + ({ name }) => ` .addMixIn(${name}::class.java, ${name}Mixin::class.java)`, + ); + return `${[ + `// ${HEADER}`, + '', + 'package com.databreeze.contracts.v1', + '', + ...unionImports, + 'import com.fasterxml.jackson.core.type.TypeReference', + 'import com.fasterxml.jackson.databind.DeserializationFeature', + 'import com.fasterxml.jackson.databind.JsonNode', + 'import com.fasterxml.jackson.databind.ObjectMapper', + 'import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper', + 'import com.networknt.schema.InputFormat', + 'import com.networknt.schema.SchemaLocation', + 'import com.networknt.schema.SchemaRegistry', + 'import com.networknt.schema.SpecificationVersion', + 'import java.util.Base64', + '', + ...unionMixins, + 'public sealed interface ContractV1ParseResult {', + ' public val accepted: Boolean', + '}', + '', + 'public data class AcceptedV1Contract(public val value: Any) : ContractV1ParseResult {', + ' public override val accepted: Boolean = true', + '}', + '', + 'public data object RejectedV1Contract : ContractV1ParseResult {', + ' public override val accepted: Boolean = false', + '}', + '', + 'private val mapper: ObjectMapper = jacksonObjectMapper()', + ' .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)', + ...mapperMixins, + '', + 'private fun decodeSchema(encoded: String): String =', + ' String(Base64.getDecoder().decode(encoded), Charsets.UTF_8)', + '', + 'private val schemaSources: Map = mapOf(', + ...schemaEntries, + ')', + '', + 'private val schemaRegistry: SchemaRegistry =', + ' SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12) { builder ->', + ' builder.schemas(schemaSources)', + ' }', + '', + 'private fun constructGeneratedModel(schemaId: String, payload: JsonNode): Any = when (schemaId) {', + ...modelCases, + ' else -> error("No generated Kotlin model for $schemaId")', + '}', + '', + 'public fun parseV1Contract(schemaId: String, payloadSource: String): ContractV1ParseResult {', + ' require(schemaId in schemaSources) { "Unknown v1 contract schema: $schemaId" }', + ' return try {', + ' val payload = mapper.readTree(payloadSource)', + ' val model = constructGeneratedModel(schemaId, payload)', + ' val schema = schemaRegistry.getSchema(SchemaLocation.of(schemaId))', + ' val errors = schema.validate(payloadSource, InputFormat.JSON) { executionContext ->', + ' executionContext.executionConfig { configuration ->', + ' configuration.formatAssertionsEnabled(true)', + ' }', + ' }', + ' if (errors.isEmpty()) AcceptedV1Contract(model) else RejectedV1Contract', + ' } catch (_: Exception) {', + ' RejectedV1Contract', + ' }', + '}', + ].join('\n')}\n`; +} + function kotlinType(node, entry, context, parameters) { if (parameters.has(node)) return parameters.get(node); if (typeof node === 'boolean' || isEmptySchema(node)) return 'Any?'; @@ -910,6 +1117,10 @@ export function generateContractFiles(sourceRoot) { const { packageInit, versionInit } = renderPythonPackage(context); return new Map([ ['kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt', renderKotlin(context)], + [ + 'kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt', + renderKotlinValidation(context), + ], ['python/databreeze_contracts/__init__.py', packageInit], ['python/databreeze_contracts/py.typed', ''], ['python/databreeze_contracts/v1/__init__.py', versionInit], @@ -917,6 +1128,7 @@ export function generateContractFiles(sourceRoot) { ['python/databreeze_contracts/v1/models.py', renderPython(context)], ['python/pyproject.toml', renderPythonProject()], ['typescript/v1/index.ts', renderTypeScript(context)], + ['typescript/v1/validation.mjs', renderTypeScriptValidation(context)], ]); } diff --git a/packages/contracts/test/compatibility.test.mjs b/packages/contracts/test/compatibility.test.mjs index 7c70cd24..35054b20 100644 --- a/packages/contracts/test/compatibility.test.mjs +++ b/packages/contracts/test/compatibility.test.mjs @@ -1,5 +1,13 @@ import assert from 'node:assert/strict'; -import { appendFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { + appendFileSync, + cpSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -62,11 +70,76 @@ test('compatibility check rejects changed generated public output in place', () assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); assert.match( run.stderr, - /Published generated output changed in place: typescript\/v1\/index\.ts/u, + /Published public output changed in place: generated\/typescript\/v1\/index\.ts/u, ); }); }); +test('compatibility check covers Python package metadata and root package markers', () => { + for (const path of [ + 'generated/python/pyproject.toml', + 'generated/python/databreeze_contracts/__init__.py', + 'generated/python/databreeze_contracts/py.typed', + ]) { + withPackageCopy((copyRoot) => { + appendFileSync(resolve(copyRoot, path), '# changed\n', 'utf8'); + + const run = runCompatibility(copyRoot, 'check'); + assert.equal(run.status, 1, `${path}\n${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, /Published public output changed in place/u); + }); + } +}); + +test('compatibility check rejects added and removed public outputs', () => { + withPackageCopy((copyRoot) => { + writeFileSync( + resolve(copyRoot, 'generated/python/databreeze_contracts/public_api.py'), + '# unexpected public output\n', + 'utf8', + ); + + const run = runCompatibility(copyRoot, 'check'); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, /Generated output is not declared in public-output inventory/u); + }); + + withPackageCopy((copyRoot) => { + rmSync(resolve(copyRoot, 'generated/python/databreeze_contracts/py.typed')); + + const run = runCompatibility(copyRoot, 'check'); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, /Published public output is missing/u); + }); +}); + +test('compatibility check rejects public package export mapping drift', () => { + withPackageCopy((copyRoot) => { + const packagePath = resolve(copyRoot, 'package.json'); + const packageManifest = JSON.parse(readFileSync(packagePath, 'utf8')); + packageManifest.exports['./v1'].import = './generated/typescript/v1/not-public.mjs'; + writeFileSync(packagePath, `${JSON.stringify(packageManifest, null, 2)}\n`, 'utf8'); + + const run = runCompatibility(copyRoot, 'check'); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, /Published package surface changed in place/u); + }); +}); + +test('compatibility check rejects a changed public-output inventory', () => { + withPackageCopy((copyRoot) => { + writeFileSync( + resolve(copyRoot, 'public-outputs.json'), + `${JSON.stringify({ inventoryFormat: 1, versions: [] }, null, 2)}\n`, + 'utf8', + ); + + const run = runCompatibility(copyRoot, 'check'); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, /Published public-output inventory changed in place/u); + }); +}); + test('compatibility check rejects a missing baseline', () => { withPackageCopy((copyRoot) => { rmSync(resolve(copyRoot, 'compatibility/v1/baseline.json'), { force: true }); diff --git a/packages/contracts/test/generation.test.mjs b/packages/contracts/test/generation.test.mjs index a1bbf3ba..8271aee6 100644 --- a/packages/contracts/test/generation.test.mjs +++ b/packages/contracts/test/generation.test.mjs @@ -24,6 +24,7 @@ const fixtureRoot = resolve(packageRoot, 'test/fixtures/generator'); const generatedRoot = resolve(packageRoot, 'generated'); const expectedFiles = [ 'kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt', + 'kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt', 'python/databreeze_contracts/__init__.py', 'python/databreeze_contracts/py.typed', 'python/databreeze_contracts/v1/__init__.py', @@ -31,6 +32,7 @@ const expectedFiles = [ 'python/databreeze_contracts/v1/models.py', 'python/pyproject.toml', 'typescript/v1/index.ts', + 'typescript/v1/validation.mjs', ]; function runGenerator(...args) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20214b9d..9f517398 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 8.43.0(eslint@9.36.0)(typescript@5.9.2) packages/contracts: - devDependencies: + dependencies: ajv: specifier: 8.17.1 version: 8.17.1 @@ -40,12 +40,9 @@ importers: tools/fixture-validation: dependencies: - ajv: - specifier: 8.17.1 - version: 8.17.1 - ajv-formats: - specifier: 3.0.1 - version: 3.0.1(ajv@8.17.1) + '@databreeze/contracts': + specifier: workspace:* + version: link:../../packages/contracts devDependencies: typescript: specifier: 5.9.2 diff --git a/tools/fixture-validation/README.md b/tools/fixture-validation/README.md index 977b0920..d4efb843 100644 --- a/tools/fixture-validation/README.md +++ b/tools/fixture-validation/README.md @@ -2,11 +2,16 @@ Cross-runtime fixture parity for generated contract consumers. -`src/run-contract-parity.mjs` type-checks the valid fixtures against generated TypeScript types, -validates every shared payload with the canonical Ajv registry, runs the generated Pydantic v2 -models under the frozen uv environment, and compiles/runs the generated standard-Kotlin models -under the checksummed Gradle/JDK 21 harness. Kotlin rejects invalid JSON with NetworkNT JSON Schema -2020-12 validation before generated model construction. +`src/run-contract-parity.mjs` type-checks valid fixtures through the supported +`@databreeze/contracts/v1` export and sends every valid and invalid payload through that export's +generated TypeScript parser. It also runs the generated Pydantic v2 models under the frozen uv +environment and compiles/runs the generated Kotlin public parser under the checksummed +Gradle/JDK 21 harness. + +The TypeScript parser owns its generated Ajv registry. The Kotlin parser embeds the same canonical +schemas, attempts generated-model construction for every parsed fixture, then applies NetworkNT +JSON Schema 2020-12 validation with format assertions. The fixture harness contains no separate +TypeScript or Kotlin acceptance pre-filter. Run from the repository root: diff --git a/tools/fixture-validation/kotlin/src/main/kotlin/com/databreeze/fixturevalidation/ContractFixtureRunner.kt b/tools/fixture-validation/kotlin/src/main/kotlin/com/databreeze/fixturevalidation/ContractFixtureRunner.kt index 72fdfdea..b3587266 100644 --- a/tools/fixture-validation/kotlin/src/main/kotlin/com/databreeze/fixturevalidation/ContractFixtureRunner.kt +++ b/tools/fixture-validation/kotlin/src/main/kotlin/com/databreeze/fixturevalidation/ContractFixtureRunner.kt @@ -1,52 +1,14 @@ package com.databreeze.fixturevalidation -import com.databreeze.contracts.v1.ActorMetadata -import com.databreeze.contracts.v1.CommandEnvelope -import com.databreeze.contracts.v1.CorrelationMetadata -import com.databreeze.contracts.v1.CursorPage -import com.databreeze.contracts.v1.EventEnvelope -import com.databreeze.contracts.v1.Identifier -import com.databreeze.contracts.v1.OrganizationScope -import com.databreeze.contracts.v1.ProblemDetails -import com.databreeze.contracts.v1.ProjectScope -import com.databreeze.contracts.v1.Revision -import com.databreeze.contracts.v1.TenantScope -import com.databreeze.contracts.v1.UtcTimestamp -import com.databreeze.contracts.v1.WorkspaceScope -import com.fasterxml.jackson.annotation.JsonSubTypes -import com.fasterxml.jackson.annotation.JsonTypeInfo -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.JsonNode +import com.databreeze.contracts.v1.parseV1Contract import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.networknt.schema.InputFormat -import com.networknt.schema.SchemaLocation -import com.networknt.schema.SchemaRegistry -import com.networknt.schema.SpecificationVersion import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.absolute import kotlin.io.path.readText -private const val SCHEMA_BASE = "https://schemas.databreeze.dev/contracts/v1" - -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.EXISTING_PROPERTY, - property = "scopeType", - visible = false, -) -@JsonSubTypes( - JsonSubTypes.Type(value = OrganizationScope::class, name = "organization"), - JsonSubTypes.Type(value = WorkspaceScope::class, name = "workspace"), - JsonSubTypes.Type(value = ProjectScope::class, name = "project"), -) -private interface TenantScopeMixin - private val mapper: ObjectMapper = jacksonObjectMapper() - .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .addMixIn(TenantScope::class.java, TenantScopeMixin::class.java) private data class Arguments( val fixtureManifest: Path, @@ -73,79 +35,8 @@ private fun parseArguments(arguments: Array): Arguments { ) } -private fun schemaRegistry(fixtureManifest: Path, manifest: JsonNode): SchemaRegistry { - val fixtureRoot = requireNotNull(fixtureManifest.parent) - val schemaManifestPath = fixtureRoot.resolve(manifest.required("schemaManifest").asText()).normalize() - val contractRoot = requireNotNull(schemaManifestPath.parent) - val schemaManifest = mapper.readTree(schemaManifestPath.toFile()) - val schemas = schemaManifest.required("schemas").associate { entry -> - val id = entry.required("id").asText() - val source = contractRoot.resolve(entry.required("path").asText()).normalize().readText() - id to source - } - return SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12) { builder -> - builder.schemas(schemas) - } -} - -private fun identifier(payload: JsonNode): Identifier = - mapper.treeToValue(payload, String::class.java) - -private fun revision(payload: JsonNode): Revision = payload.longValue() - -private fun utcTimestamp(payload: JsonNode): UtcTimestamp = - mapper.treeToValue(payload, String::class.java) - -private fun constructGeneratedModel(schemaId: String, payload: JsonNode): Any = when (schemaId) { - "$SCHEMA_BASE/actor-metadata" -> mapper.treeToValue(payload, ActorMetadata::class.java) - "$SCHEMA_BASE/command-envelope" -> mapper.convertValue( - payload, - object : TypeReference>>() {}, - ) - "$SCHEMA_BASE/correlation-metadata" -> mapper.treeToValue( - payload, - CorrelationMetadata::class.java, - ) - "$SCHEMA_BASE/cursor-page" -> mapper.convertValue( - payload, - object : TypeReference>() {}, - ) - "$SCHEMA_BASE/event-envelope" -> mapper.convertValue( - payload, - object : TypeReference>>() {}, - ) - "$SCHEMA_BASE/identifier" -> identifier(payload) - "$SCHEMA_BASE/problem-details" -> mapper.treeToValue(payload, ProblemDetails::class.java) - "$SCHEMA_BASE/revision" -> revision(payload) - "$SCHEMA_BASE/tenant-scope" -> mapper.treeToValue(payload, TenantScope::class.java) - "$SCHEMA_BASE/utc-timestamp" -> utcTimestamp(payload) - else -> error("No generated Kotlin model for $schemaId") -} - -private fun acceptsFixture( - registry: SchemaRegistry, - schemaId: String, - payloadSource: String, - payload: JsonNode, -): Boolean { - val schema = registry.getSchema(SchemaLocation.of(schemaId)) - val errors = schema.validate(payloadSource, InputFormat.JSON) { executionContext -> - executionContext.executionConfig { configuration -> - configuration.formatAssertionsEnabled(true) - } - } - if (errors.isNotEmpty()) return false - return try { - constructGeneratedModel(schemaId, payload) - true - } catch (_: Exception) { - false - } -} - private fun runFixtures(arguments: Arguments) { val manifest = mapper.readTree(arguments.fixtureManifest.toFile()) - val registry = schemaRegistry(arguments.fixtureManifest, manifest) val fixtureRoot = requireNotNull(arguments.fixtureManifest.parent) val output = mapper.createObjectNode() output.put("runtime", "kotlin") @@ -153,17 +44,14 @@ private fun runFixtures(arguments: Arguments) { for (fixtureCase in manifest.required("cases")) { val source = fixtureRoot.resolve(fixtureCase.required("source").asText()).normalize() val payloadSource = source.readText() - val payload = mapper.readTree(payloadSource) results.addObject() .put("caseId", fixtureCase.required("id").asText()) .put( "accepted", - acceptsFixture( - registry, + parseV1Contract( fixtureCase.required("schemaId").asText(), payloadSource, - payload, - ), + ).accepted, ) } Files.writeString(arguments.output, mapper.writeValueAsString(output) + "\n") diff --git a/tools/fixture-validation/package.json b/tools/fixture-validation/package.json index 93625db0..61ff3342 100644 --- a/tools/fixture-validation/package.json +++ b/tools/fixture-validation/package.json @@ -8,8 +8,7 @@ "test": "node --test test/**/*.test.mjs" }, "dependencies": { - "ajv": "8.17.1", - "ajv-formats": "3.0.1" + "@databreeze/contracts": "workspace:*" }, "devDependencies": { "typescript": "5.9.2" diff --git a/tools/fixture-validation/test/contract-parity.test.mjs b/tools/fixture-validation/test/contract-parity.test.mjs index 8347b2be..d2db53f9 100644 --- a/tools/fixture-validation/test/contract-parity.test.mjs +++ b/tools/fixture-validation/test/contract-parity.test.mjs @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'n import { tmpdir } from 'node:os'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; import { spawnSync } from 'node:child_process'; import test from 'node:test'; @@ -16,6 +17,7 @@ const fixtureManifestPath = resolve( 'packages/test-fixtures/contracts/v1/manifest.json', ); const generatedContractsRoot = resolve(repositoryRoot, 'packages/contracts/generated'); +const require = createRequire(import.meta.url); function snapshotDirectory(root, directory = root) { return readdirSync(directory, { withFileTypes: true }) @@ -112,6 +114,64 @@ test('fails when every runtime disagrees with the fixture manifest', () => { }); }); +test('fails when a required consumer result is missing', () => { + withComparisonFixture((root, manifestPath) => { + const results = ['typescript', 'python'].map((runtime) => { + const path = resolve(root, `${runtime}.json`); + writeJson(path, { + runtime, + results: [{ caseId: 'v1.identifier.valid-uuid', accepted: true }], + }); + return path; + }); + + const run = runComparator(manifestPath, results); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, /Missing runtime result: kotlin/u); + }); +}); + +test('the supported TypeScript v1 export serves generated types and runtime validation', () => { + const typecheck = spawnSync( + process.execPath, + [ + require.resolve('typescript/bin/tsc'), + '--project', + resolve(toolRoot, 'typescript/tsconfig.json'), + ], + { cwd: repositoryRoot, encoding: 'utf8' }, + ); + assert.equal(typecheck.status, 0, `${typecheck.stdout}\n${typecheck.stderr}`); + + const schemaId = 'https://schemas.databreeze.dev/contracts/v1/identifier'; + const acceptedPath = resolve( + repositoryRoot, + 'packages/test-fixtures/contracts/v1/payloads/identifier/valid-uuid.json', + ); + const rejectedPath = resolve( + repositoryRoot, + 'packages/test-fixtures/contracts/v1/payloads/identifier/malformed.json', + ); + const program = [ + "import { readFileSync } from 'node:fs';", + "import { parseV1Contract } from '@databreeze/contracts/v1';", + 'const [schemaId, acceptedPath, rejectedPath] = process.argv.slice(1);', + "const readPayload = (path) => JSON.parse(readFileSync(path, 'utf8'));", + 'const results = [acceptedPath, rejectedPath].map((path) =>', + ' parseV1Contract(schemaId, readPayload(path)).accepted,', + ');', + 'process.stdout.write(`${JSON.stringify(results)}\\n`);', + ].join('\n'); + const runtime = spawnSync( + process.execPath, + ['--input-type=module', '--eval', program, schemaId, acceptedPath, rejectedPath], + { cwd: toolRoot, encoding: 'utf8' }, + ); + + assert.equal(runtime.status, 0, `${runtime.stdout}\n${runtime.stderr}`); + assert.deepEqual(JSON.parse(runtime.stdout), [true, false]); +}); + test('the real TypeScript Python and Kotlin consumers agree on every shared fixture', () => { const generatedBefore = snapshotDirectory(generatedContractsRoot); const run = spawnSync( diff --git a/tools/fixture-validation/typescript/run-fixtures.mjs b/tools/fixture-validation/typescript/run-fixtures.mjs index 94633018..170a6de2 100644 --- a/tools/fixture-validation/typescript/run-fixtures.mjs +++ b/tools/fixture-validation/typescript/run-fixtures.mjs @@ -1,8 +1,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import Ajv2020 from 'ajv/dist/2020.js'; -import addFormats from 'ajv-formats'; +import { parseV1Contract } from '@databreeze/contracts/v1'; function fail(message) { throw new Error(message); @@ -35,34 +34,19 @@ function readArguments(argumentsList) { return options; } -function buildCanonicalRegistry(fixtureManifestPath, fixtureManifest) { - const fixtureRoot = dirname(fixtureManifestPath); - const schemaManifestPath = resolve(fixtureRoot, fixtureManifest.schemaManifest); - const contractRoot = dirname(schemaManifestPath); - const schemaManifest = parseJson(schemaManifestPath, 'Canonical schema manifest'); - const ajv = new Ajv2020({ allErrors: true, strict: true }); - addFormats(ajv); - for (const entry of schemaManifest.schemas) { - const schema = parseJson(resolve(contractRoot, entry.path), `Canonical schema ${entry.name}`); - if (schema.$id !== entry.id) fail(`Manifest ID does not match ${entry.path}`); - ajv.addSchema(schema); - } - return ajv; -} - try { const options = readArguments(process.argv.slice(2)); const fixtureManifest = parseJson(options.fixtureManifest, 'Fixture manifest'); const fixtureRoot = dirname(options.fixtureManifest); - const ajv = buildCanonicalRegistry(options.fixtureManifest, fixtureManifest); const results = fixtureManifest.cases.map((fixtureCase) => { - const validate = ajv.getSchema(fixtureCase.schemaId); - if (!validate) fail(`Canonical registry has no schema for ${fixtureCase.schemaId}`); const payload = parseJson( resolve(fixtureRoot, fixtureCase.source), `Fixture ${fixtureCase.id}`, ); - return { caseId: fixtureCase.id, accepted: validate(payload) }; + return { + caseId: fixtureCase.id, + accepted: parseV1Contract(fixtureCase.schemaId, payload).accepted, + }; }); writeFileSync(options.output, `${JSON.stringify({ runtime: 'typescript', results })}\n`, 'utf8'); } catch (error) { diff --git a/tools/fixture-validation/typescript/valid-fixture-consumer.ts b/tools/fixture-validation/typescript/valid-fixture-consumer.ts index bbafad79..993f9f2a 100644 --- a/tools/fixture-validation/typescript/valid-fixture-consumer.ts +++ b/tools/fixture-validation/typescript/valid-fixture-consumer.ts @@ -11,7 +11,7 @@ import type { Revision, UtcTimestamp, WorkspaceScope, -} from '../../../packages/contracts/generated/typescript/v1/index.js'; +} from '@databreeze/contracts/v1'; import actorPayload from '../../../packages/test-fixtures/contracts/v1/payloads/actor-metadata/valid-user.json' with { type: 'json' }; import commandPayload from '../../../packages/test-fixtures/contracts/v1/payloads/command-envelope/valid-idempotent.json' with { type: 'json' }; From 1b2c6be9e8d455af969cf803f822a17a338cecd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 06:21:45 +0700 Subject: [PATCH 16/51] feat(permissions): add scoped authorization primitives --- packages/domain/README.md | 76 +++- packages/domain/package.json | 32 ++ packages/domain/src/authorization/v1.ts | 377 +++++++++++++++++ packages/domain/src/permissions/v1.ts | 150 +++++++ packages/domain/src/tenant-scope/v1.ts | 172 ++++++++ packages/domain/src/v1.ts | 3 + .../domain/test/authorization-v1.test.mjs | 388 ++++++++++++++++++ .../domain/test/built-public-api-smoke.mjs | 15 + packages/domain/test/permissions-v1.test.mjs | 163 ++++++++ packages/domain/test/public-api-v1.test.mjs | 38 ++ .../domain/test/public-api-v1.type-test.ts | 42 ++ packages/domain/test/tenant-scope-v1.test.mjs | 181 ++++++++ packages/domain/tsconfig.build.json | 11 + packages/domain/tsconfig.json | 9 + packages/domain/turbo.json | 9 + pnpm-lock.yaml | 6 + 16 files changed, 1671 insertions(+), 1 deletion(-) create mode 100644 packages/domain/package.json create mode 100644 packages/domain/src/authorization/v1.ts create mode 100644 packages/domain/src/permissions/v1.ts create mode 100644 packages/domain/src/tenant-scope/v1.ts create mode 100644 packages/domain/src/v1.ts create mode 100644 packages/domain/test/authorization-v1.test.mjs create mode 100644 packages/domain/test/built-public-api-smoke.mjs create mode 100644 packages/domain/test/permissions-v1.test.mjs create mode 100644 packages/domain/test/public-api-v1.test.mjs create mode 100644 packages/domain/test/public-api-v1.type-test.ts create mode 100644 packages/domain/test/tenant-scope-v1.test.mjs create mode 100644 packages/domain/tsconfig.build.json create mode 100644 packages/domain/tsconfig.json create mode 100644 packages/domain/turbo.json diff --git a/packages/domain/README.md b/packages/domain/README.md index 5246171f..c1965f12 100644 --- a/packages/domain/README.md +++ b/packages/domain/README.md @@ -1,3 +1,77 @@ # Shared Domain Values -Pure TypeScript value logic with no network, filesystem, database, UI-framework, or service-implementation dependencies. +Pure TypeScript value logic with no network, filesystem, database, UI-framework, or +service-implementation dependencies. + +## Public interfaces + +All imports are explicitly versioned. There is intentionally no unversioned package root. + +- `@databreeze/domain/permissions/v1` publishes the closed version-1 permission vocabulary, + the six initial immutable role bundles, and deny-by-default lookup helpers. +- `@databreeze/domain/tenant-scope/v1` publishes branded UUIDv4/UUIDv7 and UTC values, + complete organization/workspace/project scopes, and equality, containment, and narrowing + helpers. +- `@databreeze/domain/authorization/v1` publishes an instance-scoped evaluator for exact + tenant filters, trusted resource-lookup results, evaluated contexts, and authorization + decisions. +- `@databreeze/domain/v1` aggregates the three version-1 interfaces. + +The package uses the public `@databreeze/contracts/v1` validator. It does not deep-import +generated files or duplicate the canonical protocol schemas. + +## Initial roles + +The initial identifiers are `owner`, `admin`, `analyst`, `operator`, `approver`, and `viewer`. +Their names remain Owner, Admin, Analyst, Operator, Approver, and Viewer. A role is only a +permission bundle: it never establishes tenant membership, resource ownership, or a final +authorization decision. + +Owner materializes every Admin permission plus ownership-transfer and billing permissions. +Neither Owner nor Admin receives `approval.decision.create`. Approval, retention, legal-hold, +data-mode, device, entitlement, separation-of-duties, and recent-MFA conditions remain +independent policy gates. Callers must set `policyConditionsSatisfied` only after those +applicable policies have been authoritatively evaluated. + +## Trusted authorization flow + +1. Parse a complete tenant scope from trusted application state. +2. Call `verifyTenantFilterV1` with that authority scope and the required request/repository + filter. Missing, optional, malformed, broader, narrower, or mismatched filters are rejected. +3. Perform the repository lookup with the verified exact filter. Pass only its minimal + server-side ownership tuple to `acceptTrustedResourceLookupV1`; never pass a request body, + route claim, cached UI value, or client-provided ownership object to this trust boundary. +4. Create an evaluated context from the trusted resource token, current membership result, + channel, role identifier, evaluation time, and policy outcome. +5. Call `authorizeV1`. Unknown roles, permissions, channels, foreign evaluator tokens, + inactive memberships, unmet policies, resource-type mismatch, and tenant-scope mismatch all + deny. + +Tokens are bound to the evaluator instance that created them. A structurally identical object +or a token created by another evaluator cannot establish trust. Clients may use published +permission bundles as display hints, but authoritative enforcement belongs to the server or +trusted worker using results from its own lookups. + +## Requirement traceability + +This package and its tests provide partial foundation coverage only: + +- `IAM-001`: branded UUIDv4/UUIDv7 identifiers and strict UTC timestamp parsing. +- `IAM-002`: pure action, channel, resource, and scope decision primitives. +- `IAM-003`: default denial and runtime/type-level rejection of untrusted claims. +- `IAM-004`: versioned permissions and exactly six immutable initial role bundles. +- `IAM-009`: exact scoped-lookup and trusted resource-ownership gates. +- `IAM-019`: complete scope parsing, exact filters, ancestry containment, and non-broadening + narrowing. + +These requirements are not complete. This package does not implement authentication, IAM +persistence, memberships, repository queries, API guards, custom roles, offline snapshots, +policy engines, audit writes, or feature workflows. + +## Local commands + +```text +corepack pnpm --filter @databreeze/domain typecheck +corepack pnpm --filter @databreeze/domain test +corepack pnpm --filter @databreeze/domain build +``` diff --git a/packages/domain/package.json b/packages/domain/package.json new file mode 100644 index 00000000..5db9d05e --- /dev/null +++ b/packages/domain/package.json @@ -0,0 +1,32 @@ +{ + "name": "@databreeze/domain", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./v1": { + "types": "./src/v1.ts", + "import": "./dist/v1.js" + }, + "./permissions/v1": { + "types": "./src/permissions/v1.ts", + "import": "./dist/permissions/v1.js" + }, + "./tenant-scope/v1": { + "types": "./src/tenant-scope/v1.ts", + "import": "./dist/tenant-scope/v1.js" + }, + "./authorization/v1": { + "types": "./src/authorization/v1.ts", + "import": "./dist/authorization/v1.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json && node test/built-public-api-smoke.mjs", + "test": "node --test test/**/*.test.mjs", + "typecheck": "tsc --noEmit --project tsconfig.json" + }, + "dependencies": { + "@databreeze/contracts": "workspace:*" + } +} diff --git a/packages/domain/src/authorization/v1.ts b/packages/domain/src/authorization/v1.ts new file mode 100644 index 00000000..2c38a527 --- /dev/null +++ b/packages/domain/src/authorization/v1.ts @@ -0,0 +1,377 @@ +import { + isPermissionV1, + isRoleIdV1, + roleHasPermissionV1, + type PermissionV1, +} from '../permissions/v1.ts'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.ts'; + +/** Partial foundation coverage: IAM-002, IAM-003, IAM-004, IAM-009, and IAM-019. */ + +export const AUTHORIZATION_SCHEMA_VERSION_V1 = 1 as const; + +export const AUTHORIZATION_CHANNELS_V1 = Object.freeze([ + 'api', + 'web', + 'desktop', + 'android', + 'worker', + 'sync', + 'stream', + 'shared-link', +] as const); + +export type AuthorizationChannelV1 = (typeof AUTHORIZATION_CHANNELS_V1)[number]; + +export type AuthorizationDenialCodeV1 = + | 'INACTIVE_MEMBERSHIP' + | 'POLICY_CONDITIONS_REQUIRED' + | 'RESOURCE_TYPE_MISMATCH' + | 'ROLE_PERMISSION_MISSING' + | 'TENANT_SCOPE_MISMATCH' + | 'UNKNOWN_CHANNEL' + | 'UNKNOWN_PERMISSION' + | 'UNKNOWN_ROLE' + | 'UNTRUSTED_CONTEXT'; + +export type AuthorizationDecisionV1 = + | { + readonly allowed: true; + readonly permission: PermissionV1; + readonly tenantScope: TenantScopeV1; + } + | { readonly allowed: false; readonly code: AuthorizationDenialCodeV1 }; + +declare const verifiedTenantFilterV1Brand: unique symbol; +declare const trustedResourceOwnershipV1Brand: unique symbol; +declare const evaluatedAuthorizationContextV1Brand: unique symbol; + +export interface VerifiedTenantFilterV1 { + readonly scope: TenantScopeV1; + readonly [verifiedTenantFilterV1Brand]: true; +} + +export interface TrustedResourceOwnershipV1 { + readonly resourceType: string; + readonly resourceId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly [trustedResourceOwnershipV1Brand]: true; +} + +export interface EvaluatedAuthorizationContextV1 { + readonly schemaVersion: typeof AUTHORIZATION_SCHEMA_VERSION_V1; + readonly principalId: StableIdentifierV1; + readonly roleId: string; + readonly membershipScope: TenantScopeV1; + readonly membershipActive: boolean; + readonly channel: string; + readonly policyConditionsSatisfied: boolean; + readonly evaluatedAt: StrictUtcTimestampV1; + readonly resource: TrustedResourceOwnershipV1; + readonly [evaluatedAuthorizationContextV1Brand]: true; +} + +export type TenantFilterResultV1 = + | { readonly accepted: true; readonly value: VerifiedTenantFilterV1 } + | { + readonly accepted: false; + readonly code: + | 'INVALID_AUTHORITY_SCOPE' + | 'TENANT_FILTER_INVALID' + | 'TENANT_FILTER_MISMATCH' + | 'TENANT_FILTER_REQUIRED'; + }; + +export type ResourceOwnershipResultV1 = + | { readonly accepted: true; readonly value: TrustedResourceOwnershipV1 } + | { + readonly accepted: false; + readonly code: + | 'INVALID_RESOURCE_OWNERSHIP' + | 'RESOURCE_OWNERSHIP_MISMATCH' + | 'UNVERIFIED_TENANT_FILTER'; + }; + +export type EvaluatedContextResultV1 = + | { readonly accepted: true; readonly value: EvaluatedAuthorizationContextV1 } + | { + readonly accepted: false; + readonly code: 'INVALID_EVALUATED_CONTEXT' | 'UNTRUSTED_RESOURCE_OWNERSHIP'; + }; + +export interface ScopedAuthorizationEvaluatorV1 { + readonly verifyTenantFilterV1: (authorityScope: unknown, filter: unknown) => TenantFilterResultV1; + /** Accept only the minimal ownership tuple returned by an authoritative scoped lookup. */ + readonly acceptTrustedResourceLookupV1: ( + filter: unknown, + lookupResult: unknown, + ) => ResourceOwnershipResultV1; + readonly createEvaluatedContextV1: (input: unknown) => EvaluatedContextResultV1; + readonly authorizeV1: (context: unknown, permission: unknown) => AuthorizationDecisionV1; +} + +type ResourceTypeV1 = + | 'approval-request' + | 'artifact' + | 'billing-account' + | 'device' + | 'job' + | 'organization' + | 'project' + | 'workspace'; + +const permissionResourceTypes: Readonly> = Object.freeze({ + 'organization.profile.read': 'organization', + 'organization.settings.manage': 'organization', + 'organization.ownership.transfer': 'organization', + 'workspace.settings.read': 'workspace', + 'workspace.settings.manage': 'workspace', + 'project.record.read': 'project', + 'project.record.manage': 'project', + 'artifact.record.read': 'artifact', + 'artifact.original.download': 'artifact', + 'artifact.derived.create': 'artifact', + 'job.execution.read': 'job', + 'job.execution.create': 'job', + 'job.execution.run': 'job', + 'job.execution.cancel': 'job', + 'approval.request.read': 'approval-request', + 'approval.decision.create': 'approval-request', + 'billing.account.read': 'billing-account', + 'billing.account.manage': 'billing-account', + 'device.identity.read': 'device', + 'device.identity.revoke': 'device', +}); + +const resourceScopeTypes: Readonly> = + Object.freeze({ + 'approval-request': Object.freeze(['workspace', 'project'] as const), + artifact: Object.freeze(['workspace', 'project'] as const), + 'billing-account': Object.freeze(['organization'] as const), + device: Object.freeze(['organization'] as const), + job: Object.freeze(['workspace', 'project'] as const), + organization: Object.freeze(['organization'] as const), + project: Object.freeze(['project'] as const), + workspace: Object.freeze(['workspace'] as const), + }); + +const authorizationChannelSet = new Set(AUTHORIZATION_CHANNELS_V1); +const resourceTypePattern = /^[a-z][a-z0-9-]{0,62}$/; + +function isResourceTypeV1(input: string): input is ResourceTypeV1 { + return Object.hasOwn(resourceScopeTypes, input); +} + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input); +} + +function hasExactKeys(input: Record, expectedKeys: readonly string[]): boolean { + const actualKeys = Object.keys(input).sort(); + const sortedExpected = [...expectedKeys].sort(); + return ( + actualKeys.length === sortedExpected.length && + actualKeys.every((key, index) => key === sortedExpected[index]) + ); +} + +function rejectFilter(code: Exclude['code']) { + return Object.freeze({ accepted: false as const, code }); +} + +function rejectResource(code: Exclude['code']) { + return Object.freeze({ accepted: false as const, code }); +} + +function rejectContext(code: Exclude['code']) { + return Object.freeze({ accepted: false as const, code }); +} + +function deny(code: AuthorizationDenialCodeV1): AuthorizationDecisionV1 { + return Object.freeze({ allowed: false, code }); +} + +export function createScopedAuthorizationEvaluatorV1(): ScopedAuthorizationEvaluatorV1 { + const verifiedFilters = new WeakSet(); + const trustedResources = new WeakSet(); + const evaluatedContexts = new WeakSet(); + + function verifyTenantFilterV1(authorityScope: unknown, filter: unknown): TenantFilterResultV1 { + const parsedAuthority = parseTenantScopeV1(authorityScope); + if (!parsedAuthority.accepted) { + return rejectFilter('INVALID_AUTHORITY_SCOPE'); + } + if (filter === undefined || filter === null) { + return rejectFilter('TENANT_FILTER_REQUIRED'); + } + + const parsedFilter = parseTenantScopeV1(filter); + if (!parsedFilter.accepted) { + return rejectFilter('TENANT_FILTER_INVALID'); + } + if (!tenantScopesEqualV1(parsedAuthority.value, parsedFilter.value)) { + return rejectFilter('TENANT_FILTER_MISMATCH'); + } + + const verified = Object.freeze({ scope: parsedFilter.value }) as VerifiedTenantFilterV1; + verifiedFilters.add(verified); + return Object.freeze({ accepted: true, value: verified }); + } + + function acceptTrustedResourceLookupV1( + filter: unknown, + lookupResult: unknown, + ): ResourceOwnershipResultV1 { + if (!isRecord(filter) || !verifiedFilters.has(filter)) { + return rejectResource('UNVERIFIED_TENANT_FILTER'); + } + const verifiedFilter = filter as unknown as VerifiedTenantFilterV1; + + if ( + !isRecord(lookupResult) || + !hasExactKeys(lookupResult, ['resourceId', 'resourceType', 'tenantScope']) + ) { + return rejectResource('INVALID_RESOURCE_OWNERSHIP'); + } + + const resourceType = lookupResult['resourceType']; + if ( + typeof resourceType !== 'string' || + !resourceTypePattern.test(resourceType) || + !isResourceTypeV1(resourceType) + ) { + return rejectResource('INVALID_RESOURCE_OWNERSHIP'); + } + + const resourceId = parseStableIdentifierV1(lookupResult['resourceId']); + const tenantScope = parseTenantScopeV1(lookupResult['tenantScope']); + if (!resourceId.accepted || !tenantScope.accepted) { + return rejectResource('INVALID_RESOURCE_OWNERSHIP'); + } + if (!tenantScopesEqualV1(verifiedFilter.scope, tenantScope.value)) { + return rejectResource('RESOURCE_OWNERSHIP_MISMATCH'); + } + if (!resourceScopeTypes[resourceType].includes(tenantScope.value.scopeType)) { + return rejectResource('INVALID_RESOURCE_OWNERSHIP'); + } + + const trusted = Object.freeze({ + resourceType, + resourceId: resourceId.value, + tenantScope: tenantScope.value, + }) as TrustedResourceOwnershipV1; + trustedResources.add(trusted); + return Object.freeze({ accepted: true, value: trusted }); + } + + function createEvaluatedContextV1(input: unknown): EvaluatedContextResultV1 { + if ( + !isRecord(input) || + !hasExactKeys(input, [ + 'channel', + 'evaluatedAt', + 'membershipActive', + 'membershipScope', + 'policyConditionsSatisfied', + 'principalId', + 'resource', + 'roleId', + ]) + ) { + return rejectContext('INVALID_EVALUATED_CONTEXT'); + } + const resource = input['resource']; + if (!isRecord(resource) || !trustedResources.has(resource)) { + return rejectContext('UNTRUSTED_RESOURCE_OWNERSHIP'); + } + + const principalId = parseStableIdentifierV1(input['principalId']); + const membershipScope = parseTenantScopeV1(input['membershipScope']); + const evaluatedAt = parseStrictUtcTimestampV1(input['evaluatedAt']); + const roleId = input['roleId']; + const channel = input['channel']; + const membershipActive = input['membershipActive']; + const policyConditionsSatisfied = input['policyConditionsSatisfied']; + if ( + !principalId.accepted || + !membershipScope.accepted || + !evaluatedAt.accepted || + typeof roleId !== 'string' || + roleId.length === 0 || + typeof channel !== 'string' || + channel.length === 0 || + typeof membershipActive !== 'boolean' || + typeof policyConditionsSatisfied !== 'boolean' + ) { + return rejectContext('INVALID_EVALUATED_CONTEXT'); + } + + const context = Object.freeze({ + schemaVersion: AUTHORIZATION_SCHEMA_VERSION_V1, + principalId: principalId.value, + roleId, + membershipScope: membershipScope.value, + membershipActive, + channel, + policyConditionsSatisfied, + evaluatedAt: evaluatedAt.value, + resource: resource as unknown as TrustedResourceOwnershipV1, + }) as EvaluatedAuthorizationContextV1; + evaluatedContexts.add(context); + return Object.freeze({ accepted: true, value: context }); + } + + function authorizeV1(context: unknown, permission: unknown): AuthorizationDecisionV1 { + if (!isRecord(context) || !evaluatedContexts.has(context)) { + return deny('UNTRUSTED_CONTEXT'); + } + const evaluated = context as unknown as EvaluatedAuthorizationContextV1; + + if (!isRoleIdV1(evaluated.roleId)) { + return deny('UNKNOWN_ROLE'); + } + if (!isPermissionV1(permission)) { + return deny('UNKNOWN_PERMISSION'); + } + if (!authorizationChannelSet.has(evaluated.channel)) { + return deny('UNKNOWN_CHANNEL'); + } + if (!evaluated.membershipActive) { + return deny('INACTIVE_MEMBERSHIP'); + } + if (!evaluated.policyConditionsSatisfied) { + return deny('POLICY_CONDITIONS_REQUIRED'); + } + if (!roleHasPermissionV1(evaluated.roleId, permission)) { + return deny('ROLE_PERMISSION_MISSING'); + } + if (permissionResourceTypes[permission] !== evaluated.resource.resourceType) { + return deny('RESOURCE_TYPE_MISMATCH'); + } + if (!tenantScopeContainsV1(evaluated.membershipScope, evaluated.resource.tenantScope)) { + return deny('TENANT_SCOPE_MISMATCH'); + } + + return Object.freeze({ + allowed: true, + permission, + tenantScope: evaluated.resource.tenantScope, + }); + } + + return Object.freeze({ + verifyTenantFilterV1, + acceptTrustedResourceLookupV1, + createEvaluatedContextV1, + authorizeV1, + }); +} diff --git a/packages/domain/src/permissions/v1.ts b/packages/domain/src/permissions/v1.ts new file mode 100644 index 00000000..9c9edbad --- /dev/null +++ b/packages/domain/src/permissions/v1.ts @@ -0,0 +1,150 @@ +/** + * Version 1 of the DataBreeze permission vocabulary. + * + * Partial foundation coverage: IAM-002, IAM-003, and IAM-004. + */ + +export const PERMISSION_SCHEMA_VERSION_V1 = 1 as const; + +export const PERMISSIONS_V1 = Object.freeze({ + ORGANIZATION_PROFILE_READ: 'organization.profile.read', + ORGANIZATION_SETTINGS_MANAGE: 'organization.settings.manage', + ORGANIZATION_OWNERSHIP_TRANSFER: 'organization.ownership.transfer', + WORKSPACE_SETTINGS_READ: 'workspace.settings.read', + WORKSPACE_SETTINGS_MANAGE: 'workspace.settings.manage', + PROJECT_RECORD_READ: 'project.record.read', + PROJECT_RECORD_MANAGE: 'project.record.manage', + ARTIFACT_RECORD_READ: 'artifact.record.read', + ARTIFACT_ORIGINAL_DOWNLOAD: 'artifact.original.download', + ARTIFACT_DERIVED_CREATE: 'artifact.derived.create', + JOB_EXECUTION_READ: 'job.execution.read', + JOB_EXECUTION_CREATE: 'job.execution.create', + JOB_EXECUTION_RUN: 'job.execution.run', + JOB_EXECUTION_CANCEL: 'job.execution.cancel', + APPROVAL_REQUEST_READ: 'approval.request.read', + APPROVAL_DECISION_CREATE: 'approval.decision.create', + BILLING_ACCOUNT_READ: 'billing.account.read', + BILLING_ACCOUNT_MANAGE: 'billing.account.manage', + DEVICE_IDENTITY_READ: 'device.identity.read', + DEVICE_IDENTITY_REVOKE: 'device.identity.revoke', +} as const); + +export type PermissionV1 = (typeof PERMISSIONS_V1)[keyof typeof PERMISSIONS_V1]; + +export const INITIAL_ROLE_IDS_V1 = Object.freeze([ + 'owner', + 'admin', + 'analyst', + 'operator', + 'approver', + 'viewer', +] as const); + +export type InitialRoleIdV1 = (typeof INITIAL_ROLE_IDS_V1)[number]; + +export interface InitialRoleBundleV1 { + readonly id: InitialRoleIdV1; + readonly name: 'Owner' | 'Admin' | 'Analyst' | 'Operator' | 'Approver' | 'Viewer'; + readonly schemaVersion: typeof PERMISSION_SCHEMA_VERSION_V1; + readonly permissions: readonly PermissionV1[]; +} + +function immutableBundle( + id: InitialRoleIdV1, + name: InitialRoleBundleV1['name'], + permissions: readonly PermissionV1[], +): InitialRoleBundleV1 { + return Object.freeze({ + id, + name, + schemaVersion: PERMISSION_SCHEMA_VERSION_V1, + permissions: Object.freeze([...permissions]), + }); +} + +const adminPermissions = [ + PERMISSIONS_V1.ORGANIZATION_PROFILE_READ, + PERMISSIONS_V1.ORGANIZATION_SETTINGS_MANAGE, + PERMISSIONS_V1.WORKSPACE_SETTINGS_READ, + PERMISSIONS_V1.WORKSPACE_SETTINGS_MANAGE, + PERMISSIONS_V1.PROJECT_RECORD_READ, + PERMISSIONS_V1.PROJECT_RECORD_MANAGE, + PERMISSIONS_V1.JOB_EXECUTION_READ, + PERMISSIONS_V1.DEVICE_IDENTITY_READ, + PERMISSIONS_V1.DEVICE_IDENTITY_REVOKE, +] as const; + +export const INITIAL_ROLE_BUNDLES_V1: Readonly> = + Object.freeze({ + owner: immutableBundle('owner', 'Owner', [ + PERMISSIONS_V1.ORGANIZATION_PROFILE_READ, + PERMISSIONS_V1.ORGANIZATION_SETTINGS_MANAGE, + PERMISSIONS_V1.ORGANIZATION_OWNERSHIP_TRANSFER, + PERMISSIONS_V1.WORKSPACE_SETTINGS_READ, + PERMISSIONS_V1.WORKSPACE_SETTINGS_MANAGE, + PERMISSIONS_V1.PROJECT_RECORD_READ, + PERMISSIONS_V1.PROJECT_RECORD_MANAGE, + PERMISSIONS_V1.JOB_EXECUTION_READ, + PERMISSIONS_V1.BILLING_ACCOUNT_READ, + PERMISSIONS_V1.BILLING_ACCOUNT_MANAGE, + PERMISSIONS_V1.DEVICE_IDENTITY_READ, + PERMISSIONS_V1.DEVICE_IDENTITY_REVOKE, + ]), + admin: immutableBundle('admin', 'Admin', adminPermissions), + analyst: immutableBundle('analyst', 'Analyst', [ + PERMISSIONS_V1.ORGANIZATION_PROFILE_READ, + PERMISSIONS_V1.WORKSPACE_SETTINGS_READ, + PERMISSIONS_V1.PROJECT_RECORD_READ, + PERMISSIONS_V1.ARTIFACT_RECORD_READ, + PERMISSIONS_V1.ARTIFACT_ORIGINAL_DOWNLOAD, + PERMISSIONS_V1.ARTIFACT_DERIVED_CREATE, + PERMISSIONS_V1.JOB_EXECUTION_READ, + PERMISSIONS_V1.JOB_EXECUTION_CREATE, + PERMISSIONS_V1.JOB_EXECUTION_RUN, + PERMISSIONS_V1.JOB_EXECUTION_CANCEL, + ]), + operator: immutableBundle('operator', 'Operator', [ + PERMISSIONS_V1.ORGANIZATION_PROFILE_READ, + PERMISSIONS_V1.WORKSPACE_SETTINGS_READ, + PERMISSIONS_V1.PROJECT_RECORD_READ, + PERMISSIONS_V1.ARTIFACT_RECORD_READ, + PERMISSIONS_V1.ARTIFACT_DERIVED_CREATE, + PERMISSIONS_V1.JOB_EXECUTION_READ, + PERMISSIONS_V1.JOB_EXECUTION_RUN, + ]), + approver: immutableBundle('approver', 'Approver', [ + PERMISSIONS_V1.ORGANIZATION_PROFILE_READ, + PERMISSIONS_V1.WORKSPACE_SETTINGS_READ, + PERMISSIONS_V1.PROJECT_RECORD_READ, + PERMISSIONS_V1.ARTIFACT_RECORD_READ, + PERMISSIONS_V1.JOB_EXECUTION_READ, + PERMISSIONS_V1.APPROVAL_REQUEST_READ, + PERMISSIONS_V1.APPROVAL_DECISION_CREATE, + ]), + viewer: immutableBundle('viewer', 'Viewer', [ + PERMISSIONS_V1.ORGANIZATION_PROFILE_READ, + PERMISSIONS_V1.WORKSPACE_SETTINGS_READ, + PERMISSIONS_V1.PROJECT_RECORD_READ, + PERMISSIONS_V1.ARTIFACT_RECORD_READ, + PERMISSIONS_V1.JOB_EXECUTION_READ, + ]), + }); + +const permissionSet = new Set(Object.values(PERMISSIONS_V1)); +const roleSet = new Set(INITIAL_ROLE_IDS_V1); + +export function isPermissionV1(value: unknown): value is PermissionV1 { + return typeof value === 'string' && permissionSet.has(value as PermissionV1); +} + +export function isRoleIdV1(value: unknown): value is InitialRoleIdV1 { + return typeof value === 'string' && roleSet.has(value as InitialRoleIdV1); +} + +export function roleHasPermissionV1(roleId: unknown, permission: unknown): boolean { + if (!isRoleIdV1(roleId) || !isPermissionV1(permission)) { + return false; + } + + return INITIAL_ROLE_BUNDLES_V1[roleId].permissions.includes(permission); +} diff --git a/packages/domain/src/tenant-scope/v1.ts b/packages/domain/src/tenant-scope/v1.ts new file mode 100644 index 00000000..1def0d92 --- /dev/null +++ b/packages/domain/src/tenant-scope/v1.ts @@ -0,0 +1,172 @@ +import { + parseV1Contract, + type Identifier, + type TenantScope, + type UtcTimestamp, +} from '@databreeze/contracts/v1'; + +/** Partial foundation coverage: IAM-001, IAM-009, and IAM-019. */ + +declare const stableIdentifierV1Brand: unique symbol; +declare const strictUtcTimestampV1Brand: unique symbol; + +export type StableIdentifierV1 = Identifier & { + readonly [stableIdentifierV1Brand]: 'StableIdentifierV1'; +}; + +export type StrictUtcTimestampV1 = UtcTimestamp & { + readonly [strictUtcTimestampV1Brand]: 'StrictUtcTimestampV1'; +}; + +export interface OrganizationTenantScopeV1 { + readonly scopeType: 'organization'; + readonly organizationId: StableIdentifierV1; +} + +export interface WorkspaceTenantScopeV1 { + readonly scopeType: 'workspace'; + readonly organizationId: StableIdentifierV1; + readonly workspaceId: StableIdentifierV1; +} + +export interface ProjectTenantScopeV1 { + readonly scopeType: 'project'; + readonly organizationId: StableIdentifierV1; + readonly workspaceId: StableIdentifierV1; + readonly projectId: StableIdentifierV1; +} + +export type TenantScopeV1 = + | OrganizationTenantScopeV1 + | WorkspaceTenantScopeV1 + | ProjectTenantScopeV1; + +export type ParseValueResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: TCode }; + +const identifierSchemaId = 'https://schemas.databreeze.dev/contracts/v1/identifier'; +const utcTimestampSchemaId = 'https://schemas.databreeze.dev/contracts/v1/utc-timestamp'; +const tenantScopeSchemaId = 'https://schemas.databreeze.dev/contracts/v1/tenant-scope'; + +// UUIDv4 and UUIDv7 are random/time-sortable non-guessable identifiers used by DataBreeze. +const nonGuessableUuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function rejected( + code: TCode, +): { readonly accepted: false; readonly code: TCode } { + return Object.freeze({ accepted: false, code }); +} + +function accepted(value: TValue): { readonly accepted: true; readonly value: TValue } { + return Object.freeze({ accepted: true, value }); +} + +export function parseStableIdentifierV1( + input: unknown, +): ParseValueResultV1 { + const parsed = parseV1Contract(identifierSchemaId, input); + if (!parsed.accepted || !nonGuessableUuidPattern.test(parsed.value)) { + return rejected('INVALID_IDENTIFIER'); + } + + return accepted(parsed.value as StableIdentifierV1); +} + +export function parseStrictUtcTimestampV1( + input: unknown, +): ParseValueResultV1 { + const parsed = parseV1Contract(utcTimestampSchemaId, input); + if (!parsed.accepted) { + return rejected('INVALID_UTC_TIMESTAMP'); + } + + return accepted(parsed.value as StrictUtcTimestampV1); +} + +function identifierFrom(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +export function parseTenantScopeV1( + input: unknown, +): ParseValueResultV1 { + const parsed = parseV1Contract(tenantScopeSchemaId, input); + if (!parsed.accepted) { + return rejected('INVALID_TENANT_SCOPE'); + } + + const organizationId = identifierFrom(parsed.value.organizationId); + if (organizationId === undefined) { + return rejected('INVALID_TENANT_SCOPE'); + } + + if (parsed.value.scopeType === 'organization') { + return accepted(Object.freeze({ scopeType: 'organization', organizationId })); + } + + const workspaceId = identifierFrom(parsed.value.workspaceId); + if (workspaceId === undefined) { + return rejected('INVALID_TENANT_SCOPE'); + } + + if (parsed.value.scopeType === 'workspace') { + return accepted(Object.freeze({ scopeType: 'workspace', organizationId, workspaceId })); + } + + const projectId = identifierFrom(parsed.value.projectId); + if (projectId === undefined) { + return rejected('INVALID_TENANT_SCOPE'); + } + + return accepted(Object.freeze({ scopeType: 'project', organizationId, workspaceId, projectId })); +} + +export function tenantScopesEqualV1(left: TenantScopeV1, right: TenantScopeV1): boolean { + if (left.scopeType !== right.scopeType || left.organizationId !== right.organizationId) { + return false; + } + + if (left.scopeType === 'organization' || right.scopeType === 'organization') { + return left.scopeType === right.scopeType; + } + + if (left.workspaceId !== right.workspaceId) { + return false; + } + + if (left.scopeType === 'workspace' || right.scopeType === 'workspace') { + return left.scopeType === right.scopeType; + } + + return left.projectId === right.projectId; +} + +export function tenantScopeContainsV1(container: TenantScopeV1, candidate: TenantScopeV1): boolean { + if (container.organizationId !== candidate.organizationId) { + return false; + } + + if (container.scopeType === 'organization') { + return true; + } + + if (candidate.scopeType === 'organization' || container.workspaceId !== candidate.workspaceId) { + return false; + } + + if (container.scopeType === 'workspace') { + return true; + } + + return candidate.scopeType === 'project' && container.projectId === candidate.projectId; +} + +export function narrowTenantScopeV1( + current: TenantScopeV1, + candidate: TenantScopeV1, +): TenantScopeV1 | undefined { + return tenantScopeContainsV1(current, candidate) ? candidate : undefined; +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts new file mode 100644 index 00000000..4d091b27 --- /dev/null +++ b/packages/domain/src/v1.ts @@ -0,0 +1,3 @@ +export * from './authorization/v1.ts'; +export * from './permissions/v1.ts'; +export * from './tenant-scope/v1.ts'; diff --git a/packages/domain/test/authorization-v1.test.mjs b/packages/domain/test/authorization-v1.test.mjs new file mode 100644 index 00000000..ced1323e --- /dev/null +++ b/packages/domain/test/authorization-v1.test.mjs @@ -0,0 +1,388 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +async function loadAuthorization() { + try { + return await import('../src/authorization/v1.ts'); + } catch { + return undefined; + } +} + +const ids = Object.freeze({ + principal: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + organizationA: '018f0f8c-7b77-7abc-8def-0123456789ab', + organizationB: '018f0f8c-7b77-7abc-9def-0123456789ac', + workspaceA: '11111111-1111-4111-8111-111111111111', + workspaceB: '11111111-1111-4111-8111-111111111112', + projectA: '22222222-2222-4222-8222-222222222222', + projectB: '33333333-3333-4333-8333-333333333333', + resourceA: '44444444-4444-4444-8444-444444444444', +}); + +const organizationA = Object.freeze({ + scopeType: 'organization', + organizationId: ids.organizationA, +}); +const organizationB = Object.freeze({ + scopeType: 'organization', + organizationId: ids.organizationB, +}); +const workspaceA = Object.freeze({ + scopeType: 'workspace', + organizationId: ids.organizationA, + workspaceId: ids.workspaceA, +}); +const workspaceB = Object.freeze({ + scopeType: 'workspace', + organizationId: ids.organizationA, + workspaceId: ids.workspaceB, +}); +const projectA = Object.freeze({ + scopeType: 'project', + organizationId: ids.organizationA, + workspaceId: ids.workspaceA, + projectId: ids.projectA, +}); +const projectB = Object.freeze({ + scopeType: 'project', + organizationId: ids.organizationA, + workspaceId: ids.workspaceA, + projectId: ids.projectB, +}); + +function expectAccepted(result) { + assert.equal(result.accepted, true); + return result.value; +} + +function trustedResource(evaluator, tenantScope, resourceType) { + const filter = expectAccepted(evaluator.verifyTenantFilterV1(tenantScope, tenantScope)); + return expectAccepted( + evaluator.acceptTrustedResourceLookupV1(filter, { + resourceType, + resourceId: ids.resourceA, + tenantScope, + }), + ); +} + +function evaluatedContext( + evaluator, + { + roleId, + membershipScope, + resourceScope, + resourceType, + channel = 'api', + membershipActive = true, + policyConditionsSatisfied = true, + }, +) { + return expectAccepted( + evaluator.createEvaluatedContextV1({ + principalId: ids.principal, + roleId, + membershipScope, + membershipActive, + channel, + policyConditionsSatisfied, + evaluatedAt: '2026-08-01T12:34:56Z', + resource: trustedResource(evaluator, resourceScope, resourceType), + }), + ); +} + +test('[IAM-009, IAM-019] exact required tenant filters gate trusted lookup results', async () => { + const api = await loadAuthorization(); + assert.ok(api, 'the authorization/v1 module must exist'); + const evaluator = api.createScopedAuthorizationEvaluatorV1(); + + assert.deepEqual(evaluator.verifyTenantFilterV1(projectA, undefined), { + accepted: false, + code: 'TENANT_FILTER_REQUIRED', + }); + assert.deepEqual(evaluator.verifyTenantFilterV1(projectA, { ...projectA, extra: true }), { + accepted: false, + code: 'TENANT_FILTER_INVALID', + }); + assert.deepEqual(evaluator.verifyTenantFilterV1(projectA, workspaceA), { + accepted: false, + code: 'TENANT_FILTER_MISMATCH', + }); + assert.deepEqual( + evaluator.verifyTenantFilterV1(projectA, { ...projectA, organizationId: ids.organizationB }), + { accepted: false, code: 'TENANT_FILTER_MISMATCH' }, + ); + + const verified = expectAccepted(evaluator.verifyTenantFilterV1(projectA, projectA)); + assert.ok(Object.isFrozen(verified)); + assert.deepEqual( + evaluator.acceptTrustedResourceLookupV1(projectA, { + resourceType: 'artifact', + resourceId: ids.resourceA, + tenantScope: projectA, + }), + { accepted: false, code: 'UNVERIFIED_TENANT_FILTER' }, + ); + + const foreignEvaluator = api.createScopedAuthorizationEvaluatorV1(); + assert.deepEqual( + foreignEvaluator.acceptTrustedResourceLookupV1(verified, { + resourceType: 'artifact', + resourceId: ids.resourceA, + tenantScope: projectA, + }), + { accepted: false, code: 'UNVERIFIED_TENANT_FILTER' }, + ); +}); + +test('[IAM-009, IAM-019] trusted lookup rejects cross-tenant and incomplete ownership', async () => { + const api = await loadAuthorization(); + assert.ok(api); + const evaluator = api.createScopedAuthorizationEvaluatorV1(); + const verified = expectAccepted(evaluator.verifyTenantFilterV1(projectA, projectA)); + + for (const tenantScope of [organizationB, workspaceB, projectB]) { + assert.deepEqual( + evaluator.acceptTrustedResourceLookupV1(verified, { + resourceType: 'artifact', + resourceId: ids.resourceA, + tenantScope, + }), + { accepted: false, code: 'RESOURCE_OWNERSHIP_MISMATCH' }, + ); + } + + assert.deepEqual( + evaluator.acceptTrustedResourceLookupV1(verified, { + resourceType: 'artifact', + resourceId: ids.resourceA, + tenantScope: { + scopeType: 'project', + organizationId: ids.organizationA, + projectId: ids.projectA, + }, + }), + { accepted: false, code: 'INVALID_RESOURCE_OWNERSHIP' }, + ); +}); + +test('[IAM-009, IAM-019] resource types require their complete applicable scope', async () => { + const api = await loadAuthorization(); + assert.ok(api); + + const cases = [ + ['artifact', organizationA], + ['job', organizationA], + ['approval-request', organizationA], + ['workspace', projectA], + ['project', workspaceA], + ['billing-account', workspaceA], + ['device', workspaceA], + ['future-resource', projectA], + ]; + + for (const [resourceType, tenantScope] of cases) { + const evaluator = api.createScopedAuthorizationEvaluatorV1(); + const verified = expectAccepted(evaluator.verifyTenantFilterV1(tenantScope, tenantScope)); + assert.deepEqual( + evaluator.acceptTrustedResourceLookupV1(verified, { + resourceType, + resourceId: ids.resourceA, + tenantScope, + }), + { accepted: false, code: 'INVALID_RESOURCE_OWNERSHIP' }, + ); + } +}); + +test('[IAM-002, IAM-003] plain or foreign client claims never become authorization context', async () => { + const api = await loadAuthorization(); + assert.ok(api); + const evaluator = api.createScopedAuthorizationEvaluatorV1(); + const foreignEvaluator = api.createScopedAuthorizationEvaluatorV1(); + const rawResourceClaim = { + resourceType: 'artifact', + resourceId: ids.resourceA, + tenantScope: projectA, + }; + const contextInput = { + principalId: ids.principal, + roleId: 'owner', + membershipScope: organizationA, + membershipActive: true, + channel: 'api', + policyConditionsSatisfied: true, + evaluatedAt: '2026-08-01T12:34:56Z', + resource: rawResourceClaim, + }; + + assert.deepEqual(evaluator.createEvaluatedContextV1(contextInput), { + accepted: false, + code: 'UNTRUSTED_RESOURCE_OWNERSHIP', + }); + assert.deepEqual( + evaluator.createEvaluatedContextV1({ + ...contextInput, + resource: trustedResource(evaluator, projectA, 'artifact'), + clientTenantClaim: projectA, + }), + { accepted: false, code: 'INVALID_EVALUATED_CONTEXT' }, + ); + assert.deepEqual(evaluator.authorizeV1(contextInput, 'artifact.record.read'), { + allowed: false, + code: 'UNTRUSTED_CONTEXT', + }); + + const foreignResource = trustedResource(foreignEvaluator, projectA, 'artifact'); + assert.deepEqual( + evaluator.createEvaluatedContextV1({ ...contextInput, resource: foreignResource }), + { + accepted: false, + code: 'UNTRUSTED_RESOURCE_OWNERSHIP', + }, + ); +}); + +test('[IAM-002, IAM-003, IAM-004] authorizes the representative six-role matrix only in scope', async () => { + const api = await loadAuthorization(); + assert.ok(api); + + const cases = [ + ['owner', organizationA, organizationA, 'organization', 'organization.settings.manage'], + ['owner', organizationA, organizationA, 'billing-account', 'billing.account.manage'], + ['owner', organizationA, organizationA, 'device', 'device.identity.revoke'], + ['admin', organizationA, workspaceA, 'workspace', 'workspace.settings.manage'], + ['admin', workspaceA, projectA, 'project', 'project.record.manage'], + ['analyst', workspaceA, projectA, 'artifact', 'artifact.original.download'], + ['analyst', workspaceA, workspaceA, 'job', 'job.execution.create'], + ['operator', workspaceA, workspaceA, 'job', 'job.execution.run'], + ['approver', workspaceA, projectA, 'approval-request', 'approval.decision.create'], + ['viewer', workspaceA, projectA, 'artifact', 'artifact.record.read'], + ]; + + for (const [roleId, membershipScope, resourceScope, resourceType, permission] of cases) { + const evaluator = api.createScopedAuthorizationEvaluatorV1(); + const context = evaluatedContext(evaluator, { + roleId, + membershipScope, + resourceScope, + resourceType, + }); + assert.deepEqual(evaluator.authorizeV1(context, permission), { + allowed: true, + permission, + tenantScope: resourceScope, + }); + } +}); + +test('[IAM-002, IAM-003] deny-by-default covers role, action, channel, membership, and policy', async () => { + const api = await loadAuthorization(); + assert.ok(api); + + const cases = [ + ['custom-admin', 'api', true, true, 'artifact.record.read', 'UNKNOWN_ROLE'], + ['viewer', 'api', true, true, 'future.resource.read', 'UNKNOWN_PERMISSION'], + ['viewer', 'carrier-pigeon', true, true, 'artifact.record.read', 'UNKNOWN_CHANNEL'], + ['viewer', 'api', false, true, 'artifact.record.read', 'INACTIVE_MEMBERSHIP'], + ['viewer', 'api', true, false, 'artifact.record.read', 'POLICY_CONDITIONS_REQUIRED'], + ['viewer', 'api', true, true, 'artifact.original.download', 'ROLE_PERMISSION_MISSING'], + ['owner', 'api', true, true, 'approval.decision.create', 'ROLE_PERMISSION_MISSING'], + ]; + + for (const [ + roleId, + channel, + membershipActive, + policyConditionsSatisfied, + permission, + code, + ] of cases) { + const evaluator = api.createScopedAuthorizationEvaluatorV1(); + const context = evaluatedContext(evaluator, { + roleId, + membershipScope: workspaceA, + resourceScope: projectA, + resourceType: permission.startsWith('billing.') ? 'billing-account' : 'artifact', + channel, + membershipActive, + policyConditionsSatisfied, + }); + assert.deepEqual(evaluator.authorizeV1(context, permission), { allowed: false, code }); + } +}); + +test('[IAM-002, IAM-009, IAM-019] rejects cross-scope and wrong-resource authorization', async () => { + const api = await loadAuthorization(); + assert.ok(api); + + const cases = [ + [organizationB, projectA, 'TENANT_SCOPE_MISMATCH'], + [workspaceB, projectA, 'TENANT_SCOPE_MISMATCH'], + [projectA, projectB, 'TENANT_SCOPE_MISMATCH'], + ]; + for (const [membershipScope, resourceScope, code] of cases) { + const evaluator = api.createScopedAuthorizationEvaluatorV1(); + const context = evaluatedContext(evaluator, { + roleId: 'viewer', + membershipScope, + resourceScope, + resourceType: 'artifact', + }); + assert.deepEqual(evaluator.authorizeV1(context, 'artifact.record.read'), { + allowed: false, + code, + }); + } + + for (const [membershipScope, resourceScope] of [ + [organizationA, workspaceB], + [organizationA, projectB], + [workspaceA, projectB], + ]) { + const evaluator = api.createScopedAuthorizationEvaluatorV1(); + const context = evaluatedContext(evaluator, { + roleId: 'viewer', + membershipScope, + resourceScope, + resourceType: 'artifact', + }); + assert.deepEqual(evaluator.authorizeV1(context, 'artifact.record.read'), { + allowed: true, + permission: 'artifact.record.read', + tenantScope: resourceScope, + }); + } + + const evaluator = api.createScopedAuthorizationEvaluatorV1(); + const wrongResource = evaluatedContext(evaluator, { + roleId: 'analyst', + membershipScope: workspaceA, + resourceScope: workspaceA, + resourceType: 'job', + }); + assert.deepEqual(evaluator.authorizeV1(wrongResource, 'artifact.record.read'), { + allowed: false, + code: 'RESOURCE_TYPE_MISMATCH', + }); +}); + +test('[IAM-002, IAM-003] known channels and evaluator surface are immutable', async () => { + const api = await loadAuthorization(); + assert.ok(api); + + assert.deepEqual(api.AUTHORIZATION_CHANNELS_V1, [ + 'api', + 'web', + 'desktop', + 'android', + 'worker', + 'sync', + 'stream', + 'shared-link', + ]); + assert.ok(Object.isFrozen(api.AUTHORIZATION_CHANNELS_V1)); + assert.ok(Object.isFrozen(api.createScopedAuthorizationEvaluatorV1())); +}); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs new file mode 100644 index 00000000..a2337153 --- /dev/null +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; + +const [aggregate, permissions, tenantScope, authorization] = await Promise.all([ + import('@databreeze/domain/v1'), + import('@databreeze/domain/permissions/v1'), + import('@databreeze/domain/tenant-scope/v1'), + import('@databreeze/domain/authorization/v1'), +]); + +assert.equal(aggregate.PERMISSION_SCHEMA_VERSION_V1, 1); +assert.equal(aggregate.AUTHORIZATION_SCHEMA_VERSION_V1, 1); +assert.equal(permissions.PERMISSION_SCHEMA_VERSION_V1, 1); +assert.equal(typeof tenantScope.parseTenantScopeV1, 'function'); +assert.equal(typeof authorization.createScopedAuthorizationEvaluatorV1, 'function'); +await assert.rejects(import('@databreeze/domain'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); diff --git a/packages/domain/test/permissions-v1.test.mjs b/packages/domain/test/permissions-v1.test.mjs new file mode 100644 index 00000000..29d77445 --- /dev/null +++ b/packages/domain/test/permissions-v1.test.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +async function loadPermissions() { + try { + return await import('../src/permissions/v1.ts'); + } catch { + return undefined; + } +} + +test('[IAM-004] publishes a closed versioned permission vocabulary', async () => { + const api = await loadPermissions(); + + assert.ok(api, 'the permissions/v1 module must exist'); + assert.equal(api.PERMISSION_SCHEMA_VERSION_V1, 1); + assert.deepEqual(Object.values(api.PERMISSIONS_V1), [ + 'organization.profile.read', + 'organization.settings.manage', + 'organization.ownership.transfer', + 'workspace.settings.read', + 'workspace.settings.manage', + 'project.record.read', + 'project.record.manage', + 'artifact.record.read', + 'artifact.original.download', + 'artifact.derived.create', + 'job.execution.read', + 'job.execution.create', + 'job.execution.run', + 'job.execution.cancel', + 'approval.request.read', + 'approval.decision.create', + 'billing.account.read', + 'billing.account.manage', + 'device.identity.read', + 'device.identity.revoke', + ]); + assert.ok(Object.isFrozen(api.PERMISSIONS_V1)); +}); + +test('[IAM-004] maps exactly six immutable initial role bundles', async () => { + const api = await loadPermissions(); + assert.ok(api); + + assert.deepEqual(api.INITIAL_ROLE_IDS_V1, [ + 'owner', + 'admin', + 'analyst', + 'operator', + 'approver', + 'viewer', + ]); + + const expected = { + owner: [ + 'organization.profile.read', + 'organization.settings.manage', + 'organization.ownership.transfer', + 'workspace.settings.read', + 'workspace.settings.manage', + 'project.record.read', + 'project.record.manage', + 'job.execution.read', + 'billing.account.read', + 'billing.account.manage', + 'device.identity.read', + 'device.identity.revoke', + ], + admin: [ + 'organization.profile.read', + 'organization.settings.manage', + 'workspace.settings.read', + 'workspace.settings.manage', + 'project.record.read', + 'project.record.manage', + 'job.execution.read', + 'device.identity.read', + 'device.identity.revoke', + ], + analyst: [ + 'organization.profile.read', + 'workspace.settings.read', + 'project.record.read', + 'artifact.record.read', + 'artifact.original.download', + 'artifact.derived.create', + 'job.execution.read', + 'job.execution.create', + 'job.execution.run', + 'job.execution.cancel', + ], + operator: [ + 'organization.profile.read', + 'workspace.settings.read', + 'project.record.read', + 'artifact.record.read', + 'artifact.derived.create', + 'job.execution.read', + 'job.execution.run', + ], + approver: [ + 'organization.profile.read', + 'workspace.settings.read', + 'project.record.read', + 'artifact.record.read', + 'job.execution.read', + 'approval.request.read', + 'approval.decision.create', + ], + viewer: [ + 'organization.profile.read', + 'workspace.settings.read', + 'project.record.read', + 'artifact.record.read', + 'job.execution.read', + ], + }; + + assert.deepEqual( + Object.fromEntries( + Object.entries(api.INITIAL_ROLE_BUNDLES_V1).map(([roleId, bundle]) => [ + roleId, + bundle.permissions, + ]), + ), + expected, + ); + + assert.equal(api.INITIAL_ROLE_BUNDLES_V1.owner.name, 'Owner'); + assert.equal(api.INITIAL_ROLE_BUNDLES_V1.admin.name, 'Admin'); + assert.equal(api.INITIAL_ROLE_BUNDLES_V1.analyst.name, 'Analyst'); + assert.equal(api.INITIAL_ROLE_BUNDLES_V1.operator.name, 'Operator'); + assert.equal(api.INITIAL_ROLE_BUNDLES_V1.approver.name, 'Approver'); + assert.equal(api.INITIAL_ROLE_BUNDLES_V1.viewer.name, 'Viewer'); + assert.ok(Object.isFrozen(api.INITIAL_ROLE_IDS_V1)); + assert.ok(Object.isFrozen(api.INITIAL_ROLE_BUNDLES_V1)); + for (const bundle of Object.values(api.INITIAL_ROLE_BUNDLES_V1)) { + assert.ok(Object.isFrozen(bundle)); + assert.ok(Object.isFrozen(bundle.permissions)); + } +}); + +test('[IAM-004] role lookup denies unknown roles and permissions', async () => { + const api = await loadPermissions(); + assert.ok(api); + + assert.equal(api.roleHasPermissionV1('viewer', 'artifact.record.read'), true); + assert.equal(api.roleHasPermissionV1('viewer', 'billing.account.manage'), false); + assert.equal(api.roleHasPermissionV1('custom-admin', 'artifact.record.read'), false); + assert.equal(api.roleHasPermissionV1('owner', 'future.resource.read'), false); + assert.equal(api.isRoleIdV1('Owner'), false); + assert.equal(api.isPermissionV1('artifact.read'), false); +}); + +test('[IAM-003, IAM-004] administration roles do not bypass approval policy', async () => { + const api = await loadPermissions(); + assert.ok(api); + + assert.equal(api.roleHasPermissionV1('owner', 'approval.decision.create'), false); + assert.equal(api.roleHasPermissionV1('admin', 'approval.decision.create'), false); + assert.equal(api.roleHasPermissionV1('approver', 'approval.decision.create'), true); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs new file mode 100644 index 00000000..0b5c291b --- /dev/null +++ b/packages/domain/test/public-api-v1.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes only stable versioned entry points', async () => { + const manifest = JSON.parse(readFileSync(path.join(packageDirectory, 'package.json'), 'utf8')); + assert.deepEqual(Object.keys(manifest.exports), [ + './v1', + './permissions/v1', + './tenant-scope/v1', + './authorization/v1', + ]); + + for (const entry of Object.values(manifest.exports)) { + assert.ok(existsSync(path.resolve(packageDirectory, entry.types))); + assert.match(entry.import, /^\.\/dist\/.+\.js$/); + } + + let aggregate; + try { + aggregate = await import('../src/v1.ts'); + } catch { + aggregate = undefined; + } + assert.ok(aggregate, 'the source v1 aggregate must exist'); + assert.equal(aggregate.PERMISSION_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.AUTHORIZATION_SCHEMA_VERSION_V1, 1); + assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); + assert.equal(typeof aggregate.createScopedAuthorizationEvaluatorV1, 'function'); +}); + +test('[IAM-004] does not expose an unversioned package root', async () => { + await assert.rejects(import('@databreeze/domain'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); +}); diff --git a/packages/domain/test/public-api-v1.type-test.ts b/packages/domain/test/public-api-v1.type-test.ts new file mode 100644 index 00000000..84384c70 --- /dev/null +++ b/packages/domain/test/public-api-v1.type-test.ts @@ -0,0 +1,42 @@ +import { createScopedAuthorizationEvaluatorV1 } from '@databreeze/domain/authorization/v1'; +import type { + EvaluatedAuthorizationContextV1, + TrustedResourceOwnershipV1, + VerifiedTenantFilterV1, +} from '@databreeze/domain/authorization/v1'; +import type { + StableIdentifierV1, + StrictUtcTimestampV1, + TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +declare const scope: TenantScopeV1; +declare const stableId: StableIdentifierV1; +declare const evaluatedAt: StrictUtcTimestampV1; + +// These failures prove that a structurally matching client claim cannot satisfy a trusted API type. +// @ts-expect-error -- verified filters are minted only by an evaluator instance. +const forgedFilter: VerifiedTenantFilterV1 = { scope }; +// @ts-expect-error -- trusted ownership includes a private nominal brand. +const forgedResource: TrustedResourceOwnershipV1 = { + resourceType: 'artifact', + resourceId: stableId, + tenantScope: scope, +}; +// @ts-expect-error -- evaluated contexts include a private nominal brand. +const forgedContext: EvaluatedAuthorizationContextV1 = { + schemaVersion: 1, + principalId: stableId, + roleId: 'owner', + membershipScope: scope, + membershipActive: true, + channel: 'api', + policyConditionsSatisfied: true, + evaluatedAt, + resource: forgedResource, +}; + +const evaluator = createScopedAuthorizationEvaluatorV1(); +void evaluator; +void forgedFilter; +void forgedContext; diff --git a/packages/domain/test/tenant-scope-v1.test.mjs b/packages/domain/test/tenant-scope-v1.test.mjs new file mode 100644 index 00000000..24f1527b --- /dev/null +++ b/packages/domain/test/tenant-scope-v1.test.mjs @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +async function loadTenantScope() { + try { + return await import('../src/tenant-scope/v1.ts'); + } catch { + return undefined; + } +} + +const ids = Object.freeze({ + organizationA: '018f0f8c-7b77-7abc-8def-0123456789ab', + organizationB: '018f0f8c-7b77-7abc-9def-0123456789ac', + workspaceA: '11111111-1111-4111-8111-111111111111', + workspaceB: '11111111-1111-4111-8111-111111111112', + projectA: '22222222-2222-4222-8222-222222222222', + projectB: '33333333-3333-4333-8333-333333333333', +}); + +const organizationA = Object.freeze({ + scopeType: 'organization', + organizationId: ids.organizationA, +}); +const organizationB = Object.freeze({ + scopeType: 'organization', + organizationId: ids.organizationB, +}); +const workspaceA = Object.freeze({ + scopeType: 'workspace', + organizationId: ids.organizationA, + workspaceId: ids.workspaceA, +}); +const workspaceB = Object.freeze({ + scopeType: 'workspace', + organizationId: ids.organizationA, + workspaceId: ids.workspaceB, +}); +const projectA = Object.freeze({ + scopeType: 'project', + organizationId: ids.organizationA, + workspaceId: ids.workspaceA, + projectId: ids.projectA, +}); +const projectB = Object.freeze({ + scopeType: 'project', + organizationId: ids.organizationA, + workspaceId: ids.workspaceA, + projectId: ids.projectB, +}); + +function expectAccepted(result) { + assert.equal(result.accepted, true); + return result.value; +} + +test('[IAM-001] accepts only non-guessable UUIDv4/v7 identifiers and strict UTC timestamps', async () => { + const api = await loadTenantScope(); + assert.ok(api, 'the tenant-scope/v1 module must exist'); + + assert.equal(api.parseStableIdentifierV1(ids.organizationA).accepted, true); + assert.equal(api.parseStableIdentifierV1(ids.workspaceA).accepted, true); + assert.deepEqual(api.parseStableIdentifierV1('00000000-0000-0000-0000-000000000000'), { + accepted: false, + code: 'INVALID_IDENTIFIER', + }); + assert.deepEqual(api.parseStableIdentifierV1('6ba7b810-9dad-11d1-80b4-00c04fd430c8'), { + accepted: false, + code: 'INVALID_IDENTIFIER', + }); + assert.deepEqual(api.parseStableIdentifierV1(123), { + accepted: false, + code: 'INVALID_IDENTIFIER', + }); + + assert.equal(api.parseStrictUtcTimestampV1('2026-08-01T12:34:56.123Z').accepted, true); + assert.deepEqual(api.parseStrictUtcTimestampV1('2026-08-01T19:34:56+07:00'), { + accepted: false, + code: 'INVALID_UTC_TIMESTAMP', + }); + assert.deepEqual(api.parseStrictUtcTimestampV1('2026-08-01T12:34:56z'), { + accepted: false, + code: 'INVALID_UTC_TIMESTAMP', + }); +}); + +test('[IAM-019] parses only complete closed tenant ancestry', async () => { + const api = await loadTenantScope(); + assert.ok(api); + + for (const scope of [organizationA, workspaceA, projectA]) { + const parsed = expectAccepted(api.parseTenantScopeV1(scope)); + assert.deepEqual(parsed, scope); + assert.ok(Object.isFrozen(parsed)); + } + + for (const scope of [ + undefined, + { scopeType: 'workspace', workspaceId: ids.workspaceA }, + { + scopeType: 'project', + organizationId: ids.organizationA, + workspaceId: ids.workspaceA, + }, + { ...workspaceA, projectId: ids.projectA }, + ]) { + assert.deepEqual(api.parseTenantScopeV1(scope), { + accepted: false, + code: 'INVALID_TENANT_SCOPE', + }); + } +}); + +test('[IAM-019] equality and containment require complete matching ancestry', async () => { + const api = await loadTenantScope(); + assert.ok(api); + const orgA = expectAccepted(api.parseTenantScopeV1(organizationA)); + const orgB = expectAccepted(api.parseTenantScopeV1(organizationB)); + const wsA = expectAccepted(api.parseTenantScopeV1(workspaceA)); + const wsB = expectAccepted(api.parseTenantScopeV1(workspaceB)); + const projA = expectAccepted(api.parseTenantScopeV1(projectA)); + const projB = expectAccepted(api.parseTenantScopeV1(projectB)); + + assert.equal(api.tenantScopesEqualV1(wsA, wsA), true); + assert.equal(api.tenantScopesEqualV1(wsA, wsB), false); + assert.equal(api.tenantScopeContainsV1(orgA, wsA), true); + assert.equal(api.tenantScopeContainsV1(orgA, projA), true); + assert.equal(api.tenantScopeContainsV1(orgB, wsA), false); + assert.equal(api.tenantScopeContainsV1(wsA, projA), true); + assert.equal(api.tenantScopeContainsV1(wsA, projB), true); + assert.equal(api.tenantScopeContainsV1(wsA, wsB), false); + assert.equal(api.tenantScopeContainsV1(projA, projB), false); + assert.equal(api.tenantScopeContainsV1(projA, wsA), false); +}); + +test('[IAM-019] narrowing permits descendants but never parents or siblings', async () => { + const api = await loadTenantScope(); + assert.ok(api); + const orgA = expectAccepted(api.parseTenantScopeV1(organizationA)); + const wsA = expectAccepted(api.parseTenantScopeV1(workspaceA)); + const wsB = expectAccepted(api.parseTenantScopeV1(workspaceB)); + const projA = expectAccepted(api.parseTenantScopeV1(projectA)); + const projB = expectAccepted(api.parseTenantScopeV1(projectB)); + + assert.equal(api.narrowTenantScopeV1(orgA, wsA), wsA); + assert.equal(api.narrowTenantScopeV1(orgA, projA), projA); + assert.equal(api.narrowTenantScopeV1(wsA, projA), projA); + assert.equal(api.narrowTenantScopeV1(projA, projA), projA); + assert.equal(api.narrowTenantScopeV1(wsA, orgA), undefined); + assert.equal(api.narrowTenantScopeV1(wsA, wsB), undefined); + assert.equal(api.narrowTenantScopeV1(projA, projB), undefined); +}); + +test('[IAM-019] property: successful narrowing cannot broaden a scope', async () => { + const api = await loadTenantScope(); + assert.ok(api); + + const rawScopes = []; + for (let index = 1; index <= 16; index += 1) { + const organizationId = `${index.toString(16).padStart(8, '0')}-0000-4000-8000-000000000001`; + const workspaceId = `${index.toString(16).padStart(8, '0')}-0000-4000-8000-000000000002`; + const projectId = `${index.toString(16).padStart(8, '0')}-0000-4000-8000-000000000003`; + rawScopes.push( + { scopeType: 'organization', organizationId }, + { scopeType: 'workspace', organizationId, workspaceId }, + { scopeType: 'project', organizationId, workspaceId, projectId }, + ); + } + const scopes = rawScopes.map((scope) => expectAccepted(api.parseTenantScopeV1(scope))); + + for (const current of scopes) { + for (const candidate of scopes) { + const narrowed = api.narrowTenantScopeV1(current, candidate); + assert.equal(narrowed !== undefined, api.tenantScopeContainsV1(current, candidate)); + if (narrowed !== undefined) { + assert.equal(api.tenantScopesEqualV1(narrowed, candidate), true); + assert.equal(api.tenantScopeContainsV1(current, narrowed), true); + } + } + } +}); diff --git a/packages/domain/tsconfig.build.json b/packages/domain/tsconfig.build.json new file mode 100644 index 00000000..ffb43181 --- /dev/null +++ b/packages/domain/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "dist", + "rewriteRelativeImportExtensions": true, + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json new file mode 100644 index 00000000..28754dda --- /dev/null +++ b/packages/domain/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/domain/turbo.json b/packages/domain/turbo.json new file mode 100644 index 00000000..25e76c6b --- /dev/null +++ b/packages/domain/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test": { + "outputs": [] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f517398..8b22936d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,12 @@ importers: specifier: 3.0.1 version: 3.0.1(ajv@8.17.1) + packages/domain: + dependencies: + '@databreeze/contracts': + specifier: workspace:* + version: link:../contracts + packages/test-fixtures: {} tools/fixture-validation: From df2e1866639c85101d384ba46cab057e60b0391a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 06:46:13 +0700 Subject: [PATCH 17/51] fix(permissions): enforce authoritative authorization inputs --- packages/domain/README.md | 53 +- packages/domain/src/authorization/v1.ts | 552 ++++++++-------- packages/domain/src/permissions/v1.ts | 143 +++- .../domain/test/authorization-v1.test.mjs | 620 ++++++++++-------- .../test/permission-applicability-v1.test.mjs | 74 +++ packages/domain/test/permissions-v1.test.mjs | 8 + .../domain/test/public-api-v1.type-test.ts | 73 ++- 7 files changed, 908 insertions(+), 615 deletions(-) create mode 100644 packages/domain/test/permission-applicability-v1.test.mjs diff --git a/packages/domain/README.md b/packages/domain/README.md index c1965f12..25eaf982 100644 --- a/packages/domain/README.md +++ b/packages/domain/README.md @@ -8,13 +8,13 @@ service-implementation dependencies. All imports are explicitly versioned. There is intentionally no unversioned package root. - `@databreeze/domain/permissions/v1` publishes the closed version-1 permission vocabulary, - the six initial immutable role bundles, and deny-by-default lookup helpers. + the six initial immutable role bundles, explicit resource/channel applicability, and + deny-by-default lookup helpers. - `@databreeze/domain/tenant-scope/v1` publishes branded UUIDv4/UUIDv7 and UTC values, complete organization/workspace/project scopes, and equality, containment, and narrowing helpers. -- `@databreeze/domain/authorization/v1` publishes an instance-scoped evaluator for exact - tenant filters, trusted resource-lookup results, evaluated contexts, and authorization - decisions. +- `@databreeze/domain/authorization/v1` publishes a provider-bound evaluator for exact tenant + filters, authoritative resource/membership/policy resolution, and authorization decisions. - `@databreeze/domain/v1` aggregates the three version-1 interfaces. The package uses the public `@databreeze/contracts/v1` validator. It does not deep-import @@ -30,37 +30,40 @@ authorization decision. Owner materializes every Admin permission plus ownership-transfer and billing permissions. Neither Owner nor Admin receives `approval.decision.create`. Approval, retention, legal-hold, data-mode, device, entitlement, separation-of-duties, and recent-MFA conditions remain -independent policy gates. Callers must set `policyConditionsSatisfied` only after those -applicable policies have been authoritatively evaluated. +independent policy gates. Request consumers cannot submit those results; the authority provider +evaluates applicable policy from trusted application state. ## Trusted authorization flow -1. Parse a complete tenant scope from trusted application state. -2. Call `verifyTenantFilterV1` with that authority scope and the required request/repository - filter. Missing, optional, malformed, broader, narrower, or mismatched filters are rejected. -3. Perform the repository lookup with the verified exact filter. Pass only its minimal - server-side ownership tuple to `acceptTrustedResourceLookupV1`; never pass a request body, - route claim, cached UI value, or client-provided ownership object to this trust boundary. -4. Create an evaluated context from the trusted resource token, current membership result, - channel, role identifier, evaluation time, and policy outcome. -5. Call `authorizeV1`. Unknown roles, permissions, channels, foreign evaluator tokens, - inactive memberships, unmet policies, resource-type mismatch, and tenant-scope mismatch all - deny. - -Tokens are bound to the evaluator instance that created them. A structurally identical object -or a token created by another evaluator cannot establish trust. Clients may use published -permission bundles as display hints, but authoritative enforcement belongs to the server or -trusted worker using results from its own lookups. +1. At server composition, inject an `AuthorizationAuthorityProviderV1` whose methods are backed + by the authenticated principal, scoped repositories, membership store, and policy engine. +2. Give request handling only the frozen evaluator. Its sole method is `authorizeV1`; it has no + public filter, resource, membership, role, or policy minting API. +3. Submit only the permission, channel, complete tenant filter, and resource selector. Extra + request fields are rejected before any authority lookup. +4. The evaluator validates the permission's explicit resource/channel applicability, resolves + the authenticated principal, and sends the exact frozen tenant filter to the provider's + scoped resource lookup. +5. It validates the returned resource and intrinsic organization/workspace/project identity, + then resolves membership and policy internally. Unknown or inactive roles, unavailable or + malformed authority results, unmet policy, scope mismatch, and identity mismatch all deny. + +Provider methods are captured when the evaluator is created, so later mutation cannot replace +its authority. Provider results are always runtime-validated even when an adapter is typed. +Clients may use published permission bundles and applicability as display hints, but +authoritative enforcement belongs to a server or trusted worker with its own provider-backed +evaluator. ## Requirement traceability This package and its tests provide partial foundation coverage only: - `IAM-001`: branded UUIDv4/UUIDv7 identifiers and strict UTC timestamp parsing. -- `IAM-002`: pure action, channel, resource, and scope decision primitives. -- `IAM-003`: default denial and runtime/type-level rejection of untrusted claims. +- `IAM-002`: pure action, explicit channel/resource applicability, and scoped decision + primitives. +- `IAM-003`: default denial and runtime/type-level rejection of caller-supplied authority facts. - `IAM-004`: versioned permissions and exactly six immutable initial role bundles. -- `IAM-009`: exact scoped-lookup and trusted resource-ownership gates. +- `IAM-009`: exact provider-owned scoped lookup and resource-identity gates. - `IAM-019`: complete scope parsing, exact filters, ancestry containment, and non-broadening narrowing. diff --git a/packages/domain/src/authorization/v1.ts b/packages/domain/src/authorization/v1.ts index 2c38a527..225ccceb 100644 --- a/packages/domain/src/authorization/v1.ts +++ b/packages/domain/src/authorization/v1.ts @@ -1,47 +1,49 @@ import { + AUTHORIZATION_CHANNELS_V1, + PERMISSION_APPLICABILITY_V1, + RESOURCE_TYPES_V1, isPermissionV1, isRoleIdV1, roleHasPermissionV1, + type AuthorizationChannelV1, + type InitialRoleIdV1, type PermissionV1, + type ResourceTypeV1, } from '../permissions/v1.ts'; import { parseStableIdentifierV1, - parseStrictUtcTimestampV1, parseTenantScopeV1, tenantScopeContainsV1, tenantScopesEqualV1, type StableIdentifierV1, - type StrictUtcTimestampV1, type TenantScopeV1, } from '../tenant-scope/v1.ts'; +export { AUTHORIZATION_CHANNELS_V1 } from '../permissions/v1.ts'; +export type { AuthorizationChannelV1 } from '../permissions/v1.ts'; + /** Partial foundation coverage: IAM-002, IAM-003, IAM-004, IAM-009, and IAM-019. */ export const AUTHORIZATION_SCHEMA_VERSION_V1 = 1 as const; -export const AUTHORIZATION_CHANNELS_V1 = Object.freeze([ - 'api', - 'web', - 'desktop', - 'android', - 'worker', - 'sync', - 'stream', - 'shared-link', -] as const); - -export type AuthorizationChannelV1 = (typeof AUTHORIZATION_CHANNELS_V1)[number]; - export type AuthorizationDenialCodeV1 = + | 'AUTHORITY_INVALID' + | 'AUTHORITY_UNAVAILABLE' + | 'CHANNEL_NOT_ALLOWED' | 'INACTIVE_MEMBERSHIP' + | 'INVALID_AUTHORIZATION_REQUEST' + | 'INVALID_RESOURCE_SELECTOR' | 'POLICY_CONDITIONS_REQUIRED' + | 'RESOURCE_IDENTITY_MISMATCH' + | 'RESOURCE_OWNERSHIP_MISMATCH' | 'RESOURCE_TYPE_MISMATCH' | 'ROLE_PERMISSION_MISSING' + | 'TENANT_FILTER_INVALID' + | 'TENANT_FILTER_REQUIRED' | 'TENANT_SCOPE_MISMATCH' | 'UNKNOWN_CHANNEL' | 'UNKNOWN_PERMISSION' - | 'UNKNOWN_ROLE' - | 'UNTRUSTED_CONTEXT'; + | 'UNKNOWN_ROLE'; export type AuthorizationDecisionV1 = | { @@ -51,106 +53,77 @@ export type AuthorizationDecisionV1 = } | { readonly allowed: false; readonly code: AuthorizationDenialCodeV1 }; -declare const verifiedTenantFilterV1Brand: unique symbol; -declare const trustedResourceOwnershipV1Brand: unique symbol; -declare const evaluatedAuthorizationContextV1Brand: unique symbol; +export interface AuthorizationResourceSelectorV1 { + readonly resourceType: ResourceTypeV1; + readonly resourceId: StableIdentifierV1; +} -export interface VerifiedTenantFilterV1 { - readonly scope: TenantScopeV1; - readonly [verifiedTenantFilterV1Brand]: true; +/** The only request-controlled inputs accepted by the evaluator. */ +export interface AuthorizationRequestV1 { + readonly permission: PermissionV1; + readonly channel: AuthorizationChannelV1; + readonly tenantFilter: TenantScopeV1; + readonly resource: AuthorizationResourceSelectorV1; } -export interface TrustedResourceOwnershipV1 { - readonly resourceType: string; - readonly resourceId: StableIdentifierV1; +export interface AuthoritativeResourceV1 extends AuthorizationResourceSelectorV1 { readonly tenantScope: TenantScopeV1; - readonly [trustedResourceOwnershipV1Brand]: true; } -export interface EvaluatedAuthorizationContextV1 { - readonly schemaVersion: typeof AUTHORIZATION_SCHEMA_VERSION_V1; +export interface ScopedResourceLookupQueryV1 extends AuthorizationResourceSelectorV1 { + readonly tenantScope: TenantScopeV1; +} + +export interface MembershipResolutionQueryV1 { readonly principalId: StableIdentifierV1; - readonly roleId: string; + readonly resource: AuthoritativeResourceV1; +} + +export interface EvaluatedMembershipV1 { + readonly roleId: InitialRoleIdV1; readonly membershipScope: TenantScopeV1; - readonly membershipActive: boolean; - readonly channel: string; - readonly policyConditionsSatisfied: boolean; - readonly evaluatedAt: StrictUtcTimestampV1; - readonly resource: TrustedResourceOwnershipV1; - readonly [evaluatedAuthorizationContextV1Brand]: true; + readonly membershipActive: true; } -export type TenantFilterResultV1 = - | { readonly accepted: true; readonly value: VerifiedTenantFilterV1 } - | { - readonly accepted: false; - readonly code: - | 'INVALID_AUTHORITY_SCOPE' - | 'TENANT_FILTER_INVALID' - | 'TENANT_FILTER_MISMATCH' - | 'TENANT_FILTER_REQUIRED'; - }; - -export type ResourceOwnershipResultV1 = - | { readonly accepted: true; readonly value: TrustedResourceOwnershipV1 } - | { - readonly accepted: false; - readonly code: - | 'INVALID_RESOURCE_OWNERSHIP' - | 'RESOURCE_OWNERSHIP_MISMATCH' - | 'UNVERIFIED_TENANT_FILTER'; - }; - -export type EvaluatedContextResultV1 = - | { readonly accepted: true; readonly value: EvaluatedAuthorizationContextV1 } - | { - readonly accepted: false; - readonly code: 'INVALID_EVALUATED_CONTEXT' | 'UNTRUSTED_RESOURCE_OWNERSHIP'; - }; +export interface PolicyEvaluationQueryV1 { + readonly principalId: StableIdentifierV1; + readonly permission: PermissionV1; + readonly channel: AuthorizationChannelV1; + readonly membership: EvaluatedMembershipV1; + readonly resource: AuthoritativeResourceV1; +} + +export type AwaitableV1 = TValue | PromiseLike; + +/** + * Server-composed authority boundary. Implementations resolve every fact from authenticated, + * tenant-scoped application state; request bodies must never implement this port. + * + * Results remain `unknown` so runtime validation is mandatory even for typed adapters. + */ +export interface AuthorizationAuthorityProviderV1 { + readonly resolveAuthenticatedPrincipalV1: () => AwaitableV1; + readonly lookupResourceV1: (query: ScopedResourceLookupQueryV1) => AwaitableV1; + readonly resolveMembershipV1: (query: MembershipResolutionQueryV1) => AwaitableV1; + readonly evaluatePolicyV1: (query: PolicyEvaluationQueryV1) => AwaitableV1; +} export interface ScopedAuthorizationEvaluatorV1 { - readonly verifyTenantFilterV1: (authorityScope: unknown, filter: unknown) => TenantFilterResultV1; - /** Accept only the minimal ownership tuple returned by an authoritative scoped lookup. */ - readonly acceptTrustedResourceLookupV1: ( - filter: unknown, - lookupResult: unknown, - ) => ResourceOwnershipResultV1; - readonly createEvaluatedContextV1: (input: unknown) => EvaluatedContextResultV1; - readonly authorizeV1: (context: unknown, permission: unknown) => AuthorizationDecisionV1; + readonly authorizeV1: (request: unknown) => Promise; +} + +interface ParsedMembershipV1 { + readonly roleId: string; + readonly membershipScope: TenantScopeV1; + readonly membershipActive: boolean; } -type ResourceTypeV1 = - | 'approval-request' - | 'artifact' - | 'billing-account' - | 'device' - | 'job' - | 'organization' - | 'project' - | 'workspace'; - -const permissionResourceTypes: Readonly> = Object.freeze({ - 'organization.profile.read': 'organization', - 'organization.settings.manage': 'organization', - 'organization.ownership.transfer': 'organization', - 'workspace.settings.read': 'workspace', - 'workspace.settings.manage': 'workspace', - 'project.record.read': 'project', - 'project.record.manage': 'project', - 'artifact.record.read': 'artifact', - 'artifact.original.download': 'artifact', - 'artifact.derived.create': 'artifact', - 'job.execution.read': 'job', - 'job.execution.create': 'job', - 'job.execution.run': 'job', - 'job.execution.cancel': 'job', - 'approval.request.read': 'approval-request', - 'approval.decision.create': 'approval-request', - 'billing.account.read': 'billing-account', - 'billing.account.manage': 'billing-account', - 'device.identity.read': 'device', - 'device.identity.revoke': 'device', -}); +type ParsedValueV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false }; + +const authorizationChannelSet = new Set(AUTHORIZATION_CHANNELS_V1); +const resourceTypeSet = new Set(RESOURCE_TYPES_V1); const resourceScopeTypes: Readonly> = Object.freeze({ @@ -164,13 +137,6 @@ const resourceScopeTypes: Readonly(AUTHORIZATION_CHANNELS_V1); -const resourceTypePattern = /^[a-z][a-z0-9-]{0,62}$/; - -function isResourceTypeV1(input: string): input is ResourceTypeV1 { - return Object.hasOwn(resourceScopeTypes, input); -} - function isRecord(input: unknown): input is Record { return typeof input === 'object' && input !== null && !Array.isArray(input); } @@ -184,194 +150,264 @@ function hasExactKeys(input: Record, expectedKeys: readonly str ); } -function rejectFilter(code: Exclude['code']) { - return Object.freeze({ accepted: false as const, code }); +function isAuthorizationChannelV1(input: unknown): input is AuthorizationChannelV1 { + return typeof input === 'string' && authorizationChannelSet.has(input); } -function rejectResource(code: Exclude['code']) { - return Object.freeze({ accepted: false as const, code }); +function isResourceTypeV1(input: unknown): input is ResourceTypeV1 { + return typeof input === 'string' && resourceTypeSet.has(input); } -function rejectContext(code: Exclude['code']) { - return Object.freeze({ accepted: false as const, code }); +function accepted(value: TValue): ParsedValueV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(): ParsedValueV1 { + return Object.freeze({ accepted: false }); } function deny(code: AuthorizationDenialCodeV1): AuthorizationDecisionV1 { return Object.freeze({ allowed: false, code }); } -export function createScopedAuthorizationEvaluatorV1(): ScopedAuthorizationEvaluatorV1 { - const verifiedFilters = new WeakSet(); - const trustedResources = new WeakSet(); - const evaluatedContexts = new WeakSet(); +function parseResourceSelectorV1(input: unknown): ParsedValueV1 { + if (!isRecord(input) || !hasExactKeys(input, ['resourceId', 'resourceType'])) { + return rejected(); + } - function verifyTenantFilterV1(authorityScope: unknown, filter: unknown): TenantFilterResultV1 { - const parsedAuthority = parseTenantScopeV1(authorityScope); - if (!parsedAuthority.accepted) { - return rejectFilter('INVALID_AUTHORITY_SCOPE'); - } - if (filter === undefined || filter === null) { - return rejectFilter('TENANT_FILTER_REQUIRED'); - } + const resourceType = input['resourceType']; + const resourceId = parseStableIdentifierV1(input['resourceId']); + if (!isResourceTypeV1(resourceType) || !resourceId.accepted) { + return rejected(); + } - const parsedFilter = parseTenantScopeV1(filter); - if (!parsedFilter.accepted) { - return rejectFilter('TENANT_FILTER_INVALID'); - } - if (!tenantScopesEqualV1(parsedAuthority.value, parsedFilter.value)) { - return rejectFilter('TENANT_FILTER_MISMATCH'); - } + return accepted(Object.freeze({ resourceType, resourceId: resourceId.value })); +} - const verified = Object.freeze({ scope: parsedFilter.value }) as VerifiedTenantFilterV1; - verifiedFilters.add(verified); - return Object.freeze({ accepted: true, value: verified }); +function parsePrincipalV1(input: unknown): ParsedValueV1 { + if (!isRecord(input) || !hasExactKeys(input, ['principalId'])) { + return rejected(); } - function acceptTrustedResourceLookupV1( - filter: unknown, - lookupResult: unknown, - ): ResourceOwnershipResultV1 { - if (!isRecord(filter) || !verifiedFilters.has(filter)) { - return rejectResource('UNVERIFIED_TENANT_FILTER'); - } - const verifiedFilter = filter as unknown as VerifiedTenantFilterV1; - - if ( - !isRecord(lookupResult) || - !hasExactKeys(lookupResult, ['resourceId', 'resourceType', 'tenantScope']) - ) { - return rejectResource('INVALID_RESOURCE_OWNERSHIP'); - } + const principalId = parseStableIdentifierV1(input['principalId']); + return principalId.accepted ? accepted(principalId.value) : rejected(); +} - const resourceType = lookupResult['resourceType']; - if ( - typeof resourceType !== 'string' || - !resourceTypePattern.test(resourceType) || - !isResourceTypeV1(resourceType) - ) { - return rejectResource('INVALID_RESOURCE_OWNERSHIP'); - } +function parseAuthoritativeResourceV1(input: unknown): ParsedValueV1 { + if (!isRecord(input) || !hasExactKeys(input, ['resourceId', 'resourceType', 'tenantScope'])) { + return rejected(); + } - const resourceId = parseStableIdentifierV1(lookupResult['resourceId']); - const tenantScope = parseTenantScopeV1(lookupResult['tenantScope']); - if (!resourceId.accepted || !tenantScope.accepted) { - return rejectResource('INVALID_RESOURCE_OWNERSHIP'); - } - if (!tenantScopesEqualV1(verifiedFilter.scope, tenantScope.value)) { - return rejectResource('RESOURCE_OWNERSHIP_MISMATCH'); - } - if (!resourceScopeTypes[resourceType].includes(tenantScope.value.scopeType)) { - return rejectResource('INVALID_RESOURCE_OWNERSHIP'); - } + const resourceType = input['resourceType']; + const resourceId = parseStableIdentifierV1(input['resourceId']); + const tenantScope = parseTenantScopeV1(input['tenantScope']); + if (!isResourceTypeV1(resourceType) || !resourceId.accepted || !tenantScope.accepted) { + return rejected(); + } + if (!resourceScopeTypes[resourceType].includes(tenantScope.value.scopeType)) { + return rejected(); + } - const trusted = Object.freeze({ + return accepted( + Object.freeze({ resourceType, resourceId: resourceId.value, tenantScope: tenantScope.value, - }) as TrustedResourceOwnershipV1; - trustedResources.add(trusted); - return Object.freeze({ accepted: true, value: trusted }); + }), + ); +} + +function parseMembershipV1(input: unknown): ParsedValueV1 { + if (!isRecord(input) || !hasExactKeys(input, ['membershipActive', 'membershipScope', 'roleId'])) { + return rejected(); } - function createEvaluatedContextV1(input: unknown): EvaluatedContextResultV1 { - if ( - !isRecord(input) || - !hasExactKeys(input, [ - 'channel', - 'evaluatedAt', - 'membershipActive', - 'membershipScope', - 'policyConditionsSatisfied', - 'principalId', - 'resource', - 'roleId', - ]) - ) { - return rejectContext('INVALID_EVALUATED_CONTEXT'); - } - const resource = input['resource']; - if (!isRecord(resource) || !trustedResources.has(resource)) { - return rejectContext('UNTRUSTED_RESOURCE_OWNERSHIP'); - } + const roleId = input['roleId']; + const membershipActive = input['membershipActive']; + const membershipScope = parseTenantScopeV1(input['membershipScope']); + if ( + typeof roleId !== 'string' || + roleId.length === 0 || + typeof membershipActive !== 'boolean' || + !membershipScope.accepted + ) { + return rejected(); + } - const principalId = parseStableIdentifierV1(input['principalId']); - const membershipScope = parseTenantScopeV1(input['membershipScope']); - const evaluatedAt = parseStrictUtcTimestampV1(input['evaluatedAt']); - const roleId = input['roleId']; - const channel = input['channel']; - const membershipActive = input['membershipActive']; - const policyConditionsSatisfied = input['policyConditionsSatisfied']; - if ( - !principalId.accepted || - !membershipScope.accepted || - !evaluatedAt.accepted || - typeof roleId !== 'string' || - roleId.length === 0 || - typeof channel !== 'string' || - channel.length === 0 || - typeof membershipActive !== 'boolean' || - typeof policyConditionsSatisfied !== 'boolean' - ) { - return rejectContext('INVALID_EVALUATED_CONTEXT'); - } + return accepted( + Object.freeze({ roleId, membershipScope: membershipScope.value, membershipActive }), + ); +} - const context = Object.freeze({ - schemaVersion: AUTHORIZATION_SCHEMA_VERSION_V1, - principalId: principalId.value, - roleId, - membershipScope: membershipScope.value, - membershipActive, - channel, - policyConditionsSatisfied, - evaluatedAt: evaluatedAt.value, - resource: resource as unknown as TrustedResourceOwnershipV1, - }) as EvaluatedAuthorizationContextV1; - evaluatedContexts.add(context); - return Object.freeze({ accepted: true, value: context }); +function parsePolicyResultV1(input: unknown): ParsedValueV1 { + if (!isRecord(input) || !hasExactKeys(input, ['satisfied'])) { + return rejected(); } - function authorizeV1(context: unknown, permission: unknown): AuthorizationDecisionV1 { - if (!isRecord(context) || !evaluatedContexts.has(context)) { - return deny('UNTRUSTED_CONTEXT'); - } - const evaluated = context as unknown as EvaluatedAuthorizationContextV1; + return typeof input['satisfied'] === 'boolean' ? accepted(input['satisfied']) : rejected(); +} + +function resourceIdentityIsCoherentV1(resource: AuthoritativeResourceV1): boolean { + if (resource.resourceType === 'organization') { + return ( + resource.tenantScope.scopeType === 'organization' && + resource.resourceId === resource.tenantScope.organizationId + ); + } + if (resource.resourceType === 'workspace') { + return ( + resource.tenantScope.scopeType === 'workspace' && + resource.resourceId === resource.tenantScope.workspaceId + ); + } + if (resource.resourceType === 'project') { + return ( + resource.tenantScope.scopeType === 'project' && + resource.resourceId === resource.tenantScope.projectId + ); + } + + return true; +} + +function bindAuthorityMethodV1( + provider: AuthorizationAuthorityProviderV1, + key: TKey, +): AuthorizationAuthorityProviderV1[TKey] { + const method = provider[key]; + if (typeof method !== 'function') { + throw new TypeError(`Authorization authority provider is missing ${key}`); + } + + return method.bind(provider) as AuthorizationAuthorityProviderV1[TKey]; +} + +export function createScopedAuthorizationEvaluatorV1( + provider: AuthorizationAuthorityProviderV1, +): ScopedAuthorizationEvaluatorV1 { + const resolveAuthenticatedPrincipalV1 = bindAuthorityMethodV1( + provider, + 'resolveAuthenticatedPrincipalV1', + ); + const lookupResourceV1 = bindAuthorityMethodV1(provider, 'lookupResourceV1'); + const resolveMembershipV1 = bindAuthorityMethodV1(provider, 'resolveMembershipV1'); + const evaluatePolicyV1 = bindAuthorityMethodV1(provider, 'evaluatePolicyV1'); - if (!isRoleIdV1(evaluated.roleId)) { - return deny('UNKNOWN_ROLE'); + async function authorizeV1(request: unknown): Promise { + if (!isRecord(request)) { + return deny('INVALID_AUTHORIZATION_REQUEST'); } + if (!Object.hasOwn(request, 'tenantFilter') || request['tenantFilter'] == null) { + return deny('TENANT_FILTER_REQUIRED'); + } + if (!hasExactKeys(request, ['channel', 'permission', 'resource', 'tenantFilter'])) { + return deny('INVALID_AUTHORIZATION_REQUEST'); + } + + const permission = request['permission']; if (!isPermissionV1(permission)) { return deny('UNKNOWN_PERMISSION'); } - if (!authorizationChannelSet.has(evaluated.channel)) { + + const channel = request['channel']; + if (!isAuthorizationChannelV1(channel)) { return deny('UNKNOWN_CHANNEL'); } - if (!evaluated.membershipActive) { - return deny('INACTIVE_MEMBERSHIP'); + + const applicability = PERMISSION_APPLICABILITY_V1[permission]; + if (!applicability.allowedChannels.includes(channel)) { + return deny('CHANNEL_NOT_ALLOWED'); } - if (!evaluated.policyConditionsSatisfied) { - return deny('POLICY_CONDITIONS_REQUIRED'); + + const tenantFilter = parseTenantScopeV1(request['tenantFilter']); + if (!tenantFilter.accepted) { + return deny('TENANT_FILTER_INVALID'); } - if (!roleHasPermissionV1(evaluated.roleId, permission)) { - return deny('ROLE_PERMISSION_MISSING'); + + const resourceSelector = parseResourceSelectorV1(request['resource']); + if (!resourceSelector.accepted) { + return deny('INVALID_RESOURCE_SELECTOR'); } - if (permissionResourceTypes[permission] !== evaluated.resource.resourceType) { + if (resourceSelector.value.resourceType !== applicability.resourceType) { return deny('RESOURCE_TYPE_MISMATCH'); } - if (!tenantScopeContainsV1(evaluated.membershipScope, evaluated.resource.tenantScope)) { - return deny('TENANT_SCOPE_MISMATCH'); - } - return Object.freeze({ - allowed: true, - permission, - tenantScope: evaluated.resource.tenantScope, - }); + try { + const principal = parsePrincipalV1(await resolveAuthenticatedPrincipalV1()); + if (!principal.accepted) { + return deny('AUTHORITY_INVALID'); + } + + const lookupQuery: ScopedResourceLookupQueryV1 = Object.freeze({ + resourceType: resourceSelector.value.resourceType, + resourceId: resourceSelector.value.resourceId, + tenantScope: tenantFilter.value, + }); + const resource = parseAuthoritativeResourceV1(await lookupResourceV1(lookupQuery)); + if (!resource.accepted) { + return deny('AUTHORITY_INVALID'); + } + if ( + resource.value.resourceType !== resourceSelector.value.resourceType || + resource.value.resourceId !== resourceSelector.value.resourceId || + !tenantScopesEqualV1(resource.value.tenantScope, tenantFilter.value) + ) { + return deny('RESOURCE_OWNERSHIP_MISMATCH'); + } + if (!resourceIdentityIsCoherentV1(resource.value)) { + return deny('RESOURCE_IDENTITY_MISMATCH'); + } + + const membershipQuery: MembershipResolutionQueryV1 = Object.freeze({ + principalId: principal.value, + resource: resource.value, + }); + const membership = parseMembershipV1(await resolveMembershipV1(membershipQuery)); + if (!membership.accepted) { + return deny('AUTHORITY_INVALID'); + } + if (!isRoleIdV1(membership.value.roleId)) { + return deny('UNKNOWN_ROLE'); + } + if (!membership.value.membershipActive) { + return deny('INACTIVE_MEMBERSHIP'); + } + if (!tenantScopeContainsV1(membership.value.membershipScope, resource.value.tenantScope)) { + return deny('TENANT_SCOPE_MISMATCH'); + } + if (!roleHasPermissionV1(membership.value.roleId, permission)) { + return deny('ROLE_PERMISSION_MISSING'); + } + + const evaluatedMembership: EvaluatedMembershipV1 = Object.freeze({ + roleId: membership.value.roleId, + membershipScope: membership.value.membershipScope, + membershipActive: true, + }); + const policyQuery: PolicyEvaluationQueryV1 = Object.freeze({ + principalId: principal.value, + permission, + channel, + membership: evaluatedMembership, + resource: resource.value, + }); + const policy = parsePolicyResultV1(await evaluatePolicyV1(policyQuery)); + if (!policy.accepted) { + return deny('AUTHORITY_INVALID'); + } + if (!policy.value) { + return deny('POLICY_CONDITIONS_REQUIRED'); + } + + return Object.freeze({ + allowed: true, + permission, + tenantScope: resource.value.tenantScope, + }); + } catch { + return deny('AUTHORITY_UNAVAILABLE'); + } } - return Object.freeze({ - verifyTenantFilterV1, - acceptTrustedResourceLookupV1, - createEvaluatedContextV1, - authorizeV1, - }); + return Object.freeze({ authorizeV1 }); } diff --git a/packages/domain/src/permissions/v1.ts b/packages/domain/src/permissions/v1.ts index 9c9edbad..6ee72030 100644 --- a/packages/domain/src/permissions/v1.ts +++ b/packages/domain/src/permissions/v1.ts @@ -31,6 +31,124 @@ export const PERMISSIONS_V1 = Object.freeze({ export type PermissionV1 = (typeof PERMISSIONS_V1)[keyof typeof PERMISSIONS_V1]; +export const AUTHORIZATION_CHANNELS_V1 = Object.freeze([ + 'api', + 'web', + 'desktop', + 'android', + 'worker', + 'sync', + 'stream', + 'shared-link', +] as const); + +export type AuthorizationChannelV1 = (typeof AUTHORIZATION_CHANNELS_V1)[number]; + +export const RESOURCE_TYPES_V1 = Object.freeze([ + 'approval-request', + 'artifact', + 'billing-account', + 'device', + 'job', + 'organization', + 'project', + 'workspace', +] as const); + +export type ResourceTypeV1 = (typeof RESOURCE_TYPES_V1)[number]; + +export interface PermissionApplicabilityV1 { + readonly resourceType: ResourceTypeV1; + readonly allowedChannels: readonly AuthorizationChannelV1[]; +} + +function immutableApplicability( + resourceType: ResourceTypeV1, + allowedChannels: readonly AuthorizationChannelV1[], +): PermissionApplicabilityV1 { + return Object.freeze({ resourceType, allowedChannels: Object.freeze([...allowedChannels]) }); +} + +/** + * Closed transport applicability for every v1 permission. + * + * A permission being present in a role bundle never implies that it is valid on every channel. + * New permissions and channels require a new versioned entry instead of inheriting access. + */ +export const PERMISSION_APPLICABILITY_V1: Readonly< + Record +> = Object.freeze({ + 'organization.profile.read': immutableApplicability('organization', [ + 'api', + 'web', + 'desktop', + 'android', + ]), + 'organization.settings.manage': immutableApplicability('organization', ['api', 'web']), + 'organization.ownership.transfer': immutableApplicability('organization', ['api', 'web']), + 'workspace.settings.read': immutableApplicability('workspace', [ + 'api', + 'web', + 'desktop', + 'android', + 'worker', + ]), + 'workspace.settings.manage': immutableApplicability('workspace', ['api', 'web']), + 'project.record.read': immutableApplicability('project', [ + 'api', + 'web', + 'desktop', + 'android', + 'worker', + 'sync', + ]), + 'project.record.manage': immutableApplicability('project', ['api', 'web']), + 'artifact.record.read': immutableApplicability('artifact', [ + 'api', + 'web', + 'desktop', + 'android', + 'worker', + 'sync', + 'shared-link', + ]), + 'artifact.original.download': immutableApplicability('artifact', [ + 'api', + 'web', + 'desktop', + 'android', + ]), + 'artifact.derived.create': immutableApplicability('artifact', [ + 'api', + 'web', + 'desktop', + 'worker', + ]), + 'job.execution.read': immutableApplicability('job', [ + 'api', + 'web', + 'desktop', + 'android', + 'worker', + 'sync', + 'stream', + ]), + 'job.execution.create': immutableApplicability('job', ['api', 'web', 'desktop', 'worker']), + 'job.execution.run': immutableApplicability('job', ['api', 'web', 'desktop', 'worker']), + 'job.execution.cancel': immutableApplicability('job', ['api', 'web', 'desktop']), + 'approval.request.read': immutableApplicability('approval-request', [ + 'api', + 'web', + 'desktop', + 'android', + ]), + 'approval.decision.create': immutableApplicability('approval-request', ['api', 'web', 'android']), + 'billing.account.read': immutableApplicability('billing-account', ['api', 'web']), + 'billing.account.manage': immutableApplicability('billing-account', ['api', 'web']), + 'device.identity.read': immutableApplicability('device', ['api', 'web']), + 'device.identity.revoke': immutableApplicability('device', ['api', 'web']), +}); + export const INITIAL_ROLE_IDS_V1 = Object.freeze([ 'owner', 'admin', @@ -74,22 +192,19 @@ const adminPermissions = [ PERMISSIONS_V1.DEVICE_IDENTITY_REVOKE, ] as const; +const ownerPermissionSet = new Set([ + ...adminPermissions, + PERMISSIONS_V1.ORGANIZATION_OWNERSHIP_TRANSFER, + PERMISSIONS_V1.BILLING_ACCOUNT_READ, + PERMISSIONS_V1.BILLING_ACCOUNT_MANAGE, +]); +const ownerPermissions = Object.freeze( + Object.values(PERMISSIONS_V1).filter((permission) => ownerPermissionSet.has(permission)), +); + export const INITIAL_ROLE_BUNDLES_V1: Readonly> = Object.freeze({ - owner: immutableBundle('owner', 'Owner', [ - PERMISSIONS_V1.ORGANIZATION_PROFILE_READ, - PERMISSIONS_V1.ORGANIZATION_SETTINGS_MANAGE, - PERMISSIONS_V1.ORGANIZATION_OWNERSHIP_TRANSFER, - PERMISSIONS_V1.WORKSPACE_SETTINGS_READ, - PERMISSIONS_V1.WORKSPACE_SETTINGS_MANAGE, - PERMISSIONS_V1.PROJECT_RECORD_READ, - PERMISSIONS_V1.PROJECT_RECORD_MANAGE, - PERMISSIONS_V1.JOB_EXECUTION_READ, - PERMISSIONS_V1.BILLING_ACCOUNT_READ, - PERMISSIONS_V1.BILLING_ACCOUNT_MANAGE, - PERMISSIONS_V1.DEVICE_IDENTITY_READ, - PERMISSIONS_V1.DEVICE_IDENTITY_REVOKE, - ]), + owner: immutableBundle('owner', 'Owner', ownerPermissions), admin: immutableBundle('admin', 'Admin', adminPermissions), analyst: immutableBundle('analyst', 'Analyst', [ PERMISSIONS_V1.ORGANIZATION_PROFILE_READ, diff --git a/packages/domain/test/authorization-v1.test.mjs b/packages/domain/test/authorization-v1.test.mjs index ced1323e..434a0c83 100644 --- a/packages/domain/test/authorization-v1.test.mjs +++ b/packages/domain/test/authorization-v1.test.mjs @@ -1,13 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -async function loadAuthorization() { - try { - return await import('../src/authorization/v1.ts'); - } catch { - return undefined; - } -} +import { createScopedAuthorizationEvaluatorV1 } from '../src/authorization/v1.ts'; const ids = Object.freeze({ principal: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', @@ -18,6 +12,7 @@ const ids = Object.freeze({ projectA: '22222222-2222-4222-8222-222222222222', projectB: '33333333-3333-4333-8333-333333333333', resourceA: '44444444-4444-4444-8444-444444444444', + resourceB: '55555555-5555-4555-8555-555555555555', }); const organizationA = Object.freeze({ @@ -51,338 +46,389 @@ const projectB = Object.freeze({ projectId: ids.projectB, }); -function expectAccepted(result) { - assert.equal(result.accepted, true); - return result.value; -} - -function trustedResource(evaluator, tenantScope, resourceType) { - const filter = expectAccepted(evaluator.verifyTenantFilterV1(tenantScope, tenantScope)); - return expectAccepted( - evaluator.acceptTrustedResourceLookupV1(filter, { - resourceType, +function resourceFor(resourceType, overrides = {}) { + const defaults = { + organization: { + resourceType: 'organization', + resourceId: ids.organizationA, + tenantScope: organizationA, + }, + workspace: { + resourceType: 'workspace', + resourceId: ids.workspaceA, + tenantScope: workspaceA, + }, + project: { + resourceType: 'project', + resourceId: ids.projectA, + tenantScope: projectA, + }, + artifact: { + resourceType: 'artifact', resourceId: ids.resourceA, - tenantScope, - }), - ); + tenantScope: projectA, + }, + job: { + resourceType: 'job', + resourceId: ids.resourceA, + tenantScope: workspaceA, + }, + 'approval-request': { + resourceType: 'approval-request', + resourceId: ids.resourceA, + tenantScope: projectA, + }, + 'billing-account': { + resourceType: 'billing-account', + resourceId: ids.resourceA, + tenantScope: organizationA, + }, + device: { + resourceType: 'device', + resourceId: ids.resourceA, + tenantScope: organizationA, + }, + }; + return Object.freeze({ ...defaults[resourceType], ...overrides }); } -function evaluatedContext( - evaluator, - { - roleId, - membershipScope, - resourceScope, - resourceType, - channel = 'api', - membershipActive = true, - policyConditionsSatisfied = true, - }, -) { - return expectAccepted( - evaluator.createEvaluatedContextV1({ - principalId: ids.principal, - roleId, - membershipScope, - membershipActive, - channel, - policyConditionsSatisfied, - evaluatedAt: '2026-08-01T12:34:56Z', - resource: trustedResource(evaluator, resourceScope, resourceType), - }), - ); +function requestFor(permission, channel, resource, overrides = {}) { + return { + permission, + channel, + tenantFilter: resource.tenantScope, + resource: { + resourceType: resource.resourceType, + resourceId: resource.resourceId, + }, + ...overrides, + }; } -test('[IAM-009, IAM-019] exact required tenant filters gate trusted lookup results', async () => { - const api = await loadAuthorization(); - assert.ok(api, 'the authorization/v1 module must exist'); - const evaluator = api.createScopedAuthorizationEvaluatorV1(); +function authorityProvider({ + principalId = ids.principal, + resource = resourceFor('artifact'), + roleId = 'viewer', + membershipScope = resource.tenantScope, + membershipActive = true, + policyConditionsSatisfied = true, + principalResult, + membershipResult, + policyResult, + throwFrom, +} = {}) { + const calls = { + principal: 0, + lookup: 0, + membership: 0, + policy: 0, + lookupQuery: undefined, + }; - assert.deepEqual(evaluator.verifyTenantFilterV1(projectA, undefined), { - accepted: false, - code: 'TENANT_FILTER_REQUIRED', - }); - assert.deepEqual(evaluator.verifyTenantFilterV1(projectA, { ...projectA, extra: true }), { - accepted: false, - code: 'TENANT_FILTER_INVALID', - }); - assert.deepEqual(evaluator.verifyTenantFilterV1(projectA, workspaceA), { - accepted: false, - code: 'TENANT_FILTER_MISMATCH', + const provider = Object.freeze({ + async resolveAuthenticatedPrincipalV1() { + calls.principal += 1; + if (throwFrom === 'principal') throw new Error('principal unavailable'); + return principalResult ?? { principalId }; + }, + async lookupResourceV1(query) { + calls.lookup += 1; + calls.lookupQuery = query; + if (throwFrom === 'lookup') throw new Error('lookup unavailable'); + return resource; + }, + async resolveMembershipV1() { + calls.membership += 1; + if (throwFrom === 'membership') throw new Error('membership unavailable'); + return membershipResult ?? { roleId, membershipScope, membershipActive }; + }, + async evaluatePolicyV1() { + calls.policy += 1; + if (throwFrom === 'policy') throw new Error('policy unavailable'); + return policyResult ?? { satisfied: policyConditionsSatisfied }; + }, }); + + return { provider, calls }; +} + +test('[IAM-002, IAM-003] evaluator owns authority and exposes no caller minting API', async () => { + const billing = resourceFor('billing-account'); + const { provider, calls } = authorityProvider({ resource: billing, roleId: 'owner' }); + const evaluator = createScopedAuthorizationEvaluatorV1(provider); + + assert.deepEqual(Object.keys(evaluator), ['authorizeV1']); assert.deepEqual( - evaluator.verifyTenantFilterV1(projectA, { ...projectA, organizationId: ids.organizationB }), - { accepted: false, code: 'TENANT_FILTER_MISMATCH' }, + await evaluator.authorizeV1(requestFor('billing.account.manage', 'web', billing)), + { + allowed: true, + permission: 'billing.account.manage', + tenantScope: organizationA, + }, ); - const verified = expectAccepted(evaluator.verifyTenantFilterV1(projectA, projectA)); - assert.ok(Object.isFrozen(verified)); + const callsBeforeExploit = { ...calls }; + const fabricatedOwnerRequest = requestFor('billing.account.manage', 'web', billing, { + roleId: 'owner', + membershipActive: true, + membershipScope: organizationA, + policyConditionsSatisfied: true, + resourceOwnership: billing, + }); + assert.deepEqual(await evaluator.authorizeV1(fabricatedOwnerRequest), { + allowed: false, + code: 'INVALID_AUTHORIZATION_REQUEST', + }); + assert.deepEqual(calls, callsBeforeExploit); +}); + +test('[IAM-002, IAM-003] provider facts cannot be overridden by request claims', async () => { + const billing = resourceFor('billing-account'); + const viewerAuthority = authorityProvider({ resource: billing, roleId: 'viewer' }); + const viewerEvaluator = createScopedAuthorizationEvaluatorV1(viewerAuthority.provider); assert.deepEqual( - evaluator.acceptTrustedResourceLookupV1(projectA, { - resourceType: 'artifact', - resourceId: ids.resourceA, - tenantScope: projectA, - }), - { accepted: false, code: 'UNVERIFIED_TENANT_FILTER' }, + await viewerEvaluator.authorizeV1(requestFor('billing.account.manage', 'web', billing)), + { allowed: false, code: 'ROLE_PERMISSION_MISSING' }, ); - const foreignEvaluator = api.createScopedAuthorizationEvaluatorV1(); + const blockedOwnerAuthority = authorityProvider({ + resource: billing, + roleId: 'owner', + policyConditionsSatisfied: false, + }); + const blockedOwnerEvaluator = createScopedAuthorizationEvaluatorV1( + blockedOwnerAuthority.provider, + ); assert.deepEqual( - foreignEvaluator.acceptTrustedResourceLookupV1(verified, { - resourceType: 'artifact', - resourceId: ids.resourceA, - tenantScope: projectA, - }), - { accepted: false, code: 'UNVERIFIED_TENANT_FILTER' }, + await blockedOwnerEvaluator.authorizeV1(requestFor('billing.account.manage', 'web', billing)), + { allowed: false, code: 'POLICY_CONDITIONS_REQUIRED' }, ); }); -test('[IAM-009, IAM-019] trusted lookup rejects cross-tenant and incomplete ownership', async () => { - const api = await loadAuthorization(); - assert.ok(api); - const evaluator = api.createScopedAuthorizationEvaluatorV1(); - const verified = expectAccepted(evaluator.verifyTenantFilterV1(projectA, projectA)); +test('[IAM-009, IAM-019] exact tenant filters are enforced before authoritative lookup', async () => { + const artifact = resourceFor('artifact'); + const { provider, calls } = authorityProvider({ resource: artifact }); + const evaluator = createScopedAuthorizationEvaluatorV1(provider); - for (const tenantScope of [organizationB, workspaceB, projectB]) { - assert.deepEqual( - evaluator.acceptTrustedResourceLookupV1(verified, { - resourceType: 'artifact', - resourceId: ids.resourceA, - tenantScope, + const missingFilter = requestFor('artifact.record.read', 'web', artifact); + delete missingFilter.tenantFilter; + assert.deepEqual(await evaluator.authorizeV1(missingFilter), { + allowed: false, + code: 'TENANT_FILTER_REQUIRED', + }); + assert.equal(calls.lookup, 0); + + assert.deepEqual( + await evaluator.authorizeV1( + requestFor('artifact.record.read', 'web', artifact, { + tenantFilter: { ...projectA, optionalWorkspaceId: undefined }, }), - { accepted: false, code: 'RESOURCE_OWNERSHIP_MISMATCH' }, - ); - } + ), + { allowed: false, code: 'TENANT_FILTER_INVALID' }, + ); + assert.equal(calls.lookup, 0); assert.deepEqual( - evaluator.acceptTrustedResourceLookupV1(verified, { - resourceType: 'artifact', - resourceId: ids.resourceA, - tenantScope: { - scopeType: 'project', - organizationId: ids.organizationA, - projectId: ids.projectA, - }, - }), - { accepted: false, code: 'INVALID_RESOURCE_OWNERSHIP' }, + await evaluator.authorizeV1( + requestFor('artifact.record.read', 'web', artifact, { tenantFilter: projectB }), + ), + { allowed: false, code: 'RESOURCE_OWNERSHIP_MISMATCH' }, ); + assert.equal(calls.lookup, 1); + assert.ok(Object.isFrozen(calls.lookupQuery)); + assert.deepEqual(calls.lookupQuery.tenantScope, projectB); }); -test('[IAM-009, IAM-019] resource types require their complete applicable scope', async () => { - const api = await loadAuthorization(); - assert.ok(api); +test('[IAM-009, IAM-019] organization, workspace, and project identities match their ancestry', async () => { + const cases = [ + resourceFor('organization', { resourceId: ids.resourceB }), + resourceFor('workspace', { resourceId: ids.resourceB }), + resourceFor('project', { resourceId: ids.resourceB }), + ]; + for (const resource of cases) { + const { provider } = authorityProvider({ resource, roleId: 'viewer' }); + const evaluator = createScopedAuthorizationEvaluatorV1(provider); + const permission = { + organization: 'organization.profile.read', + workspace: 'workspace.settings.read', + project: 'project.record.read', + }[resource.resourceType]; + assert.deepEqual(await evaluator.authorizeV1(requestFor(permission, 'web', resource)), { + allowed: false, + code: 'RESOURCE_IDENTITY_MISMATCH', + }); + } +}); + +test('[IAM-002, IAM-003] unknown and inactive authoritative facts deny by default', async () => { + const artifact = resourceFor('artifact'); const cases = [ - ['artifact', organizationA], - ['job', organizationA], - ['approval-request', organizationA], - ['workspace', projectA], - ['project', workspaceA], - ['billing-account', workspaceA], - ['device', workspaceA], - ['future-resource', projectA], + [{ roleId: 'custom-admin' }, 'artifact.record.read', 'web', 'UNKNOWN_ROLE'], + [{}, 'future.resource.read', 'web', 'UNKNOWN_PERMISSION'], + [{}, 'artifact.record.read', 'carrier-pigeon', 'UNKNOWN_CHANNEL'], + [{ membershipActive: false }, 'artifact.record.read', 'web', 'INACTIVE_MEMBERSHIP'], ]; - for (const [resourceType, tenantScope] of cases) { - const evaluator = api.createScopedAuthorizationEvaluatorV1(); - const verified = expectAccepted(evaluator.verifyTenantFilterV1(tenantScope, tenantScope)); - assert.deepEqual( - evaluator.acceptTrustedResourceLookupV1(verified, { - resourceType, - resourceId: ids.resourceA, - tenantScope, - }), - { accepted: false, code: 'INVALID_RESOURCE_OWNERSHIP' }, - ); + for (const [authorityOverrides, permission, channel, code] of cases) { + const { provider } = authorityProvider({ resource: artifact, ...authorityOverrides }); + const evaluator = createScopedAuthorizationEvaluatorV1(provider); + assert.deepEqual(await evaluator.authorizeV1(requestFor(permission, channel, artifact)), { + allowed: false, + code, + }); } }); -test('[IAM-002, IAM-003] plain or foreign client claims never become authorization context', async () => { - const api = await loadAuthorization(); - assert.ok(api); - const evaluator = api.createScopedAuthorizationEvaluatorV1(); - const foreignEvaluator = api.createScopedAuthorizationEvaluatorV1(); - const rawResourceClaim = { - resourceType: 'artifact', - resourceId: ids.resourceA, - tenantScope: projectA, - }; - const contextInput = { - principalId: ids.principal, - roleId: 'owner', - membershipScope: organizationA, - membershipActive: true, - channel: 'api', - policyConditionsSatisfied: true, - evaluatedAt: '2026-08-01T12:34:56Z', - resource: rawResourceClaim, +test('[IAM-002, IAM-003] every permission is restricted to its explicit channels', async () => { + const cases = { + 'organization.profile.read': ['viewer', 'organization', ['api', 'web', 'desktop', 'android']], + 'organization.settings.manage': ['admin', 'organization', ['api', 'web']], + 'organization.ownership.transfer': ['owner', 'organization', ['api', 'web']], + 'workspace.settings.read': [ + 'viewer', + 'workspace', + ['api', 'web', 'desktop', 'android', 'worker'], + ], + 'workspace.settings.manage': ['admin', 'workspace', ['api', 'web']], + 'project.record.read': [ + 'viewer', + 'project', + ['api', 'web', 'desktop', 'android', 'worker', 'sync'], + ], + 'project.record.manage': ['admin', 'project', ['api', 'web']], + 'artifact.record.read': [ + 'viewer', + 'artifact', + ['api', 'web', 'desktop', 'android', 'worker', 'sync', 'shared-link'], + ], + 'artifact.original.download': ['analyst', 'artifact', ['api', 'web', 'desktop', 'android']], + 'artifact.derived.create': ['analyst', 'artifact', ['api', 'web', 'desktop', 'worker']], + 'job.execution.read': [ + 'viewer', + 'job', + ['api', 'web', 'desktop', 'android', 'worker', 'sync', 'stream'], + ], + 'job.execution.create': ['analyst', 'job', ['api', 'web', 'desktop', 'worker']], + 'job.execution.run': ['operator', 'job', ['api', 'web', 'desktop', 'worker']], + 'job.execution.cancel': ['analyst', 'job', ['api', 'web', 'desktop']], + 'approval.request.read': ['approver', 'approval-request', ['api', 'web', 'desktop', 'android']], + 'approval.decision.create': ['approver', 'approval-request', ['api', 'web', 'android']], + 'billing.account.read': ['owner', 'billing-account', ['api', 'web']], + 'billing.account.manage': ['owner', 'billing-account', ['api', 'web']], + 'device.identity.read': ['admin', 'device', ['api', 'web']], + 'device.identity.revoke': ['admin', 'device', ['api', 'web']], }; + const allChannels = [ + 'api', + 'web', + 'desktop', + 'android', + 'worker', + 'sync', + 'stream', + 'shared-link', + ]; - assert.deepEqual(evaluator.createEvaluatedContextV1(contextInput), { - accepted: false, - code: 'UNTRUSTED_RESOURCE_OWNERSHIP', - }); - assert.deepEqual( - evaluator.createEvaluatedContextV1({ - ...contextInput, - resource: trustedResource(evaluator, projectA, 'artifact'), - clientTenantClaim: projectA, - }), - { accepted: false, code: 'INVALID_EVALUATED_CONTEXT' }, - ); - assert.deepEqual(evaluator.authorizeV1(contextInput, 'artifact.record.read'), { - allowed: false, - code: 'UNTRUSTED_CONTEXT', - }); + for (const [permission, [roleId, resourceType, allowedChannels]] of Object.entries(cases)) { + for (const channel of allChannels) { + const resource = resourceFor(resourceType); + const { provider } = authorityProvider({ + resource, + roleId, + membershipScope: organizationA, + }); + const evaluator = createScopedAuthorizationEvaluatorV1(provider); + const decision = await evaluator.authorizeV1(requestFor(permission, channel, resource)); - const foreignResource = trustedResource(foreignEvaluator, projectA, 'artifact'); - assert.deepEqual( - evaluator.createEvaluatedContextV1({ ...contextInput, resource: foreignResource }), - { - accepted: false, - code: 'UNTRUSTED_RESOURCE_OWNERSHIP', - }, - ); + if (allowedChannels.includes(channel)) { + assert.deepEqual(decision, { + allowed: true, + permission, + tenantScope: resource.tenantScope, + }); + } else { + assert.deepEqual(decision, { allowed: false, code: 'CHANNEL_NOT_ALLOWED' }); + } + } + } }); -test('[IAM-002, IAM-003, IAM-004] authorizes the representative six-role matrix only in scope', async () => { - const api = await loadAuthorization(); - assert.ok(api); - +test('[IAM-002, IAM-003, IAM-004] representative actions use the six authoritative roles', async () => { const cases = [ - ['owner', organizationA, organizationA, 'organization', 'organization.settings.manage'], - ['owner', organizationA, organizationA, 'billing-account', 'billing.account.manage'], - ['owner', organizationA, organizationA, 'device', 'device.identity.revoke'], - ['admin', organizationA, workspaceA, 'workspace', 'workspace.settings.manage'], - ['admin', workspaceA, projectA, 'project', 'project.record.manage'], - ['analyst', workspaceA, projectA, 'artifact', 'artifact.original.download'], - ['analyst', workspaceA, workspaceA, 'job', 'job.execution.create'], - ['operator', workspaceA, workspaceA, 'job', 'job.execution.run'], - ['approver', workspaceA, projectA, 'approval-request', 'approval.decision.create'], - ['viewer', workspaceA, projectA, 'artifact', 'artifact.record.read'], + ['owner', 'organization', 'organization.settings.manage'], + ['owner', 'billing-account', 'billing.account.manage'], + ['owner', 'device', 'device.identity.revoke'], + ['admin', 'workspace', 'workspace.settings.manage'], + ['admin', 'project', 'project.record.manage'], + ['analyst', 'artifact', 'artifact.original.download'], + ['analyst', 'job', 'job.execution.create'], + ['operator', 'job', 'job.execution.run'], + ['approver', 'approval-request', 'approval.decision.create'], + ['viewer', 'artifact', 'artifact.record.read'], ]; - for (const [roleId, membershipScope, resourceScope, resourceType, permission] of cases) { - const evaluator = api.createScopedAuthorizationEvaluatorV1(); - const context = evaluatedContext(evaluator, { + for (const [roleId, resourceType, permission] of cases) { + const resource = resourceFor(resourceType); + const { provider } = authorityProvider({ + resource, roleId, - membershipScope, - resourceScope, - resourceType, + membershipScope: organizationA, }); - assert.deepEqual(evaluator.authorizeV1(context, permission), { + const evaluator = createScopedAuthorizationEvaluatorV1(provider); + assert.deepEqual(await evaluator.authorizeV1(requestFor(permission, 'web', resource)), { allowed: true, permission, - tenantScope: resourceScope, + tenantScope: resource.tenantScope, }); } }); -test('[IAM-002, IAM-003] deny-by-default covers role, action, channel, membership, and policy', async () => { - const api = await loadAuthorization(); - assert.ok(api); - - const cases = [ - ['custom-admin', 'api', true, true, 'artifact.record.read', 'UNKNOWN_ROLE'], - ['viewer', 'api', true, true, 'future.resource.read', 'UNKNOWN_PERMISSION'], - ['viewer', 'carrier-pigeon', true, true, 'artifact.record.read', 'UNKNOWN_CHANNEL'], - ['viewer', 'api', false, true, 'artifact.record.read', 'INACTIVE_MEMBERSHIP'], - ['viewer', 'api', true, false, 'artifact.record.read', 'POLICY_CONDITIONS_REQUIRED'], - ['viewer', 'api', true, true, 'artifact.original.download', 'ROLE_PERMISSION_MISSING'], - ['owner', 'api', true, true, 'approval.decision.create', 'ROLE_PERMISSION_MISSING'], - ]; +test('[IAM-002, IAM-009, IAM-019] authoritative memberships cannot expand tenant scope', async () => { + const artifact = resourceFor('artifact'); + const deniedScopes = [organizationB, workspaceB, projectB]; + for (const membershipScope of deniedScopes) { + const { provider } = authorityProvider({ resource: artifact, membershipScope }); + const evaluator = createScopedAuthorizationEvaluatorV1(provider); + assert.deepEqual( + await evaluator.authorizeV1(requestFor('artifact.record.read', 'web', artifact)), + { allowed: false, code: 'TENANT_SCOPE_MISMATCH' }, + ); + } - for (const [ - roleId, - channel, - membershipActive, - policyConditionsSatisfied, - permission, - code, - ] of cases) { - const evaluator = api.createScopedAuthorizationEvaluatorV1(); - const context = evaluatedContext(evaluator, { - roleId, - membershipScope: workspaceA, - resourceScope: projectA, - resourceType: permission.startsWith('billing.') ? 'billing-account' : 'artifact', - channel, - membershipActive, - policyConditionsSatisfied, - }); - assert.deepEqual(evaluator.authorizeV1(context, permission), { allowed: false, code }); + for (const membershipScope of [organizationA, workspaceA, projectA]) { + const { provider } = authorityProvider({ resource: artifact, membershipScope }); + const evaluator = createScopedAuthorizationEvaluatorV1(provider); + assert.equal( + (await evaluator.authorizeV1(requestFor('artifact.record.read', 'web', artifact))).allowed, + true, + ); } }); -test('[IAM-002, IAM-009, IAM-019] rejects cross-scope and wrong-resource authorization', async () => { - const api = await loadAuthorization(); - assert.ok(api); - - const cases = [ - [organizationB, projectA, 'TENANT_SCOPE_MISMATCH'], - [workspaceB, projectA, 'TENANT_SCOPE_MISMATCH'], - [projectA, projectB, 'TENANT_SCOPE_MISMATCH'], - ]; - for (const [membershipScope, resourceScope, code] of cases) { - const evaluator = api.createScopedAuthorizationEvaluatorV1(); - const context = evaluatedContext(evaluator, { - roleId: 'viewer', - membershipScope, - resourceScope, - resourceType: 'artifact', - }); - assert.deepEqual(evaluator.authorizeV1(context, 'artifact.record.read'), { - allowed: false, - code, - }); +test('[IAM-002, IAM-003] authority failures and malformed results fail closed', async () => { + const artifact = resourceFor('artifact'); + for (const throwFrom of ['principal', 'lookup', 'membership', 'policy']) { + const { provider } = authorityProvider({ resource: artifact, throwFrom }); + const evaluator = createScopedAuthorizationEvaluatorV1(provider); + assert.deepEqual( + await evaluator.authorizeV1(requestFor('artifact.record.read', 'web', artifact)), + { allowed: false, code: 'AUTHORITY_UNAVAILABLE' }, + ); } - for (const [membershipScope, resourceScope] of [ - [organizationA, workspaceB], - [organizationA, projectB], - [workspaceA, projectB], - ]) { - const evaluator = api.createScopedAuthorizationEvaluatorV1(); - const context = evaluatedContext(evaluator, { - roleId: 'viewer', - membershipScope, - resourceScope, - resourceType: 'artifact', - }); - assert.deepEqual(evaluator.authorizeV1(context, 'artifact.record.read'), { - allowed: true, - permission: 'artifact.record.read', - tenantScope: resourceScope, - }); + const malformedAuthorities = [ + { principalResult: { principalId: 'request-user' } }, + { resource: { ...artifact, clientSuppliedOwner: true } }, + { membershipResult: { roleId: 'viewer', membershipScope: projectA } }, + { policyResult: { satisfied: 'yes' } }, + ]; + for (const overrides of malformedAuthorities) { + const { provider } = authorityProvider({ ...overrides, roleId: 'viewer' }); + const evaluator = createScopedAuthorizationEvaluatorV1(provider); + assert.deepEqual( + await evaluator.authorizeV1(requestFor('artifact.record.read', 'web', artifact)), + { allowed: false, code: 'AUTHORITY_INVALID' }, + ); } - - const evaluator = api.createScopedAuthorizationEvaluatorV1(); - const wrongResource = evaluatedContext(evaluator, { - roleId: 'analyst', - membershipScope: workspaceA, - resourceScope: workspaceA, - resourceType: 'job', - }); - assert.deepEqual(evaluator.authorizeV1(wrongResource, 'artifact.record.read'), { - allowed: false, - code: 'RESOURCE_TYPE_MISMATCH', - }); -}); - -test('[IAM-002, IAM-003] known channels and evaluator surface are immutable', async () => { - const api = await loadAuthorization(); - assert.ok(api); - - assert.deepEqual(api.AUTHORIZATION_CHANNELS_V1, [ - 'api', - 'web', - 'desktop', - 'android', - 'worker', - 'sync', - 'stream', - 'shared-link', - ]); - assert.ok(Object.isFrozen(api.AUTHORIZATION_CHANNELS_V1)); - assert.ok(Object.isFrozen(api.createScopedAuthorizationEvaluatorV1())); }); diff --git a/packages/domain/test/permission-applicability-v1.test.mjs b/packages/domain/test/permission-applicability-v1.test.mjs new file mode 100644 index 00000000..510594f9 --- /dev/null +++ b/packages/domain/test/permission-applicability-v1.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as api from '../src/permissions/v1.ts'; + +const { AUTHORIZATION_CHANNELS_V1, PERMISSION_APPLICABILITY_V1, PERMISSIONS_V1 } = api; + +const expectedChannels = Object.freeze({ + 'organization.profile.read': ['api', 'web', 'desktop', 'android'], + 'organization.settings.manage': ['api', 'web'], + 'organization.ownership.transfer': ['api', 'web'], + 'workspace.settings.read': ['api', 'web', 'desktop', 'android', 'worker'], + 'workspace.settings.manage': ['api', 'web'], + 'project.record.read': ['api', 'web', 'desktop', 'android', 'worker', 'sync'], + 'project.record.manage': ['api', 'web'], + 'artifact.record.read': ['api', 'web', 'desktop', 'android', 'worker', 'sync', 'shared-link'], + 'artifact.original.download': ['api', 'web', 'desktop', 'android'], + 'artifact.derived.create': ['api', 'web', 'desktop', 'worker'], + 'job.execution.read': ['api', 'web', 'desktop', 'android', 'worker', 'sync', 'stream'], + 'job.execution.create': ['api', 'web', 'desktop', 'worker'], + 'job.execution.run': ['api', 'web', 'desktop', 'worker'], + 'job.execution.cancel': ['api', 'web', 'desktop'], + 'approval.request.read': ['api', 'web', 'desktop', 'android'], + 'approval.decision.create': ['api', 'web', 'android'], + 'billing.account.read': ['api', 'web'], + 'billing.account.manage': ['api', 'web'], + 'device.identity.read': ['api', 'web'], + 'device.identity.revoke': ['api', 'web'], +}); + +test('[IAM-002, IAM-003] every permission has an explicit closed channel policy', () => { + assert.deepEqual(Object.keys(PERMISSION_APPLICABILITY_V1), Object.values(PERMISSIONS_V1)); + assert.deepEqual( + Object.fromEntries( + Object.entries(PERMISSION_APPLICABILITY_V1).map(([permission, policy]) => [ + permission, + policy.allowedChannels, + ]), + ), + expectedChannels, + ); + + assert.ok(Object.isFrozen(AUTHORIZATION_CHANNELS_V1)); + assert.ok(Object.isFrozen(PERMISSION_APPLICABILITY_V1)); + for (const policy of Object.values(PERMISSION_APPLICABILITY_V1)) { + assert.ok(Object.isFrozen(policy)); + assert.ok(Object.isFrozen(policy.allowedChannels)); + } +}); + +test('[IAM-002, IAM-003] sensitive actions are closed to shared-link, stream, and sync', () => { + const sensitive = [ + 'organization.settings.manage', + 'organization.ownership.transfer', + 'workspace.settings.manage', + 'project.record.manage', + 'artifact.derived.create', + 'job.execution.create', + 'job.execution.run', + 'job.execution.cancel', + 'approval.decision.create', + 'billing.account.manage', + 'device.identity.revoke', + ]; + + for (const permission of sensitive) { + for (const channel of ['shared-link', 'stream', 'sync']) { + assert.equal( + PERMISSION_APPLICABILITY_V1[permission].allowedChannels.includes(channel), + false, + ); + } + } +}); diff --git a/packages/domain/test/permissions-v1.test.mjs b/packages/domain/test/permissions-v1.test.mjs index 29d77445..a3e32b61 100644 --- a/packages/domain/test/permissions-v1.test.mjs +++ b/packages/domain/test/permissions-v1.test.mjs @@ -139,6 +139,14 @@ test('[IAM-004] maps exactly six immutable initial role bundles', async () => { assert.ok(Object.isFrozen(bundle)); assert.ok(Object.isFrozen(bundle.permissions)); } + + for (const permission of api.INITIAL_ROLE_BUNDLES_V1.admin.permissions) { + assert.equal( + api.INITIAL_ROLE_BUNDLES_V1.owner.permissions.includes(permission), + true, + `Owner must retain the Admin permission ${permission}`, + ); + } }); test('[IAM-004] role lookup denies unknown roles and permissions', async () => { diff --git a/packages/domain/test/public-api-v1.type-test.ts b/packages/domain/test/public-api-v1.type-test.ts index 84384c70..6e0ef7ea 100644 --- a/packages/domain/test/public-api-v1.type-test.ts +++ b/packages/domain/test/public-api-v1.type-test.ts @@ -1,42 +1,53 @@ import { createScopedAuthorizationEvaluatorV1 } from '@databreeze/domain/authorization/v1'; import type { - EvaluatedAuthorizationContextV1, - TrustedResourceOwnershipV1, - VerifiedTenantFilterV1, + AuthorizationAuthorityProviderV1, + AuthorizationRequestV1, + ScopedResourceLookupQueryV1, } from '@databreeze/domain/authorization/v1'; -import type { - StableIdentifierV1, - StrictUtcTimestampV1, - TenantScopeV1, -} from '@databreeze/domain/tenant-scope/v1'; +import type { StableIdentifierV1, TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; declare const scope: TenantScopeV1; declare const stableId: StableIdentifierV1; -declare const evaluatedAt: StrictUtcTimestampV1; -// These failures prove that a structurally matching client claim cannot satisfy a trusted API type. -// @ts-expect-error -- verified filters are minted only by an evaluator instance. -const forgedFilter: VerifiedTenantFilterV1 = { scope }; -// @ts-expect-error -- trusted ownership includes a private nominal brand. -const forgedResource: TrustedResourceOwnershipV1 = { - resourceType: 'artifact', - resourceId: stableId, - tenantScope: scope, +const provider: AuthorizationAuthorityProviderV1 = { + resolveAuthenticatedPrincipalV1() { + return { principalId: stableId }; + }, + lookupResourceV1(query: ScopedResourceLookupQueryV1) { + return { + resourceType: query.resourceType, + resourceId: query.resourceId, + tenantScope: query.tenantScope, + }; + }, + resolveMembershipV1() { + return { roleId: 'viewer', membershipScope: scope, membershipActive: true }; + }, + evaluatePolicyV1() { + return { satisfied: true }; + }, +}; + +const evaluator = createScopedAuthorizationEvaluatorV1(provider); +const request: AuthorizationRequestV1 = { + permission: 'artifact.record.read', + channel: 'web', + tenantFilter: scope, + resource: { resourceType: 'artifact', resourceId: stableId }, }; -// @ts-expect-error -- evaluated contexts include a private nominal brand. -const forgedContext: EvaluatedAuthorizationContextV1 = { - schemaVersion: 1, - principalId: stableId, + +void evaluator.authorizeV1(request); + +const fabricatedAuthority: AuthorizationRequestV1 = { + ...request, + // @ts-expect-error -- request consumers cannot provide authoritative role facts. roleId: 'owner', - membershipScope: scope, - membershipActive: true, - channel: 'api', - policyConditionsSatisfied: true, - evaluatedAt, - resource: forgedResource, }; -const evaluator = createScopedAuthorizationEvaluatorV1(); -void evaluator; -void forgedFilter; -void forgedContext; +const allowedEvaluatorKey: keyof typeof evaluator = 'authorizeV1'; +// @ts-expect-error -- the evaluator does not expose authority-minting methods. +const forbiddenEvaluatorKey: keyof typeof evaluator = 'createEvaluatedContextV1'; + +void allowedEvaluatorKey; +void forbiddenEvaluatorKey; +void fabricatedAuthority; From 1602b2f0dc2cce7d7cd2cb20ba969ea56471f97b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 07:21:21 +0700 Subject: [PATCH 18/51] feat(config): define portable provider boundaries --- packages/config/README.md | 56 ++ packages/config/package.json | 17 + .../config/src/runtime-config/loader-v1.ts | 898 ++++++++++++++++++ .../config/src/runtime-config/types-v1.ts | 167 ++++ packages/config/src/runtime-config/v1.ts | 2 + .../config/test/built-public-api-smoke.mjs | 7 + packages/config/test/public-api-v1.test.mjs | 26 + packages/config/test/runtime-v1.test.mjs | 344 +++++++ packages/config/tsconfig.build.json | 12 + packages/config/tsconfig.json | 10 + packages/config/turbo.json | 9 + packages/provider-ports/README.md | 55 ++ packages/provider-ports/package.json | 17 + packages/provider-ports/src/common-v1.ts | 494 ++++++++++ packages/provider-ports/src/ports-v1.ts | 270 ++++++ packages/provider-ports/src/v1.ts | 2 + .../test/built-public-api-smoke.mjs | 8 + .../provider-ports/test/common-v1.test.mjs | 214 +++++ .../test/interchangeability-v1.test.mjs | 118 +++ .../provider-ports/test/ports-v1.type-test.ts | 55 ++ .../test/public-api-v1.test.mjs | 28 + packages/provider-ports/tsconfig.build.json | 11 + packages/provider-ports/tsconfig.json | 9 + packages/provider-ports/turbo.json | 9 + pnpm-lock.yaml | 4 + 25 files changed, 2842 insertions(+) create mode 100644 packages/config/README.md create mode 100644 packages/config/package.json create mode 100644 packages/config/src/runtime-config/loader-v1.ts create mode 100644 packages/config/src/runtime-config/types-v1.ts create mode 100644 packages/config/src/runtime-config/v1.ts create mode 100644 packages/config/test/built-public-api-smoke.mjs create mode 100644 packages/config/test/public-api-v1.test.mjs create mode 100644 packages/config/test/runtime-v1.test.mjs create mode 100644 packages/config/tsconfig.build.json create mode 100644 packages/config/tsconfig.json create mode 100644 packages/config/turbo.json create mode 100644 packages/provider-ports/README.md create mode 100644 packages/provider-ports/package.json create mode 100644 packages/provider-ports/src/common-v1.ts create mode 100644 packages/provider-ports/src/ports-v1.ts create mode 100644 packages/provider-ports/src/v1.ts create mode 100644 packages/provider-ports/test/built-public-api-smoke.mjs create mode 100644 packages/provider-ports/test/common-v1.test.mjs create mode 100644 packages/provider-ports/test/interchangeability-v1.test.mjs create mode 100644 packages/provider-ports/test/ports-v1.type-test.ts create mode 100644 packages/provider-ports/test/public-api-v1.test.mjs create mode 100644 packages/provider-ports/tsconfig.build.json create mode 100644 packages/provider-ports/tsconfig.json create mode 100644 packages/provider-ports/turbo.json diff --git a/packages/config/README.md b/packages/config/README.md new file mode 100644 index 00000000..85dc643c --- /dev/null +++ b/packages/config/README.md @@ -0,0 +1,56 @@ +# Runtime Configuration + +Pure, versioned deployment-configuration loading for DataBreeze. This package validates runtime +settings before an application or service constructs any adapter. It does not read `process.env` +itself, contact a provider, or choose product policy. + +## Public interface + +`@databreeze/config/runtime/v1` exports: + +- `loadRuntimeConfigV1`, which accepts an explicit environment record/entry list plus optional + structured overrides and returns a deeply frozen configuration; +- the five explicit profiles: `development`, `test`, `preview`, `staging`, and `production`; +- typed object-storage, email, push, OCR, AI, payments, telemetry, and secrets selections; +- `ConfigValidationErrorV1`, whose diagnostics contain only safe paths and codes; and +- opaque `SecretReferenceV1` values. Their string/JSON representation is redacted, while + `secretReferenceHandleV1` gives trusted composition code the reference needed by a secrets port. + +There is intentionally no unversioned package root. + +## Loading and safety rules + +Precedence is: + +`explicit overrides -> DATABREEZE_* environment -> profile defaults` + +The profile itself is always explicit. Development and test are the only profiles with defaults, +and those defaults use loopback endpoints, in-memory/local facilities, or disabled providers. +Preview, staging, and production have no provider-selection defaults: all eight provider modes must +be declared; object storage and secrets must be remote; every other port may be explicitly disabled. + +Environment parsing is exact. Unknown DataBreeze keys, duplicate entry-list keys, whitespace or +alternate boolean/integer spellings, unknown structured override fields, incomplete active +providers, and fields attached to a disabled provider are rejected. Cleartext endpoints are allowed +only for an explicitly local adapter on loopback in development/test. URLs with credentials and +all cleartext nonlocal endpoints are rejected. Configuration accepts secret references, never API +keys, passwords, tokens, webhook secrets, or other credential values. + +## Forbidden dependencies + +- Provider SDKs, cloud SDKs, service implementations, frameworks, filesystem/database/network I/O. +- Business configuration, feature flags, organization/workspace/project policy, entitlements, or + tenant state. +- Provider credentials or implicit host-environment reads. + +The product-policy precedence `platform default -> plan/region -> organization -> workspace -> +project -> recipe/job` remains owned by later domain/application plans. This package covers only +fail-closed deployment composition. + +## Local commands + +```text +corepack pnpm --filter @databreeze/config test +corepack pnpm --filter @databreeze/config typecheck +corepack pnpm --filter @databreeze/config build +``` diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 00000000..5c0cf712 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,17 @@ +{ + "name": "@databreeze/config", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./runtime/v1": { + "types": "./src/runtime-config/v1.ts", + "import": "./dist/runtime-config/v1.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json && node test/built-public-api-smoke.mjs", + "test": "node --test test/**/*.test.mjs", + "typecheck": "tsc --noEmit --project tsconfig.json" + } +} diff --git a/packages/config/src/runtime-config/loader-v1.ts b/packages/config/src/runtime-config/loader-v1.ts new file mode 100644 index 00000000..99c46099 --- /dev/null +++ b/packages/config/src/runtime-config/loader-v1.ts @@ -0,0 +1,898 @@ +import { + ConfigValidationErrorV1, + RUNTIME_CONFIG_SCHEMA_VERSION_V1, + createSecretReferenceV1, +} from './types-v1.ts'; +import type { + ActiveDocumentProviderConfigV1, + AiConfigV1, + ConfigIssueV1, + EmailConfigV1, + EnvironmentEntriesV1, + LoadRuntimeConfigInputV1, + ObjectStorageConfigV1, + OcrConfigV1, + PaymentsConfigV1, + ProviderRuntimeConfigV1, + PushConfigV1, + RuntimeConfigV1, + RuntimeProfileV1, + SecretReferenceV1, + SecretsConfigV1, + TelemetryConfigV1, +} from './types-v1.ts'; + +type UnknownRecord = Record; +type EnvironmentValueKind = 'boolean' | 'integer' | 'string'; + +interface EnvironmentDefinition { + readonly path: readonly string[]; + readonly kind: EnvironmentValueKind; +} + +const profiles = new Set([ + 'development', + 'test', + 'preview', + 'staging', + 'production', +]); + +const strictProfiles = new Set(['preview', 'staging', 'production']); + +const providerKeys = [ + 'objectStorage', + 'email', + 'push', + 'ocr', + 'ai', + 'payments', + 'telemetry', + 'secrets', +] as const; + +const environmentDefinitions: Readonly> = { + DATABREEZE_PROFILE: { path: ['profile'], kind: 'string' }, + DATABREEZE_PROVIDER_TIMEOUT_MS: { + path: ['providerPolicy', 'timeoutMs'], + kind: 'integer', + }, + DATABREEZE_PROVIDER_MAX_ATTEMPTS: { + path: ['providerPolicy', 'maxAttempts'], + kind: 'integer', + }, + DATABREEZE_OBJECT_STORAGE_MODE: { + path: ['providers', 'objectStorage', 'mode'], + kind: 'string', + }, + DATABREEZE_OBJECT_STORAGE_ENDPOINT_URL: { + path: ['providers', 'objectStorage', 'endpointUrl'], + kind: 'string', + }, + DATABREEZE_OBJECT_STORAGE_REGION: { + path: ['providers', 'objectStorage', 'region'], + kind: 'string', + }, + DATABREEZE_OBJECT_STORAGE_BUCKET: { + path: ['providers', 'objectStorage', 'bucket'], + kind: 'string', + }, + DATABREEZE_OBJECT_STORAGE_CREDENTIAL_REF: { + path: ['providers', 'objectStorage', 'credentialRef'], + kind: 'string', + }, + DATABREEZE_OBJECT_STORAGE_FORCE_PATH_STYLE: { + path: ['providers', 'objectStorage', 'forcePathStyle'], + kind: 'boolean', + }, + DATABREEZE_EMAIL_MODE: { path: ['providers', 'email', 'mode'], kind: 'string' }, + DATABREEZE_EMAIL_ENDPOINT_URL: { + path: ['providers', 'email', 'endpointUrl'], + kind: 'string', + }, + DATABREEZE_EMAIL_FROM_ADDRESS: { + path: ['providers', 'email', 'fromAddress'], + kind: 'string', + }, + DATABREEZE_EMAIL_CREDENTIAL_REF: { + path: ['providers', 'email', 'credentialRef'], + kind: 'string', + }, + DATABREEZE_PUSH_MODE: { path: ['providers', 'push', 'mode'], kind: 'string' }, + DATABREEZE_PUSH_ENDPOINT_URL: { + path: ['providers', 'push', 'endpointUrl'], + kind: 'string', + }, + DATABREEZE_PUSH_APPLICATION_ID: { + path: ['providers', 'push', 'applicationId'], + kind: 'string', + }, + DATABREEZE_PUSH_CREDENTIAL_REF: { + path: ['providers', 'push', 'credentialRef'], + kind: 'string', + }, + DATABREEZE_OCR_MODE: { path: ['providers', 'ocr', 'mode'], kind: 'string' }, + DATABREEZE_OCR_ENDPOINT_URL: { + path: ['providers', 'ocr', 'endpointUrl'], + kind: 'string', + }, + DATABREEZE_OCR_CREDENTIAL_REF: { + path: ['providers', 'ocr', 'credentialRef'], + kind: 'string', + }, + DATABREEZE_AI_MODE: { path: ['providers', 'ai', 'mode'], kind: 'string' }, + DATABREEZE_AI_ENDPOINT_URL: { + path: ['providers', 'ai', 'endpointUrl'], + kind: 'string', + }, + DATABREEZE_AI_CREDENTIAL_REF: { + path: ['providers', 'ai', 'credentialRef'], + kind: 'string', + }, + DATABREEZE_PAYMENTS_MODE: { path: ['providers', 'payments', 'mode'], kind: 'string' }, + DATABREEZE_PAYMENTS_ENDPOINT_URL: { + path: ['providers', 'payments', 'endpointUrl'], + kind: 'string', + }, + DATABREEZE_PAYMENTS_CREDENTIAL_REF: { + path: ['providers', 'payments', 'credentialRef'], + kind: 'string', + }, + DATABREEZE_PAYMENTS_WEBHOOK_SECRET_REF: { + path: ['providers', 'payments', 'webhookSecretRef'], + kind: 'string', + }, + DATABREEZE_TELEMETRY_MODE: { + path: ['providers', 'telemetry', 'mode'], + kind: 'string', + }, + DATABREEZE_TELEMETRY_ENDPOINT_URL: { + path: ['providers', 'telemetry', 'endpointUrl'], + kind: 'string', + }, + DATABREEZE_TELEMETRY_CREDENTIAL_REF: { + path: ['providers', 'telemetry', 'credentialRef'], + kind: 'string', + }, + DATABREEZE_SECRETS_MODE: { path: ['providers', 'secrets', 'mode'], kind: 'string' }, + DATABREEZE_SECRETS_ENDPOINT_URL: { + path: ['providers', 'secrets', 'endpointUrl'], + kind: 'string', + }, + DATABREEZE_SECRETS_NAMESPACE: { + path: ['providers', 'secrets', 'namespace'], + kind: 'string', + }, +}; + +const allowedOverrideKeys: Readonly> = { + '': ['profile', 'providerPolicy', 'providers'], + providerPolicy: ['timeoutMs', 'maxAttempts'], + providers: [...providerKeys], + 'providers.objectStorage': [ + 'mode', + 'endpointUrl', + 'region', + 'bucket', + 'credentialRef', + 'forcePathStyle', + ], + 'providers.email': ['mode', 'endpointUrl', 'fromAddress', 'credentialRef'], + 'providers.push': ['mode', 'endpointUrl', 'applicationId', 'credentialRef'], + 'providers.ocr': ['mode', 'endpointUrl', 'credentialRef'], + 'providers.ai': ['mode', 'endpointUrl', 'credentialRef'], + 'providers.payments': ['mode', 'endpointUrl', 'credentialRef', 'webhookSecretRef'], + 'providers.telemetry': ['mode', 'endpointUrl', 'credentialRef'], + 'providers.secrets': ['mode', 'endpointUrl', 'namespace'], +}; + +const placeholderSegments = new Set([ + 'changeme', + 'change-me', + 'dummy', + 'example', + 'password', + 'placeholder', + 'replace-me', + 'secret', + 'todo', +]); + +function isRecord(value: unknown): value is UnknownRecord { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value) as unknown; + return prototype === Object.prototype || prototype === null; +} + +function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) { + deepFreeze(child); + } + Object.freeze(value); + } + return value; +} + +function environmentEntries( + environment: EnvironmentEntriesV1 | undefined, +): readonly (readonly [string, string | undefined])[] { + if (environment === undefined) { + return []; + } + if (!Array.isArray(environment)) { + return Object.entries(environment); + } + return environment as readonly (readonly [string, string | undefined])[]; +} + +function validateOverrideKeys(value: unknown, issues: ConfigIssueV1[], path = ''): void { + if (!isRecord(value)) { + if (path === '') { + issues.push({ path: 'overrides', code: 'invalid_string' }); + } + return; + } + + const allowed = allowedOverrideKeys[path]; + if (allowed === undefined) { + return; + } + + for (const [key, child] of Object.entries(value)) { + const childPath = path === '' ? key : `${path}.${key}`; + if (!allowed.includes(key)) { + issues.push({ path: `overrides.${childPath}`, code: 'unknown_key' }); + continue; + } + if (allowedOverrideKeys[childPath] !== undefined) { + if (!isRecord(child)) { + issues.push({ path: `overrides.${childPath}`, code: 'invalid_string' }); + } else { + validateOverrideKeys(child, issues, childPath); + } + } + } +} + +function setPath(target: UnknownRecord, path: readonly string[], value: unknown): void { + let cursor = target; + for (const segment of path.slice(0, -1)) { + const existing = cursor[segment]; + if (!isRecord(existing)) { + cursor[segment] = {}; + } + cursor = cursor[segment] as UnknownRecord; + } + const finalSegment = path.at(-1); + if (finalSegment !== undefined) { + cursor[finalSegment] = value; + } +} + +function mergeRecords(base: UnknownRecord, overlay: UnknownRecord): UnknownRecord { + const result: UnknownRecord = { ...base }; + for (const [key, value] of Object.entries(overlay)) { + const current = result[key]; + result[key] = isRecord(current) && isRecord(value) ? mergeRecords(current, value) : value; + } + return result; +} + +function parseEnvironmentValue( + value: string, + definition: EnvironmentDefinition, + issues: ConfigIssueV1[], +): unknown { + const path = definition.path.join('.'); + if (definition.kind === 'string') { + return value; + } + if (definition.kind === 'boolean') { + if (value === 'true') return true; + if (value === 'false') return false; + issues.push({ path, code: 'invalid_boolean' }); + return undefined; + } + if (!/^(0|[1-9][0-9]*)$/.test(value)) { + issues.push({ path, code: 'invalid_integer' }); + return undefined; + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + issues.push({ path, code: 'invalid_integer' }); + return undefined; + } + return parsed; +} + +function readEnvironment( + environment: EnvironmentEntriesV1 | undefined, + issues: ConfigIssueV1[], +): UnknownRecord { + const seen = new Set(); + const result: UnknownRecord = {}; + + for (const [key, value] of environmentEntries(environment)) { + if (seen.has(key)) { + issues.push({ path: `environment.${key}`, code: 'duplicate' }); + continue; + } + seen.add(key); + + const definition = environmentDefinitions[key]; + if (definition === undefined) { + if (key.startsWith('DATABREEZE_')) { + issues.push({ path: `environment.${key}`, code: 'unknown_key' }); + } + continue; + } + if (value === undefined) { + continue; + } + const parsed = parseEnvironmentValue(value, definition, issues); + if (parsed !== undefined) { + setPath(result, definition.path, parsed); + } + } + return result; +} + +function profileDefaults(profile: RuntimeProfileV1): UnknownRecord { + const providerPolicy = { timeoutMs: 10_000, maxAttempts: 3 }; + if (profile === 'development') { + return { + profile, + providerPolicy, + providers: { + objectStorage: { + mode: 'local', + endpointUrl: 'http://127.0.0.1:9000', + region: 'local', + bucket: 'databreeze-development', + forcePathStyle: true, + }, + email: { + mode: 'local', + endpointUrl: 'smtp://127.0.0.1:1025', + fromAddress: 'noreply@databreeze.local', + }, + push: { mode: 'disabled' }, + ocr: { mode: 'disabled' }, + ai: { mode: 'disabled' }, + payments: { mode: 'disabled' }, + telemetry: { mode: 'local', endpointUrl: 'http://127.0.0.1:4318' }, + secrets: { mode: 'memory', namespace: 'development' }, + }, + }; + } + if (profile === 'test') { + return { + profile, + providerPolicy, + providers: { + objectStorage: { + mode: 'local', + endpointUrl: 'http://127.0.0.1:9000', + region: 'local', + bucket: 'databreeze-test', + forcePathStyle: true, + }, + email: { mode: 'disabled' }, + push: { mode: 'disabled' }, + ocr: { mode: 'disabled' }, + ai: { mode: 'disabled' }, + payments: { mode: 'disabled' }, + telemetry: { mode: 'disabled' }, + secrets: { mode: 'memory', namespace: 'test' }, + }, + }; + } + return { profile, providerPolicy, providers: {} }; +} + +function requiredString( + record: UnknownRecord, + key: string, + path: string, + issues: ConfigIssueV1[], +): string { + const value = record[key]; + if (value === undefined) { + issues.push({ path, code: 'required' }); + return ''; + } + if (typeof value !== 'string' || value.length === 0 || value.trim() !== value) { + issues.push({ path, code: 'invalid_string' }); + return ''; + } + return value; +} + +function requiredBoolean( + record: UnknownRecord, + key: string, + path: string, + issues: ConfigIssueV1[], +): boolean { + const value = record[key]; + if (value === undefined) { + issues.push({ path, code: 'required' }); + return false; + } + if (typeof value !== 'boolean') { + issues.push({ path, code: 'invalid_boolean' }); + return false; + } + return value; +} + +function requiredInteger( + record: UnknownRecord, + key: string, + path: string, + minimum: number, + maximum: number, + issues: ConfigIssueV1[], +): number { + const value = record[key]; + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < minimum || + value > maximum + ) { + issues.push({ path, code: 'invalid_integer' }); + return minimum; + } + return value; +} + +function recordAt(record: UnknownRecord, key: string): UnknownRecord { + const value = record[key]; + return isRecord(value) ? value : {}; +} + +function validEndpoint( + value: string, + path: string, + profile: RuntimeProfileV1, + mode: string, + kind: 'email' | 'network', + issues: ConfigIssueV1[], +): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + issues.push({ path, code: 'unsafe_url' }); + return value; + } + + if ( + parsed.username !== '' || + parsed.password !== '' || + parsed.search !== '' || + parsed.hash !== '' + ) { + issues.push({ path, code: 'unsafe_url' }); + return value; + } + + const secureProtocols = kind === 'email' ? new Set(['https:', 'smtps:']) : new Set(['https:']); + if (secureProtocols.has(parsed.protocol)) { + return value; + } + + const localProtocols = kind === 'email' ? new Set(['http:', 'smtp:']) : new Set(['http:']); + const hostname = parsed.hostname.toLowerCase(); + const loopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; + const safeLocal = + (profile === 'development' || profile === 'test') && + mode === 'local' && + loopback && + localProtocols.has(parsed.protocol); + if (!safeLocal) { + issues.push({ path, code: 'unsafe_url' }); + } + return value; +} + +function secretReference( + value: unknown, + path: string, + issues: ConfigIssueV1[], + required: boolean, +): SecretReferenceV1 | undefined { + if (value === undefined) { + if (required) issues.push({ path, code: 'required' }); + return undefined; + } + if (typeof value !== 'string' || value.trim() !== value) { + issues.push({ path, code: 'invalid_secret_reference' }); + return undefined; + } + const match = + /^secret:\/\/([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._/-]*)(?:#[a-z0-9._-]+)?$/.exec(value); + const segments = match?.slice(1).flatMap((part) => part.split(/[./_-]+/)) ?? []; + if (match === null || segments.some((segment) => placeholderSegments.has(segment))) { + issues.push({ path, code: 'invalid_secret_reference' }); + return undefined; + } + return createSecretReferenceV1(value); +} + +function modeOf( + record: UnknownRecord, + path: string, + allowed: readonly string[], + issues: ConfigIssueV1[], +): string { + const mode = record['mode']; + if (mode === undefined) { + issues.push({ path: `${path}.mode`, code: 'required' }); + return ''; + } + if (typeof mode !== 'string' || !allowed.includes(mode)) { + issues.push({ path: `${path}.mode`, code: 'invalid_mode' }); + return ''; + } + return mode; +} + +function forbidDisabledFields( + record: UnknownRecord, + path: string, + fields: readonly string[], + issues: ConfigIssueV1[], +): void { + for (const field of fields) { + if (record[field] !== undefined) { + issues.push({ path: `${path}.${field}`, code: 'forbidden_when_disabled' }); + } + } +} + +function validateObjectStorage( + record: UnknownRecord, + profile: RuntimeProfileV1, + issues: ConfigIssueV1[], +): ObjectStorageConfigV1 { + const path = 'providers.objectStorage'; + const mode = modeOf(record, path, ['local', 'remote'], issues); + if (strictProfiles.has(profile) && mode !== '' && mode !== 'remote') { + issues.push({ path: `${path}.mode`, code: 'invalid_mode' }); + } + const endpointUrl = validEndpoint( + requiredString(record, 'endpointUrl', `${path}.endpointUrl`, issues), + `${path}.endpointUrl`, + profile, + mode, + 'network', + issues, + ); + const region = requiredString(record, 'region', `${path}.region`, issues); + const bucket = requiredString(record, 'bucket', `${path}.bucket`, issues); + const forcePathStyle = requiredBoolean( + record, + 'forcePathStyle', + `${path}.forcePathStyle`, + issues, + ); + const credentialRef = secretReference( + record['credentialRef'], + `${path}.credentialRef`, + issues, + mode === 'remote', + ); + return { + mode: mode === 'remote' ? 'remote' : 'local', + endpointUrl, + region, + bucket, + ...(credentialRef === undefined ? {} : { credentialRef }), + forcePathStyle, + }; +} + +function validateEmail( + record: UnknownRecord, + profile: RuntimeProfileV1, + issues: ConfigIssueV1[], +): EmailConfigV1 { + const path = 'providers.email'; + const mode = modeOf(record, path, ['disabled', 'local', 'remote'], issues); + if (mode === 'disabled') { + forbidDisabledFields(record, path, ['endpointUrl', 'fromAddress', 'credentialRef'], issues); + return { mode: 'disabled' }; + } + if (strictProfiles.has(profile) && mode === 'local') { + issues.push({ path: `${path}.mode`, code: 'invalid_mode' }); + } + const endpointUrl = validEndpoint( + requiredString(record, 'endpointUrl', `${path}.endpointUrl`, issues), + `${path}.endpointUrl`, + profile, + mode, + 'email', + issues, + ); + const fromAddress = requiredString(record, 'fromAddress', `${path}.fromAddress`, issues); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fromAddress)) { + issues.push({ path: `${path}.fromAddress`, code: 'invalid_email' }); + } + const credentialRef = secretReference( + record['credentialRef'], + `${path}.credentialRef`, + issues, + mode === 'remote', + ); + return { + mode: mode === 'remote' ? 'remote' : 'local', + endpointUrl, + fromAddress, + ...(credentialRef === undefined ? {} : { credentialRef }), + }; +} + +function validatePush( + record: UnknownRecord, + profile: RuntimeProfileV1, + issues: ConfigIssueV1[], +): PushConfigV1 { + const path = 'providers.push'; + const mode = modeOf(record, path, ['disabled', 'remote'], issues); + if (mode === 'disabled') { + forbidDisabledFields(record, path, ['endpointUrl', 'applicationId', 'credentialRef'], issues); + return { mode: 'disabled' }; + } + const endpointUrl = validEndpoint( + requiredString(record, 'endpointUrl', `${path}.endpointUrl`, issues), + `${path}.endpointUrl`, + profile, + mode, + 'network', + issues, + ); + const applicationId = requiredString(record, 'applicationId', `${path}.applicationId`, issues); + const credentialRef = secretReference( + record['credentialRef'], + `${path}.credentialRef`, + issues, + true, + ); + return { + mode: 'remote', + endpointUrl, + applicationId, + credentialRef: credentialRef as SecretReferenceV1, + }; +} + +function validateDocumentProvider( + record: UnknownRecord, + profile: RuntimeProfileV1, + name: 'ocr' | 'ai', + issues: ConfigIssueV1[], +): ActiveDocumentProviderConfigV1 | { readonly mode: 'disabled' } { + const path = `providers.${name}`; + const mode = modeOf(record, path, ['disabled', 'local', 'remote'], issues); + if (mode === 'disabled') { + forbidDisabledFields(record, path, ['endpointUrl', 'credentialRef'], issues); + return { mode: 'disabled' }; + } + if (strictProfiles.has(profile) && mode === 'local') { + issues.push({ path: `${path}.mode`, code: 'invalid_mode' }); + } + const endpointUrl = validEndpoint( + requiredString(record, 'endpointUrl', `${path}.endpointUrl`, issues), + `${path}.endpointUrl`, + profile, + mode, + 'network', + issues, + ); + const credentialRef = secretReference( + record['credentialRef'], + `${path}.credentialRef`, + issues, + mode === 'remote', + ); + return { + mode: mode === 'remote' ? 'remote' : 'local', + endpointUrl, + ...(credentialRef === undefined ? {} : { credentialRef }), + }; +} + +function validatePayments( + record: UnknownRecord, + profile: RuntimeProfileV1, + issues: ConfigIssueV1[], +): PaymentsConfigV1 { + const path = 'providers.payments'; + const mode = modeOf(record, path, ['disabled', 'remote'], issues); + if (mode === 'disabled') { + forbidDisabledFields( + record, + path, + ['endpointUrl', 'credentialRef', 'webhookSecretRef'], + issues, + ); + return { mode: 'disabled' }; + } + const endpointUrl = validEndpoint( + requiredString(record, 'endpointUrl', `${path}.endpointUrl`, issues), + `${path}.endpointUrl`, + profile, + mode, + 'network', + issues, + ); + const credentialRef = secretReference( + record['credentialRef'], + `${path}.credentialRef`, + issues, + true, + ); + const webhookSecretRef = secretReference( + record['webhookSecretRef'], + `${path}.webhookSecretRef`, + issues, + true, + ); + return { + mode: 'remote', + endpointUrl, + credentialRef: credentialRef as SecretReferenceV1, + webhookSecretRef: webhookSecretRef as SecretReferenceV1, + }; +} + +function validateTelemetry( + record: UnknownRecord, + profile: RuntimeProfileV1, + issues: ConfigIssueV1[], +): TelemetryConfigV1 { + const path = 'providers.telemetry'; + const mode = modeOf(record, path, ['disabled', 'local', 'remote'], issues); + if (mode === 'disabled') { + forbidDisabledFields(record, path, ['endpointUrl', 'credentialRef'], issues); + return { mode: 'disabled' }; + } + if (strictProfiles.has(profile) && mode === 'local') { + issues.push({ path: `${path}.mode`, code: 'invalid_mode' }); + } + const endpointUrl = validEndpoint( + requiredString(record, 'endpointUrl', `${path}.endpointUrl`, issues), + `${path}.endpointUrl`, + profile, + mode, + 'network', + issues, + ); + const credentialRef = secretReference( + record['credentialRef'], + `${path}.credentialRef`, + issues, + false, + ); + return { + mode: mode === 'remote' ? 'remote' : 'local', + endpointUrl, + ...(credentialRef === undefined ? {} : { credentialRef }), + }; +} + +function validateSecrets( + record: UnknownRecord, + profile: RuntimeProfileV1, + issues: ConfigIssueV1[], +): SecretsConfigV1 { + const path = 'providers.secrets'; + const mode = modeOf(record, path, ['memory', 'remote'], issues); + if (strictProfiles.has(profile) && mode !== '' && mode !== 'remote') { + issues.push({ path: `${path}.mode`, code: 'invalid_mode' }); + } + const namespace = requiredString(record, 'namespace', `${path}.namespace`, issues); + if (mode === 'remote') { + return { + mode: 'remote', + endpointUrl: validEndpoint( + requiredString(record, 'endpointUrl', `${path}.endpointUrl`, issues), + `${path}.endpointUrl`, + profile, + mode, + 'network', + issues, + ), + namespace, + }; + } + if (record['endpointUrl'] !== undefined) { + issues.push({ path: `${path}.endpointUrl`, code: 'forbidden_when_disabled' }); + } + return { mode: 'memory', namespace }; +} + +function validateProviders( + record: UnknownRecord, + profile: RuntimeProfileV1, + issues: ConfigIssueV1[], +): ProviderRuntimeConfigV1 { + return { + objectStorage: validateObjectStorage(recordAt(record, 'objectStorage'), profile, issues), + email: validateEmail(recordAt(record, 'email'), profile, issues), + push: validatePush(recordAt(record, 'push'), profile, issues), + ocr: validateDocumentProvider(recordAt(record, 'ocr'), profile, 'ocr', issues) as OcrConfigV1, + ai: validateDocumentProvider(recordAt(record, 'ai'), profile, 'ai', issues) as AiConfigV1, + payments: validatePayments(recordAt(record, 'payments'), profile, issues), + telemetry: validateTelemetry(recordAt(record, 'telemetry'), profile, issues), + secrets: validateSecrets(recordAt(record, 'secrets'), profile, issues), + }; +} + +function selectedProfile( + environment: UnknownRecord, + overrides: UnknownRecord, + issues: ConfigIssueV1[], +): RuntimeProfileV1 | undefined { + const value = overrides['profile'] ?? environment['profile']; + if (value === undefined) { + issues.push({ path: 'profile', code: 'required' }); + return undefined; + } + if (typeof value !== 'string' || !profiles.has(value as RuntimeProfileV1)) { + issues.push({ path: 'profile', code: 'invalid_profile' }); + return undefined; + } + return value as RuntimeProfileV1; +} + +export function loadRuntimeConfigV1(input: LoadRuntimeConfigInputV1 = {}): RuntimeConfigV1 { + const issues: ConfigIssueV1[] = []; + const environment = readEnvironment(input.environment, issues); + const overrides = input.overrides === undefined ? {} : input.overrides; + validateOverrideKeys(overrides, issues); + const overrideRecord = isRecord(overrides) ? overrides : {}; + const profile = selectedProfile(environment, overrideRecord, issues); + + if (profile === undefined) { + throw new ConfigValidationErrorV1(issues); + } + + const merged = mergeRecords(mergeRecords(profileDefaults(profile), environment), overrideRecord); + const providerPolicyRecord = recordAt(merged, 'providerPolicy'); + const providerPolicy = { + timeoutMs: requiredInteger( + providerPolicyRecord, + 'timeoutMs', + 'providerPolicy.timeoutMs', + 100, + 120_000, + issues, + ), + maxAttempts: requiredInteger( + providerPolicyRecord, + 'maxAttempts', + 'providerPolicy.maxAttempts', + 1, + 10, + issues, + ), + }; + const providers = validateProviders(recordAt(merged, 'providers'), profile, issues); + + if (issues.length > 0) { + throw new ConfigValidationErrorV1(issues); + } + + return deepFreeze({ + schemaVersion: RUNTIME_CONFIG_SCHEMA_VERSION_V1, + profile, + providerPolicy, + providers, + }); +} diff --git a/packages/config/src/runtime-config/types-v1.ts b/packages/config/src/runtime-config/types-v1.ts new file mode 100644 index 00000000..d0e1e94a --- /dev/null +++ b/packages/config/src/runtime-config/types-v1.ts @@ -0,0 +1,167 @@ +export const RUNTIME_CONFIG_SCHEMA_VERSION_V1 = 1 as const; + +export type RuntimeProfileV1 = 'development' | 'test' | 'preview' | 'staging' | 'production'; + +export type ConfigIssueCodeV1 = + | 'duplicate' + | 'forbidden_when_disabled' + | 'invalid_boolean' + | 'invalid_email' + | 'invalid_integer' + | 'invalid_mode' + | 'invalid_profile' + | 'invalid_secret_reference' + | 'invalid_string' + | 'required' + | 'unknown_key' + | 'unsafe_url'; + +export interface ConfigIssueV1 { + readonly path: string; + readonly code: ConfigIssueCodeV1; +} + +export class ConfigValidationErrorV1 extends Error { + public readonly issues: readonly ConfigIssueV1[]; + + public constructor(issues: readonly ConfigIssueV1[]) { + super('Runtime configuration is invalid.'); + this.name = 'ConfigValidationErrorV1'; + this.issues = Object.freeze( + issues.map((issue) => Object.freeze({ path: issue.path, code: issue.code })), + ); + Object.freeze(this); + } + + public toJSON(): Readonly<{ name: string; issues: readonly ConfigIssueV1[] }> { + return Object.freeze({ name: this.name, issues: this.issues }); + } +} + +export interface SecretReferenceV1 { + readonly kind: 'secret-reference'; + toString(): '[REDACTED_SECRET_REFERENCE]'; + toJSON(): '[REDACTED_SECRET_REFERENCE]'; +} + +const secretReferenceHandles = new WeakMap(); + +export function createSecretReferenceV1(handle: string): SecretReferenceV1 { + const reference: SecretReferenceV1 = { + kind: 'secret-reference', + toString: () => '[REDACTED_SECRET_REFERENCE]', + toJSON: () => '[REDACTED_SECRET_REFERENCE]', + }; + secretReferenceHandles.set(reference, handle); + return Object.freeze(reference); +} + +export function secretReferenceHandleV1(reference: SecretReferenceV1): string { + const handle = secretReferenceHandles.get(reference); + if (handle === undefined) { + throw new TypeError('Unknown secret reference.'); + } + return handle; +} + +export interface ProviderPolicyConfigV1 { + readonly timeoutMs: number; + readonly maxAttempts: number; +} + +export interface DisabledProviderConfigV1 { + readonly mode: 'disabled'; +} + +export interface ObjectStorageConfigV1 { + readonly mode: 'local' | 'remote'; + readonly endpointUrl: string; + readonly region: string; + readonly bucket: string; + readonly credentialRef?: SecretReferenceV1; + readonly forcePathStyle: boolean; +} + +export interface ActiveEmailConfigV1 { + readonly mode: 'local' | 'remote'; + readonly endpointUrl: string; + readonly fromAddress: string; + readonly credentialRef?: SecretReferenceV1; +} + +export type EmailConfigV1 = DisabledProviderConfigV1 | ActiveEmailConfigV1; + +export interface ActivePushConfigV1 { + readonly mode: 'remote'; + readonly endpointUrl: string; + readonly applicationId: string; + readonly credentialRef: SecretReferenceV1; +} + +export type PushConfigV1 = DisabledProviderConfigV1 | ActivePushConfigV1; + +export interface ActiveDocumentProviderConfigV1 { + readonly mode: 'local' | 'remote'; + readonly endpointUrl: string; + readonly credentialRef?: SecretReferenceV1; +} + +export type OcrConfigV1 = DisabledProviderConfigV1 | ActiveDocumentProviderConfigV1; +export type AiConfigV1 = DisabledProviderConfigV1 | ActiveDocumentProviderConfigV1; + +export interface ActivePaymentsConfigV1 { + readonly mode: 'remote'; + readonly endpointUrl: string; + readonly credentialRef: SecretReferenceV1; + readonly webhookSecretRef: SecretReferenceV1; +} + +export type PaymentsConfigV1 = DisabledProviderConfigV1 | ActivePaymentsConfigV1; + +export interface ActiveTelemetryConfigV1 { + readonly mode: 'local' | 'remote'; + readonly endpointUrl: string; + readonly credentialRef?: SecretReferenceV1; +} + +export type TelemetryConfigV1 = DisabledProviderConfigV1 | ActiveTelemetryConfigV1; + +export interface MemorySecretsConfigV1 { + readonly mode: 'memory'; + readonly namespace: string; +} + +export interface RemoteSecretsConfigV1 { + readonly mode: 'remote'; + readonly endpointUrl: string; + readonly namespace: string; +} + +export type SecretsConfigV1 = MemorySecretsConfigV1 | RemoteSecretsConfigV1; + +export interface ProviderRuntimeConfigV1 { + readonly objectStorage: ObjectStorageConfigV1; + readonly email: EmailConfigV1; + readonly push: PushConfigV1; + readonly ocr: OcrConfigV1; + readonly ai: AiConfigV1; + readonly payments: PaymentsConfigV1; + readonly telemetry: TelemetryConfigV1; + readonly secrets: SecretsConfigV1; +} + +export interface RuntimeConfigV1 { + readonly schemaVersion: typeof RUNTIME_CONFIG_SCHEMA_VERSION_V1; + readonly profile: RuntimeProfileV1; + readonly providerPolicy: ProviderPolicyConfigV1; + readonly providers: ProviderRuntimeConfigV1; +} + +export type EnvironmentEntriesV1 = + | Readonly> + | readonly (readonly [string, string | undefined])[]; + +export interface LoadRuntimeConfigInputV1 { + readonly environment?: EnvironmentEntriesV1; + readonly overrides?: unknown; +} diff --git a/packages/config/src/runtime-config/v1.ts b/packages/config/src/runtime-config/v1.ts new file mode 100644 index 00000000..3955bf90 --- /dev/null +++ b/packages/config/src/runtime-config/v1.ts @@ -0,0 +1,2 @@ +export * from './loader-v1.ts'; +export * from './types-v1.ts'; diff --git a/packages/config/test/built-public-api-smoke.mjs b/packages/config/test/built-public-api-smoke.mjs new file mode 100644 index 00000000..ff72ef32 --- /dev/null +++ b/packages/config/test/built-public-api-smoke.mjs @@ -0,0 +1,7 @@ +import assert from 'node:assert/strict'; + +const runtime = await import('../dist/runtime-config/v1.js'); + +assert.equal(runtime.RUNTIME_CONFIG_SCHEMA_VERSION_V1, 1); +assert.equal(typeof runtime.loadRuntimeConfigV1, 'function'); +assert.equal(typeof runtime.secretReferenceHandleV1, 'function'); diff --git a/packages/config/test/public-api-v1.test.mjs b/packages/config/test/public-api-v1.test.mjs new file mode 100644 index 00000000..cf05169f --- /dev/null +++ b/packages/config/test/public-api-v1.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('publishes a versioned runtime configuration loader', async () => { + let runtime; + try { + runtime = await import('../src/runtime-config/v1.ts'); + } catch { + runtime = undefined; + } + + assert.ok(runtime, 'the runtime/v1 source entry point must exist'); + assert.equal(runtime.RUNTIME_CONFIG_SCHEMA_VERSION_V1, 1); + assert.equal(typeof runtime.loadRuntimeConfigV1, 'function'); +}); + +test('exposes only the versioned runtime entry point', async () => { + const manifest = JSON.parse(readFileSync(path.join(packageDirectory, 'package.json'), 'utf8')); + assert.deepEqual(Object.keys(manifest.exports), ['./runtime/v1']); + await assert.rejects(import('@databreeze/config'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); +}); diff --git a/packages/config/test/runtime-v1.test.mjs b/packages/config/test/runtime-v1.test.mjs new file mode 100644 index 00000000..c22611ba --- /dev/null +++ b/packages/config/test/runtime-v1.test.mjs @@ -0,0 +1,344 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + ConfigValidationErrorV1, + loadRuntimeConfigV1, + secretReferenceHandleV1, +} from '../src/runtime-config/v1.ts'; + +function nonLocalEnvironment(profile) { + return [ + ['DATABREEZE_PROFILE', profile], + ['DATABREEZE_OBJECT_STORAGE_MODE', 'remote'], + ['DATABREEZE_OBJECT_STORAGE_ENDPOINT_URL', 'https://objects.example.test'], + ['DATABREEZE_OBJECT_STORAGE_REGION', 'sg-1'], + ['DATABREEZE_OBJECT_STORAGE_BUCKET', `databreeze-${profile}`], + ['DATABREEZE_OBJECT_STORAGE_CREDENTIAL_REF', `secret://${profile}/object-storage`], + ['DATABREEZE_OBJECT_STORAGE_FORCE_PATH_STYLE', 'false'], + ['DATABREEZE_EMAIL_MODE', 'disabled'], + ['DATABREEZE_PUSH_MODE', 'disabled'], + ['DATABREEZE_OCR_MODE', 'disabled'], + ['DATABREEZE_AI_MODE', 'disabled'], + ['DATABREEZE_PAYMENTS_MODE', 'disabled'], + ['DATABREEZE_TELEMETRY_MODE', 'disabled'], + ['DATABREEZE_SECRETS_MODE', 'remote'], + ['DATABREEZE_SECRETS_ENDPOINT_URL', 'https://secrets.example.test'], + ['DATABREEZE_SECRETS_NAMESPACE', `databreeze-${profile}`], + ]; +} + +function expectConfigIssue(action, expectedPath, expectedCode) { + assert.throws(action, (error) => { + assert.ok(error instanceof ConfigValidationErrorV1); + assert.ok( + error.issues.some((issue) => issue.path === expectedPath && issue.code === expectedCode), + `expected ${expectedCode} at ${expectedPath}, received ${JSON.stringify(error.issues)}`, + ); + return true; + }); +} + +test('requires an explicit runtime profile', () => { + expectConfigIssue(() => loadRuntimeConfigV1({ environment: [] }), 'profile', 'required'); +}); + +test('loads safe development defaults only after development is explicit', () => { + const config = loadRuntimeConfigV1({ + environment: [['DATABREEZE_PROFILE', 'development']], + }); + + assert.equal(config.profile, 'development'); + assert.deepEqual(config.providerPolicy, { timeoutMs: 10_000, maxAttempts: 3 }); + assert.deepEqual(config.providers.objectStorage, { + mode: 'local', + endpointUrl: 'http://127.0.0.1:9000', + region: 'local', + bucket: 'databreeze-development', + forcePathStyle: true, + }); + assert.equal(config.providers.email.mode, 'local'); + assert.equal(config.providers.push.mode, 'disabled'); + assert.equal(config.providers.ocr.mode, 'disabled'); + assert.equal(config.providers.ai.mode, 'disabled'); + assert.equal(config.providers.payments.mode, 'disabled'); + assert.equal(config.providers.telemetry.mode, 'local'); + assert.deepEqual(config.providers.secrets, { mode: 'memory', namespace: 'development' }); +}); + +test('loads deterministic test defaults distinct from development', () => { + const config = loadRuntimeConfigV1({ environment: { DATABREEZE_PROFILE: 'test' } }); + + assert.equal(config.profile, 'test'); + assert.equal(config.providers.objectStorage.bucket, 'databreeze-test'); + assert.equal(config.providers.email.mode, 'disabled'); + assert.equal(config.providers.telemetry.mode, 'disabled'); + assert.deepEqual(config.providers.secrets, { mode: 'memory', namespace: 'test' }); +}); + +for (const profile of ['preview', 'staging', 'production']) { + test(`loads an explicitly complete ${profile} profile`, () => { + const config = loadRuntimeConfigV1({ environment: nonLocalEnvironment(profile) }); + + assert.equal(config.profile, profile); + assert.equal(config.providers.objectStorage.mode, 'remote'); + assert.equal(config.providers.objectStorage.bucket, `databreeze-${profile}`); + assert.equal( + secretReferenceHandleV1(config.providers.objectStorage.credentialRef), + `secret://${profile}/object-storage`, + ); + assert.deepEqual(config.providers.secrets, { + mode: 'remote', + endpointUrl: 'https://secrets.example.test', + namespace: `databreeze-${profile}`, + }); + }); +} + +for (const profile of ['preview', 'staging', 'production']) { + test(`${profile} fails closed when provider selections are absent`, () => { + expectConfigIssue( + () => loadRuntimeConfigV1({ environment: [['DATABREEZE_PROFILE', profile]] }), + 'providers.objectStorage.mode', + 'required', + ); + }); +} + +test('applies explicit overrides over environment and environment over local defaults', () => { + const config = loadRuntimeConfigV1({ + environment: [ + ['DATABREEZE_PROFILE', 'development'], + ['DATABREEZE_PROVIDER_TIMEOUT_MS', '2000'], + ['DATABREEZE_EMAIL_MODE', 'disabled'], + ], + overrides: { + providerPolicy: { timeoutMs: 3_000, maxAttempts: 4 }, + providers: { + email: { + mode: 'local', + endpointUrl: 'smtp://localhost:2525', + fromAddress: 'notify@databreeze.local', + }, + }, + }, + }); + + assert.deepEqual(config.providerPolicy, { timeoutMs: 3_000, maxAttempts: 4 }); + assert.deepEqual(config.providers.email, { + mode: 'local', + endpointUrl: 'smtp://localhost:2525', + fromAddress: 'notify@databreeze.local', + }); +}); + +test('rejects duplicate environment entries instead of choosing one', () => { + expectConfigIssue( + () => + loadRuntimeConfigV1({ + environment: [ + ['DATABREEZE_PROFILE', 'development'], + ['DATABREEZE_PROFILE', 'production'], + ], + }), + 'environment.DATABREEZE_PROFILE', + 'duplicate', + ); +}); + +test('rejects unknown DataBreeze environment keys but ignores host environment keys', () => { + expectConfigIssue( + () => + loadRuntimeConfigV1({ + environment: [ + ['PATH', 'not-product-configuration'], + ['DATABREEZE_PROFILE', 'development'], + ['DATABREEZE_UNKNOWN_OPTION', 'true'], + ], + }), + 'environment.DATABREEZE_UNKNOWN_OPTION', + 'unknown_key', + ); +}); + +test('rejects unknown nested override keys', () => { + expectConfigIssue( + () => + loadRuntimeConfigV1({ + environment: [['DATABREEZE_PROFILE', 'development']], + overrides: { providers: { ai: { mode: 'disabled', apiKey: 'not-allowed' } } }, + }), + 'overrides.providers.ai.apiKey', + 'unknown_key', + ); +}); + +for (const value of ['1e3', '01000', ' 1000', '1000 ']) { + test(`rejects ambiguous integer coercion from ${JSON.stringify(value)}`, () => { + expectConfigIssue( + () => + loadRuntimeConfigV1({ + environment: [ + ['DATABREEZE_PROFILE', 'development'], + ['DATABREEZE_PROVIDER_TIMEOUT_MS', value], + ], + }), + 'providerPolicy.timeoutMs', + 'invalid_integer', + ); + }); +} + +for (const value of ['1', 'TRUE', 'yes', 'false ']) { + test(`rejects ambiguous boolean coercion from ${JSON.stringify(value)}`, () => { + expectConfigIssue( + () => + loadRuntimeConfigV1({ + environment: [ + ['DATABREEZE_PROFILE', 'development'], + ['DATABREEZE_OBJECT_STORAGE_FORCE_PATH_STYLE', value], + ], + }), + 'providers.objectStorage.forcePathStyle', + 'invalid_boolean', + ); + }); +} + +test('rejects non-loopback cleartext origins even in development', () => { + expectConfigIssue( + () => + loadRuntimeConfigV1({ + environment: [ + ['DATABREEZE_PROFILE', 'development'], + ['DATABREEZE_OBJECT_STORAGE_ENDPOINT_URL', 'http://objects.example.test'], + ], + }), + 'providers.objectStorage.endpointUrl', + 'unsafe_url', + ); +}); + +test('rejects cleartext origins in production even when loopback', () => { + const environment = nonLocalEnvironment('production').map(([key, value]) => + key === 'DATABREEZE_OBJECT_STORAGE_ENDPOINT_URL' + ? [key, 'http://127.0.0.1:9000'] + : [key, value], + ); + + expectConfigIssue( + () => loadRuntimeConfigV1({ environment }), + 'providers.objectStorage.endpointUrl', + 'unsafe_url', + ); +}); + +test('rejects URL credentials without echoing them in the error', () => { + const exposed = 'top-secret-password'; + + assert.throws( + () => + loadRuntimeConfigV1({ + environment: [ + ['DATABREEZE_PROFILE', 'development'], + ['DATABREEZE_OBJECT_STORAGE_ENDPOINT_URL', `http://user:${exposed}@127.0.0.1:9000`], + ], + }), + (error) => { + assert.ok(error instanceof ConfigValidationErrorV1); + assert.doesNotMatch(error.message, new RegExp(exposed)); + assert.doesNotMatch(JSON.stringify(error), new RegExp(exposed)); + return true; + }, + ); +}); + +test('rejects endpoint query credentials without echoing them in the error', () => { + const exposed = 'query-token-that-must-not-escape'; + + assert.throws( + () => + loadRuntimeConfigV1({ + environment: [ + ['DATABREEZE_PROFILE', 'development'], + ['DATABREEZE_OBJECT_STORAGE_ENDPOINT_URL', `http://127.0.0.1:9000?token=${exposed}`], + ], + }), + (error) => { + assert.ok(error instanceof ConfigValidationErrorV1); + assert.ok( + error.issues.some( + (issue) => + issue.path === 'providers.objectStorage.endpointUrl' && issue.code === 'unsafe_url', + ), + ); + assert.doesNotMatch(error.message, new RegExp(exposed)); + assert.doesNotMatch(JSON.stringify(error), new RegExp(exposed)); + return true; + }, + ); +}); + +for (const reference of ['', 'super-secret-value', 'secret://production/changeme']) { + test(`rejects empty, raw, or placeholder credential input ${JSON.stringify(reference)}`, () => { + const environment = nonLocalEnvironment('production').map(([key, value]) => + key === 'DATABREEZE_OBJECT_STORAGE_CREDENTIAL_REF' ? [key, reference] : [key, value], + ); + + assert.throws( + () => loadRuntimeConfigV1({ environment }), + (error) => { + assert.ok(error instanceof ConfigValidationErrorV1); + assert.ok( + error.issues.some( + (issue) => + issue.path === 'providers.objectStorage.credentialRef' && + issue.code === 'invalid_secret_reference', + ), + ); + if (reference.length > 0) { + assert.doesNotMatch(error.message, new RegExp(reference.replaceAll('/', '\\/'))); + assert.doesNotMatch(JSON.stringify(error), new RegExp(reference.replaceAll('/', '\\/'))); + } + return true; + }, + ); + }); +} + +test('redacts valid secret references during string and JSON serialization', () => { + const config = loadRuntimeConfigV1({ environment: nonLocalEnvironment('production') }); + const reference = config.providers.objectStorage.credentialRef; + + assert.equal(String(reference), '[REDACTED_SECRET_REFERENCE]'); + assert.equal(JSON.stringify(reference), '"[REDACTED_SECRET_REFERENCE]"'); + assert.doesNotMatch(JSON.stringify(config), /secret:\/\//); +}); + +test('returns a deeply immutable configuration graph', () => { + const config = loadRuntimeConfigV1({ + environment: [['DATABREEZE_PROFILE', 'development']], + }); + + assert.equal(Object.isFrozen(config), true); + assert.equal(Object.isFrozen(config.providers), true); + assert.equal(Object.isFrozen(config.providers.objectStorage), true); + assert.equal(Object.isFrozen(config.providerPolicy), true); + assert.throws(() => { + config.providers.objectStorage.bucket = 'mutated'; + }, TypeError); + assert.equal(config.providers.objectStorage.bucket, 'databreeze-development'); +}); + +test('rejects settings on a provider explicitly disabled in a strict profile', () => { + const environment = [ + ...nonLocalEnvironment('staging'), + ['DATABREEZE_AI_ENDPOINT_URL', 'https://ai.example.test'], + ]; + + expectConfigIssue( + () => loadRuntimeConfigV1({ environment }), + 'providers.ai.endpointUrl', + 'forbidden_when_disabled', + ); +}); diff --git a/packages/config/tsconfig.build.json b/packages/config/tsconfig.build.json new file mode 100644 index 00000000..c85c6910 --- /dev/null +++ b/packages/config/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "lib": ["ES2024", "DOM"], + "outDir": "dist", + "rewriteRelativeImportExtensions": true, + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 00000000..fe24d784 --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "lib": ["ES2024", "DOM"], + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/config/turbo.json b/packages/config/turbo.json new file mode 100644 index 00000000..25e76c6b --- /dev/null +++ b/packages/config/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test": { + "outputs": [] + } + } +} diff --git a/packages/provider-ports/README.md b/packages/provider-ports/README.md new file mode 100644 index 00000000..3b193585 --- /dev/null +++ b/packages/provider-ports/README.md @@ -0,0 +1,55 @@ +# Provider Ports + +Pure, provider-neutral TypeScript contracts for replaceable DataBreeze infrastructure adapters. +This package defines boundaries only; it contains no provider implementation, network call, +credential value, persistence, framework, or SDK dependency. + +## Public interface + +`@databreeze/provider-ports/v1` exports common provider contracts plus ports for: + +- S3-compatible object storage; +- transactional email and Android push; +- OCR and structured AI assistance; +- optional DataBreeze subscription billing; +- telemetry export; and +- opaque secret-handle resolution. + +Every port shares descriptor, health, and state-export operations. A descriptor declares typed +capabilities, idempotency, cancellation, timeouts, retry limits, data regions, retention/training +behavior, failover/degraded behavior, and an exit/export format. Common helpers validate and freeze +that metadata, enforce cancellation/deadlines/idempotency, and normalize failures to safe stable +codes without retaining raw provider causes. Secret handles expose no value and redact string/JSON +serialization. + +There is intentionally no unversioned package root. Provider-specific identifiers may appear only +as opaque external references returned by an adapter; they never replace DataBreeze domain IDs or +become the only representation of customer state. + +## Payment boundary + +`PaymentsProviderPortV1` is restricted to hosted checkout/portal, subscription upsert, verified +subscription webhooks, and reconciliation for DataBreeze's own organization subscriptions. It has +no customer charge, capture, refund, transfer, withholding, reversal, settlement, or raw payment- +credential operation. Built-in Free/Development/Admin-granted entitlement operation remains +provider-independent; a missing payment adapter must not block it. + +## Forbidden dependencies + +- Provider/cloud SDKs and concrete adapters. +- Service/application implementations, databases, queues, filesystems, or UI frameworks. +- Raw secrets, API keys, payment credentials, or provider response bodies in errors. +- Product workflows, entitlement authority, storage authority, notification durability, OCR/AI + truth decisions, content-safe telemetry policy, and adapter failover orchestration. + +Concrete adapters, provider webhook persistence, telemetry allowlists/redaction, billing workflow, +object-placement authority, and provider selection/failover execution are deferred to their owning +plans. The ports deliberately preserve those boundaries rather than implementing them here. + +## Local commands + +```text +corepack pnpm --filter @databreeze/provider-ports test +corepack pnpm --filter @databreeze/provider-ports typecheck +corepack pnpm --filter @databreeze/provider-ports build +``` diff --git a/packages/provider-ports/package.json b/packages/provider-ports/package.json new file mode 100644 index 00000000..d943252c --- /dev/null +++ b/packages/provider-ports/package.json @@ -0,0 +1,17 @@ +{ + "name": "@databreeze/provider-ports", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./v1": { + "types": "./src/v1.ts", + "import": "./dist/v1.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json && node test/built-public-api-smoke.mjs", + "test": "node --test test/**/*.test.mjs", + "typecheck": "tsc --noEmit --project tsconfig.json" + } +} diff --git a/packages/provider-ports/src/common-v1.ts b/packages/provider-ports/src/common-v1.ts new file mode 100644 index 00000000..8ab0961c --- /dev/null +++ b/packages/provider-ports/src/common-v1.ts @@ -0,0 +1,494 @@ +export const PROVIDER_PORT_SCHEMA_VERSION_V1 = 1 as const; + +export type ProviderKindV1 = + | 'object-storage' + | 'email' + | 'push' + | 'ocr' + | 'ai' + | 'payments' + | 'telemetry' + | 'secrets'; + +export type ProviderErrorCodeV1 = + | 'INVALID_REQUEST' + | 'AUTHENTICATION_FAILED' + | 'AUTHORIZATION_DENIED' + | 'NOT_FOUND' + | 'CONFLICT' + | 'RATE_LIMITED' + | 'QUOTA_EXCEEDED' + | 'TIMEOUT' + | 'ABORTED' + | 'UNAVAILABLE' + | 'POLICY_DENIED' + | 'UNSUPPORTED' + | 'INTEGRITY_FAILED' + | 'UNKNOWN'; + +export type ProviderIdempotencyV1 = 'required' | 'supported' | 'not_applicable'; +export type ProviderCancellationV1 = 'cooperative' | 'supported' | 'not_supported'; +export type ProviderHealthStatusV1 = 'healthy' | 'degraded' | 'unavailable'; +export type ProviderContentRetentionV1 = 'none' | 'transient' | 'durable' | 'provider_policy'; +export type ProviderTrainingUseV1 = 'prohibited' | 'policy_controlled' | 'not_applicable'; +export type ProviderFailoverV1 = 'none' | 'manual' | 'automatic'; +export type ProviderDegradedBehaviorV1 = + | 'fail_closed' + | 'queue' + | 'local_fallback' + | 'in_app_only' + | 'read_only'; +export type ProviderStatePortabilityV1 = 'none' | 'manifest' | 'full'; +export type ProviderCredentialRevocationV1 = 'not_applicable' | 'supported' | 'manual'; + +export interface ProviderCapabilityV1 { + readonly operation: string; + readonly idempotency: ProviderIdempotencyV1; + readonly cancellation: ProviderCancellationV1; + readonly timeoutMs: number; + readonly maxAttempts: number; +} + +export interface ProviderDataHandlingV1 { + readonly regions: readonly string[]; + readonly contentRetention: ProviderContentRetentionV1; + readonly maximumRetentionSeconds?: number; + readonly trainingUse: ProviderTrainingUseV1; +} + +export interface ProviderResilienceV1 { + readonly failover: ProviderFailoverV1; + readonly degradedBehavior: ProviderDegradedBehaviorV1; +} + +export interface ProviderExitV1 { + readonly statePortability: ProviderStatePortabilityV1; + readonly exportFormat: string; + readonly credentialRevocation: ProviderCredentialRevocationV1; +} + +export interface ProviderDescriptorInputV1 { + readonly kind: K; + readonly adapterKey: string; + readonly capabilities: readonly ProviderCapabilityV1[]; + readonly dataHandling: ProviderDataHandlingV1; + readonly resilience: ProviderResilienceV1; + readonly exit: ProviderExitV1; +} + +export interface ProviderDescriptorV1 + extends ProviderDescriptorInputV1 { + readonly schemaVersion: typeof PROVIDER_PORT_SCHEMA_VERSION_V1; +} + +export class ProviderContractErrorV1 extends Error { + public readonly code = 'INVALID_DESCRIPTOR' as const; + + public constructor() { + super('Provider contract is invalid.'); + this.name = 'ProviderContractErrorV1'; + Object.freeze(this); + } + + public toJSON(): Readonly<{ name: string; code: 'INVALID_DESCRIPTOR' }> { + return Object.freeze({ name: this.name, code: this.code }); + } +} + +const providerKinds = new Set([ + 'object-storage', + 'email', + 'push', + 'ocr', + 'ai', + 'payments', + 'telemetry', + 'secrets', +]); +const idempotencyValues = new Set([ + 'required', + 'supported', + 'not_applicable', +]); +const cancellationValues = new Set([ + 'cooperative', + 'supported', + 'not_supported', +]); +const contentRetentionValues = new Set([ + 'none', + 'transient', + 'durable', + 'provider_policy', +]); +const trainingUseValues = new Set([ + 'prohibited', + 'policy_controlled', + 'not_applicable', +]); +const failoverValues = new Set(['none', 'manual', 'automatic']); +const degradedBehaviorValues = new Set([ + 'fail_closed', + 'queue', + 'local_fallback', + 'in_app_only', + 'read_only', +]); +const statePortabilityValues = new Set(['none', 'manifest', 'full']); +const credentialRevocationValues = new Set([ + 'not_applicable', + 'supported', + 'manual', +]); +const errorCodes = new Set([ + 'INVALID_REQUEST', + 'AUTHENTICATION_FAILED', + 'AUTHORIZATION_DENIED', + 'NOT_FOUND', + 'CONFLICT', + 'RATE_LIMITED', + 'QUOTA_EXCEEDED', + 'TIMEOUT', + 'ABORTED', + 'UNAVAILABLE', + 'POLICY_DENIED', + 'UNSUPPORTED', + 'INTEGRITY_FAILED', + 'UNKNOWN', +]); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isCapabilityArray(value: unknown): value is readonly ProviderCapabilityV1[] { + return Array.isArray(value); +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function isSafeToken(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= 200 && + value.trim() === value && + /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(value) + ); +} + +function isPositiveInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): value is number { + return Number.isSafeInteger(value) && (value as number) > 0 && (value as number) <= maximum; +} + +function isUtcTimestamp(value: unknown): value is string { + return ( + typeof value === 'string' && + /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{3})?Z$/.test( + value, + ) && + !Number.isNaN(Date.parse(value)) + ); +} + +function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +export function defineProviderDescriptorV1( + input: ProviderDescriptorInputV1, +): ProviderDescriptorV1 { + if ( + !isRecord(input) || + !providerKinds.has(input.kind) || + !isSafeToken(input.adapterKey) || + !isCapabilityArray(input.capabilities) || + input.capabilities.length === 0 || + !isRecord(input.dataHandling) || + !isRecord(input.resilience) || + !isRecord(input.exit) + ) { + throw new ProviderContractErrorV1(); + } + + const operations = new Set(); + for (const capability of input.capabilities) { + if ( + !isRecord(capability) || + !isSafeToken(capability['operation']) || + operations.has(capability['operation']) || + !idempotencyValues.has(capability['idempotency']) || + !cancellationValues.has(capability['cancellation']) || + !isPositiveInteger(capability['timeoutMs'], 300_000) || + !isPositiveInteger(capability['maxAttempts'], 20) + ) { + throw new ProviderContractErrorV1(); + } + operations.add(capability['operation']); + } + + const dataHandling = input.dataHandling; + if ( + !isStringArray(dataHandling.regions) || + dataHandling.regions.length === 0 || + dataHandling.regions.some((region) => !isSafeToken(region)) || + !contentRetentionValues.has(dataHandling.contentRetention) || + !trainingUseValues.has(dataHandling.trainingUse) || + (dataHandling.maximumRetentionSeconds !== undefined && + !isPositiveInteger(dataHandling.maximumRetentionSeconds)) + ) { + throw new ProviderContractErrorV1(); + } + + if ( + !failoverValues.has(input.resilience.failover) || + !degradedBehaviorValues.has(input.resilience.degradedBehavior) || + !statePortabilityValues.has(input.exit.statePortability) || + !isSafeToken(input.exit.exportFormat) || + !credentialRevocationValues.has(input.exit.credentialRevocation) + ) { + throw new ProviderContractErrorV1(); + } + + return deepFreeze({ + schemaVersion: PROVIDER_PORT_SCHEMA_VERSION_V1, + kind: input.kind, + adapterKey: input.adapterKey, + capabilities: input.capabilities.map((capability) => ({ + operation: capability.operation, + idempotency: capability.idempotency, + cancellation: capability.cancellation, + timeoutMs: capability.timeoutMs, + maxAttempts: capability.maxAttempts, + })), + dataHandling: { + regions: [...input.dataHandling.regions], + contentRetention: input.dataHandling.contentRetention, + ...(input.dataHandling.maximumRetentionSeconds === undefined + ? {} + : { maximumRetentionSeconds: input.dataHandling.maximumRetentionSeconds }), + trainingUse: input.dataHandling.trainingUse, + }, + resilience: { ...input.resilience }, + exit: { ...input.exit }, + }); +} + +export interface ProviderAbortSignalV1 { + readonly aborted: boolean; +} + +export interface ProviderInvocationContextInputV1 { + readonly operationId: string; + readonly correlationId: string; + readonly deadlineAt: string; + readonly timeoutMs: number; + readonly idempotencyKey?: string; + readonly abortSignal: ProviderAbortSignalV1; +} + +export type ProviderInvocationContextV1 = ProviderInvocationContextInputV1; + +export function createProviderInvocationContextV1( + input: ProviderInvocationContextInputV1, +): ProviderInvocationContextV1 { + if ( + !isRecord(input) || + !isSafeToken(input.operationId) || + !isSafeToken(input.correlationId) || + !isUtcTimestamp(input.deadlineAt) || + !isPositiveInteger(input.timeoutMs, 300_000) || + (input.idempotencyKey !== undefined && !isSafeToken(input.idempotencyKey)) || + !isRecord(input.abortSignal) || + typeof input.abortSignal.aborted !== 'boolean' + ) { + throw createProviderFailureV1({ + code: 'INVALID_REQUEST', + operation: 'create-invocation-context', + retryable: false, + safeMessageKey: 'provider.invalid_request', + }); + } + + const sourceAbortSignal = input.abortSignal; + const abortSignal: ProviderAbortSignalV1 = Object.freeze({ + get aborted() { + return sourceAbortSignal.aborted; + }, + }); + + return deepFreeze({ + operationId: input.operationId, + correlationId: input.correlationId, + deadlineAt: input.deadlineAt, + timeoutMs: input.timeoutMs, + ...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }), + abortSignal, + }); +} + +export interface ProviderFailureInputV1 { + readonly code: ProviderErrorCodeV1; + readonly providerKind?: ProviderKindV1; + readonly operation: string; + readonly retryable: boolean; + readonly retryAfterMs?: number; + readonly safeMessageKey: string; + readonly providerCause?: unknown; +} + +export class ProviderOperationErrorV1 extends Error { + public readonly code: ProviderErrorCodeV1; + public readonly providerKind?: ProviderKindV1; + public readonly operation: string; + public readonly retryable: boolean; + public readonly retryAfterMs?: number; + public readonly safeMessageKey: string; + + public constructor(input: Omit) { + super('Provider operation failed.'); + this.name = 'ProviderOperationErrorV1'; + this.code = input.code; + if (input.providerKind !== undefined) this.providerKind = input.providerKind; + this.operation = input.operation; + this.retryable = input.retryable; + if (input.retryAfterMs !== undefined) this.retryAfterMs = input.retryAfterMs; + this.safeMessageKey = input.safeMessageKey; + Object.freeze(this); + } + + public toJSON(): Readonly> { + return Object.freeze({ + name: this.name, + code: this.code, + ...(this.providerKind === undefined ? {} : { providerKind: this.providerKind }), + operation: this.operation, + retryable: this.retryable, + ...(this.retryAfterMs === undefined ? {} : { retryAfterMs: this.retryAfterMs }), + safeMessageKey: this.safeMessageKey, + }); + } +} + +export function createProviderFailureV1(input: ProviderFailureInputV1): ProviderOperationErrorV1 { + void input.providerCause; + if ( + !errorCodes.has(input.code) || + (input.providerKind !== undefined && !providerKinds.has(input.providerKind)) || + !isSafeToken(input.operation) || + typeof input.retryable !== 'boolean' || + (input.retryAfterMs !== undefined && !isPositiveInteger(input.retryAfterMs, 86_400_000)) || + !isSafeToken(input.safeMessageKey) + ) { + return new ProviderOperationErrorV1({ + code: 'UNKNOWN', + operation: 'invalid-provider-failure', + retryable: false, + safeMessageKey: 'provider.unknown', + }); + } + return new ProviderOperationErrorV1(input); +} + +export function assertProviderInvocationActiveV1( + context: ProviderInvocationContextV1, + now: string, +): void { + if (context.abortSignal.aborted) { + throw createProviderFailureV1({ + code: 'ABORTED', + operation: context.operationId, + retryable: false, + safeMessageKey: 'provider.aborted', + }); + } + if (!isUtcTimestamp(now) || Date.parse(now) >= Date.parse(context.deadlineAt)) { + throw createProviderFailureV1({ + code: 'TIMEOUT', + operation: context.operationId, + retryable: true, + safeMessageKey: 'provider.timeout', + }); + } +} + +export function requireProviderIdempotencyV1(context: ProviderInvocationContextV1): string { + if (!isSafeToken(context.idempotencyKey)) { + throw createProviderFailureV1({ + code: 'INVALID_REQUEST', + operation: context.operationId, + retryable: false, + safeMessageKey: 'provider.idempotency_required', + }); + } + return context.idempotencyKey; +} + +export interface ProviderHealthInputV1 { + readonly status: ProviderHealthStatusV1; + readonly checkedAt: string; + readonly latencyMs?: number; + readonly safeReasonCodes: readonly string[]; +} + +export type ProviderHealthV1 = ProviderHealthInputV1; + +export function defineProviderHealthV1(input: ProviderHealthInputV1): ProviderHealthV1 { + if ( + !(['healthy', 'degraded', 'unavailable'] as const).includes(input.status) || + !isUtcTimestamp(input.checkedAt) || + (input.latencyMs !== undefined && + (!Number.isSafeInteger(input.latencyMs) || input.latencyMs < 0)) || + !isStringArray(input.safeReasonCodes) || + input.safeReasonCodes.some((code) => !isSafeToken(code)) + ) { + throw new ProviderContractErrorV1(); + } + return deepFreeze({ + status: input.status, + checkedAt: input.checkedAt, + ...(input.latencyMs === undefined ? {} : { latencyMs: input.latencyMs }), + safeReasonCodes: input.safeReasonCodes.map((code) => code), + }); +} + +export interface SecretHandleV1 { + readonly kind: 'secret-handle'; + readonly expiresAt?: string; + toString(): '[REDACTED_SECRET_HANDLE]'; + toJSON(): '[REDACTED_SECRET_HANDLE]'; +} + +const secretHandleIds = new WeakMap(); + +export function defineSecretHandleV1(input: { + readonly handleId: string; + readonly expiresAt?: string; +}): SecretHandleV1 { + if ( + !isSafeToken(input.handleId) || + (input.expiresAt !== undefined && !isUtcTimestamp(input.expiresAt)) + ) { + throw new ProviderContractErrorV1(); + } + const handle: SecretHandleV1 = { + kind: 'secret-handle', + ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }), + toString: () => '[REDACTED_SECRET_HANDLE]', + toJSON: () => '[REDACTED_SECRET_HANDLE]', + }; + secretHandleIds.set(handle, input.handleId); + return Object.freeze(handle); +} + +export function secretHandleIdV1(handle: SecretHandleV1): string { + const id = secretHandleIds.get(handle); + if (id === undefined) throw new TypeError('Unknown secret handle.'); + return id; +} diff --git a/packages/provider-ports/src/ports-v1.ts b/packages/provider-ports/src/ports-v1.ts new file mode 100644 index 00000000..e540457d --- /dev/null +++ b/packages/provider-ports/src/ports-v1.ts @@ -0,0 +1,270 @@ +import type { + ProviderDescriptorV1, + ProviderHealthV1, + ProviderInvocationContextV1, + ProviderKindV1, + SecretHandleV1, +} from './common-v1.ts'; + +export interface ProviderStateExportRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly cursor?: string; + readonly limit: number; +} + +export interface ProviderStateExportResultV1 { + readonly manifestFormat: string; + readonly entries: readonly Readonly>[]; + readonly nextCursor?: string; + readonly complete: boolean; +} + +export interface ProviderPortV1 { + descriptor(): ProviderDescriptorV1; + checkHealth(context: ProviderInvocationContextV1): Promise; + exportState(request: ProviderStateExportRequestV1): Promise; +} + +export interface ObjectStoragePutRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly objectKey: string; + readonly content: Uint8Array; + readonly sha256: string; +} + +export interface ObjectStoragePutResultV1 { + readonly objectRef: string; + readonly sha256: string; + readonly byteLength: number; +} + +export interface ObjectStorageRangeRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly objectRef: string; + readonly offset: number; + readonly length: number; +} + +export interface ObjectStorageDigestRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly objectRef: string; + readonly expectedSha256: string; +} + +export interface ObjectStorageRetentionRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly objectRef: string; + readonly retainUntil: string; +} + +export interface ObjectStorageDeleteRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly objectRef: string; + readonly expectedSha256: string; +} + +export interface ObjectStorageReadGrantRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly objectRef: string; + readonly disposition: 'inline' | 'attachment'; + readonly expiresAt: string; +} + +export interface ObjectStorageReadGrantV1 { + readonly grantRef: string; + readonly expiresAt: string; +} + +export interface ObjectStorageProviderPortV1 extends ProviderPortV1<'object-storage'> { + putImmutable(request: ObjectStoragePutRequestV1): Promise; + readRange(request: ObjectStorageRangeRequestV1): Promise; + verifyDigest(request: ObjectStorageDigestRequestV1): Promise>; + applyRetention(request: ObjectStorageRetentionRequestV1): Promise>; + deleteVerified(request: ObjectStorageDeleteRequestV1): Promise>; + createReadGrant(request: ObjectStorageReadGrantRequestV1): Promise; +} + +export interface ExternalDeliveryWebhookRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly headers: Readonly>; + readonly body: Uint8Array; +} + +export interface ExternalDeliveryEventV1 { + readonly providerMessageRef: string; + readonly status: 'accepted' | 'delivered' | 'bounced' | 'failed' | 'suppressed'; + readonly occurredAt: string; + readonly eventId: string; +} + +export interface EmailTemplateRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly recipientHandle: string; + readonly locale: 'vi-VN' | 'en'; + readonly templateKey: string; + readonly safeParameters: Readonly>; +} + +export interface EmailProviderPortV1 extends ProviderPortV1<'email'> { + sendTemplate(request: EmailTemplateRequestV1): Promise>; + verifyDeliveryWebhook( + request: ExternalDeliveryWebhookRequestV1, + ): Promise; +} + +export interface PushRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly recipientTokenHandle: string; + readonly locale: 'vi-VN' | 'en'; + readonly messageKey: string; + readonly opaqueDeepLinkToken: string; + readonly safeParameters: Readonly>; +} + +export interface PushProviderPortV1 extends ProviderPortV1<'push'> { + send(request: PushRequestV1): Promise>; + verifyDeliveryWebhook( + request: ExternalDeliveryWebhookRequestV1, + ): Promise; +} + +export interface ProviderContentReferenceV1 { + readonly handle: string; + readonly mediaType: string; + readonly sha256: string; + readonly byteLength: number; +} + +export interface OcrRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly input: ProviderContentReferenceV1; + readonly localeHints: readonly string[]; + readonly outputSchemaId: string; +} + +export interface OcrResultV1 { + readonly text: string; + readonly confidence: number; + readonly evidenceRegions: readonly Readonly>[]; + readonly providerModelRef: string; +} + +export interface OcrProviderPortV1 extends ProviderPortV1<'ocr'> { + extract(request: OcrRequestV1): Promise; +} + +export interface AiStructuredRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly taskType: string; + readonly modelPolicyRef: string; + readonly inputRefs: readonly ProviderContentReferenceV1[]; + readonly outputSchemaId: string; + readonly deterministicPlanRequired: boolean; +} + +export interface AiStructuredResultV1 { + readonly output: Readonly>; + readonly confidence: number; + readonly providerModelRef: string; + readonly configurationRef: string; +} + +export interface AiProviderPortV1 extends ProviderPortV1<'ai'> { + generateStructured(request: AiStructuredRequestV1): Promise; +} + +export interface DatabreezeSubscriptionRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly organizationId: string; + readonly planVersionId: string; + readonly externalPriceRef: string; +} + +export interface HostedSubscriptionCheckoutRequestV1 extends DatabreezeSubscriptionRequestV1 { + readonly successUrl: string; + readonly cancelUrl: string; +} + +export interface SubscriptionPortalRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly organizationId: string; + readonly providerCustomerRef: string; + readonly returnUrl: string; +} + +export interface SubscriptionProviderReferenceV1 { + readonly providerCustomerRef: string; + readonly providerSubscriptionRef?: string; + readonly hostedUrl?: string; +} + +export interface SubscriptionWebhookEventV1 { + readonly eventId: string; + readonly providerObjectRef: string; + readonly eventType: string; + readonly occurredAt: string; + readonly payloadDigest: string; +} + +export interface SubscriptionReconciliationV1 { + readonly providerObjectRef: string; + readonly state: 'trialing' | 'active' | 'past_due' | 'cancel_at_period_end' | 'cancelled'; + readonly effectiveAt: string; + readonly externalPriceRef: string; +} + +export interface PaymentsProviderPortV1 extends ProviderPortV1<'payments'> { + createHostedSubscriptionCheckout( + request: HostedSubscriptionCheckoutRequestV1, + ): Promise; + createSubscriptionPortal( + request: SubscriptionPortalRequestV1, + ): Promise; + upsertDatabreezeSubscription( + request: DatabreezeSubscriptionRequestV1, + ): Promise; + verifySubscriptionWebhook( + request: ExternalDeliveryWebhookRequestV1, + ): Promise; + reconcileDatabreezeSubscription( + request: Readonly<{ + context: ProviderInvocationContextV1; + providerSubscriptionRef: string; + }>, + ): Promise; +} + +export interface SafeTelemetryRecordV1 { + readonly signal: 'metric' | 'trace' | 'log'; + readonly name: string; + readonly timestamp: string; + readonly correlationId: string; + readonly safeAttributes: Readonly>; +} + +export interface TelemetryProviderPortV1 extends ProviderPortV1<'telemetry'> { + exportBatch( + request: Readonly<{ + context: ProviderInvocationContextV1; + batchId: string; + records: readonly SafeTelemetryRecordV1[]; + }>, + ): Promise>; +} + +export interface SecretReferenceRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly reference: string; + readonly purpose: string; + readonly expiresAt: string; +} + +export interface SecretsProviderPortV1 extends ProviderPortV1<'secrets'> { + resolveHandle(request: SecretReferenceRequestV1): Promise; + revokeHandle( + request: Readonly<{ + context: ProviderInvocationContextV1; + handle: SecretHandleV1; + }>, + ): Promise>; +} diff --git a/packages/provider-ports/src/v1.ts b/packages/provider-ports/src/v1.ts new file mode 100644 index 00000000..f8dff001 --- /dev/null +++ b/packages/provider-ports/src/v1.ts @@ -0,0 +1,2 @@ +export * from './common-v1.ts'; +export * from './ports-v1.ts'; diff --git a/packages/provider-ports/test/built-public-api-smoke.mjs b/packages/provider-ports/test/built-public-api-smoke.mjs new file mode 100644 index 00000000..3dad2ac1 --- /dev/null +++ b/packages/provider-ports/test/built-public-api-smoke.mjs @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; + +const ports = await import('../dist/v1.js'); + +assert.equal(ports.PROVIDER_PORT_SCHEMA_VERSION_V1, 1); +assert.equal(typeof ports.defineProviderDescriptorV1, 'function'); +assert.equal(typeof ports.createProviderFailureV1, 'function'); +assert.equal(typeof ports.defineSecretHandleV1, 'function'); diff --git a/packages/provider-ports/test/common-v1.test.mjs b/packages/provider-ports/test/common-v1.test.mjs new file mode 100644 index 00000000..d4a8af07 --- /dev/null +++ b/packages/provider-ports/test/common-v1.test.mjs @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +const ports = await import('../src/v1.ts'); + +function validDescriptor(kind = 'object-storage') { + return { + kind, + adapterKey: 'in-memory-v1', + capabilities: [ + { + operation: 'put-immutable', + idempotency: 'required', + cancellation: 'cooperative', + timeoutMs: 5_000, + maxAttempts: 3, + }, + ], + dataHandling: { + regions: ['local'], + contentRetention: 'durable', + maximumRetentionSeconds: 86_400, + trainingUse: 'not_applicable', + }, + resilience: { + failover: 'manual', + degradedBehavior: 'fail_closed', + }, + exit: { + statePortability: 'full', + exportFormat: 'databreeze-object-manifest-v1', + credentialRevocation: 'supported', + }, + }; +} + +test('defines and deeply freezes complete provider metadata', () => { + const descriptor = ports.defineProviderDescriptorV1(validDescriptor()); + + assert.equal(descriptor.schemaVersion, 1); + assert.equal(descriptor.kind, 'object-storage'); + assert.equal(descriptor.capabilities[0].operation, 'put-immutable'); + assert.equal(Object.isFrozen(descriptor), true); + assert.equal(Object.isFrozen(descriptor.capabilities), true); + assert.equal(Object.isFrozen(descriptor.dataHandling.regions), true); + assert.throws(() => descriptor.capabilities.push({}), TypeError); +}); + +test('rejects duplicate capability operations', () => { + const input = validDescriptor(); + input.capabilities.push({ ...input.capabilities[0] }); + + assert.throws( + () => ports.defineProviderDescriptorV1(input), + (error) => + error instanceof ports.ProviderContractErrorV1 && error.code === 'INVALID_DESCRIPTOR', + ); +}); + +test('rejects incomplete retry, data-handling, resilience, and exit metadata', () => { + const input = validDescriptor(); + delete input.capabilities[0].timeoutMs; + delete input.dataHandling.trainingUse; + delete input.resilience.degradedBehavior; + delete input.exit.statePortability; + + assert.throws( + () => ports.defineProviderDescriptorV1(input), + (error) => + error instanceof ports.ProviderContractErrorV1 && error.code === 'INVALID_DESCRIPTOR', + ); +}); + +test('normalizes provider failures without retaining provider causes or secret values', () => { + const secret = 'provider-token-that-must-not-escape'; + const error = ports.createProviderFailureV1({ + code: 'RATE_LIMITED', + providerKind: 'email', + operation: 'send-template', + retryable: true, + retryAfterMs: 2_000, + safeMessageKey: 'provider.rate_limited', + providerCause: new Error(secret), + }); + + assert.ok(error instanceof ports.ProviderOperationErrorV1); + assert.deepEqual(JSON.parse(JSON.stringify(error)), { + name: 'ProviderOperationErrorV1', + code: 'RATE_LIMITED', + providerKind: 'email', + operation: 'send-template', + retryable: true, + retryAfterMs: 2_000, + safeMessageKey: 'provider.rate_limited', + }); + assert.doesNotMatch(error.message, new RegExp(secret)); + assert.doesNotMatch(JSON.stringify(error), new RegExp(secret)); + assert.equal('cause' in error, false); +}); + +test('creates immutable invocation metadata with deadline, timeout, cancellation, and idempotency', () => { + const context = ports.createProviderInvocationContextV1({ + operationId: 'op-0001', + correlationId: 'corr-0001', + deadlineAt: '2026-08-01T10:00:05.000Z', + timeoutMs: 5_000, + idempotencyKey: 'idem-0001', + abortSignal: { aborted: false }, + }); + + assert.equal(Object.isFrozen(context), true); + assert.equal(Object.isFrozen(context.abortSignal), true); + assert.doesNotThrow(() => + ports.assertProviderInvocationActiveV1(context, '2026-08-01T10:00:00.000Z'), + ); + assert.doesNotThrow(() => ports.requireProviderIdempotencyV1(context)); +}); + +test('rejects an aborted invocation with a normalized non-retryable error', () => { + const context = ports.createProviderInvocationContextV1({ + operationId: 'op-aborted', + correlationId: 'corr-aborted', + deadlineAt: '2026-08-01T10:00:05.000Z', + timeoutMs: 5_000, + abortSignal: { aborted: true }, + }); + + assert.throws( + () => ports.assertProviderInvocationActiveV1(context, '2026-08-01T10:00:00.000Z'), + (error) => + error instanceof ports.ProviderOperationErrorV1 && + error.code === 'ABORTED' && + error.retryable === false, + ); +}); + +test('observes cancellation that occurs after invocation context creation', () => { + const abortSignal = { aborted: false }; + const context = ports.createProviderInvocationContextV1({ + operationId: 'op-later-abort', + correlationId: 'corr-later-abort', + deadlineAt: '2026-08-01T10:00:05.000Z', + timeoutMs: 5_000, + abortSignal, + }); + + abortSignal.aborted = true; + + assert.throws( + () => ports.assertProviderInvocationActiveV1(context, '2026-08-01T10:00:00.000Z'), + (error) => error instanceof ports.ProviderOperationErrorV1 && error.code === 'ABORTED', + ); +}); + +test('rejects an expired invocation with a normalized timeout error', () => { + const context = ports.createProviderInvocationContextV1({ + operationId: 'op-timeout', + correlationId: 'corr-timeout', + deadlineAt: '2026-08-01T10:00:05.000Z', + timeoutMs: 5_000, + abortSignal: { aborted: false }, + }); + + assert.throws( + () => ports.assertProviderInvocationActiveV1(context, '2026-08-01T10:00:05.000Z'), + (error) => + error instanceof ports.ProviderOperationErrorV1 && + error.code === 'TIMEOUT' && + error.retryable === true, + ); +}); + +test('requires idempotency only when an operation declares it', () => { + const context = ports.createProviderInvocationContextV1({ + operationId: 'op-no-idempotency', + correlationId: 'corr-no-idempotency', + deadlineAt: '2026-08-01T10:00:05.000Z', + timeoutMs: 5_000, + abortSignal: { aborted: false }, + }); + + assert.throws( + () => ports.requireProviderIdempotencyV1(context), + (error) => error instanceof ports.ProviderOperationErrorV1 && error.code === 'INVALID_REQUEST', + ); +}); + +test('defines provider health with safe reason codes and no raw detail channel', () => { + const health = ports.defineProviderHealthV1({ + status: 'degraded', + checkedAt: '2026-08-01T10:00:00.000Z', + latencyMs: 125, + safeReasonCodes: ['UPSTREAM_RATE_LIMITED'], + }); + + assert.deepEqual(health, { + status: 'degraded', + checkedAt: '2026-08-01T10:00:00.000Z', + latencyMs: 125, + safeReasonCodes: ['UPSTREAM_RATE_LIMITED'], + }); + assert.equal(Object.isFrozen(health.safeReasonCodes), true); +}); + +test('creates opaque secret handles that redact serialization', () => { + const handle = ports.defineSecretHandleV1({ + handleId: 'opaque-secret-handle', + expiresAt: '2026-08-01T10:05:00.000Z', + }); + + assert.equal(String(handle), '[REDACTED_SECRET_HANDLE]'); + assert.equal(JSON.stringify(handle), '"[REDACTED_SECRET_HANDLE]"'); + assert.equal(ports.secretHandleIdV1(handle), 'opaque-secret-handle'); +}); diff --git a/packages/provider-ports/test/interchangeability-v1.test.mjs b/packages/provider-ports/test/interchangeability-v1.test.mjs new file mode 100644 index 00000000..6d8427e1 --- /dev/null +++ b/packages/provider-ports/test/interchangeability-v1.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + assertProviderInvocationActiveV1, + defineProviderDescriptorV1, + requireProviderIdempotencyV1, +} from '../src/v1.ts'; + +const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function descriptor(adapterKey) { + return defineProviderDescriptorV1({ + kind: 'object-storage', + adapterKey, + capabilities: [ + { + operation: 'put-immutable', + idempotency: 'required', + cancellation: 'cooperative', + timeoutMs: 5_000, + maxAttempts: 3, + }, + ], + dataHandling: { + regions: ['local'], + contentRetention: 'durable', + maximumRetentionSeconds: 86_400, + trainingUse: 'not_applicable', + }, + resilience: { failover: 'manual', degradedBehavior: 'fail_closed' }, + exit: { + statePortability: 'full', + exportFormat: 'databreeze-object-manifest-v1', + credentialRevocation: 'supported', + }, + }); +} + +function mapStorageFake() { + const byIdempotencyKey = new Map(); + return { + descriptor: () => descriptor('map-memory-v1'), + async putImmutable(request) { + const prior = byIdempotencyKey.get(request.context.idempotencyKey); + if (prior !== undefined) return prior; + const result = Object.freeze({ + objectRef: `object:${request.objectKey}`, + sha256: request.sha256, + byteLength: request.content.byteLength, + }); + byIdempotencyKey.set(request.context.idempotencyKey, result); + return result; + }, + }; +} + +function recordStorageFake() { + const byIdempotencyKey = Object.create(null); + return { + descriptor: () => descriptor('record-memory-v1'), + async putImmutable(request) { + const key = request.context.idempotencyKey; + byIdempotencyKey[key] ??= Object.freeze({ + objectRef: `object:${request.objectKey}`, + sha256: request.sha256, + byteLength: request.content.byteLength, + }); + return byIdempotencyKey[key]; + }, + }; +} + +async function storeTwice(port, context) { + assertProviderInvocationActiveV1(context, '2026-08-01T10:00:00.000Z'); + requireProviderIdempotencyV1(context); + const request = { + context, + objectKey: 'workspace/object-1', + content: new Uint8Array([1, 2, 3]), + sha256: 'a'.repeat(64), + }; + return Promise.all([port.putImmutable(request), port.putImmutable(request)]); +} + +for (const [name, createFake] of [ + ['map-backed adapter', mapStorageFake], + ['record-backed adapter', recordStorageFake], +]) { + test(`uses the same object-storage contract with a ${name}`, async () => { + const context = { + operationId: 'op-interchangeable', + correlationId: 'corr-interchangeable', + deadlineAt: '2026-08-01T10:00:05.000Z', + timeoutMs: 5_000, + idempotencyKey: 'idem-interchangeable', + abortSignal: { aborted: false }, + }; + const [first, replay] = await storeTwice(createFake(), context); + + assert.deepEqual(first, { + objectRef: 'object:workspace/object-1', + sha256: 'a'.repeat(64), + byteLength: 3, + }); + assert.equal(first, replay, 'an idempotent replay returns the original receipt'); + }); +} + +test('declares no provider SDK or service implementation dependency', () => { + const manifest = JSON.parse(readFileSync(path.join(packageDirectory, 'package.json'), 'utf8')); + + assert.deepEqual(manifest.dependencies ?? {}, {}); + assert.deepEqual(manifest.optionalDependencies ?? {}, {}); +}); diff --git a/packages/provider-ports/test/ports-v1.type-test.ts b/packages/provider-ports/test/ports-v1.type-test.ts new file mode 100644 index 00000000..b7fcc5d7 --- /dev/null +++ b/packages/provider-ports/test/ports-v1.type-test.ts @@ -0,0 +1,55 @@ +import type { + AiProviderPortV1, + EmailProviderPortV1, + ObjectStorageProviderPortV1, + OcrProviderPortV1, + PaymentsProviderPortV1, + PushProviderPortV1, + SecretsProviderPortV1, + TelemetryProviderPortV1, +} from '@databreeze/provider-ports/v1'; + +declare const objectStorage: ObjectStorageProviderPortV1; +declare const email: EmailProviderPortV1; +declare const push: PushProviderPortV1; +declare const ocr: OcrProviderPortV1; +declare const ai: AiProviderPortV1; +declare const payments: PaymentsProviderPortV1; +declare const telemetry: TelemetryProviderPortV1; +declare const secrets: SecretsProviderPortV1; + +void objectStorage.putImmutable; +void objectStorage.readRange; +void objectStorage.verifyDigest; +void objectStorage.applyRetention; +void objectStorage.deleteVerified; +void objectStorage.createReadGrant; +void email.sendTemplate; +void email.verifyDeliveryWebhook; +void push.send; +void push.verifyDeliveryWebhook; +void ocr.extract; +void ai.generateStructured; +void payments.createHostedSubscriptionCheckout; +void payments.createSubscriptionPortal; +void payments.upsertDatabreezeSubscription; +void payments.verifySubscriptionWebhook; +void payments.reconcileDatabreezeSubscription; +void telemetry.exportBatch; +void secrets.resolveHandle; +void secrets.revokeHandle; + +for (const port of [objectStorage, email, push, ocr, ai, payments, telemetry, secrets]) { + void port.descriptor; + void port.checkHealth; + void port.exportState; +} + +// @ts-expect-error -- the billing port cannot charge customer funds. +void payments.chargeCustomer; +// @ts-expect-error -- the billing port cannot refund customer funds. +void payments.refundPayment; +// @ts-expect-error -- the billing port cannot transfer or settle customer funds. +void payments.transferFunds; +// @ts-expect-error -- the billing port cannot capture raw payment credentials. +void payments.attachPaymentMethod; diff --git a/packages/provider-ports/test/public-api-v1.test.mjs b/packages/provider-ports/test/public-api-v1.test.mjs new file mode 100644 index 00000000..860abd19 --- /dev/null +++ b/packages/provider-ports/test/public-api-v1.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('publishes versioned provider boundaries', async () => { + let ports; + try { + ports = await import('../src/v1.ts'); + } catch { + ports = undefined; + } + + assert.ok(ports, 'the provider v1 source entry point must exist'); + assert.equal(ports.PROVIDER_PORT_SCHEMA_VERSION_V1, 1); + assert.equal(typeof ports.defineProviderDescriptorV1, 'function'); +}); + +test('exposes only the versioned provider entry point', async () => { + const manifest = JSON.parse(readFileSync(path.join(packageDirectory, 'package.json'), 'utf8')); + assert.deepEqual(Object.keys(manifest.exports), ['./v1']); + await assert.rejects(import('@databreeze/provider-ports'), { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }); +}); diff --git a/packages/provider-ports/tsconfig.build.json b/packages/provider-ports/tsconfig.build.json new file mode 100644 index 00000000..ffb43181 --- /dev/null +++ b/packages/provider-ports/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "dist", + "rewriteRelativeImportExtensions": true, + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/provider-ports/tsconfig.json b/packages/provider-ports/tsconfig.json new file mode 100644 index 00000000..28754dda --- /dev/null +++ b/packages/provider-ports/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/provider-ports/turbo.json b/packages/provider-ports/turbo.json new file mode 100644 index 00000000..25e76c6b --- /dev/null +++ b/packages/provider-ports/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test": { + "outputs": [] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b22936d..f5863b77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,8 @@ importers: specifier: 8.43.0 version: 8.43.0(eslint@9.36.0)(typescript@5.9.2) + packages/config: {} + packages/contracts: dependencies: ajv: @@ -42,6 +44,8 @@ importers: specifier: workspace:* version: link:../contracts + packages/provider-ports: {} + packages/test-fixtures: {} tools/fixture-validation: From 8151055db9e6660035e888a7700d564c1af9282b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 08:02:26 +0700 Subject: [PATCH 19/51] fix(config): close provider boundary gaps --- packages/config/README.md | 24 +- packages/config/package.json | 3 + .../config/src/runtime-config/loader-v1.ts | 207 ++++-- .../config/src/runtime-config/types-v1.ts | 34 +- .../config/test/built-public-api-smoke.mjs | 3 +- .../test/review-regressions-v1.test.mjs | 307 ++++++++ packages/config/test/runtime-v1.test.mjs | 16 +- packages/provider-ports/README.md | 35 +- packages/provider-ports/package.json | 3 + packages/provider-ports/src/common-v1.ts | 653 ++++++++++++++---- packages/provider-ports/src/ports-v1.ts | 491 ++++++++++++- .../test/built-public-api-smoke.mjs | 5 + .../provider-ports/test/common-v1.test.mjs | 117 ++-- .../test/interchangeability-v1.test.mjs | 247 +++++-- .../provider-ports/test/ports-v1.type-test.ts | 70 +- .../test/public-api-v1.test.mjs | 2 + .../test/review-regressions-v1.test.mjs | 294 ++++++++ pnpm-lock.yaml | 12 +- 18 files changed, 2119 insertions(+), 404 deletions(-) create mode 100644 packages/config/test/review-regressions-v1.test.mjs create mode 100644 packages/provider-ports/test/review-regressions-v1.test.mjs diff --git a/packages/config/README.md b/packages/config/README.md index 85dc643c..db948724 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -13,8 +13,9 @@ itself, contact a provider, or choose product policy. - the five explicit profiles: `development`, `test`, `preview`, `staging`, and `production`; - typed object-storage, email, push, OCR, AI, payments, telemetry, and secrets selections; - `ConfigValidationErrorV1`, whose diagnostics contain only safe paths and codes; and -- opaque `SecretReferenceV1` values. Their string/JSON representation is redacted, while - `secretReferenceHandleV1` gives trusted composition code the reference needed by a secrets port. +- canonical `SecretReferenceV1` identifier objects shared with the secrets port. References expose + only validated namespace/path/version identifiers, serialize as redacted values, and have no raw + string extractor. There is intentionally no unversioned package root. @@ -29,12 +30,16 @@ and those defaults use loopback endpoints, in-memory/local facilities, or disabl Preview, staging, and production have no provider-selection defaults: all eight provider modes must be declared; object storage and secrets must be remote; every other port may be explicitly disabled. -Environment parsing is exact. Unknown DataBreeze keys, duplicate entry-list keys, whitespace or -alternate boolean/integer spellings, unknown structured override fields, incomplete active -providers, and fields attached to a disabled provider are rejected. Cleartext endpoints are allowed -only for an explicitly local adapter on loopback in development/test. URLs with credentials and -all cleartext nonlocal endpoints are rejected. Configuration accepts secret references, never API -keys, passwords, tokens, webhook secrets, or other credential values. +Environment and override inputs are snapshotted from own data-property descriptors before parsing; +accessors and failed proxy inspection become bounded, stable, redacted validation diagnostics. +Unknown DataBreeze keys, duplicate entry-list keys, whitespace or alternate boolean/integer +spellings, unknown structured override fields, incomplete active providers, and fields attached to +a disabled provider are rejected. A higher-precedence provider `mode` that changes the selected +variant atomically replaces the lower-precedence provider record, so local fields cannot leak into +a disabled or remote selection. Cleartext endpoints are allowed only for an explicitly local +adapter on loopback in development/test. URLs with credentials and all cleartext nonlocal endpoints +are rejected. Secret reference paths are canonical non-traversing segments. Configuration accepts +references, never API keys, passwords, tokens, webhook secrets, or other credential values. ## Forbidden dependencies @@ -43,6 +48,9 @@ keys, passwords, tokens, webhook secrets, or other credential values. tenant state. - Provider credentials or implicit host-environment reads. +The only runtime dependency is the pure versioned provider-contract package used to construct the +same structured secret-reference object accepted by `SecretsProviderPortV1`. + The product-policy precedence `platform default -> plan/region -> organization -> workspace -> project -> recipe/job` remains owned by later domain/application plans. This package covers only fail-closed deployment composition. diff --git a/packages/config/package.json b/packages/config/package.json index 5c0cf712..cc4ec514 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -13,5 +13,8 @@ "build": "tsc --project tsconfig.build.json && node test/built-public-api-smoke.mjs", "test": "node --test test/**/*.test.mjs", "typecheck": "tsc --noEmit --project tsconfig.json" + }, + "dependencies": { + "@databreeze/provider-ports": "workspace:*" } } diff --git a/packages/config/src/runtime-config/loader-v1.ts b/packages/config/src/runtime-config/loader-v1.ts index 99c46099..55f1413f 100644 --- a/packages/config/src/runtime-config/loader-v1.ts +++ b/packages/config/src/runtime-config/loader-v1.ts @@ -1,8 +1,5 @@ -import { - ConfigValidationErrorV1, - RUNTIME_CONFIG_SCHEMA_VERSION_V1, - createSecretReferenceV1, -} from './types-v1.ts'; +import { ConfigValidationErrorV1, RUNTIME_CONFIG_SCHEMA_VERSION_V1 } from './types-v1.ts'; +import { defineSecretReferenceV1 } from '@databreeze/provider-ports/v1'; import type { ActiveDocumentProviderConfigV1, AiConfigV1, @@ -199,11 +196,7 @@ const placeholderSegments = new Set([ ]); function isRecord(value: unknown): value is UnknownRecord { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - const prototype = Object.getPrototypeOf(value) as unknown; - return prototype === Object.prototype || prototype === null; + return value !== null && typeof value === 'object' && !Array.isArray(value); } function deepFreeze(value: T): T { @@ -216,45 +209,138 @@ function deepFreeze(value: T): T { return value; } -function environmentEntries( - environment: EnvironmentEntriesV1 | undefined, -): readonly (readonly [string, string | undefined])[] { - if (environment === undefined) { - return []; +function ownDataDescriptors(value: unknown): Record | undefined { + if (value === null || typeof value !== 'object') return undefined; + try { + return Object.getOwnPropertyDescriptors(value) as Record; + } catch { + return undefined; } - if (!Array.isArray(environment)) { - return Object.entries(environment); +} + +function isArraySafely(value: unknown): boolean | undefined { + try { + return Array.isArray(value); + } catch { + return undefined; } - return environment as readonly (readonly [string, string | undefined])[]; } -function validateOverrideKeys(value: unknown, issues: ConfigIssueV1[], path = ''): void { - if (!isRecord(value)) { - if (path === '') { - issues.push({ path: 'overrides', code: 'invalid_string' }); +function snapshotLoadInput( + input: LoadRuntimeConfigInputV1, + issues: ConfigIssueV1[], +): Readonly<{ environment?: EnvironmentEntriesV1; overrides?: unknown }> { + const descriptors = ownDataDescriptors(input); + if (descriptors === undefined || isArraySafely(input) !== false) { + issues.push({ path: 'configuration.invalid_input', code: 'invalid_string' }); + return {}; + } + const result: { environment?: EnvironmentEntriesV1; overrides?: unknown } = {}; + for (const [key, descriptor] of Object.entries(descriptors)) { + if (key !== 'environment' && key !== 'overrides') { + issues.push({ path: 'configuration.unknown_key', code: 'unknown_key' }); + continue; + } + if (!('value' in descriptor)) { + issues.push({ path: 'configuration.invalid_input', code: 'invalid_string' }); + continue; } - return; + if (key === 'environment') result.environment = descriptor.value as EnvironmentEntriesV1; + if (key === 'overrides') result.overrides = descriptor.value; } + return result; +} - const allowed = allowedOverrideKeys[path]; - if (allowed === undefined) { - return; +function snapshotEnvironment( + environment: EnvironmentEntriesV1 | undefined, + issues: ConfigIssueV1[], +): readonly (readonly [string, string | undefined])[] { + if (environment === undefined) return []; + const descriptors = ownDataDescriptors(environment); + if (descriptors === undefined) { + issues.push({ path: 'environment.invalid_input', code: 'invalid_string' }); + return []; + } + const entries: (readonly [string, string | undefined])[] = []; + if (isArraySafely(environment) === false) { + for (const [key, descriptor] of Object.entries(descriptors)) { + if ( + !('value' in descriptor) || + (descriptor.value !== undefined && typeof descriptor.value !== 'string') + ) { + issues.push({ path: 'environment.invalid_input', code: 'invalid_string' }); + continue; + } + entries.push([key, descriptor.value as string | undefined]); + } + return entries; } + const lengthDescriptor = descriptors['length']; + if ( + lengthDescriptor === undefined || + !('value' in lengthDescriptor) || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 || + lengthDescriptor.value > 1_000 + ) { + issues.push({ path: 'environment.invalid_input', code: 'invalid_string' }); + return []; + } + for (let index = 0; index < (lengthDescriptor.value as number); index += 1) { + const entryDescriptor = descriptors[String(index)]; + if (entryDescriptor === undefined || !('value' in entryDescriptor)) { + issues.push({ path: 'environment.invalid_entry', code: 'invalid_string' }); + continue; + } + const tupleDescriptors = ownDataDescriptors(entryDescriptor.value); + const tupleLength = tupleDescriptors?.['length']; + const keyDescriptor = tupleDescriptors?.['0']; + const valueDescriptor = tupleDescriptors?.['1']; + if ( + tupleDescriptors === undefined || + tupleLength === undefined || + !('value' in tupleLength) || + tupleLength.value !== 2 || + keyDescriptor === undefined || + !('value' in keyDescriptor) || + typeof keyDescriptor.value !== 'string' || + valueDescriptor === undefined || + !('value' in valueDescriptor) || + (valueDescriptor.value !== undefined && typeof valueDescriptor.value !== 'string') + ) { + issues.push({ path: 'environment.invalid_entry', code: 'invalid_string' }); + continue; + } + entries.push([keyDescriptor.value, valueDescriptor.value as string | undefined]); + } + return entries; +} - for (const [key, child] of Object.entries(value)) { - const childPath = path === '' ? key : `${path}.${key}`; +function snapshotOverrides(value: unknown, issues: ConfigIssueV1[], path = ''): UnknownRecord { + const descriptors = ownDataDescriptors(value); + if (descriptors === undefined || isArraySafely(value) !== false) { + issues.push({ path: 'overrides.invalid_input', code: 'invalid_string' }); + return {}; + } + const allowed = allowedOverrideKeys[path]; + if (allowed === undefined) return {}; + const result: UnknownRecord = {}; + for (const [key, descriptor] of Object.entries(descriptors)) { if (!allowed.includes(key)) { - issues.push({ path: `overrides.${childPath}`, code: 'unknown_key' }); + issues.push({ path: 'overrides.unknown_key', code: 'unknown_key' }); continue; } - if (allowedOverrideKeys[childPath] !== undefined) { - if (!isRecord(child)) { - issues.push({ path: `overrides.${childPath}`, code: 'invalid_string' }); - } else { - validateOverrideKeys(child, issues, childPath); - } + if (!('value' in descriptor)) { + issues.push({ path: 'overrides.invalid_input', code: 'invalid_string' }); + continue; } + const childPath = path === '' ? key : `${path}.${key}`; + result[key] = + allowedOverrideKeys[childPath] === undefined + ? descriptor.value + : snapshotOverrides(descriptor.value, issues, childPath); } + return result; } function setPath(target: UnknownRecord, path: readonly string[], value: unknown): void { @@ -272,11 +358,20 @@ function setPath(target: UnknownRecord, path: readonly string[], value: unknown) } } -function mergeRecords(base: UnknownRecord, overlay: UnknownRecord): UnknownRecord { +function mergeRecords(base: UnknownRecord, overlay: UnknownRecord, path = ''): UnknownRecord { + if ( + path.startsWith('providers.') && + typeof overlay['mode'] === 'string' && + overlay['mode'] !== base['mode'] + ) { + return { ...overlay }; + } const result: UnknownRecord = { ...base }; for (const [key, value] of Object.entries(overlay)) { const current = result[key]; - result[key] = isRecord(current) && isRecord(value) ? mergeRecords(current, value) : value; + const childPath = path === '' ? key : `${path}.${key}`; + result[key] = + isRecord(current) && isRecord(value) ? mergeRecords(current, value, childPath) : value; } return result; } @@ -315,7 +410,7 @@ function readEnvironment( const seen = new Set(); const result: UnknownRecord = {}; - for (const [key, value] of environmentEntries(environment)) { + for (const [key, value] of snapshotEnvironment(environment, issues)) { if (seen.has(key)) { issues.push({ path: `environment.${key}`, code: 'duplicate' }); continue; @@ -325,7 +420,7 @@ function readEnvironment( const definition = environmentDefinitions[key]; if (definition === undefined) { if (key.startsWith('DATABREEZE_')) { - issues.push({ path: `environment.${key}`, code: 'unknown_key' }); + issues.push({ path: 'environment.unknown_key', code: 'unknown_key' }); } continue; } @@ -515,13 +610,31 @@ function secretReference( return undefined; } const match = - /^secret:\/\/([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._/-]*)(?:#[a-z0-9._-]+)?$/.exec(value); - const segments = match?.slice(1).flatMap((part) => part.split(/[./_-]+/)) ?? []; - if (match === null || segments.some((segment) => placeholderSegments.has(segment))) { + /^secret:\/\/([a-z0-9][a-z0-9._-]{0,62})\/([^#]+?)(?:#([a-z0-9][a-z0-9._-]{0,62}))?$/.exec( + value, + ); + const pathSegments = match?.[2]?.split('/') ?? []; + const canonicalSegments = pathSegments.every( + (segment) => /^[a-z0-9][a-z0-9._-]{0,62}$/.test(segment) && segment !== '.' && segment !== '..', + ); + const placeholderTokens = [match?.[1] ?? '', ...pathSegments, match?.[3] ?? ''].flatMap((part) => + part.split(/[._-]+/), + ); + if ( + match === null || + !canonicalSegments || + value.includes('//', 'secret://'.length) || + value.includes('%') || + placeholderTokens.some((segment) => placeholderSegments.has(segment)) + ) { issues.push({ path, code: 'invalid_secret_reference' }); return undefined; } - return createSecretReferenceV1(value); + return defineSecretReferenceV1({ + namespace: match[1] as string, + pathSegments, + ...(match[3] === undefined ? {} : { version: match[3] }), + }); } function modeOf( @@ -853,10 +966,10 @@ function selectedProfile( export function loadRuntimeConfigV1(input: LoadRuntimeConfigInputV1 = {}): RuntimeConfigV1 { const issues: ConfigIssueV1[] = []; - const environment = readEnvironment(input.environment, issues); - const overrides = input.overrides === undefined ? {} : input.overrides; - validateOverrideKeys(overrides, issues); - const overrideRecord = isRecord(overrides) ? overrides : {}; + const safeInput = snapshotLoadInput(input, issues); + const environment = readEnvironment(safeInput.environment, issues); + const overrideRecord = + safeInput.overrides === undefined ? {} : snapshotOverrides(safeInput.overrides, issues); const profile = selectedProfile(environment, overrideRecord, issues); if (profile === undefined) { diff --git a/packages/config/src/runtime-config/types-v1.ts b/packages/config/src/runtime-config/types-v1.ts index d0e1e94a..0ba186a8 100644 --- a/packages/config/src/runtime-config/types-v1.ts +++ b/packages/config/src/runtime-config/types-v1.ts @@ -1,3 +1,7 @@ +import type { SecretReferenceV1 } from '@databreeze/provider-ports/v1'; + +export type { SecretReferenceV1 } from '@databreeze/provider-ports/v1'; + export const RUNTIME_CONFIG_SCHEMA_VERSION_V1 = 1 as const; export type RuntimeProfileV1 = 'development' | 'test' | 'preview' | 'staging' | 'production'; @@ -28,7 +32,9 @@ export class ConfigValidationErrorV1 extends Error { super('Runtime configuration is invalid.'); this.name = 'ConfigValidationErrorV1'; this.issues = Object.freeze( - issues.map((issue) => Object.freeze({ path: issue.path, code: issue.code })), + issues + .slice(0, 100) + .map((issue) => Object.freeze({ path: issue.path.slice(0, 80), code: issue.code })), ); Object.freeze(this); } @@ -38,32 +44,6 @@ export class ConfigValidationErrorV1 extends Error { } } -export interface SecretReferenceV1 { - readonly kind: 'secret-reference'; - toString(): '[REDACTED_SECRET_REFERENCE]'; - toJSON(): '[REDACTED_SECRET_REFERENCE]'; -} - -const secretReferenceHandles = new WeakMap(); - -export function createSecretReferenceV1(handle: string): SecretReferenceV1 { - const reference: SecretReferenceV1 = { - kind: 'secret-reference', - toString: () => '[REDACTED_SECRET_REFERENCE]', - toJSON: () => '[REDACTED_SECRET_REFERENCE]', - }; - secretReferenceHandles.set(reference, handle); - return Object.freeze(reference); -} - -export function secretReferenceHandleV1(reference: SecretReferenceV1): string { - const handle = secretReferenceHandles.get(reference); - if (handle === undefined) { - throw new TypeError('Unknown secret reference.'); - } - return handle; -} - export interface ProviderPolicyConfigV1 { readonly timeoutMs: number; readonly maxAttempts: number; diff --git a/packages/config/test/built-public-api-smoke.mjs b/packages/config/test/built-public-api-smoke.mjs index ff72ef32..7f722107 100644 --- a/packages/config/test/built-public-api-smoke.mjs +++ b/packages/config/test/built-public-api-smoke.mjs @@ -4,4 +4,5 @@ const runtime = await import('../dist/runtime-config/v1.js'); assert.equal(runtime.RUNTIME_CONFIG_SCHEMA_VERSION_V1, 1); assert.equal(typeof runtime.loadRuntimeConfigV1, 'function'); -assert.equal(typeof runtime.secretReferenceHandleV1, 'function'); +assert.equal(runtime.secretReferenceHandleV1, undefined); +assert.equal(runtime.createSecretReferenceV1, undefined); diff --git a/packages/config/test/review-regressions-v1.test.mjs b/packages/config/test/review-regressions-v1.test.mjs new file mode 100644 index 00000000..d795bb21 --- /dev/null +++ b/packages/config/test/review-regressions-v1.test.mjs @@ -0,0 +1,307 @@ +import assert from 'node:assert/strict'; +import { inspect } from 'node:util'; +import test from 'node:test'; + +import { ConfigValidationErrorV1, loadRuntimeConfigV1 } from '../src/runtime-config/v1.ts'; + +function issue(error, path, code) { + return ( + error instanceof ConfigValidationErrorV1 && + error.issues.some((entry) => entry.path === path && entry.code === code) + ); +} + +function expectSafeConfigFailure(run, path, code, exposed = []) { + assert.throws(run, (error) => { + assert.ok(issue(error, path, code), inspect(error)); + for (const value of exposed) { + assert.doesNotMatch(String(error), new RegExp(value, 'u')); + assert.doesNotMatch(JSON.stringify(error), new RegExp(value, 'u')); + assert.doesNotMatch(inspect(error), new RegExp(value, 'u')); + } + assert.ok(error.issues.every((entry) => entry.path.length <= 80)); + return true; + }); +} + +test('an environment mode change replaces the lower-precedence provider record', () => { + const disabledEmail = loadRuntimeConfigV1({ + environment: { + DATABREEZE_PROFILE: 'development', + DATABREEZE_EMAIL_MODE: 'disabled', + }, + }); + assert.deepEqual(disabledEmail.providers.email, { mode: 'disabled' }); + + const disabledTelemetry = loadRuntimeConfigV1({ + environment: { + DATABREEZE_PROFILE: 'development', + DATABREEZE_TELEMETRY_MODE: 'disabled', + }, + }); + assert.deepEqual(disabledTelemetry.providers.telemetry, { mode: 'disabled' }); + + expectSafeConfigFailure( + () => + loadRuntimeConfigV1({ + environment: { + DATABREEZE_PROFILE: 'development', + DATABREEZE_OBJECT_STORAGE_MODE: 'remote', + }, + }), + 'providers.objectStorage.endpointUrl', + 'required', + ); + expectSafeConfigFailure( + () => + loadRuntimeConfigV1({ + environment: { + DATABREEZE_PROFILE: 'development', + DATABREEZE_SECRETS_MODE: 'remote', + }, + }), + 'providers.secrets.namespace', + 'required', + ); +}); + +const activeEnvironmentByProvider = { + email: { + DATABREEZE_EMAIL_MODE: 'local', + DATABREEZE_EMAIL_ENDPOINT_URL: 'smtp://127.0.0.1:1025', + DATABREEZE_EMAIL_FROM_ADDRESS: 'notify@databreeze.local', + }, + push: { + DATABREEZE_PUSH_MODE: 'remote', + DATABREEZE_PUSH_ENDPOINT_URL: 'https://push.example.test', + DATABREEZE_PUSH_APPLICATION_ID: 'databreeze', + DATABREEZE_PUSH_CREDENTIAL_REF: 'secret://development/push/credential', + }, + ocr: { + DATABREEZE_OCR_MODE: 'local', + DATABREEZE_OCR_ENDPOINT_URL: 'http://127.0.0.1:8181', + }, + ai: { + DATABREEZE_AI_MODE: 'local', + DATABREEZE_AI_ENDPOINT_URL: 'http://127.0.0.1:8282', + }, + payments: { + DATABREEZE_PAYMENTS_MODE: 'remote', + DATABREEZE_PAYMENTS_ENDPOINT_URL: 'https://payments.example.test', + DATABREEZE_PAYMENTS_CREDENTIAL_REF: 'secret://development/payments/credential', + DATABREEZE_PAYMENTS_WEBHOOK_SECRET_REF: 'secret://development/payments/webhook', + }, + telemetry: { + DATABREEZE_TELEMETRY_MODE: 'local', + DATABREEZE_TELEMETRY_ENDPOINT_URL: 'http://127.0.0.1:4318', + }, +}; + +for (const [provider, providerEnvironment] of Object.entries(activeEnvironmentByProvider)) { + test(`an override mode change replaces the environment ${provider} record`, () => { + const config = loadRuntimeConfigV1({ + environment: { DATABREEZE_PROFILE: 'development', ...providerEnvironment }, + overrides: { providers: { [provider]: { mode: 'disabled' } } }, + }); + assert.deepEqual(config.providers[provider], { mode: 'disabled' }); + }); +} + +test('override mode replacement also closes object-storage and secrets variants', () => { + expectSafeConfigFailure( + () => + loadRuntimeConfigV1({ + environment: { DATABREEZE_PROFILE: 'development' }, + overrides: { providers: { objectStorage: { mode: 'remote' } } }, + }), + 'providers.objectStorage.endpointUrl', + 'required', + ); + + const secrets = loadRuntimeConfigV1({ + environment: { + DATABREEZE_PROFILE: 'development', + DATABREEZE_SECRETS_MODE: 'remote', + DATABREEZE_SECRETS_ENDPOINT_URL: 'https://secrets.example.test', + DATABREEZE_SECRETS_NAMESPACE: 'remote', + }, + overrides: { providers: { secrets: { mode: 'memory', namespace: 'local' } } }, + }); + assert.deepEqual(secrets.providers.secrets, { mode: 'memory', namespace: 'local' }); +}); + +test('configuration snapshots data properties without invoking accessors', () => { + let getterCalls = 0; + const environment = {}; + Object.defineProperty(environment, 'DATABREEZE_PROFILE', { + enumerable: true, + get() { + getterCalls += 1; + throw new Error('environment-getter-secret'); + }, + }); + + expectSafeConfigFailure( + () => loadRuntimeConfigV1({ environment }), + 'environment.invalid_input', + 'invalid_string', + ['environment-getter-secret'], + ); + assert.equal(getterCalls, 0); + + const overrides = { providers: {} }; + Object.defineProperty(overrides.providers, 'ai', { + enumerable: true, + get() { + getterCalls += 1; + throw new Error('override-getter-secret'); + }, + }); + expectSafeConfigFailure( + () => + loadRuntimeConfigV1({ + environment: { DATABREEZE_PROFILE: 'development' }, + overrides, + }), + 'overrides.invalid_input', + 'invalid_string', + ['override-getter-secret'], + ); + assert.equal(getterCalls, 0); +}); + +test('configuration converts proxy and malformed tuple failures to bounded redacted diagnostics', () => { + const exposed = 'proxy-own-keys-secret'; + const environment = new Proxy( + {}, + { + ownKeys() { + throw new Error(exposed); + }, + }, + ); + expectSafeConfigFailure( + () => loadRuntimeConfigV1({ environment }), + 'environment.invalid_input', + 'invalid_string', + [exposed], + ); + + expectSafeConfigFailure( + () => + loadRuntimeConfigV1({ + environment: [ + ['DATABREEZE_PROFILE', 'development'], + [42, { value: 'must-not-leak' }], + ], + }), + 'environment.invalid_entry', + 'invalid_string', + ['must-not-leak'], + ); +}); + +test('configuration snapshots the load request itself and bounds repeated diagnostics', () => { + let getterCalls = 0; + const input = {}; + Object.defineProperty(input, 'environment', { + enumerable: true, + get() { + getterCalls += 1; + throw new Error('load-request-secret-X9Y8Z7'); + }, + }); + expectSafeConfigFailure( + () => loadRuntimeConfigV1(input), + 'configuration.invalid_input', + 'invalid_string', + ['load-request-secret-X9Y8Z7'], + ); + assert.equal(getterCalls, 0); + + assert.throws( + () => + loadRuntimeConfigV1({ + environment: [ + ['DATABREEZE_PROFILE', 'development'], + ...Array.from({ length: 500 }, () => [42, 'invalid']), + ], + }), + (error) => error instanceof ConfigValidationErrorV1 && error.issues.length <= 100, + ); +}); + +test('unknown attacker-controlled keys never become diagnostic field names', () => { + const exposedEnvironmentKey = 'DATABREEZE_TOKEN_X9Y8Z7'; + expectSafeConfigFailure( + () => + loadRuntimeConfigV1({ + environment: { + DATABREEZE_PROFILE: 'development', + [exposedEnvironmentKey]: 'raw-value-X9Y8Z7', + }, + }), + 'environment.unknown_key', + 'unknown_key', + [exposedEnvironmentKey, 'raw-value-X9Y8Z7'], + ); + + const exposedOverrideKey = 'privateKey_X9Y8Z7'; + expectSafeConfigFailure( + () => + loadRuntimeConfigV1({ + environment: { DATABREEZE_PROFILE: 'development' }, + overrides: { providers: { ai: { mode: 'disabled', [exposedOverrideKey]: 'raw' } } }, + }), + 'overrides.unknown_key', + 'unknown_key', + [exposedOverrideKey], + ); +}); + +for (const reference of [ + 'secret://production/.', + 'secret://production/..', + 'secret://production/a//b', + 'secret://production/a/', + 'secret://production/a/../b', + 'secret://production/a/./b', +]) { + test(`rejects non-canonical secret reference ${reference}`, () => { + expectSafeConfigFailure( + () => + loadRuntimeConfigV1({ + environment: { + DATABREEZE_PROFILE: 'development', + DATABREEZE_PUSH_MODE: 'remote', + DATABREEZE_PUSH_ENDPOINT_URL: 'https://push.example.test', + DATABREEZE_PUSH_APPLICATION_ID: 'databreeze', + DATABREEZE_PUSH_CREDENTIAL_REF: reference, + }, + }), + 'providers.push.credentialRef', + 'invalid_secret_reference', + [reference.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')], + ); + }); +} + +test('returns a canonical structured secret reference without a raw extractor', async () => { + const runtime = await import('../src/runtime-config/v1.ts'); + const config = loadRuntimeConfigV1({ + environment: { + DATABREEZE_PROFILE: 'development', + DATABREEZE_PUSH_MODE: 'remote', + DATABREEZE_PUSH_ENDPOINT_URL: 'https://push.example.test', + DATABREEZE_PUSH_APPLICATION_ID: 'databreeze', + DATABREEZE_PUSH_CREDENTIAL_REF: 'secret://development/push/credential#active', + }, + }); + const reference = config.providers.push.credentialRef; + assert.deepEqual(reference.pathSegments, ['push', 'credential']); + assert.equal(reference.namespace, 'development'); + assert.equal(reference.version, 'active'); + assert.equal(runtime.secretReferenceHandleV1, undefined); + assert.equal(runtime.createSecretReferenceV1, undefined); + assert.equal(JSON.stringify(reference), '"[REDACTED_SECRET_REFERENCE]"'); +}); diff --git a/packages/config/test/runtime-v1.test.mjs b/packages/config/test/runtime-v1.test.mjs index c22611ba..e246f8fe 100644 --- a/packages/config/test/runtime-v1.test.mjs +++ b/packages/config/test/runtime-v1.test.mjs @@ -1,11 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { - ConfigValidationErrorV1, - loadRuntimeConfigV1, - secretReferenceHandleV1, -} from '../src/runtime-config/v1.ts'; +import { ConfigValidationErrorV1, loadRuntimeConfigV1 } from '../src/runtime-config/v1.ts'; function nonLocalEnvironment(profile) { return [ @@ -83,10 +79,8 @@ for (const profile of ['preview', 'staging', 'production']) { assert.equal(config.profile, profile); assert.equal(config.providers.objectStorage.mode, 'remote'); assert.equal(config.providers.objectStorage.bucket, `databreeze-${profile}`); - assert.equal( - secretReferenceHandleV1(config.providers.objectStorage.credentialRef), - `secret://${profile}/object-storage`, - ); + assert.equal(config.providers.objectStorage.credentialRef.namespace, profile); + assert.deepEqual(config.providers.objectStorage.credentialRef.pathSegments, ['object-storage']); assert.deepEqual(config.providers.secrets, { mode: 'remote', endpointUrl: 'https://secrets.example.test', @@ -156,7 +150,7 @@ test('rejects unknown DataBreeze environment keys but ignores host environment k ['DATABREEZE_UNKNOWN_OPTION', 'true'], ], }), - 'environment.DATABREEZE_UNKNOWN_OPTION', + 'environment.unknown_key', 'unknown_key', ); }); @@ -168,7 +162,7 @@ test('rejects unknown nested override keys', () => { environment: [['DATABREEZE_PROFILE', 'development']], overrides: { providers: { ai: { mode: 'disabled', apiKey: 'not-allowed' } } }, }), - 'overrides.providers.ai.apiKey', + 'overrides.unknown_key', 'unknown_key', ); }); diff --git a/packages/provider-ports/README.md b/packages/provider-ports/README.md index 3b193585..7cc50bb6 100644 --- a/packages/provider-ports/README.md +++ b/packages/provider-ports/README.md @@ -15,12 +15,23 @@ credential value, persistence, framework, or SDK dependency. - telemetry export; and - opaque secret-handle resolution. -Every port shares descriptor, health, and state-export operations. A descriptor declares typed -capabilities, idempotency, cancellation, timeouts, retry limits, data regions, retention/training -behavior, failover/degraded behavior, and an exit/export format. Common helpers validate and freeze -that metadata, enforce cancellation/deadlines/idempotency, and normalize failures to safe stable -codes without retaining raw provider causes. Secret handles expose no value and redact string/JSON -serialization. +Every port shares only descriptor and health operations. State leaves through provider-specific, +closed, content-safe contracts: object manifests, delivery-suppression manifests, subscription +migration manifests, or secrets portability metadata. Stateless OCR, AI, and telemetry adapters do +not invent an arbitrary export record. A descriptor declares the complete operation set for its +provider kind plus idempotency, cancellation, timeouts, retry limits, data regions, +retention/training behavior, failover/degraded behavior, and coherent exit metadata. + +Common helpers validate and freeze closed metadata, reuse the canonical contract timestamp parser, +enforce cancellation/deadlines/idempotency, and create errors only through a redacting factory with +allowlisted operations and code-derived message keys. Raw provider causes are neither accessed nor +retained. Structured secret references flow directly into the secrets port; secret handles contain +no material or public raw handle ID, and both redact string/JSON serialization. + +Object storage is resumable and bounded-memory: begin, upload a validated 8-64 MiB part, complete, +or abort. Plans support immutable objects through 20 GiB with declared whole-object and per-part +SHA-256 digests. Email and push expose explicit typed recipient-suppression operations; durable +notification policy remains owned by NCO. There is intentionally no unversioned package root. Provider-specific identifiers may appear only as opaque external references returned by an adapter; they never replace DataBreeze domain IDs or @@ -29,14 +40,16 @@ become the only representation of customer state. ## Payment boundary `PaymentsProviderPortV1` is restricted to hosted checkout/portal, subscription upsert, verified -subscription webhooks, and reconciliation for DataBreeze's own organization subscriptions. It has -no customer charge, capture, refund, transfer, withholding, reversal, settlement, or raw payment- -credential operation. Built-in Free/Development/Admin-granted entitlement operation remains -provider-independent; a missing payment adapter must not block it. +subscription webhooks, reconciliation, and a schema-validated migration manifest for DataBreeze's +own organization subscriptions. It has no customer charge, capture, refund, transfer, withholding, +reversal, settlement, raw payment credential, or arbitrary provider-state operation. Built-in +Free/Development/Admin-granted entitlement operation remains provider-independent; a missing +payment adapter must not block it. ## Forbidden dependencies -- Provider/cloud SDKs and concrete adapters. +- Provider/cloud SDKs and concrete adapters. The sole dependency is the generated canonical + DataBreeze contract validator used for timestamps. - Service/application implementations, databases, queues, filesystems, or UI frameworks. - Raw secrets, API keys, payment credentials, or provider response bodies in errors. - Product workflows, entitlement authority, storage authority, notification durability, OCR/AI diff --git a/packages/provider-ports/package.json b/packages/provider-ports/package.json index d943252c..32591ab1 100644 --- a/packages/provider-ports/package.json +++ b/packages/provider-ports/package.json @@ -13,5 +13,8 @@ "build": "tsc --project tsconfig.build.json && node test/built-public-api-smoke.mjs", "test": "node --test test/**/*.test.mjs", "typecheck": "tsc --noEmit --project tsconfig.json" + }, + "dependencies": { + "@databreeze/contracts": "workspace:*" } } diff --git a/packages/provider-ports/src/common-v1.ts b/packages/provider-ports/src/common-v1.ts index 8ab0961c..fc730444 100644 --- a/packages/provider-ports/src/common-v1.ts +++ b/packages/provider-ports/src/common-v1.ts @@ -1,3 +1,5 @@ +import { parseV1Contract } from '@databreeze/contracts/v1'; + export const PROVIDER_PORT_SCHEMA_VERSION_V1 = 1 as const; export type ProviderKindV1 = @@ -10,6 +12,49 @@ export type ProviderKindV1 = | 'telemetry' | 'secrets'; +export const PROVIDER_OPERATIONS_BY_KIND_V1 = Object.freeze({ + 'object-storage': Object.freeze([ + 'begin-multipart-upload', + 'upload-part', + 'complete-multipart-upload', + 'abort-multipart-upload', + 'read-range', + 'verify-digest', + 'apply-retention', + 'delete-verified', + 'create-read-grant', + 'export-object-manifest', + ] as const), + email: Object.freeze([ + 'send-template', + 'verify-delivery-webhook', + 'suppress-recipient', + 'export-suppression-manifest', + ] as const), + push: Object.freeze([ + 'send-push', + 'verify-delivery-webhook', + 'suppress-recipient', + 'export-suppression-manifest', + ] as const), + ocr: Object.freeze(['extract'] as const), + ai: Object.freeze(['generate-structured'] as const), + payments: Object.freeze([ + 'create-hosted-subscription-checkout', + 'create-subscription-portal', + 'upsert-databreeze-subscription', + 'verify-subscription-webhook', + 'reconcile-databreeze-subscription', + 'export-subscription-migration', + ] as const), + telemetry: Object.freeze(['export-telemetry-batch'] as const), + secrets: Object.freeze(['resolve-handle', 'revoke-handle', 'describe-portability'] as const), +} satisfies Readonly>); + +export type ProviderOperationV1 = + | (typeof PROVIDER_OPERATIONS_BY_KIND_V1)[ProviderKindV1][number] + | 'contract-validation'; + export type ProviderErrorCodeV1 = | 'INVALID_REQUEST' | 'AUTHENTICATION_FAILED' @@ -42,7 +87,7 @@ export type ProviderStatePortabilityV1 = 'none' | 'manifest' | 'full'; export type ProviderCredentialRevocationV1 = 'not_applicable' | 'supported' | 'manual'; export interface ProviderCapabilityV1 { - readonly operation: string; + readonly operation: ProviderOperationV1; readonly idempotency: ProviderIdempotencyV1; readonly cancellation: ProviderCancellationV1; readonly timeoutMs: number; @@ -95,15 +140,14 @@ export class ProviderContractErrorV1 extends Error { } } -const providerKinds = new Set([ - 'object-storage', - 'email', - 'push', - 'ocr', - 'ai', - 'payments', - 'telemetry', - 'secrets', +type UnknownRecord = Record; + +const providerKinds = new Set( + Object.keys(PROVIDER_OPERATIONS_BY_KIND_V1) as ProviderKindV1[], +); +const providerOperations = new Set([ + ...Object.values(PROVIDER_OPERATIONS_BY_KIND_V1).flat(), + 'contract-validation', ]); const idempotencyValues = new Set([ 'required', @@ -156,41 +200,118 @@ const errorCodes = new Set([ 'INTEGRITY_FAILED', 'UNKNOWN', ]); - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); +const nonRetryableErrorCodes = new Set([ + 'INVALID_REQUEST', + 'AUTHENTICATION_FAILED', + 'AUTHORIZATION_DENIED', + 'NOT_FOUND', + 'ABORTED', + 'POLICY_DENIED', + 'UNSUPPORTED', + 'INTEGRITY_FAILED', +]); +const healthReasonCodes = new Set([ + 'AUTHENTICATION_FAILED', + 'DEGRADED_CAPACITY', + 'POLICY_RESTRICTED', + 'UPSTREAM_RATE_LIMITED', + 'UPSTREAM_UNAVAILABLE', +]); +const errorMessageKeys: Readonly> = Object.freeze({ + INVALID_REQUEST: 'provider.invalid_request', + AUTHENTICATION_FAILED: 'provider.authentication_failed', + AUTHORIZATION_DENIED: 'provider.authorization_denied', + NOT_FOUND: 'provider.not_found', + CONFLICT: 'provider.conflict', + RATE_LIMITED: 'provider.rate_limited', + QUOTA_EXCEEDED: 'provider.quota_exceeded', + TIMEOUT: 'provider.timeout', + ABORTED: 'provider.aborted', + UNAVAILABLE: 'provider.unavailable', + POLICY_DENIED: 'provider.policy_denied', + UNSUPPORTED: 'provider.unsupported', + INTEGRITY_FAILED: 'provider.integrity_failed', + UNKNOWN: 'provider.unknown', +}); +const UTC_TIMESTAMP_SCHEMA_ID = 'https://schemas.databreeze.dev/contracts/v1/utc-timestamp'; + +function isObject(value: unknown): value is object { + return value !== null && typeof value === 'object'; } -function isCapabilityArray(value: unknown): value is readonly ProviderCapabilityV1[] { - return Array.isArray(value); +function readClosedRecord( + value: unknown, + allowedKeys: readonly string[], +): UnknownRecord | undefined { + if (!isObject(value)) return undefined; + const allowed = new Set(allowedKeys); + const result: UnknownRecord = Object.create(null) as UnknownRecord; + try { + if (Array.isArray(value)) return undefined; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !allowed.has(key)) return undefined; + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) return undefined; + result[key] = descriptor.value; + } + } catch { + return undefined; + } + return result; } -function isStringArray(value: unknown): value is readonly string[] { - return Array.isArray(value) && value.every((item) => typeof item === 'string'); +function readArray(value: unknown, maximum = 100): readonly unknown[] | undefined { + let descriptors: Record; + try { + if (!Array.isArray(value)) return undefined; + descriptors = Object.getOwnPropertyDescriptors(value) as Record; + } catch { + return undefined; + } + const lengthDescriptor = descriptors['length']; + if ( + lengthDescriptor === undefined || + !('value' in lengthDescriptor) || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 || + lengthDescriptor.value > maximum + ) { + return undefined; + } + const length = lengthDescriptor.value as number; + const result: unknown[] = []; + for (const key of Object.keys(descriptors)) { + if (key === 'length') continue; + if (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= length) return undefined; + } + for (let index = 0; index < length; index += 1) { + const descriptor = descriptors[String(index)]; + if (descriptor === undefined || !('value' in descriptor)) return undefined; + result.push(descriptor.value); + } + return result; } -function isSafeToken(value: unknown): value is string { +function isSafeToken(value: unknown, maximum = 200): value is string { return ( typeof value === 'string' && value.length > 0 && - value.length <= 200 && + value.length <= maximum && value.trim() === value && - /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(value) + /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value) ); } +function isRegion(value: unknown): value is string { + return typeof value === 'string' && /^(?:local|global|[a-z]{2}(?:-[a-z0-9]+)+)$/.test(value); +} + function isPositiveInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): value is number { return Number.isSafeInteger(value) && (value as number) > 0 && (value as number) <= maximum; } function isUtcTimestamp(value: unknown): value is string { - return ( - typeof value === 'string' && - /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{3})?Z$/.test( - value, - ) && - !Number.isNaN(Date.parse(value)) - ); + return parseV1Contract(UTC_TIMESTAMP_SCHEMA_ID, value).accepted; } function deepFreeze(value: T): T { @@ -201,82 +322,162 @@ function deepFreeze(value: T): T { return value; } +function sameStringSet(left: ReadonlySet, right: ReadonlySet): boolean { + return left.size === right.size && [...left].every((value) => right.has(value)); +} + export function defineProviderDescriptorV1( input: ProviderDescriptorInputV1, ): ProviderDescriptorV1 { + const root = readClosedRecord(input, [ + 'kind', + 'adapterKey', + 'capabilities', + 'dataHandling', + 'resilience', + 'exit', + ]); if ( - !isRecord(input) || - !providerKinds.has(input.kind) || - !isSafeToken(input.adapterKey) || - !isCapabilityArray(input.capabilities) || - input.capabilities.length === 0 || - !isRecord(input.dataHandling) || - !isRecord(input.resilience) || - !isRecord(input.exit) + root === undefined || + !providerKinds.has(root['kind'] as ProviderKindV1) || + !isSafeToken(root['adapterKey']) ) { throw new ProviderContractErrorV1(); } - + const kind = root['kind'] as K; + const rawCapabilities = readArray(root['capabilities'], 32); + if (rawCapabilities === undefined || rawCapabilities.length === 0) { + throw new ProviderContractErrorV1(); + } + const capabilities: ProviderCapabilityV1[] = []; const operations = new Set(); - for (const capability of input.capabilities) { + for (const rawCapability of rawCapabilities) { + const capability = readClosedRecord(rawCapability, [ + 'operation', + 'idempotency', + 'cancellation', + 'timeoutMs', + 'maxAttempts', + ]); if ( - !isRecord(capability) || - !isSafeToken(capability['operation']) || - operations.has(capability['operation']) || - !idempotencyValues.has(capability['idempotency']) || - !cancellationValues.has(capability['cancellation']) || + capability === undefined || + !providerOperations.has(capability['operation'] as ProviderOperationV1) || + operations.has(capability['operation'] as string) || + !idempotencyValues.has(capability['idempotency'] as ProviderIdempotencyV1) || + !cancellationValues.has(capability['cancellation'] as ProviderCancellationV1) || !isPositiveInteger(capability['timeoutMs'], 300_000) || !isPositiveInteger(capability['maxAttempts'], 20) ) { throw new ProviderContractErrorV1(); } - operations.add(capability['operation']); + operations.add(capability['operation'] as string); + capabilities.push({ + operation: capability['operation'] as ProviderOperationV1, + idempotency: capability['idempotency'] as ProviderIdempotencyV1, + cancellation: capability['cancellation'] as ProviderCancellationV1, + timeoutMs: capability['timeoutMs'], + maxAttempts: capability['maxAttempts'], + }); + } + if (!sameStringSet(operations, new Set(PROVIDER_OPERATIONS_BY_KIND_V1[kind]))) { + throw new ProviderContractErrorV1(); } - const dataHandling = input.dataHandling; + const dataHandling = readClosedRecord(root['dataHandling'], [ + 'regions', + 'contentRetention', + 'maximumRetentionSeconds', + 'trainingUse', + ]); + const rawRegions = + dataHandling === undefined ? undefined : readArray(dataHandling['regions'], 32); + if ( + dataHandling === undefined || + rawRegions === undefined || + rawRegions.length === 0 || + rawRegions.some((region) => !isRegion(region)) || + new Set(rawRegions).size !== rawRegions.length || + !contentRetentionValues.has(dataHandling['contentRetention'] as ProviderContentRetentionV1) || + !trainingUseValues.has(dataHandling['trainingUse'] as ProviderTrainingUseV1) + ) { + throw new ProviderContractErrorV1(); + } + const retention = dataHandling['contentRetention'] as ProviderContentRetentionV1; + const maximumRetentionSeconds = dataHandling['maximumRetentionSeconds']; + if ( + (retention === 'none' && maximumRetentionSeconds !== undefined) || + (retention === 'transient' && !isPositiveInteger(maximumRetentionSeconds, 31_536_000)) || + ((retention === 'durable' || retention === 'provider_policy') && + maximumRetentionSeconds !== undefined && + !isPositiveInteger(maximumRetentionSeconds)) + ) { + throw new ProviderContractErrorV1(); + } + const trainingUse = dataHandling['trainingUse'] as ProviderTrainingUseV1; if ( - !isStringArray(dataHandling.regions) || - dataHandling.regions.length === 0 || - dataHandling.regions.some((region) => !isSafeToken(region)) || - !contentRetentionValues.has(dataHandling.contentRetention) || - !trainingUseValues.has(dataHandling.trainingUse) || - (dataHandling.maximumRetentionSeconds !== undefined && - !isPositiveInteger(dataHandling.maximumRetentionSeconds)) + (kind === 'ai' && trainingUse === 'not_applicable') || + (kind !== 'ai' && trainingUse !== 'not_applicable') ) { throw new ProviderContractErrorV1(); } + const resilience = readClosedRecord(root['resilience'], ['failover', 'degradedBehavior']); + const exit = readClosedRecord(root['exit'], [ + 'statePortability', + 'exportFormat', + 'credentialRevocation', + ]); + if ( + resilience === undefined || + exit === undefined || + !failoverValues.has(resilience['failover'] as ProviderFailoverV1) || + !degradedBehaviorValues.has(resilience['degradedBehavior'] as ProviderDegradedBehaviorV1) || + !statePortabilityValues.has(exit['statePortability'] as ProviderStatePortabilityV1) || + !isSafeToken(exit['exportFormat']) || + !credentialRevocationValues.has(exit['credentialRevocation'] as ProviderCredentialRevocationV1) + ) { + throw new ProviderContractErrorV1(); + } + const statePortability = exit['statePortability'] as ProviderStatePortabilityV1; + const statefulKind = new Set([ + 'object-storage', + 'email', + 'push', + 'payments', + 'secrets', + ]).has(kind); if ( - !failoverValues.has(input.resilience.failover) || - !degradedBehaviorValues.has(input.resilience.degradedBehavior) || - !statePortabilityValues.has(input.exit.statePortability) || - !isSafeToken(input.exit.exportFormat) || - !credentialRevocationValues.has(input.exit.credentialRevocation) + (statePortability === 'none' && exit['exportFormat'] !== 'not-applicable') || + (statePortability !== 'none' && exit['exportFormat'] === 'not-applicable') || + (statefulKind && statePortability === 'none') || + (!statefulKind && statePortability !== 'none') || + (kind === 'secrets' && exit['credentialRevocation'] === 'not_applicable') ) { throw new ProviderContractErrorV1(); } return deepFreeze({ schemaVersion: PROVIDER_PORT_SCHEMA_VERSION_V1, - kind: input.kind, - adapterKey: input.adapterKey, - capabilities: input.capabilities.map((capability) => ({ - operation: capability.operation, - idempotency: capability.idempotency, - cancellation: capability.cancellation, - timeoutMs: capability.timeoutMs, - maxAttempts: capability.maxAttempts, - })), + kind, + adapterKey: root['adapterKey'], + capabilities, dataHandling: { - regions: [...input.dataHandling.regions], - contentRetention: input.dataHandling.contentRetention, - ...(input.dataHandling.maximumRetentionSeconds === undefined + regions: rawRegions as string[], + contentRetention: retention, + ...(maximumRetentionSeconds === undefined ? {} - : { maximumRetentionSeconds: input.dataHandling.maximumRetentionSeconds }), - trainingUse: input.dataHandling.trainingUse, + : { maximumRetentionSeconds: maximumRetentionSeconds as number }), + trainingUse, + }, + resilience: { + failover: resilience['failover'] as ProviderFailoverV1, + degradedBehavior: resilience['degradedBehavior'] as ProviderDegradedBehaviorV1, + }, + exit: { + statePortability, + exportFormat: exit['exportFormat'], + credentialRevocation: exit['credentialRevocation'] as ProviderCredentialRevocationV1, }, - resilience: { ...input.resilience }, - exit: { ...input.exit }, }); } @@ -285,6 +486,7 @@ export interface ProviderAbortSignalV1 { } export interface ProviderInvocationContextInputV1 { + readonly operation: ProviderOperationV1; readonly operationId: string; readonly correlationId: string; readonly deadlineAt: string; @@ -295,63 +497,88 @@ export interface ProviderInvocationContextInputV1 { export type ProviderInvocationContextV1 = ProviderInvocationContextInputV1; +const invocationContexts = new WeakSet(); + export function createProviderInvocationContextV1( input: ProviderInvocationContextInputV1, ): ProviderInvocationContextV1 { + const record = readClosedRecord(input, [ + 'operation', + 'operationId', + 'correlationId', + 'deadlineAt', + 'timeoutMs', + 'idempotencyKey', + 'abortSignal', + ]); + const abortRecord = + record === undefined ? undefined : readClosedRecord(record['abortSignal'], ['aborted']); if ( - !isRecord(input) || - !isSafeToken(input.operationId) || - !isSafeToken(input.correlationId) || - !isUtcTimestamp(input.deadlineAt) || - !isPositiveInteger(input.timeoutMs, 300_000) || - (input.idempotencyKey !== undefined && !isSafeToken(input.idempotencyKey)) || - !isRecord(input.abortSignal) || - typeof input.abortSignal.aborted !== 'boolean' + record === undefined || + !providerOperations.has(record['operation'] as ProviderOperationV1) || + !isSafeToken(record['operationId']) || + !isSafeToken(record['correlationId']) || + !isUtcTimestamp(record['deadlineAt']) || + !isPositiveInteger(record['timeoutMs'], 300_000) || + (record['idempotencyKey'] !== undefined && !isSafeToken(record['idempotencyKey'])) || + abortRecord === undefined || + typeof abortRecord['aborted'] !== 'boolean' ) { throw createProviderFailureV1({ code: 'INVALID_REQUEST', - operation: 'create-invocation-context', + operation: 'contract-validation', retryable: false, - safeMessageKey: 'provider.invalid_request', }); } - const sourceAbortSignal = input.abortSignal; + const sourceAbortSignal = record['abortSignal'] as object; const abortSignal: ProviderAbortSignalV1 = Object.freeze({ - get aborted() { - return sourceAbortSignal.aborted; + get aborted(): boolean { + try { + const descriptor = Reflect.getOwnPropertyDescriptor(sourceAbortSignal, 'aborted'); + return descriptor === undefined || !('value' in descriptor) || descriptor.value !== false; + } catch { + return true; + } }, }); - - return deepFreeze({ - operationId: input.operationId, - correlationId: input.correlationId, - deadlineAt: input.deadlineAt, - timeoutMs: input.timeoutMs, - ...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }), + const context = deepFreeze({ + operation: record['operation'] as ProviderOperationV1, + operationId: record['operationId'], + correlationId: record['correlationId'], + deadlineAt: record['deadlineAt'], + timeoutMs: record['timeoutMs'], + ...(record['idempotencyKey'] === undefined ? {} : { idempotencyKey: record['idempotencyKey'] }), abortSignal, }); + invocationContexts.add(context); + return context; } export interface ProviderFailureInputV1 { readonly code: ProviderErrorCodeV1; readonly providerKind?: ProviderKindV1; - readonly operation: string; + readonly operation: ProviderOperationV1; readonly retryable: boolean; readonly retryAfterMs?: number; +} + +interface NormalizedProviderFailureV1 extends ProviderFailureInputV1 { readonly safeMessageKey: string; - readonly providerCause?: unknown; } +const providerErrorFactoryToken = Symbol('ProviderOperationErrorV1.factory'); + export class ProviderOperationErrorV1 extends Error { public readonly code: ProviderErrorCodeV1; public readonly providerKind?: ProviderKindV1; - public readonly operation: string; + public readonly operation: ProviderOperationV1; public readonly retryable: boolean; public readonly retryAfterMs?: number; public readonly safeMessageKey: string; - public constructor(input: Omit) { + private constructor(token: symbol, input: NormalizedProviderFailureV1) { + if (token !== providerErrorFactoryToken) throw new TypeError('Use createProviderFailureV1.'); super('Provider operation failed.'); this.name = 'ProviderOperationErrorV1'; this.code = input.code; @@ -363,7 +590,15 @@ export class ProviderOperationErrorV1 extends Error { Object.freeze(this); } - public toJSON(): Readonly> { + public toJSON(): Readonly<{ + name: string; + code: ProviderErrorCodeV1; + providerKind?: ProviderKindV1; + operation: ProviderOperationV1; + retryable: boolean; + retryAfterMs?: number; + safeMessageKey: string; + }> { return Object.freeze({ name: this.name, code: this.code, @@ -376,56 +611,114 @@ export class ProviderOperationErrorV1 extends Error { } } +function constructProviderError(input: NormalizedProviderFailureV1): ProviderOperationErrorV1 { + const FactoryConstructor = ProviderOperationErrorV1 as unknown as new ( + token: symbol, + normalized: NormalizedProviderFailureV1, + ) => ProviderOperationErrorV1; + return new FactoryConstructor(providerErrorFactoryToken, input); +} + +function fallbackProviderError(): ProviderOperationErrorV1 { + return constructProviderError({ + code: 'UNKNOWN', + operation: 'contract-validation', + retryable: false, + safeMessageKey: errorMessageKeys.UNKNOWN, + }); +} + export function createProviderFailureV1(input: ProviderFailureInputV1): ProviderOperationErrorV1 { - void input.providerCause; + if (!isObject(input)) return fallbackProviderError(); + let keys: readonly (string | symbol)[]; + try { + if (Array.isArray(input)) return fallbackProviderError(); + keys = Reflect.ownKeys(input); + } catch { + return fallbackProviderError(); + } + const allowed = new Set(['code', 'providerKind', 'operation', 'retryable', 'retryAfterMs']); + const record: UnknownRecord = Object.create(null) as UnknownRecord; + for (const key of keys) { + if (key === 'providerCause') continue; + if (typeof key !== 'string' || !allowed.has(key)) return fallbackProviderError(); + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Reflect.getOwnPropertyDescriptor(input, key); + } catch { + return fallbackProviderError(); + } + if (descriptor === undefined || !('value' in descriptor)) return fallbackProviderError(); + record[key] = descriptor.value; + } if ( - !errorCodes.has(input.code) || - (input.providerKind !== undefined && !providerKinds.has(input.providerKind)) || - !isSafeToken(input.operation) || - typeof input.retryable !== 'boolean' || - (input.retryAfterMs !== undefined && !isPositiveInteger(input.retryAfterMs, 86_400_000)) || - !isSafeToken(input.safeMessageKey) + !errorCodes.has(record['code'] as ProviderErrorCodeV1) || + (record['providerKind'] !== undefined && + !providerKinds.has(record['providerKind'] as ProviderKindV1)) || + !providerOperations.has(record['operation'] as ProviderOperationV1) || + typeof record['retryable'] !== 'boolean' || + (record['retryAfterMs'] !== undefined && !isPositiveInteger(record['retryAfterMs'], 86_400_000)) ) { - return new ProviderOperationErrorV1({ - code: 'UNKNOWN', - operation: 'invalid-provider-failure', - retryable: false, - safeMessageKey: 'provider.unknown', - }); + return fallbackProviderError(); + } + const code = record['code'] as ProviderErrorCodeV1; + const retryable = record['retryable']; + if ( + (nonRetryableErrorCodes.has(code) && retryable) || + (record['retryAfterMs'] !== undefined && !retryable) + ) { + return fallbackProviderError(); } - return new ProviderOperationErrorV1(input); + return constructProviderError({ + code, + ...(record['providerKind'] === undefined + ? {} + : { providerKind: record['providerKind'] as ProviderKindV1 }), + operation: record['operation'] as ProviderOperationV1, + retryable, + ...(record['retryAfterMs'] === undefined ? {} : { retryAfterMs: record['retryAfterMs'] }), + safeMessageKey: errorMessageKeys[code], + }); +} + +function invalidInvocation(): ProviderOperationErrorV1 { + return createProviderFailureV1({ + code: 'INVALID_REQUEST', + operation: 'contract-validation', + retryable: false, + }); } export function assertProviderInvocationActiveV1( context: ProviderInvocationContextV1, now: string, ): void { + if (!isObject(context) || !invocationContexts.has(context) || !isUtcTimestamp(now)) { + throw invalidInvocation(); + } if (context.abortSignal.aborted) { throw createProviderFailureV1({ code: 'ABORTED', - operation: context.operationId, + operation: context.operation, retryable: false, - safeMessageKey: 'provider.aborted', }); } - if (!isUtcTimestamp(now) || Date.parse(now) >= Date.parse(context.deadlineAt)) { + if (Date.parse(now) >= Date.parse(context.deadlineAt)) { throw createProviderFailureV1({ code: 'TIMEOUT', - operation: context.operationId, + operation: context.operation, retryable: true, - safeMessageKey: 'provider.timeout', }); } } export function requireProviderIdempotencyV1(context: ProviderInvocationContextV1): string { - if (!isSafeToken(context.idempotencyKey)) { - throw createProviderFailureV1({ - code: 'INVALID_REQUEST', - operation: context.operationId, - retryable: false, - safeMessageKey: 'provider.idempotency_required', - }); + if ( + !isObject(context) || + !invocationContexts.has(context) || + !isSafeToken(context.idempotencyKey) + ) { + throw invalidInvocation(); } return context.idempotencyKey; } @@ -440,55 +733,117 @@ export interface ProviderHealthInputV1 { export type ProviderHealthV1 = ProviderHealthInputV1; export function defineProviderHealthV1(input: ProviderHealthInputV1): ProviderHealthV1 { + const record = readClosedRecord(input, ['status', 'checkedAt', 'latencyMs', 'safeReasonCodes']); + const reasons = record === undefined ? undefined : readArray(record['safeReasonCodes'], 16); if ( - !(['healthy', 'degraded', 'unavailable'] as const).includes(input.status) || - !isUtcTimestamp(input.checkedAt) || - (input.latencyMs !== undefined && - (!Number.isSafeInteger(input.latencyMs) || input.latencyMs < 0)) || - !isStringArray(input.safeReasonCodes) || - input.safeReasonCodes.some((code) => !isSafeToken(code)) + record === undefined || + !(['healthy', 'degraded', 'unavailable'] as const).includes( + record['status'] as ProviderHealthStatusV1, + ) || + !isUtcTimestamp(record['checkedAt']) || + (record['latencyMs'] !== undefined && + (!Number.isSafeInteger(record['latencyMs']) || (record['latencyMs'] as number) < 0)) || + reasons === undefined || + reasons.some((code) => typeof code !== 'string' || !healthReasonCodes.has(code)) || + new Set(reasons).size !== reasons.length || + (record['status'] === 'healthy' && reasons.length !== 0) || + (record['status'] !== 'healthy' && reasons.length === 0) ) { throw new ProviderContractErrorV1(); } return deepFreeze({ - status: input.status, - checkedAt: input.checkedAt, - ...(input.latencyMs === undefined ? {} : { latencyMs: input.latencyMs }), - safeReasonCodes: input.safeReasonCodes.map((code) => code), + status: record['status'] as ProviderHealthStatusV1, + checkedAt: record['checkedAt'], + ...(record['latencyMs'] === undefined ? {} : { latencyMs: record['latencyMs'] as number }), + safeReasonCodes: reasons as string[], + }); +} + +const secretReferenceBrandV1: unique symbol = Symbol('SecretReferenceV1'); + +export interface SecretReferenceV1 { + readonly [secretReferenceBrandV1]: true; + readonly kind: 'secret-reference'; + readonly namespace: string; + readonly pathSegments: readonly string[]; + readonly version?: string; + toString(): '[REDACTED_SECRET_REFERENCE]'; + toJSON(): '[REDACTED_SECRET_REFERENCE]'; +} + +const secretReferences = new WeakSet(); +const secretSegmentPattern = /^[a-z0-9][a-z0-9._-]{0,62}$/; + +export function defineSecretReferenceV1(input: { + readonly namespace: string; + readonly pathSegments: readonly string[]; + readonly version?: string; +}): SecretReferenceV1 { + const record = readClosedRecord(input, ['namespace', 'pathSegments', 'version']); + const segments = record === undefined ? undefined : readArray(record['pathSegments'], 32); + if ( + record === undefined || + typeof record['namespace'] !== 'string' || + !secretSegmentPattern.test(record['namespace']) || + record['namespace'] === '.' || + record['namespace'] === '..' || + segments === undefined || + segments.length === 0 || + segments.some( + (segment) => + typeof segment !== 'string' || + !secretSegmentPattern.test(segment) || + segment === '.' || + segment === '..', + ) || + (record['version'] !== undefined && + (typeof record['version'] !== 'string' || !secretSegmentPattern.test(record['version']))) + ) { + throw new ProviderContractErrorV1(); + } + const reference: SecretReferenceV1 = deepFreeze({ + [secretReferenceBrandV1]: true, + kind: 'secret-reference', + namespace: record['namespace'], + pathSegments: segments as string[], + ...(record['version'] === undefined ? {} : { version: record['version'] }), + toString: () => '[REDACTED_SECRET_REFERENCE]', + toJSON: () => '[REDACTED_SECRET_REFERENCE]', }); + secretReferences.add(reference); + return reference; } +const secretHandleBrandV1: unique symbol = Symbol('SecretHandleV1'); + export interface SecretHandleV1 { + readonly [secretHandleBrandV1]: true; readonly kind: 'secret-handle'; + readonly reference: SecretReferenceV1; readonly expiresAt?: string; toString(): '[REDACTED_SECRET_HANDLE]'; toJSON(): '[REDACTED_SECRET_HANDLE]'; } -const secretHandleIds = new WeakMap(); - export function defineSecretHandleV1(input: { - readonly handleId: string; + readonly reference: SecretReferenceV1; readonly expiresAt?: string; }): SecretHandleV1 { + const record = readClosedRecord(input, ['reference', 'expiresAt']); if ( - !isSafeToken(input.handleId) || - (input.expiresAt !== undefined && !isUtcTimestamp(input.expiresAt)) + record === undefined || + !isObject(record['reference']) || + !secretReferences.has(record['reference']) || + (record['expiresAt'] !== undefined && !isUtcTimestamp(record['expiresAt'])) ) { throw new ProviderContractErrorV1(); } - const handle: SecretHandleV1 = { + return deepFreeze({ + [secretHandleBrandV1]: true, kind: 'secret-handle', - ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }), + reference: record['reference'] as SecretReferenceV1, + ...(record['expiresAt'] === undefined ? {} : { expiresAt: record['expiresAt'] }), toString: () => '[REDACTED_SECRET_HANDLE]', toJSON: () => '[REDACTED_SECRET_HANDLE]', - }; - secretHandleIds.set(handle, input.handleId); - return Object.freeze(handle); -} - -export function secretHandleIdV1(handle: SecretHandleV1): string { - const id = secretHandleIds.get(handle); - if (id === undefined) throw new TypeError('Unknown secret handle.'); - return id; + }); } diff --git a/packages/provider-ports/src/ports-v1.ts b/packages/provider-ports/src/ports-v1.ts index e540457d..775e5c9a 100644 --- a/packages/provider-ports/src/ports-v1.ts +++ b/packages/provider-ports/src/ports-v1.ts @@ -1,37 +1,200 @@ +import { ProviderContractErrorV1, requireProviderIdempotencyV1 } from './common-v1.ts'; +import { parseV1Contract } from '@databreeze/contracts/v1'; import type { ProviderDescriptorV1, ProviderHealthV1, ProviderInvocationContextV1, ProviderKindV1, SecretHandleV1, + SecretReferenceV1, } from './common-v1.ts'; -export interface ProviderStateExportRequestV1 { - readonly context: ProviderInvocationContextV1; - readonly cursor?: string; - readonly limit: number; -} - -export interface ProviderStateExportResultV1 { - readonly manifestFormat: string; - readonly entries: readonly Readonly>[]; - readonly nextCursor?: string; - readonly complete: boolean; +export const OBJECT_STORAGE_MIN_PART_BYTES_V1 = 8 * 1024 * 1024; +export const OBJECT_STORAGE_MAX_PART_BYTES_V1 = 64 * 1024 * 1024; +export const OBJECT_STORAGE_MAX_OBJECT_BYTES_V1 = 20 * 1024 * 1024 * 1024; +export const OBJECT_STORAGE_MAX_PARTS_V1 = 10_000; + +const sha256Pattern = /^[a-f0-9]{64}$/; +const safeReferencePattern = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511}$/; +const UTC_TIMESTAMP_SCHEMA_ID = 'https://schemas.databreeze.dev/contracts/v1/utc-timestamp'; + +function isSafeReference(value: unknown): value is string { + return typeof value === 'string' && safeReferencePattern.test(value); +} + +function isSha256(value: unknown): value is string { + return typeof value === 'string' && sha256Pattern.test(value); +} + +function isPositiveInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): value is number { + return Number.isSafeInteger(value) && (value as number) > 0 && (value as number) <= maximum; +} + +function readClosedRecord( + value: unknown, + allowedKeys: readonly string[], +): Record | undefined { + if (value === null || typeof value !== 'object') return undefined; + const allowed = new Set(allowedKeys); + const result: Record = Object.create(null) as Record; + try { + if (Array.isArray(value)) return undefined; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !allowed.has(key)) return undefined; + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) return undefined; + result[key] = descriptor.value; + } + } catch { + return undefined; + } + return result; +} + +function readArray(value: unknown, maximum: number): readonly unknown[] | undefined { + let descriptors: Record; + try { + if (!Array.isArray(value)) return undefined; + descriptors = Object.getOwnPropertyDescriptors(value) as Record; + } catch { + return undefined; + } + const length = descriptors['length']?.value as unknown; + if (!Number.isSafeInteger(length) || (length as number) < 0 || (length as number) > maximum) { + return undefined; + } + const result: unknown[] = []; + for (let index = 0; index < (length as number); index += 1) { + const descriptor = descriptors[String(index)]; + if (descriptor === undefined || !('value' in descriptor)) return undefined; + result.push(descriptor.value); + } + if ( + Object.keys(descriptors).some((key) => key !== 'length' && Number(key) >= (length as number)) + ) { + return undefined; + } + return result; +} + +function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; } export interface ProviderPortV1 { descriptor(): ProviderDescriptorV1; checkHealth(context: ProviderInvocationContextV1): Promise; - exportState(request: ProviderStateExportRequestV1): Promise; } -export interface ObjectStoragePutRequestV1 { - readonly context: ProviderInvocationContextV1; +export interface ObjectStorageMultipartPlanInputV1 { readonly objectKey: string; + readonly expectedSha256: string; + readonly expectedByteLength: number; + readonly partSizeBytes: number; +} + +export interface ObjectStorageMultipartPlanV1 extends ObjectStorageMultipartPlanInputV1 { + readonly maximumParts: number; +} + +export function defineObjectStorageMultipartPlanV1( + input: ObjectStorageMultipartPlanInputV1, +): ObjectStorageMultipartPlanV1 { + const record = readClosedRecord(input, [ + 'objectKey', + 'expectedSha256', + 'expectedByteLength', + 'partSizeBytes', + ]); + if ( + record === undefined || + !isSafeReference(record['objectKey']) || + !isSha256(record['expectedSha256']) || + !isPositiveInteger(record['expectedByteLength'], OBJECT_STORAGE_MAX_OBJECT_BYTES_V1) || + !isPositiveInteger(record['partSizeBytes'], OBJECT_STORAGE_MAX_PART_BYTES_V1) || + record['partSizeBytes'] < OBJECT_STORAGE_MIN_PART_BYTES_V1 + ) { + throw new ProviderContractErrorV1(); + } + const maximumParts = Math.ceil(record['expectedByteLength'] / record['partSizeBytes']); + if (maximumParts > OBJECT_STORAGE_MAX_PARTS_V1) throw new ProviderContractErrorV1(); + return Object.freeze({ + objectKey: record['objectKey'], + expectedSha256: record['expectedSha256'], + expectedByteLength: record['expectedByteLength'], + partSizeBytes: record['partSizeBytes'], + maximumParts, + }); +} + +export interface ObjectStoragePartInputV1 { + readonly partNumber: number; readonly content: Uint8Array; readonly sha256: string; } +export type ObjectStoragePartV1 = ObjectStoragePartInputV1; + +export function defineObjectStoragePartV1(input: ObjectStoragePartInputV1): ObjectStoragePartV1 { + const record = readClosedRecord(input, ['partNumber', 'content', 'sha256']); + if ( + record === undefined || + !isPositiveInteger(record['partNumber'], OBJECT_STORAGE_MAX_PARTS_V1) || + !(record['content'] instanceof Uint8Array) || + record['content'].byteLength === 0 || + record['content'].byteLength > OBJECT_STORAGE_MAX_PART_BYTES_V1 || + !isSha256(record['sha256']) + ) { + throw new ProviderContractErrorV1(); + } + return Object.freeze({ + partNumber: record['partNumber'], + content: record['content'], + sha256: record['sha256'], + }); +} + +export interface ObjectStorageBeginMultipartRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly plan: ObjectStorageMultipartPlanV1; +} + +export interface ObjectStorageBeginMultipartResultV1 { + readonly uploadRef: string; + readonly acceptedPartSizeBytes: number; + readonly maximumParts: number; +} + +export interface ObjectStorageUploadPartRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly uploadRef: string; + readonly part: ObjectStoragePartV1; +} + +export interface ObjectStorageUploadedPartV1 { + readonly partNumber: number; + readonly sha256: string; + readonly byteLength: number; + readonly receiptRef: string; +} + +export interface ObjectStorageCompleteMultipartRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly uploadRef: string; + readonly orderedParts: readonly ObjectStorageUploadedPartV1[]; + readonly expectedSha256: string; + readonly expectedByteLength: number; +} + +export interface ObjectStorageAbortMultipartRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly uploadRef: string; +} + export interface ObjectStoragePutResultV1 { readonly objectRef: string; readonly sha256: string; @@ -75,13 +238,90 @@ export interface ObjectStorageReadGrantV1 { readonly expiresAt: string; } +export interface ObjectStorageExitRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly cursor?: string; + readonly limit: number; +} + +export interface ObjectStorageExitEntryV1 { + readonly objectRef: string; + readonly sha256: string; + readonly byteLength: number; + readonly retentionState: 'none' | 'active' | 'expired'; +} + +export interface ObjectStorageExitManifestV1 { + readonly manifestFormat: 'databreeze-object-storage-exit-v1'; + readonly entries: readonly ObjectStorageExitEntryV1[]; + readonly nextCursor?: string; + readonly complete: boolean; +} + +export function defineObjectStorageExitManifestV1( + input: ObjectStorageExitManifestV1, +): ObjectStorageExitManifestV1 { + const record = readClosedRecord(input, ['manifestFormat', 'entries', 'nextCursor', 'complete']); + const rawEntries = record === undefined ? undefined : readArray(record['entries'], 1_000); + if ( + record === undefined || + record['manifestFormat'] !== 'databreeze-object-storage-exit-v1' || + rawEntries === undefined || + typeof record['complete'] !== 'boolean' || + (record['nextCursor'] !== undefined && !isSafeReference(record['nextCursor'])) || + (record['complete'] && record['nextCursor'] !== undefined) || + (!record['complete'] && record['nextCursor'] === undefined) + ) { + throw new ProviderContractErrorV1(); + } + const entries = rawEntries.map((rawEntry) => { + const entry = readClosedRecord(rawEntry, [ + 'objectRef', + 'sha256', + 'byteLength', + 'retentionState', + ]); + if ( + entry === undefined || + !isSafeReference(entry['objectRef']) || + !isSha256(entry['sha256']) || + !isPositiveInteger(entry['byteLength'], OBJECT_STORAGE_MAX_OBJECT_BYTES_V1) || + !(['none', 'active', 'expired'] as const).includes(entry['retentionState'] as never) + ) { + throw new ProviderContractErrorV1(); + } + return { + objectRef: entry['objectRef'], + sha256: entry['sha256'], + byteLength: entry['byteLength'], + retentionState: entry['retentionState'] as ObjectStorageExitEntryV1['retentionState'], + }; + }); + return deepFreeze({ + manifestFormat: 'databreeze-object-storage-exit-v1', + entries, + ...(record['nextCursor'] === undefined ? {} : { nextCursor: record['nextCursor'] }), + complete: record['complete'], + }); +} + export interface ObjectStorageProviderPortV1 extends ProviderPortV1<'object-storage'> { - putImmutable(request: ObjectStoragePutRequestV1): Promise; + beginMultipartUpload( + request: ObjectStorageBeginMultipartRequestV1, + ): Promise; + uploadPart(request: ObjectStorageUploadPartRequestV1): Promise; + completeMultipartUpload( + request: ObjectStorageCompleteMultipartRequestV1, + ): Promise; + abortMultipartUpload( + request: ObjectStorageAbortMultipartRequestV1, + ): Promise>; readRange(request: ObjectStorageRangeRequestV1): Promise; verifyDigest(request: ObjectStorageDigestRequestV1): Promise>; applyRetention(request: ObjectStorageRetentionRequestV1): Promise>; deleteVerified(request: ObjectStorageDeleteRequestV1): Promise>; createReadGrant(request: ObjectStorageReadGrantRequestV1): Promise; + exportObjectManifest(request: ObjectStorageExitRequestV1): Promise; } export interface ExternalDeliveryWebhookRequestV1 { @@ -105,11 +345,83 @@ export interface EmailTemplateRequestV1 { readonly safeParameters: Readonly>; } +export interface DeliverySuppressionRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly recipientHandle: string; + readonly reason: 'hard_bounce' | 'complaint' | 'administrator'; + readonly occurredAt: string; +} + +export interface DeliverySuppressionManifestRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly cursor?: string; + readonly limit: number; +} + +export interface DeliverySuppressionManifestV1 { + readonly manifestFormat: 'databreeze-delivery-suppression-v1'; + readonly entries: readonly Readonly<{ + recipientHandle: string; + reason: 'hard_bounce' | 'complaint' | 'administrator'; + occurredAt: string; + }>[]; + readonly nextCursor?: string; + readonly complete: boolean; +} + +export function defineDeliverySuppressionManifestV1( + input: DeliverySuppressionManifestV1, +): DeliverySuppressionManifestV1 { + const record = readClosedRecord(input, ['manifestFormat', 'entries', 'nextCursor', 'complete']); + const rawEntries = record === undefined ? undefined : readArray(record['entries'], 1_000); + if ( + record === undefined || + record['manifestFormat'] !== 'databreeze-delivery-suppression-v1' || + rawEntries === undefined || + typeof record['complete'] !== 'boolean' || + (record['nextCursor'] !== undefined && !isSafeReference(record['nextCursor'])) || + (record['complete'] && record['nextCursor'] !== undefined) || + (!record['complete'] && record['nextCursor'] === undefined) + ) { + throw new ProviderContractErrorV1(); + } + const entries = rawEntries.map((rawEntry) => { + const entry = readClosedRecord(rawEntry, ['recipientHandle', 'reason', 'occurredAt']); + if ( + entry === undefined || + !isSafeReference(entry['recipientHandle']) || + !(['hard_bounce', 'complaint', 'administrator'] as const).includes( + entry['reason'] as never, + ) || + !parseV1Contract(UTC_TIMESTAMP_SCHEMA_ID, entry['occurredAt']).accepted + ) { + throw new ProviderContractErrorV1(); + } + return { + recipientHandle: entry['recipientHandle'], + reason: entry['reason'] as DeliverySuppressionRequestV1['reason'], + occurredAt: entry['occurredAt'] as string, + }; + }); + return deepFreeze({ + manifestFormat: 'databreeze-delivery-suppression-v1', + entries, + ...(record['nextCursor'] === undefined ? {} : { nextCursor: record['nextCursor'] }), + complete: record['complete'], + }); +} + export interface EmailProviderPortV1 extends ProviderPortV1<'email'> { sendTemplate(request: EmailTemplateRequestV1): Promise>; verifyDeliveryWebhook( request: ExternalDeliveryWebhookRequestV1, ): Promise; + suppressRecipient( + request: DeliverySuppressionRequestV1, + ): Promise>; + exportSuppressionManifest( + request: DeliverySuppressionManifestRequestV1, + ): Promise; } export interface PushRequestV1 { @@ -126,6 +438,12 @@ export interface PushProviderPortV1 extends ProviderPortV1<'push'> { verifyDeliveryWebhook( request: ExternalDeliveryWebhookRequestV1, ): Promise; + suppressRecipient( + request: DeliverySuppressionRequestV1, + ): Promise>; + exportSuppressionManifest( + request: DeliverySuppressionManifestRequestV1, + ): Promise; } export interface ProviderContentReferenceV1 { @@ -213,6 +531,86 @@ export interface SubscriptionReconciliationV1 { readonly externalPriceRef: string; } +export interface SubscriptionMigrationManifestRequestV1 { + readonly context: ProviderInvocationContextV1; + readonly cursor?: string; + readonly limit: number; +} + +export interface SubscriptionMigrationEntryV1 { + readonly organizationId: string; + readonly planVersionId: string; + readonly providerCustomerRef: string; + readonly providerSubscriptionRef?: string; + readonly state: SubscriptionReconciliationV1['state']; + readonly effectiveAt: string; +} + +export interface SubscriptionMigrationManifestV1 { + readonly manifestFormat: 'databreeze-subscription-migration-v1'; + readonly entries: readonly SubscriptionMigrationEntryV1[]; + readonly nextCursor?: string; + readonly complete: boolean; +} + +export function defineSubscriptionMigrationManifestV1( + input: SubscriptionMigrationManifestV1, +): SubscriptionMigrationManifestV1 { + const record = readClosedRecord(input, ['manifestFormat', 'entries', 'nextCursor', 'complete']); + const rawEntries = record === undefined ? undefined : readArray(record['entries'], 1_000); + if ( + record === undefined || + record['manifestFormat'] !== 'databreeze-subscription-migration-v1' || + rawEntries === undefined || + typeof record['complete'] !== 'boolean' || + (record['nextCursor'] !== undefined && !isSafeReference(record['nextCursor'])) || + (record['complete'] && record['nextCursor'] !== undefined) || + (!record['complete'] && record['nextCursor'] === undefined) + ) { + throw new ProviderContractErrorV1(); + } + const entries = rawEntries.map((rawEntry) => { + const entry = readClosedRecord(rawEntry, [ + 'organizationId', + 'planVersionId', + 'providerCustomerRef', + 'providerSubscriptionRef', + 'state', + 'effectiveAt', + ]); + if ( + entry === undefined || + !isSafeReference(entry['organizationId']) || + !isSafeReference(entry['planVersionId']) || + !isSafeReference(entry['providerCustomerRef']) || + (entry['providerSubscriptionRef'] !== undefined && + !isSafeReference(entry['providerSubscriptionRef'])) || + !(['trialing', 'active', 'past_due', 'cancel_at_period_end', 'cancelled'] as const).includes( + entry['state'] as never, + ) || + !parseV1Contract(UTC_TIMESTAMP_SCHEMA_ID, entry['effectiveAt']).accepted + ) { + throw new ProviderContractErrorV1(); + } + return { + organizationId: entry['organizationId'], + planVersionId: entry['planVersionId'], + providerCustomerRef: entry['providerCustomerRef'], + ...(entry['providerSubscriptionRef'] === undefined + ? {} + : { providerSubscriptionRef: entry['providerSubscriptionRef'] }), + state: entry['state'] as SubscriptionMigrationEntryV1['state'], + effectiveAt: entry['effectiveAt'] as string, + }; + }); + return deepFreeze({ + manifestFormat: 'databreeze-subscription-migration-v1', + entries, + ...(record['nextCursor'] === undefined ? {} : { nextCursor: record['nextCursor'] }), + complete: record['complete'], + }); +} + export interface PaymentsProviderPortV1 extends ProviderPortV1<'payments'> { createHostedSubscriptionCheckout( request: HostedSubscriptionCheckoutRequestV1, @@ -232,6 +630,9 @@ export interface PaymentsProviderPortV1 extends ProviderPortV1<'payments'> { providerSubscriptionRef: string; }>, ): Promise; + exportSubscriptionMigration( + request: SubscriptionMigrationManifestRequestV1, + ): Promise; } export interface SafeTelemetryRecordV1 { @@ -252,13 +653,62 @@ export interface TelemetryProviderPortV1 extends ProviderPortV1<'telemetry'> { ): Promise>; } +export type SecretPurposeV1 = + | 'provider-authentication' + | 'webhook-verification' + | 'encryption-key' + | 'signing-key'; + export interface SecretReferenceRequestV1 { readonly context: ProviderInvocationContextV1; - readonly reference: string; - readonly purpose: string; + readonly reference: SecretReferenceV1; + readonly purpose: SecretPurposeV1; readonly expiresAt: string; } +export interface SecretsPortabilityManifestRequestV1 { + readonly context: ProviderInvocationContextV1; +} + +export interface SecretsPortabilityManifestV1 { + readonly manifestFormat: 'databreeze-secrets-portability-v1'; + readonly referenceCount: number; + readonly activeHandleCount: number; + readonly revocation: 'automatic' | 'manual'; + readonly portability: 'references-only' | 'rebind-required'; +} + +export function defineSecretsPortabilityManifestV1( + input: SecretsPortabilityManifestV1, +): SecretsPortabilityManifestV1 { + const record = readClosedRecord(input, [ + 'manifestFormat', + 'referenceCount', + 'activeHandleCount', + 'revocation', + 'portability', + ]); + if ( + record === undefined || + record['manifestFormat'] !== 'databreeze-secrets-portability-v1' || + !Number.isSafeInteger(record['referenceCount']) || + (record['referenceCount'] as number) < 0 || + !Number.isSafeInteger(record['activeHandleCount']) || + (record['activeHandleCount'] as number) < 0 || + !(['automatic', 'manual'] as const).includes(record['revocation'] as never) || + !(['references-only', 'rebind-required'] as const).includes(record['portability'] as never) + ) { + throw new ProviderContractErrorV1(); + } + return Object.freeze({ + manifestFormat: 'databreeze-secrets-portability-v1', + referenceCount: record['referenceCount'] as number, + activeHandleCount: record['activeHandleCount'] as number, + revocation: record['revocation'] as SecretsPortabilityManifestV1['revocation'], + portability: record['portability'] as SecretsPortabilityManifestV1['portability'], + }); +} + export interface SecretsProviderPortV1 extends ProviderPortV1<'secrets'> { resolveHandle(request: SecretReferenceRequestV1): Promise; revokeHandle( @@ -267,4 +717,11 @@ export interface SecretsProviderPortV1 extends ProviderPortV1<'secrets'> { handle: SecretHandleV1; }>, ): Promise>; + describePortability( + request: SecretsPortabilityManifestRequestV1, + ): Promise; +} + +export function assertMutatingProviderRequestV1(context: ProviderInvocationContextV1): string { + return requireProviderIdempotencyV1(context); } diff --git a/packages/provider-ports/test/built-public-api-smoke.mjs b/packages/provider-ports/test/built-public-api-smoke.mjs index 3dad2ac1..76d7c0d3 100644 --- a/packages/provider-ports/test/built-public-api-smoke.mjs +++ b/packages/provider-ports/test/built-public-api-smoke.mjs @@ -5,4 +5,9 @@ const ports = await import('../dist/v1.js'); assert.equal(ports.PROVIDER_PORT_SCHEMA_VERSION_V1, 1); assert.equal(typeof ports.defineProviderDescriptorV1, 'function'); assert.equal(typeof ports.createProviderFailureV1, 'function'); +assert.equal(typeof ports.defineSecretReferenceV1, 'function'); assert.equal(typeof ports.defineSecretHandleV1, 'function'); +assert.equal(typeof ports.defineObjectStorageMultipartPlanV1, 'function'); +assert.equal(typeof ports.defineSubscriptionMigrationManifestV1, 'function'); +assert.equal(ports.secretHandleIdV1, undefined); +assert.equal(ports.secretReferenceHandleV1, undefined); diff --git a/packages/provider-ports/test/common-v1.test.mjs b/packages/provider-ports/test/common-v1.test.mjs index d4a8af07..f9204adf 100644 --- a/packages/provider-ports/test/common-v1.test.mjs +++ b/packages/provider-ports/test/common-v1.test.mjs @@ -7,29 +7,30 @@ function validDescriptor(kind = 'object-storage') { return { kind, adapterKey: 'in-memory-v1', - capabilities: [ - { - operation: 'put-immutable', - idempotency: 'required', - cancellation: 'cooperative', - timeoutMs: 5_000, - maxAttempts: 3, - }, - ], + capabilities: ports.PROVIDER_OPERATIONS_BY_KIND_V1[kind].map((operation) => ({ + operation, + idempotency: 'required', + cancellation: 'cooperative', + timeoutMs: 5_000, + maxAttempts: 3, + })), dataHandling: { regions: ['local'], - contentRetention: 'durable', - maximumRetentionSeconds: 86_400, - trainingUse: 'not_applicable', + contentRetention: kind === 'secrets' ? 'none' : 'durable', + ...(kind === 'secrets' ? {} : { maximumRetentionSeconds: 86_400 }), + trainingUse: kind === 'ai' ? 'prohibited' : 'not_applicable', }, resilience: { failover: 'manual', degradedBehavior: 'fail_closed', }, exit: { - statePortability: 'full', - exportFormat: 'databreeze-object-manifest-v1', - credentialRevocation: 'supported', + statePortability: kind === 'ocr' || kind === 'ai' || kind === 'telemetry' ? 'none' : 'full', + exportFormat: + kind === 'ocr' || kind === 'ai' || kind === 'telemetry' + ? 'not-applicable' + : `databreeze-${kind}-manifest-v1`, + credentialRevocation: kind === 'secrets' ? 'supported' : 'not_applicable', }, }; } @@ -39,7 +40,7 @@ test('defines and deeply freezes complete provider metadata', () => { assert.equal(descriptor.schemaVersion, 1); assert.equal(descriptor.kind, 'object-storage'); - assert.equal(descriptor.capabilities[0].operation, 'put-immutable'); + assert.equal(descriptor.capabilities[0].operation, 'begin-multipart-upload'); assert.equal(Object.isFrozen(descriptor), true); assert.equal(Object.isFrozen(descriptor.capabilities), true); assert.equal(Object.isFrozen(descriptor.dataHandling.regions), true); @@ -73,15 +74,15 @@ test('rejects incomplete retry, data-handling, resilience, and exit metadata', ( test('normalizes provider failures without retaining provider causes or secret values', () => { const secret = 'provider-token-that-must-not-escape'; - const error = ports.createProviderFailureV1({ + const input = { code: 'RATE_LIMITED', providerKind: 'email', operation: 'send-template', retryable: true, retryAfterMs: 2_000, - safeMessageKey: 'provider.rate_limited', - providerCause: new Error(secret), - }); + }; + Object.defineProperty(input, 'providerCause', { value: new Error(secret), enumerable: true }); + const error = ports.createProviderFailureV1(input); assert.ok(error instanceof ports.ProviderOperationErrorV1); assert.deepEqual(JSON.parse(JSON.stringify(error)), { @@ -98,35 +99,32 @@ test('normalizes provider failures without retaining provider causes or secret v assert.equal('cause' in error, false); }); -test('creates immutable invocation metadata with deadline, timeout, cancellation, and idempotency', () => { - const context = ports.createProviderInvocationContextV1({ +function context(overrides = {}) { + return ports.createProviderInvocationContextV1({ + operation: 'extract', operationId: 'op-0001', correlationId: 'corr-0001', deadlineAt: '2026-08-01T10:00:05.000Z', timeoutMs: 5_000, - idempotencyKey: 'idem-0001', abortSignal: { aborted: false }, + ...overrides, }); +} - assert.equal(Object.isFrozen(context), true); - assert.equal(Object.isFrozen(context.abortSignal), true); +test('creates immutable invocation metadata with deadline, timeout, cancellation, and idempotency', () => { + const invocation = context({ idempotencyKey: 'idem-0001' }); + assert.equal(Object.isFrozen(invocation), true); + assert.equal(Object.isFrozen(invocation.abortSignal), true); assert.doesNotThrow(() => - ports.assertProviderInvocationActiveV1(context, '2026-08-01T10:00:00.000Z'), + ports.assertProviderInvocationActiveV1(invocation, '2026-08-01T10:00:00.000Z'), ); - assert.doesNotThrow(() => ports.requireProviderIdempotencyV1(context)); + assert.equal(ports.requireProviderIdempotencyV1(invocation), 'idem-0001'); }); test('rejects an aborted invocation with a normalized non-retryable error', () => { - const context = ports.createProviderInvocationContextV1({ - operationId: 'op-aborted', - correlationId: 'corr-aborted', - deadlineAt: '2026-08-01T10:00:05.000Z', - timeoutMs: 5_000, - abortSignal: { aborted: true }, - }); - + const invocation = context({ abortSignal: { aborted: true } }); assert.throws( - () => ports.assertProviderInvocationActiveV1(context, '2026-08-01T10:00:00.000Z'), + () => ports.assertProviderInvocationActiveV1(invocation, '2026-08-01T10:00:00.000Z'), (error) => error instanceof ports.ProviderOperationErrorV1 && error.code === 'ABORTED' && @@ -136,33 +134,18 @@ test('rejects an aborted invocation with a normalized non-retryable error', () = test('observes cancellation that occurs after invocation context creation', () => { const abortSignal = { aborted: false }; - const context = ports.createProviderInvocationContextV1({ - operationId: 'op-later-abort', - correlationId: 'corr-later-abort', - deadlineAt: '2026-08-01T10:00:05.000Z', - timeoutMs: 5_000, - abortSignal, - }); - + const invocation = context({ abortSignal }); abortSignal.aborted = true; - assert.throws( - () => ports.assertProviderInvocationActiveV1(context, '2026-08-01T10:00:00.000Z'), + () => ports.assertProviderInvocationActiveV1(invocation, '2026-08-01T10:00:00.000Z'), (error) => error instanceof ports.ProviderOperationErrorV1 && error.code === 'ABORTED', ); }); test('rejects an expired invocation with a normalized timeout error', () => { - const context = ports.createProviderInvocationContextV1({ - operationId: 'op-timeout', - correlationId: 'corr-timeout', - deadlineAt: '2026-08-01T10:00:05.000Z', - timeoutMs: 5_000, - abortSignal: { aborted: false }, - }); - + const invocation = context(); assert.throws( - () => ports.assertProviderInvocationActiveV1(context, '2026-08-01T10:00:05.000Z'), + () => ports.assertProviderInvocationActiveV1(invocation, '2026-08-01T10:00:05.000Z'), (error) => error instanceof ports.ProviderOperationErrorV1 && error.code === 'TIMEOUT' && @@ -170,17 +153,9 @@ test('rejects an expired invocation with a normalized timeout error', () => { ); }); -test('requires idempotency only when an operation declares it', () => { - const context = ports.createProviderInvocationContextV1({ - operationId: 'op-no-idempotency', - correlationId: 'corr-no-idempotency', - deadlineAt: '2026-08-01T10:00:05.000Z', - timeoutMs: 5_000, - abortSignal: { aborted: false }, - }); - +test('requires an idempotency key at mutating adapter boundaries', () => { assert.throws( - () => ports.requireProviderIdempotencyV1(context), + () => ports.requireProviderIdempotencyV1(context()), (error) => error instanceof ports.ProviderOperationErrorV1 && error.code === 'INVALID_REQUEST', ); }); @@ -192,7 +167,6 @@ test('defines provider health with safe reason codes and no raw detail channel', latencyMs: 125, safeReasonCodes: ['UPSTREAM_RATE_LIMITED'], }); - assert.deepEqual(health, { status: 'degraded', checkedAt: '2026-08-01T10:00:00.000Z', @@ -202,13 +176,16 @@ test('defines provider health with safe reason codes and no raw detail channel', assert.equal(Object.isFrozen(health.safeReasonCodes), true); }); -test('creates opaque secret handles that redact serialization', () => { +test('creates opaque secret handles that redact serialization and expose no material or raw IDs', () => { + const reference = ports.defineSecretReferenceV1({ + namespace: 'production', + pathSegments: ['email', 'credential'], + }); const handle = ports.defineSecretHandleV1({ - handleId: 'opaque-secret-handle', + reference, expiresAt: '2026-08-01T10:05:00.000Z', }); - assert.equal(String(handle), '[REDACTED_SECRET_HANDLE]'); assert.equal(JSON.stringify(handle), '"[REDACTED_SECRET_HANDLE]"'); - assert.equal(ports.secretHandleIdV1(handle), 'opaque-secret-handle'); + assert.equal(ports.secretHandleIdV1, undefined); }); diff --git a/packages/provider-ports/test/interchangeability-v1.test.mjs b/packages/provider-ports/test/interchangeability-v1.test.mjs index 6d8427e1..4fff47e7 100644 --- a/packages/provider-ports/test/interchangeability-v1.test.mjs +++ b/packages/provider-ports/test/interchangeability-v1.test.mjs @@ -5,9 +5,14 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { + assertMutatingProviderRequestV1, assertProviderInvocationActiveV1, + createProviderInvocationContextV1, + defineObjectStorageExitManifestV1, + defineObjectStorageMultipartPlanV1, + defineObjectStoragePartV1, defineProviderDescriptorV1, - requireProviderIdempotencyV1, + defineProviderHealthV1, } from '../src/v1.ts'; const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -17,14 +22,23 @@ function descriptor(adapterKey) { kind: 'object-storage', adapterKey, capabilities: [ - { - operation: 'put-immutable', - idempotency: 'required', - cancellation: 'cooperative', - timeoutMs: 5_000, - maxAttempts: 3, - }, - ], + 'begin-multipart-upload', + 'upload-part', + 'complete-multipart-upload', + 'abort-multipart-upload', + 'read-range', + 'verify-digest', + 'apply-retention', + 'delete-verified', + 'create-read-grant', + 'export-object-manifest', + ].map((operation) => ({ + operation, + idempotency: 'required', + cancellation: 'cooperative', + timeoutMs: 5_000, + maxAttempts: 3, + })), dataHandling: { regions: ['local'], contentRetention: 'durable', @@ -34,85 +48,200 @@ function descriptor(adapterKey) { resilience: { failover: 'manual', degradedBehavior: 'fail_closed' }, exit: { statePortability: 'full', - exportFormat: 'databreeze-object-manifest-v1', - credentialRevocation: 'supported', + exportFormat: 'databreeze-object-storage-exit-v1', + credentialRevocation: 'not_applicable', }, }); } -function mapStorageFake() { - const byIdempotencyKey = new Map(); +function createBacking(kind) { + if (kind === 'map') { + const values = new Map(); + return { + get: (key) => values.get(key), + set: (key, value) => values.set(key, value), + delete: (key) => values.delete(key), + }; + } + const values = Object.create(null); return { - descriptor: () => descriptor('map-memory-v1'), - async putImmutable(request) { - const prior = byIdempotencyKey.get(request.context.idempotencyKey); - if (prior !== undefined) return prior; - const result = Object.freeze({ - objectRef: `object:${request.objectKey}`, - sha256: request.sha256, - byteLength: request.content.byteLength, - }); - byIdempotencyKey.set(request.context.idempotencyKey, result); - return result; + get: (key) => values[key], + set: (key, value) => { + values[key] = value; + return value; }, + delete: (key) => delete values[key], }; } -function recordStorageFake() { - const byIdempotencyKey = Object.create(null); +function storageFake(adapterKey, backingKind) { + const receipts = createBacking(backingKind); + const parts = createBacking(backingKind); + const results = createBacking(backingKind); return { - descriptor: () => descriptor('record-memory-v1'), - async putImmutable(request) { - const key = request.context.idempotencyKey; - byIdempotencyKey[key] ??= Object.freeze({ - objectRef: `object:${request.objectKey}`, - sha256: request.sha256, - byteLength: request.content.byteLength, + descriptor: () => descriptor(adapterKey), + async checkHealth() { + return defineProviderHealthV1({ + status: 'healthy', + checkedAt: '2026-08-01T10:00:00.000Z', + latencyMs: 0, + safeReasonCodes: [], + }); + }, + async beginMultipartUpload(request) { + const key = assertMutatingProviderRequestV1(request.context); + const prior = receipts.get(key); + if (prior !== undefined) return prior; + const value = Object.freeze({ + uploadRef: `upload:${request.plan.objectKey}`, + acceptedPartSizeBytes: request.plan.partSizeBytes, + maximumParts: request.plan.maximumParts, + }); + receipts.set(key, value); + return value; + }, + async uploadPart(request) { + const key = assertMutatingProviderRequestV1(request.context); + const prior = receipts.get(key); + if (prior !== undefined) return prior; + const part = defineObjectStoragePartV1(request.part); + const value = Object.freeze({ + partNumber: part.partNumber, + sha256: part.sha256, + byteLength: part.content.byteLength, + receiptRef: `part:${part.partNumber}`, + }); + parts.set(`${request.uploadRef}:${part.partNumber}`, part.content); + receipts.set(key, value); + return value; + }, + async completeMultipartUpload(request) { + const key = assertMutatingProviderRequestV1(request.context); + const prior = results.get(key); + if (prior !== undefined) return prior; + const byteLength = request.orderedParts.reduce((total, part) => total + part.byteLength, 0); + assert.equal(byteLength, request.expectedByteLength); + const value = Object.freeze({ + objectRef: `object:${request.uploadRef.slice('upload:'.length)}`, + sha256: request.expectedSha256, + byteLength, + }); + results.set(key, value); + return value; + }, + async abortMultipartUpload(request) { + assertMutatingProviderRequestV1(request.context); + return Object.freeze({ aborted: true }); + }, + async readRange(request) { + return ( + parts + .get(`upload:${request.objectRef.slice('object:'.length)}:1`) + ?.slice(request.offset, request.offset + request.length) ?? new Uint8Array() + ); + }, + async verifyDigest() { + return Object.freeze({ verified: true }); + }, + async applyRetention(request) { + assertMutatingProviderRequestV1(request.context); + return Object.freeze({ applied: true }); + }, + async deleteVerified(request) { + assertMutatingProviderRequestV1(request.context); + results.delete(request.objectRef); + return Object.freeze({ deleted: true }); + }, + async createReadGrant(request) { + assertMutatingProviderRequestV1(request.context); + return Object.freeze({ + grantRef: `grant:${request.objectRef}`, + expiresAt: request.expiresAt, + }); + }, + async exportObjectManifest() { + return defineObjectStorageExitManifestV1({ + manifestFormat: 'databreeze-object-storage-exit-v1', + entries: [], + complete: true, }); - return byIdempotencyKey[key]; }, }; } -async function storeTwice(port, context) { - assertProviderInvocationActiveV1(context, '2026-08-01T10:00:00.000Z'); - requireProviderIdempotencyV1(context); - const request = { - context, +function context(operation, idempotencyKey) { + return createProviderInvocationContextV1({ + operation, + operationId: `op-${idempotencyKey}`, + correlationId: 'corr-interchangeable', + deadlineAt: '2026-08-01T10:00:05.000Z', + timeoutMs: 5_000, + idempotencyKey, + abortSignal: { aborted: false }, + }); +} + +async function storeWithReplay(port) { + const plan = defineObjectStorageMultipartPlanV1({ objectKey: 'workspace/object-1', - content: new Uint8Array([1, 2, 3]), - sha256: 'a'.repeat(64), + expectedSha256: 'a'.repeat(64), + expectedByteLength: 3, + partSizeBytes: 8 * 1024 * 1024, + }); + const beginContext = context('begin-multipart-upload', 'idem-begin'); + assertProviderInvocationActiveV1(beginContext, '2026-08-01T10:00:00.000Z'); + const [upload, replayedUpload] = await Promise.all([ + port.beginMultipartUpload({ context: beginContext, plan }), + port.beginMultipartUpload({ context: beginContext, plan }), + ]); + assert.equal(upload, replayedUpload); + + const uploadedPart = await port.uploadPart({ + context: context('upload-part', 'idem-part-1'), + uploadRef: upload.uploadRef, + part: defineObjectStoragePartV1({ + partNumber: 1, + content: new Uint8Array([1, 2, 3]), + sha256: 'b'.repeat(64), + }), + }); + const completeContext = context('complete-multipart-upload', 'idem-complete'); + const request = { + context: completeContext, + uploadRef: upload.uploadRef, + orderedParts: [uploadedPart], + expectedSha256: plan.expectedSha256, + expectedByteLength: plan.expectedByteLength, }; - return Promise.all([port.putImmutable(request), port.putImmutable(request)]); + return Promise.all([ + port.completeMultipartUpload(request), + port.completeMultipartUpload(request), + ]); } -for (const [name, createFake] of [ - ['map-backed adapter', mapStorageFake], - ['record-backed adapter', recordStorageFake], +for (const [name, port] of [ + ['map-backed adapter', storageFake('map-memory-v1', 'map')], + ['record-backed adapter', storageFake('record-memory-v1', 'record')], ]) { - test(`uses the same object-storage contract with a ${name}`, async () => { - const context = { - operationId: 'op-interchangeable', - correlationId: 'corr-interchangeable', - deadlineAt: '2026-08-01T10:00:05.000Z', - timeoutMs: 5_000, - idempotencyKey: 'idem-interchangeable', - abortSignal: { aborted: false }, - }; - const [first, replay] = await storeTwice(createFake(), context); - + test(`uses the same resumable object-storage contract with a ${name}`, async () => { + const [first, replay] = await storeWithReplay(port); assert.deepEqual(first, { objectRef: 'object:workspace/object-1', sha256: 'a'.repeat(64), byteLength: 3, }); assert.equal(first, replay, 'an idempotent replay returns the original receipt'); + assert.equal(port.descriptor().capabilities.length, 10); + assert.equal((await port.checkHealth()).status, 'healthy'); + assert.equal( + (await port.exportObjectManifest()).manifestFormat, + 'databreeze-object-storage-exit-v1', + ); }); } -test('declares no provider SDK or service implementation dependency', () => { +test('declares only the canonical contracts dependency and no provider SDK', () => { const manifest = JSON.parse(readFileSync(path.join(packageDirectory, 'package.json'), 'utf8')); - - assert.deepEqual(manifest.dependencies ?? {}, {}); + assert.deepEqual(manifest.dependencies, { '@databreeze/contracts': 'workspace:*' }); assert.deepEqual(manifest.optionalDependencies ?? {}, {}); }); diff --git a/packages/provider-ports/test/ports-v1.type-test.ts b/packages/provider-ports/test/ports-v1.type-test.ts index b7fcc5d7..2bd3d88f 100644 --- a/packages/provider-ports/test/ports-v1.type-test.ts +++ b/packages/provider-ports/test/ports-v1.type-test.ts @@ -1,3 +1,4 @@ +import { ProviderOperationErrorV1 } from '@databreeze/provider-ports/v1'; import type { AiProviderPortV1, EmailProviderPortV1, @@ -7,8 +8,59 @@ import type { PushProviderPortV1, SecretsProviderPortV1, TelemetryProviderPortV1, + SecretReferenceV1, } from '@databreeze/provider-ports/v1'; +const unavailable = (): Promise => Promise.reject(new Error('compile-only adapter')); + +const completeObjectStorageAdapter = { + descriptor: (): never => { + throw new Error('compile-only adapter'); + }, + checkHealth: unavailable, + beginMultipartUpload: unavailable, + uploadPart: unavailable, + completeMultipartUpload: unavailable, + abortMultipartUpload: unavailable, + readRange: unavailable, + verifyDigest: unavailable, + applyRetention: unavailable, + deleteVerified: unavailable, + createReadGrant: unavailable, + exportObjectManifest: unavailable, +} satisfies ObjectStorageProviderPortV1; + +const completeEmailAdapter = { + descriptor: (): never => { + throw new Error('compile-only adapter'); + }, + checkHealth: unavailable, + sendTemplate: unavailable, + verifyDeliveryWebhook: unavailable, + suppressRecipient: unavailable, + exportSuppressionManifest: unavailable, +} satisfies EmailProviderPortV1; + +void completeObjectStorageAdapter; +void completeEmailAdapter; + +// @ts-expect-error -- secret references are branded values created by the validated factory. +const structurallyForgedSecretReference: SecretReferenceV1 = { + kind: 'secret-reference', + namespace: 'production', + pathSegments: ['email'], + toString: () => '[REDACTED_SECRET_REFERENCE]', + toJSON: () => '[REDACTED_SECRET_REFERENCE]', +}; +void structurallyForgedSecretReference; + +// @ts-expect-error -- provider operation errors are created only by createProviderFailureV1. +new ProviderOperationErrorV1({ + code: 'UNKNOWN', + operation: 'contract-validation', + retryable: false, +}); + declare const objectStorage: ObjectStorageProviderPortV1; declare const email: EmailProviderPortV1; declare const push: PushProviderPortV1; @@ -18,16 +70,24 @@ declare const payments: PaymentsProviderPortV1; declare const telemetry: TelemetryProviderPortV1; declare const secrets: SecretsProviderPortV1; -void objectStorage.putImmutable; +void objectStorage.beginMultipartUpload; +void objectStorage.uploadPart; +void objectStorage.completeMultipartUpload; +void objectStorage.abortMultipartUpload; void objectStorage.readRange; void objectStorage.verifyDigest; void objectStorage.applyRetention; void objectStorage.deleteVerified; void objectStorage.createReadGrant; +void objectStorage.exportObjectManifest; void email.sendTemplate; void email.verifyDeliveryWebhook; +void email.suppressRecipient; +void email.exportSuppressionManifest; void push.send; void push.verifyDeliveryWebhook; +void push.suppressRecipient; +void push.exportSuppressionManifest; void ocr.extract; void ai.generateStructured; void payments.createHostedSubscriptionCheckout; @@ -35,16 +95,22 @@ void payments.createSubscriptionPortal; void payments.upsertDatabreezeSubscription; void payments.verifySubscriptionWebhook; void payments.reconcileDatabreezeSubscription; +void payments.exportSubscriptionMigration; void telemetry.exportBatch; void secrets.resolveHandle; void secrets.revokeHandle; +void secrets.describePortability; for (const port of [objectStorage, email, push, ocr, ai, payments, telemetry, secrets]) { void port.descriptor; void port.checkHealth; - void port.exportState; } +// @ts-expect-error -- provider families expose only their content-safe, typed exit contract. +void objectStorage.exportState; +// @ts-expect-error -- immutable storage is streamed in bounded parts, never a whole-object buffer. +void objectStorage.putImmutable; + // @ts-expect-error -- the billing port cannot charge customer funds. void payments.chargeCustomer; // @ts-expect-error -- the billing port cannot refund customer funds. diff --git a/packages/provider-ports/test/public-api-v1.test.mjs b/packages/provider-ports/test/public-api-v1.test.mjs index 860abd19..f9d2bfee 100644 --- a/packages/provider-ports/test/public-api-v1.test.mjs +++ b/packages/provider-ports/test/public-api-v1.test.mjs @@ -17,6 +17,8 @@ test('publishes versioned provider boundaries', async () => { assert.ok(ports, 'the provider v1 source entry point must exist'); assert.equal(ports.PROVIDER_PORT_SCHEMA_VERSION_V1, 1); assert.equal(typeof ports.defineProviderDescriptorV1, 'function'); + assert.equal(typeof ports.defineObjectStorageMultipartPlanV1, 'function'); + assert.equal(ports.secretHandleIdV1, undefined); }); test('exposes only the versioned provider entry point', async () => { diff --git a/packages/provider-ports/test/review-regressions-v1.test.mjs b/packages/provider-ports/test/review-regressions-v1.test.mjs new file mode 100644 index 00000000..9b78975e --- /dev/null +++ b/packages/provider-ports/test/review-regressions-v1.test.mjs @@ -0,0 +1,294 @@ +import assert from 'node:assert/strict'; +import { URL } from 'node:url'; +import { inspect } from 'node:util'; +import test from 'node:test'; + +const ports = await import('../src/v1.ts'); + +function capability(operation, idempotency = 'supported') { + return { + operation, + idempotency, + cancellation: 'cooperative', + timeoutMs: 5_000, + maxAttempts: 3, + }; +} + +function validDescriptor(kind = 'object-storage') { + return { + kind, + adapterKey: `${kind}-memory-v1`, + capabilities: ports.PROVIDER_OPERATIONS_BY_KIND_V1[kind].map((operation) => + capability( + operation, + operation.includes('begin') || operation.includes('complete') ? 'required' : 'supported', + ), + ), + dataHandling: { + regions: ['local'], + contentRetention: kind === 'secrets' ? 'none' : 'durable', + ...(kind === 'secrets' ? {} : { maximumRetentionSeconds: 86_400 }), + trainingUse: kind === 'ai' ? 'prohibited' : 'not_applicable', + }, + resilience: { failover: 'manual', degradedBehavior: 'fail_closed' }, + exit: { + statePortability: + kind === 'ocr' || kind === 'ai' || kind === 'telemetry' ? 'none' : 'manifest', + exportFormat: + kind === 'ocr' || kind === 'ai' || kind === 'telemetry' + ? 'not-applicable' + : `databreeze-${kind}-exit-v1`, + credentialRevocation: kind === 'secrets' ? 'supported' : 'not_applicable', + }, + }; +} + +test('provider descriptors are recursively closed and kind-specific', () => { + const rootExtra = { ...validDescriptor(), rawProviderConfig: 'must-not-survive' }; + assert.throws(() => ports.defineProviderDescriptorV1(rootExtra), ports.ProviderContractErrorV1); + + const nestedExtra = validDescriptor(); + nestedExtra.dataHandling.rawRetentionPolicy = 'must-not-survive'; + assert.throws(() => ports.defineProviderDescriptorV1(nestedExtra), ports.ProviderContractErrorV1); + + const wrongOperation = validDescriptor('email'); + wrongOperation.capabilities[0].operation = 'charge-customer'; + assert.throws( + () => ports.defineProviderDescriptorV1(wrongOperation), + ports.ProviderContractErrorV1, + ); +}); + +test('revoked proxies become stable provider contract failures', () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + assert.throws(() => ports.defineProviderDescriptorV1(proxy), ports.ProviderContractErrorV1); + const failure = ports.createProviderFailureV1(proxy); + assert.equal(failure.code, 'UNKNOWN'); + assert.equal(failure.operation, 'contract-validation'); +}); + +test('provider descriptor and health coherence fails closed', () => { + const noneWithRetention = validDescriptor('secrets'); + noneWithRetention.dataHandling.maximumRetentionSeconds = 60; + assert.throws( + () => ports.defineProviderDescriptorV1(noneWithRetention), + ports.ProviderContractErrorV1, + ); + + assert.throws( + () => + ports.defineProviderHealthV1({ + status: 'healthy', + checkedAt: '2026-08-01T10:00:00.000Z', + safeReasonCodes: ['UPSTREAM_FAILED'], + }), + ports.ProviderContractErrorV1, + ); + assert.throws( + () => + ports.defineProviderHealthV1({ + status: 'unavailable', + checkedAt: '2026-08-01T10:00:00.000Z', + safeReasonCodes: [], + }), + ports.ProviderContractErrorV1, + ); +}); + +test('provider operation errors are factory-only and ignore hostile causes', () => { + assert.throws( + () => + new ports.ProviderOperationErrorV1({ + code: 'UNKNOWN', + operation: 'extract', + retryable: false, + }), + TypeError, + ); + + let getterCalls = 0; + const input = { + code: 'RATE_LIMITED', + providerKind: 'email', + operation: 'send-template', + retryable: true, + retryAfterMs: 2_000, + }; + Object.defineProperty(input, 'providerCause', { + enumerable: true, + get() { + getterCalls += 1; + throw new Error('provider-cause-secret-X9Y8Z7'); + }, + }); + const normalized = ports.createProviderFailureV1(input); + assert.equal(getterCalls, 0); + assert.doesNotMatch(inspect(normalized), /provider-cause-secret-X9Y8Z7/u); + assert.doesNotMatch(JSON.stringify(normalized), /provider-cause-secret-X9Y8Z7/u); + assert.equal(normalized.safeMessageKey, 'provider.rate_limited'); + + const attackerControlled = ports.createProviderFailureV1({ + code: 'UNAVAILABLE', + operation: 'attacker/raw-operation-X9Y8Z7', + retryable: true, + }); + assert.equal(attackerControlled.operation, 'contract-validation'); + assert.doesNotMatch(JSON.stringify(attackerControlled), /X9Y8Z7/u); +}); + +function invocation(overrides = {}) { + return ports.createProviderInvocationContextV1({ + operation: 'extract', + operationId: 'op-0001', + correlationId: 'corr-0001', + deadlineAt: '2026-08-01T10:00:05.000Z', + timeoutMs: 5_000, + abortSignal: { aborted: false }, + ...overrides, + }); +} + +test('canonical contract timestamp validation rejects impossible calendar dates', () => { + assert.throws( + () => invocation({ deadlineAt: '2026-02-30T10:00:05.000Z' }), + (error) => error instanceof ports.ProviderOperationErrorV1 && error.code === 'INVALID_REQUEST', + ); + + const context = invocation(); + assert.throws( + () => ports.assertProviderInvocationActiveV1(context, '2026-02-30T10:00:00.000Z'), + (error) => + error instanceof ports.ProviderOperationErrorV1 && + error.code === 'INVALID_REQUEST' && + error.retryable === false, + ); +}); + +test('invocation validation is closed and revalidates the whole context', () => { + assert.throws( + () => invocation({ unexpected: 'raw' }), + (error) => error instanceof ports.ProviderOperationErrorV1 && error.code === 'INVALID_REQUEST', + ); + + const context = invocation(); + const malformed = { ...context, deadlineAt: 'not-a-date' }; + assert.throws( + () => ports.assertProviderInvocationActiveV1(malformed, '2026-08-01T10:00:00.000Z'), + (error) => error instanceof ports.ProviderOperationErrorV1 && error.code === 'INVALID_REQUEST', + ); +}); + +test('secret references and handles are opaque objects with no raw extractors', () => { + const reference = ports.defineSecretReferenceV1({ + namespace: 'production', + pathSegments: ['email', 'credential'], + version: 'active', + }); + const handle = ports.defineSecretHandleV1({ reference, expiresAt: '2026-08-01T10:05:00.000Z' }); + assert.equal(String(reference), '[REDACTED_SECRET_REFERENCE]'); + assert.equal(String(handle), '[REDACTED_SECRET_HANDLE]'); + assert.equal(ports.secretHandleIdV1, undefined); + assert.equal(ports.secretReferenceHandleV1, undefined); + assert.doesNotMatch(JSON.stringify({ reference, handle }), /production|email|credential|active/u); +}); + +test('base ports have no generic arbitrary state export', async () => { + const source = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('../src/ports-v1.ts', import.meta.url), 'utf8'), + ); + assert.doesNotMatch(source, /exportState\s*\(/u); + assert.doesNotMatch(source, /ProviderStateExportResultV1/u); + assert.match(source, /ObjectStorageExitManifestV1/u); + assert.match(source, /SubscriptionMigrationManifestV1/u); + assert.match(source, /SecretsPortabilityManifestV1/u); +}); + +test('payment exit manifests are closed and contain only subscription migration metadata', () => { + const manifest = ports.defineSubscriptionMigrationManifestV1({ + manifestFormat: 'databreeze-subscription-migration-v1', + entries: [ + { + organizationId: 'org-1', + planVersionId: 'plan-v1', + providerCustomerRef: 'customer-1', + providerSubscriptionRef: 'subscription-1', + state: 'active', + effectiveAt: '2026-08-01T10:00:00.000Z', + }, + ], + complete: true, + }); + assert.equal(manifest.entries[0].state, 'active'); + assert.throws( + () => + ports.defineSubscriptionMigrationManifestV1({ + ...manifest, + paymentMethod: 'raw-payment-material', + }), + ports.ProviderContractErrorV1, + ); +}); + +test('delivery and secrets exit manifests are closed and content-safe', () => { + const delivery = ports.defineDeliverySuppressionManifestV1({ + manifestFormat: 'databreeze-delivery-suppression-v1', + entries: [ + { + recipientHandle: 'recipient-1', + reason: 'hard_bounce', + occurredAt: '2026-08-01T10:00:00.000Z', + }, + ], + complete: true, + }); + assert.equal(delivery.entries.length, 1); + + assert.throws( + () => + ports.defineSecretsPortabilityManifestV1({ + manifestFormat: 'databreeze-secrets-portability-v1', + referenceCount: 1, + activeHandleCount: 0, + revocation: 'automatic', + portability: 'references-only', + secretMaterial: 'must-not-survive', + }), + ports.ProviderContractErrorV1, + ); +}); + +test('object storage uses bounded resumable multipart requests for 20 GiB objects', () => { + assert.equal(ports.OBJECT_STORAGE_MAX_OBJECT_BYTES_V1, 20 * 1024 * 1024 * 1024); + const plan = ports.defineObjectStorageMultipartPlanV1({ + objectKey: 'workspace/object-1', + expectedSha256: 'a'.repeat(64), + expectedByteLength: 20 * 1024 * 1024 * 1024, + partSizeBytes: 8 * 1024 * 1024, + }); + assert.equal(plan.maximumParts, 2_560); + + const part = ports.defineObjectStoragePartV1({ + partNumber: 1, + content: new Uint8Array(8 * 1024 * 1024), + sha256: 'b'.repeat(64), + }); + assert.equal(part.content.byteLength, 8 * 1024 * 1024); + assert.throws( + () => + ports.defineObjectStoragePartV1({ + partNumber: 1, + content: new Uint8Array(64 * 1024 * 1024 + 1), + sha256: 'b'.repeat(64), + }), + ports.ProviderContractErrorV1, + ); +}); + +test('email and push ports include typed suppression operations', async () => { + const source = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('../src/ports-v1.ts', import.meta.url), 'utf8'), + ); + assert.match(source, /suppressRecipient\s*\(/u); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5863b77..87ed62fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,11 @@ importers: specifier: 8.43.0 version: 8.43.0(eslint@9.36.0)(typescript@5.9.2) - packages/config: {} + packages/config: + dependencies: + '@databreeze/provider-ports': + specifier: workspace:* + version: link:../provider-ports packages/contracts: dependencies: @@ -44,7 +48,11 @@ importers: specifier: workspace:* version: link:../contracts - packages/provider-ports: {} + packages/provider-ports: + dependencies: + '@databreeze/contracts': + specifier: workspace:* + version: link:../contracts packages/test-fixtures: {} From 0a826cf78b3ebc2e879efe01d1680d3dde9d5a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 08:40:40 +0700 Subject: [PATCH 20/51] fix(config): harden secret and multipart invariants --- packages/config/README.md | 16 +- .../config/src/runtime-config/loader-v1.ts | 157 ++++++++-- .../config/src/runtime-config/types-v1.ts | 6 +- .../test/review-regressions-v1.test.mjs | 74 ++++- packages/config/test/runtime-v1.test.mjs | 21 +- packages/provider-ports/README.md | 15 +- packages/provider-ports/src/common-v1.ts | 140 +++++++-- packages/provider-ports/src/ports-v1.ts | 292 +++++++++++++++++- .../test/built-public-api-smoke.mjs | 7 +- .../provider-ports/test/common-v1.test.mjs | 2 +- .../test/fixtures/storage-fake-v1.ts | 214 +++++++++++++ .../test/interchangeability-v1.test.mjs | 227 ++++---------- .../provider-ports/test/ports-v1.type-test.ts | 26 +- .../test/review-regressions-v1.test.mjs | 175 ++++++++++- 14 files changed, 1080 insertions(+), 292 deletions(-) create mode 100644 packages/provider-ports/test/fixtures/storage-fake-v1.ts diff --git a/packages/config/README.md b/packages/config/README.md index db948724..71f32e0c 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -8,14 +8,15 @@ itself, contact a provider, or choose product policy. `@databreeze/config/runtime/v1` exports: -- `loadRuntimeConfigV1`, which accepts an explicit environment record/entry list plus optional - structured overrides and returns a deeply frozen configuration; +- `loadRuntimeConfigV1`, which accepts an explicit environment record/entry list, optional + structured overrides, and the composition-owned secret-reference issuer used by enabled + credential references, then returns a deeply frozen configuration; - the five explicit profiles: `development`, `test`, `preview`, `staging`, and `production`; - typed object-storage, email, push, OCR, AI, payments, telemetry, and secrets selections; - `ConfigValidationErrorV1`, whose diagnostics contain only safe paths and codes; and -- canonical `SecretReferenceV1` identifier objects shared with the secrets port. References expose - only validated namespace/path/version identifiers, serialize as redacted values, and have no raw - string extractor. +- canonical `SecretReferenceV1` identifier objects shared with the secrets port. References have no + enumerable identifier fields or global extractor and redact string, JSON, and diagnostic + inspection; only the matching composition-owned resolver can recover their validated metadata. There is intentionally no unversioned package root. @@ -48,8 +49,9 @@ references, never API keys, passwords, tokens, webhook secrets, or other credent tenant state. - Provider credentials or implicit host-environment reads. -The only runtime dependency is the pure versioned provider-contract package used to construct the -same structured secret-reference object accepted by `SecretsProviderPortV1`. +The only runtime dependency is the pure versioned provider-contract package used to accept a +scoped secret-reference issuer and construct the same opaque reference accepted by +`SecretsProviderPortV1`. The product-policy precedence `platform default -> plan/region -> organization -> workspace -> project -> recipe/job` remains owned by later domain/application plans. This package covers only diff --git a/packages/config/src/runtime-config/loader-v1.ts b/packages/config/src/runtime-config/loader-v1.ts index 55f1413f..6ad05ef9 100644 --- a/packages/config/src/runtime-config/loader-v1.ts +++ b/packages/config/src/runtime-config/loader-v1.ts @@ -1,5 +1,4 @@ import { ConfigValidationErrorV1, RUNTIME_CONFIG_SCHEMA_VERSION_V1 } from './types-v1.ts'; -import { defineSecretReferenceV1 } from '@databreeze/provider-ports/v1'; import type { ActiveDocumentProviderConfigV1, AiConfigV1, @@ -14,6 +13,7 @@ import type { PushConfigV1, RuntimeConfigV1, RuntimeProfileV1, + SecretReferenceIssuerV1, SecretReferenceV1, SecretsConfigV1, TelemetryConfigV1, @@ -194,6 +194,7 @@ const placeholderSegments = new Set([ 'secret', 'todo', ]); +const canonicalSecretSegmentPattern = /^[a-z0-9][a-z0-9._-]{0,62}$/; function isRecord(value: unknown): value is UnknownRecord { return value !== null && typeof value === 'object' && !Array.isArray(value); @@ -226,18 +227,41 @@ function isArraySafely(value: unknown): boolean | undefined { } } +function hasOnlyArrayIndexDescriptors( + descriptors: Record, + length: number, +): boolean { + try { + return Reflect.ownKeys(descriptors).every( + (key) => + typeof key === 'string' && + (key === 'length' || (/^(0|[1-9][0-9]*)$/.test(key) && Number(key) < length)), + ); + } catch { + return false; + } +} + function snapshotLoadInput( input: LoadRuntimeConfigInputV1, issues: ConfigIssueV1[], -): Readonly<{ environment?: EnvironmentEntriesV1; overrides?: unknown }> { +): Readonly<{ + environment?: EnvironmentEntriesV1; + overrides?: unknown; + secretReferenceIssuer?: SecretReferenceIssuerV1; +}> { const descriptors = ownDataDescriptors(input); if (descriptors === undefined || isArraySafely(input) !== false) { issues.push({ path: 'configuration.invalid_input', code: 'invalid_string' }); return {}; } - const result: { environment?: EnvironmentEntriesV1; overrides?: unknown } = {}; + const result: { + environment?: EnvironmentEntriesV1; + overrides?: unknown; + secretReferenceIssuer?: SecretReferenceIssuerV1; + } = {}; for (const [key, descriptor] of Object.entries(descriptors)) { - if (key !== 'environment' && key !== 'overrides') { + if (key !== 'environment' && key !== 'overrides' && key !== 'secretReferenceIssuer') { issues.push({ path: 'configuration.unknown_key', code: 'unknown_key' }); continue; } @@ -247,6 +271,9 @@ function snapshotLoadInput( } if (key === 'environment') result.environment = descriptor.value as EnvironmentEntriesV1; if (key === 'overrides') result.overrides = descriptor.value; + if (key === 'secretReferenceIssuer') { + result.secretReferenceIssuer = descriptor.value as SecretReferenceIssuerV1; + } } return result; } @@ -286,6 +313,10 @@ function snapshotEnvironment( issues.push({ path: 'environment.invalid_input', code: 'invalid_string' }); return []; } + if (!hasOnlyArrayIndexDescriptors(descriptors, lengthDescriptor.value as number)) { + issues.push({ path: 'environment.invalid_input', code: 'invalid_string' }); + return []; + } for (let index = 0; index < (lengthDescriptor.value as number); index += 1) { const entryDescriptor = descriptors[String(index)]; if (entryDescriptor === undefined || !('value' in entryDescriptor)) { @@ -311,6 +342,10 @@ function snapshotEnvironment( issues.push({ path: 'environment.invalid_entry', code: 'invalid_string' }); continue; } + if (!hasOnlyArrayIndexDescriptors(tupleDescriptors, 2)) { + issues.push({ path: 'environment.invalid_entry', code: 'invalid_string' }); + continue; + } entries.push([keyDescriptor.value, valueDescriptor.value as string | undefined]); } return entries; @@ -412,7 +447,7 @@ function readEnvironment( for (const [key, value] of snapshotEnvironment(environment, issues)) { if (seen.has(key)) { - issues.push({ path: `environment.${key}`, code: 'duplicate' }); + issues.push({ path: 'environment.duplicate_key', code: 'duplicate' }); continue; } seen.add(key); @@ -506,6 +541,34 @@ function requiredString( return value; } +function requiredSecretNamespace( + record: UnknownRecord, + path: string, + issues: ConfigIssueV1[], +): string { + const value = record['namespace']; + if (value === undefined) { + issues.push({ path, code: 'required' }); + return ''; + } + const segments = typeof value === 'string' ? value.split('/') : []; + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 2_047 || + value.trim() !== value || + segments.length > 32 || + segments.some( + (segment) => + !canonicalSecretSegmentPattern.test(segment) || segment === '.' || segment === '..', + ) + ) { + issues.push({ path, code: 'invalid_secret_namespace' }); + return ''; + } + return value; +} + function requiredBoolean( record: UnknownRecord, key: string, @@ -595,11 +658,16 @@ function validEndpoint( return value; } +function isSecretReferenceIssueFunction(value: unknown): value is SecretReferenceIssuerV1['issue'] { + return typeof value === 'function'; +} + function secretReference( value: unknown, path: string, issues: ConfigIssueV1[], required: boolean, + issuer: SecretReferenceIssuerV1 | undefined, ): SecretReferenceV1 | undefined { if (value === undefined) { if (required) issues.push({ path, code: 'required' }); @@ -630,11 +698,26 @@ function secretReference( issues.push({ path, code: 'invalid_secret_reference' }); return undefined; } - return defineSecretReferenceV1({ - namespace: match[1] as string, - pathSegments, - ...(match[3] === undefined ? {} : { version: match[3] }), - }); + const issuerDescriptors = ownDataDescriptors(issuer); + const issueDescriptor = issuerDescriptors?.['issue']; + const issueValue: unknown = + issueDescriptor !== undefined && 'value' in issueDescriptor ? issueDescriptor.value : undefined; + if (!isSecretReferenceIssueFunction(issueValue)) { + issues.push({ path: 'configuration.secret_reference_issuer', code: 'required' }); + return undefined; + } + try { + return Reflect.apply(issueValue, undefined, [ + { + namespace: match[1] as string, + pathSegments, + ...(match[3] === undefined ? {} : { version: match[3] }), + }, + ]); + } catch { + issues.push({ path, code: 'invalid_secret_reference' }); + return undefined; + } } function modeOf( @@ -672,6 +755,7 @@ function validateObjectStorage( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], + issuer: SecretReferenceIssuerV1 | undefined, ): ObjectStorageConfigV1 { const path = 'providers.objectStorage'; const mode = modeOf(record, path, ['local', 'remote'], issues); @@ -699,6 +783,7 @@ function validateObjectStorage( `${path}.credentialRef`, issues, mode === 'remote', + issuer, ); return { mode: mode === 'remote' ? 'remote' : 'local', @@ -714,6 +799,7 @@ function validateEmail( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], + issuer: SecretReferenceIssuerV1 | undefined, ): EmailConfigV1 { const path = 'providers.email'; const mode = modeOf(record, path, ['disabled', 'local', 'remote'], issues); @@ -741,6 +827,7 @@ function validateEmail( `${path}.credentialRef`, issues, mode === 'remote', + issuer, ); return { mode: mode === 'remote' ? 'remote' : 'local', @@ -754,6 +841,7 @@ function validatePush( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], + issuer: SecretReferenceIssuerV1 | undefined, ): PushConfigV1 { const path = 'providers.push'; const mode = modeOf(record, path, ['disabled', 'remote'], issues); @@ -775,6 +863,7 @@ function validatePush( `${path}.credentialRef`, issues, true, + issuer, ); return { mode: 'remote', @@ -789,6 +878,7 @@ function validateDocumentProvider( profile: RuntimeProfileV1, name: 'ocr' | 'ai', issues: ConfigIssueV1[], + issuer: SecretReferenceIssuerV1 | undefined, ): ActiveDocumentProviderConfigV1 | { readonly mode: 'disabled' } { const path = `providers.${name}`; const mode = modeOf(record, path, ['disabled', 'local', 'remote'], issues); @@ -812,6 +902,7 @@ function validateDocumentProvider( `${path}.credentialRef`, issues, mode === 'remote', + issuer, ); return { mode: mode === 'remote' ? 'remote' : 'local', @@ -824,6 +915,7 @@ function validatePayments( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], + issuer: SecretReferenceIssuerV1 | undefined, ): PaymentsConfigV1 { const path = 'providers.payments'; const mode = modeOf(record, path, ['disabled', 'remote'], issues); @@ -849,12 +941,14 @@ function validatePayments( `${path}.credentialRef`, issues, true, + issuer, ); const webhookSecretRef = secretReference( record['webhookSecretRef'], `${path}.webhookSecretRef`, issues, true, + issuer, ); return { mode: 'remote', @@ -868,6 +962,7 @@ function validateTelemetry( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], + issuer: SecretReferenceIssuerV1 | undefined, ): TelemetryConfigV1 { const path = 'providers.telemetry'; const mode = modeOf(record, path, ['disabled', 'local', 'remote'], issues); @@ -891,6 +986,7 @@ function validateTelemetry( `${path}.credentialRef`, issues, false, + issuer, ); return { mode: mode === 'remote' ? 'remote' : 'local', @@ -909,7 +1005,7 @@ function validateSecrets( if (strictProfiles.has(profile) && mode !== '' && mode !== 'remote') { issues.push({ path: `${path}.mode`, code: 'invalid_mode' }); } - const namespace = requiredString(record, 'namespace', `${path}.namespace`, issues); + const namespace = requiredSecretNamespace(record, `${path}.namespace`, issues); if (mode === 'remote') { return { mode: 'remote', @@ -934,15 +1030,33 @@ function validateProviders( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], + issuer: SecretReferenceIssuerV1 | undefined, ): ProviderRuntimeConfigV1 { return { - objectStorage: validateObjectStorage(recordAt(record, 'objectStorage'), profile, issues), - email: validateEmail(recordAt(record, 'email'), profile, issues), - push: validatePush(recordAt(record, 'push'), profile, issues), - ocr: validateDocumentProvider(recordAt(record, 'ocr'), profile, 'ocr', issues) as OcrConfigV1, - ai: validateDocumentProvider(recordAt(record, 'ai'), profile, 'ai', issues) as AiConfigV1, - payments: validatePayments(recordAt(record, 'payments'), profile, issues), - telemetry: validateTelemetry(recordAt(record, 'telemetry'), profile, issues), + objectStorage: validateObjectStorage( + recordAt(record, 'objectStorage'), + profile, + issues, + issuer, + ), + email: validateEmail(recordAt(record, 'email'), profile, issues, issuer), + push: validatePush(recordAt(record, 'push'), profile, issues, issuer), + ocr: validateDocumentProvider( + recordAt(record, 'ocr'), + profile, + 'ocr', + issues, + issuer, + ) as OcrConfigV1, + ai: validateDocumentProvider( + recordAt(record, 'ai'), + profile, + 'ai', + issues, + issuer, + ) as AiConfigV1, + payments: validatePayments(recordAt(record, 'payments'), profile, issues, issuer), + telemetry: validateTelemetry(recordAt(record, 'telemetry'), profile, issues, issuer), secrets: validateSecrets(recordAt(record, 'secrets'), profile, issues), }; } @@ -996,7 +1110,12 @@ export function loadRuntimeConfigV1(input: LoadRuntimeConfigInputV1 = {}): Runti issues, ), }; - const providers = validateProviders(recordAt(merged, 'providers'), profile, issues); + const providers = validateProviders( + recordAt(merged, 'providers'), + profile, + issues, + safeInput.secretReferenceIssuer, + ); if (issues.length > 0) { throw new ConfigValidationErrorV1(issues); diff --git a/packages/config/src/runtime-config/types-v1.ts b/packages/config/src/runtime-config/types-v1.ts index 0ba186a8..6587f350 100644 --- a/packages/config/src/runtime-config/types-v1.ts +++ b/packages/config/src/runtime-config/types-v1.ts @@ -1,6 +1,6 @@ -import type { SecretReferenceV1 } from '@databreeze/provider-ports/v1'; +import type { SecretReferenceIssuerV1, SecretReferenceV1 } from '@databreeze/provider-ports/v1'; -export type { SecretReferenceV1 } from '@databreeze/provider-ports/v1'; +export type { SecretReferenceIssuerV1, SecretReferenceV1 } from '@databreeze/provider-ports/v1'; export const RUNTIME_CONFIG_SCHEMA_VERSION_V1 = 1 as const; @@ -15,6 +15,7 @@ export type ConfigIssueCodeV1 = | 'invalid_mode' | 'invalid_profile' | 'invalid_secret_reference' + | 'invalid_secret_namespace' | 'invalid_string' | 'required' | 'unknown_key' @@ -144,4 +145,5 @@ export type EnvironmentEntriesV1 = export interface LoadRuntimeConfigInputV1 { readonly environment?: EnvironmentEntriesV1; readonly overrides?: unknown; + readonly secretReferenceIssuer?: SecretReferenceIssuerV1; } diff --git a/packages/config/test/review-regressions-v1.test.mjs b/packages/config/test/review-regressions-v1.test.mjs index d795bb21..1c20c7a0 100644 --- a/packages/config/test/review-regressions-v1.test.mjs +++ b/packages/config/test/review-regressions-v1.test.mjs @@ -4,6 +4,8 @@ import test from 'node:test'; import { ConfigValidationErrorV1, loadRuntimeConfigV1 } from '../src/runtime-config/v1.ts'; +const providerPorts = await import('../../provider-ports/src/v1.ts'); + function issue(error, path, code) { return ( error instanceof ConfigValidationErrorV1 && @@ -201,6 +203,44 @@ test('configuration converts proxy and malformed tuple failures to bounded redac ); }); +test('duplicate environment keys use a stable bounded path without exposing attacker text', () => { + const exposedKey = 'DATABREEZE_DUPLICATE_SECRET_X9Y8Z7'; + const exposedValue = 'duplicate-value-X9Y8Z7'; + expectSafeConfigFailure( + () => + loadRuntimeConfigV1({ + environment: [ + ['DATABREEZE_PROFILE', 'development'], + [exposedKey, exposedValue], + [exposedKey, exposedValue], + ], + }), + 'environment.duplicate_key', + 'duplicate', + [exposedKey, exposedValue], + ); +}); + +test('environment arrays and tuples reject non-index string and symbol properties', () => { + const environment = [['DATABREEZE_PROFILE', 'development']]; + environment.extra = 'must-not-be-read'; + expectSafeConfigFailure( + () => loadRuntimeConfigV1({ environment }), + 'environment.invalid_input', + 'invalid_string', + ['must-not-be-read'], + ); + + const tuple = ['DATABREEZE_PROFILE', 'development']; + tuple[Symbol('hidden')] = 'must-not-be-read'; + expectSafeConfigFailure( + () => loadRuntimeConfigV1({ environment: [tuple] }), + 'environment.invalid_entry', + 'invalid_string', + ['must-not-be-read'], + ); +}); + test('configuration snapshots the load request itself and bounds repeated diagnostics', () => { let getterCalls = 0; const input = {}; @@ -286,8 +326,26 @@ for (const reference of [ }); } -test('returns a canonical structured secret reference without a raw extractor', async () => { +for (const namespace of ['', '.', '..', 'team//prod', 'team/', 'team/../prod', 'team/./prod']) { + test(`rejects non-canonical secret namespace ${JSON.stringify(namespace)}`, () => { + expectSafeConfigFailure( + () => + loadRuntimeConfigV1({ + environment: { + DATABREEZE_PROFILE: 'development', + DATABREEZE_SECRETS_MODE: 'memory', + DATABREEZE_SECRETS_NAMESPACE: namespace, + }, + }), + 'providers.secrets.namespace', + 'invalid_secret_namespace', + ); + }); +} + +test('returns an issuer-created opaque secret reference without a raw extractor', async () => { const runtime = await import('../src/runtime-config/v1.ts'); + const capability = providerPorts.createSecretReferenceCapabilityV1(); const config = loadRuntimeConfigV1({ environment: { DATABREEZE_PROFILE: 'development', @@ -296,11 +354,19 @@ test('returns a canonical structured secret reference without a raw extractor', DATABREEZE_PUSH_APPLICATION_ID: 'databreeze', DATABREEZE_PUSH_CREDENTIAL_REF: 'secret://development/push/credential#active', }, + secretReferenceIssuer: capability.issuer, }); const reference = config.providers.push.credentialRef; - assert.deepEqual(reference.pathSegments, ['push', 'credential']); - assert.equal(reference.namespace, 'development'); - assert.equal(reference.version, 'active'); + assert.deepEqual(capability.resolver.resolve(reference), { + namespace: 'development', + pathSegments: ['push', 'credential'], + version: 'active', + }); + assert.equal(reference.namespace, undefined); + assert.equal(reference.pathSegments, undefined); + assert.equal(reference.version, undefined); + assert.deepEqual(Reflect.ownKeys(reference), []); + assert.doesNotMatch(inspect(reference), /development|push|credential|active/u); assert.equal(runtime.secretReferenceHandleV1, undefined); assert.equal(runtime.createSecretReferenceV1, undefined); assert.equal(JSON.stringify(reference), '"[REDACTED_SECRET_REFERENCE]"'); diff --git a/packages/config/test/runtime-v1.test.mjs b/packages/config/test/runtime-v1.test.mjs index e246f8fe..97eeea67 100644 --- a/packages/config/test/runtime-v1.test.mjs +++ b/packages/config/test/runtime-v1.test.mjs @@ -2,6 +2,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { ConfigValidationErrorV1, loadRuntimeConfigV1 } from '../src/runtime-config/v1.ts'; +import { createSecretReferenceCapabilityV1 } from '../../provider-ports/src/v1.ts'; + +const secretReferenceCapability = createSecretReferenceCapabilityV1(); function nonLocalEnvironment(profile) { return [ @@ -74,13 +77,18 @@ test('loads deterministic test defaults distinct from development', () => { for (const profile of ['preview', 'staging', 'production']) { test(`loads an explicitly complete ${profile} profile`, () => { - const config = loadRuntimeConfigV1({ environment: nonLocalEnvironment(profile) }); + const config = loadRuntimeConfigV1({ + environment: nonLocalEnvironment(profile), + secretReferenceIssuer: secretReferenceCapability.issuer, + }); assert.equal(config.profile, profile); assert.equal(config.providers.objectStorage.mode, 'remote'); assert.equal(config.providers.objectStorage.bucket, `databreeze-${profile}`); - assert.equal(config.providers.objectStorage.credentialRef.namespace, profile); - assert.deepEqual(config.providers.objectStorage.credentialRef.pathSegments, ['object-storage']); + assert.deepEqual( + secretReferenceCapability.resolver.resolve(config.providers.objectStorage.credentialRef), + { namespace: profile, pathSegments: ['object-storage'] }, + ); assert.deepEqual(config.providers.secrets, { mode: 'remote', endpointUrl: 'https://secrets.example.test', @@ -135,7 +143,7 @@ test('rejects duplicate environment entries instead of choosing one', () => { ['DATABREEZE_PROFILE', 'production'], ], }), - 'environment.DATABREEZE_PROFILE', + 'environment.duplicate_key', 'duplicate', ); }); @@ -301,7 +309,10 @@ for (const reference of ['', 'super-secret-value', 'secret://production/changeme } test('redacts valid secret references during string and JSON serialization', () => { - const config = loadRuntimeConfigV1({ environment: nonLocalEnvironment('production') }); + const config = loadRuntimeConfigV1({ + environment: nonLocalEnvironment('production'), + secretReferenceIssuer: secretReferenceCapability.issuer, + }); const reference = config.providers.objectStorage.credentialRef; assert.equal(String(reference), '[REDACTED_SECRET_REFERENCE]'); diff --git a/packages/provider-ports/README.md b/packages/provider-ports/README.md index 7cc50bb6..f0ff92b8 100644 --- a/packages/provider-ports/README.md +++ b/packages/provider-ports/README.md @@ -25,13 +25,16 @@ retention/training behavior, failover/degraded behavior, and coherent exit metad Common helpers validate and freeze closed metadata, reuse the canonical contract timestamp parser, enforce cancellation/deadlines/idempotency, and create errors only through a redacting factory with allowlisted operations and code-derived message keys. Raw provider causes are neither accessed nor -retained. Structured secret references flow directly into the secrets port; secret handles contain -no material or public raw handle ID, and both redact string/JSON serialization. +retained. A composition-owned issuer/resolver capability creates and resolves secret references; +references and handles expose no identifier fields or raw public ID and redact string, JSON, and +diagnostic inspection. -Object storage is resumable and bounded-memory: begin, upload a validated 8-64 MiB part, complete, -or abort. Plans support immutable objects through 20 GiB with declared whole-object and per-part -SHA-256 digests. Email and push expose explicit typed recipient-suppression operations; durable -notification policy remains owned by NCO. +Object storage is resumable and bounded-memory: begin, upload a validated copy-isolated 8-64 MiB +part, complete, or abort. Factory-issued uploads bind their immutable plan; uploaded-part receipts +bind their upload and part metadata; completion accepts only the exact ordered, contiguous receipt +set and derives total length and digest from the bound plan. Plans support immutable objects through +20 GiB with declared whole-object and per-part SHA-256 digests. Email and push expose explicit typed +recipient-suppression operations; durable notification policy remains owned by NCO. There is intentionally no unversioned package root. Provider-specific identifiers may appear only as opaque external references returned by an adapter; they never replace DataBreeze domain IDs or diff --git a/packages/provider-ports/src/common-v1.ts b/packages/provider-ports/src/common-v1.ts index fc730444..2fd6b641 100644 --- a/packages/provider-ports/src/common-v1.ts +++ b/packages/provider-ports/src/common-v1.ts @@ -280,7 +280,8 @@ function readArray(value: unknown, maximum = 100): readonly unknown[] | undefine } const length = lengthDescriptor.value as number; const result: unknown[] = []; - for (const key of Object.keys(descriptors)) { + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== 'string') return undefined; if (key === 'length') continue; if (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= length) return undefined; } @@ -314,6 +315,49 @@ function isUtcTimestamp(value: unknown): value is string { return parseV1Contract(UTC_TIMESTAMP_SCHEMA_ID, value).accepted; } +interface ComparableUtcTimestampV1 { + readonly epochSecond: number; + readonly fractionalSecond: string; +} + +const comparableUtcTimestampPattern = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?Z$/; + +function comparableUtcTimestamp(value: string): ComparableUtcTimestampV1 | undefined { + const match = comparableUtcTimestampPattern.exec(value); + if (match === null) return undefined; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const calendar = new Date(0); + calendar.setUTCHours(0, 0, 0, 0); + calendar.setUTCFullYear(year, month - 1, day); + calendar.setUTCHours(hour, minute, Math.min(second, 59), 0); + const epochSecond = calendar.getTime() / 1_000 + (second === 60 ? 1 : 0); + if (!Number.isSafeInteger(epochSecond)) return undefined; + return { epochSecond, fractionalSecond: match[7] ?? '' }; +} + +function compareUtcTimestamps(left: string, right: string): number | undefined { + const leftValue = comparableUtcTimestamp(left); + const rightValue = comparableUtcTimestamp(right); + if (leftValue === undefined || rightValue === undefined) return undefined; + if (leftValue.epochSecond !== rightValue.epochSecond) { + return leftValue.epochSecond < rightValue.epochSecond ? -1 : 1; + } + const fractionalLength = Math.max( + leftValue.fractionalSecond.length, + rightValue.fractionalSecond.length, + ); + const leftFraction = leftValue.fractionalSecond.padEnd(fractionalLength, '0'); + const rightFraction = rightValue.fractionalSecond.padEnd(fractionalLength, '0'); + if (leftFraction === rightFraction) return 0; + return leftFraction < rightFraction ? -1 : 1; +} + function deepFreeze(value: T): T { if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { for (const child of Object.values(value)) deepFreeze(child); @@ -703,7 +747,9 @@ export function assertProviderInvocationActiveV1( retryable: false, }); } - if (Date.parse(now) >= Date.parse(context.deadlineAt)) { + const deadlineComparison = compareUtcTimestamps(now, context.deadlineAt); + if (deadlineComparison === undefined) throw invalidInvocation(); + if (deadlineComparison >= 0) { throw createProviderFailureV1({ code: 'TIMEOUT', operation: context.operation, @@ -763,22 +809,43 @@ const secretReferenceBrandV1: unique symbol = Symbol('SecretReferenceV1'); export interface SecretReferenceV1 { readonly [secretReferenceBrandV1]: true; - readonly kind: 'secret-reference'; + toString(): '[REDACTED_SECRET_REFERENCE]'; + toJSON(): '[REDACTED_SECRET_REFERENCE]'; +} + +export interface SecretReferenceMetadataV1 { readonly namespace: string; readonly pathSegments: readonly string[]; readonly version?: string; - toString(): '[REDACTED_SECRET_REFERENCE]'; - toJSON(): '[REDACTED_SECRET_REFERENCE]'; +} + +export interface SecretReferenceIssuerV1 { + issue(input: SecretReferenceMetadataV1): SecretReferenceV1; +} + +export interface SecretReferenceResolverV1 { + resolve(reference: SecretReferenceV1): SecretReferenceMetadataV1; +} + +export interface SecretReferenceCapabilityV1 { + readonly issuer: SecretReferenceIssuerV1; + readonly resolver: SecretReferenceResolverV1; } const secretReferences = new WeakSet(); const secretSegmentPattern = /^[a-z0-9][a-z0-9._-]{0,62}$/; +const inspectCustomV1 = Symbol.for('nodejs.util.inspect.custom'); + +const secretReferencePrototypeV1 = Object.freeze( + Object.defineProperties(Object.create(null) as object, { + toString: { value: () => '[REDACTED_SECRET_REFERENCE]' }, + toJSON: { value: () => '[REDACTED_SECRET_REFERENCE]' }, + [Symbol.toPrimitive]: { value: () => '[REDACTED_SECRET_REFERENCE]' }, + [inspectCustomV1]: { value: () => '[REDACTED_SECRET_REFERENCE]' }, + }), +); -export function defineSecretReferenceV1(input: { - readonly namespace: string; - readonly pathSegments: readonly string[]; - readonly version?: string; -}): SecretReferenceV1 { +function validatedSecretReferenceMetadataV1(input: unknown): SecretReferenceMetadataV1 { const record = readClosedRecord(input, ['namespace', 'pathSegments', 'version']); const segments = record === undefined ? undefined : readArray(record['pathSegments'], 32); if ( @@ -801,30 +868,54 @@ export function defineSecretReferenceV1(input: { ) { throw new ProviderContractErrorV1(); } - const reference: SecretReferenceV1 = deepFreeze({ - [secretReferenceBrandV1]: true, - kind: 'secret-reference', + return deepFreeze({ namespace: record['namespace'], pathSegments: segments as string[], ...(record['version'] === undefined ? {} : { version: record['version'] }), - toString: () => '[REDACTED_SECRET_REFERENCE]', - toJSON: () => '[REDACTED_SECRET_REFERENCE]', }); - secretReferences.add(reference); - return reference; +} + +export function createSecretReferenceCapabilityV1(): SecretReferenceCapabilityV1 { + const metadataByReference = new WeakMap(); + const issuer: SecretReferenceIssuerV1 = Object.freeze({ + issue(input: SecretReferenceMetadataV1): SecretReferenceV1 { + const metadata = validatedSecretReferenceMetadataV1(input); + const reference = Object.freeze( + Object.create(secretReferencePrototypeV1) as SecretReferenceV1, + ); + secretReferences.add(reference); + metadataByReference.set(reference, metadata); + return reference; + }, + }); + const resolver: SecretReferenceResolverV1 = Object.freeze({ + resolve(reference: SecretReferenceV1): SecretReferenceMetadataV1 { + if (!isObject(reference)) throw new ProviderContractErrorV1(); + const metadata = metadataByReference.get(reference); + if (metadata === undefined) throw new ProviderContractErrorV1(); + return metadata; + }, + }); + return Object.freeze({ issuer, resolver }); } const secretHandleBrandV1: unique symbol = Symbol('SecretHandleV1'); export interface SecretHandleV1 { readonly [secretHandleBrandV1]: true; - readonly kind: 'secret-handle'; - readonly reference: SecretReferenceV1; - readonly expiresAt?: string; toString(): '[REDACTED_SECRET_HANDLE]'; toJSON(): '[REDACTED_SECRET_HANDLE]'; } +const secretHandlePrototypeV1 = Object.freeze( + Object.defineProperties(Object.create(null) as object, { + toString: { value: () => '[REDACTED_SECRET_HANDLE]' }, + toJSON: { value: () => '[REDACTED_SECRET_HANDLE]' }, + [Symbol.toPrimitive]: { value: () => '[REDACTED_SECRET_HANDLE]' }, + [inspectCustomV1]: { value: () => '[REDACTED_SECRET_HANDLE]' }, + }), +); + export function defineSecretHandleV1(input: { readonly reference: SecretReferenceV1; readonly expiresAt?: string; @@ -838,12 +929,5 @@ export function defineSecretHandleV1(input: { ) { throw new ProviderContractErrorV1(); } - return deepFreeze({ - [secretHandleBrandV1]: true, - kind: 'secret-handle', - reference: record['reference'] as SecretReferenceV1, - ...(record['expiresAt'] === undefined ? {} : { expiresAt: record['expiresAt'] }), - toString: () => '[REDACTED_SECRET_HANDLE]', - toJSON: () => '[REDACTED_SECRET_HANDLE]', - }); + return Object.freeze(Object.create(secretHandlePrototypeV1) as SecretHandleV1); } diff --git a/packages/provider-ports/src/ports-v1.ts b/packages/provider-ports/src/ports-v1.ts index 775e5c9a..ab081658 100644 --- a/packages/provider-ports/src/ports-v1.ts +++ b/packages/provider-ports/src/ports-v1.ts @@ -64,16 +64,18 @@ function readArray(value: unknown, maximum: number): readonly unknown[] | undefi return undefined; } const result: unknown[] = []; + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== 'string') return undefined; + if (key === 'length') continue; + if (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= (length as number)) { + return undefined; + } + } for (let index = 0; index < (length as number); index += 1) { const descriptor = descriptors[String(index)]; if (descriptor === undefined || !('value' in descriptor)) return undefined; result.push(descriptor.value); } - if ( - Object.keys(descriptors).some((key) => key !== 'length' && Number(key) >= (length as number)) - ) { - return undefined; - } return result; } @@ -97,10 +99,15 @@ export interface ObjectStorageMultipartPlanInputV1 { readonly partSizeBytes: number; } +const objectStorageMultipartPlanBrandV1: unique symbol = Symbol('ObjectStorageMultipartPlanV1'); + export interface ObjectStorageMultipartPlanV1 extends ObjectStorageMultipartPlanInputV1 { + readonly [objectStorageMultipartPlanBrandV1]: true; readonly maximumParts: number; } +const multipartPlansV1 = new WeakSet(); + export function defineObjectStorageMultipartPlanV1( input: ObjectStorageMultipartPlanInputV1, ): ObjectStorageMultipartPlanV1 { @@ -122,13 +129,15 @@ export function defineObjectStorageMultipartPlanV1( } const maximumParts = Math.ceil(record['expectedByteLength'] / record['partSizeBytes']); if (maximumParts > OBJECT_STORAGE_MAX_PARTS_V1) throw new ProviderContractErrorV1(); - return Object.freeze({ + const plan = Object.freeze({ objectKey: record['objectKey'], expectedSha256: record['expectedSha256'], expectedByteLength: record['expectedByteLength'], partSizeBytes: record['partSizeBytes'], maximumParts, - }); + }) as ObjectStorageMultipartPlanV1; + multipartPlansV1.add(plan); + return plan; } export interface ObjectStoragePartInputV1 { @@ -137,25 +146,56 @@ export interface ObjectStoragePartInputV1 { readonly sha256: string; } -export type ObjectStoragePartV1 = ObjectStoragePartInputV1; +const objectStoragePartBrandV1: unique symbol = Symbol('ObjectStoragePartV1'); + +export interface ObjectStoragePartV1 { + readonly [objectStoragePartBrandV1]: true; + readonly partNumber: number; + readonly sha256: string; + readonly byteLength: number; + readContent(): Uint8Array; +} + +const contentByObjectStoragePartV1 = new WeakMap(); +const objectStoragePartPrototypeV1 = Object.freeze( + Object.defineProperty(Object.create(null) as object, 'readContent', { + value(this: object): Uint8Array { + const content = contentByObjectStoragePartV1.get(this); + if (content === undefined) throw new ProviderContractErrorV1(); + return Uint8Array.prototype.slice.call(content) as Uint8Array; + }, + }), +); + +function snapshotPartContentV1(value: unknown): Uint8Array | undefined { + if (!(value instanceof Uint8Array)) return undefined; + try { + return Uint8Array.prototype.slice.call(value) as Uint8Array; + } catch { + return undefined; + } +} export function defineObjectStoragePartV1(input: ObjectStoragePartInputV1): ObjectStoragePartV1 { const record = readClosedRecord(input, ['partNumber', 'content', 'sha256']); + const content = record === undefined ? undefined : snapshotPartContentV1(record['content']); if ( record === undefined || !isPositiveInteger(record['partNumber'], OBJECT_STORAGE_MAX_PARTS_V1) || - !(record['content'] instanceof Uint8Array) || - record['content'].byteLength === 0 || - record['content'].byteLength > OBJECT_STORAGE_MAX_PART_BYTES_V1 || + content === undefined || + content.byteLength === 0 || + content.byteLength > OBJECT_STORAGE_MAX_PART_BYTES_V1 || !isSha256(record['sha256']) ) { throw new ProviderContractErrorV1(); } - return Object.freeze({ + const part = Object.assign(Object.create(objectStoragePartPrototypeV1) as object, { partNumber: record['partNumber'], - content: record['content'], sha256: record['sha256'], - }); + byteLength: content.byteLength, + }) as ObjectStoragePartV1; + contentByObjectStoragePartV1.set(part, content); + return Object.freeze(part); } export interface ObjectStorageBeginMultipartRequestV1 { @@ -163,36 +203,254 @@ export interface ObjectStorageBeginMultipartRequestV1 { readonly plan: ObjectStorageMultipartPlanV1; } -export interface ObjectStorageBeginMultipartResultV1 { +const objectStorageMultipartUploadBrandV1: unique symbol = Symbol('ObjectStorageMultipartUploadV1'); + +export interface ObjectStorageMultipartUploadV1 { + readonly [objectStorageMultipartUploadBrandV1]: true; readonly uploadRef: string; + readonly plan: ObjectStorageMultipartPlanV1; readonly acceptedPartSizeBytes: number; readonly maximumParts: number; } +export type ObjectStorageBeginMultipartResultV1 = ObjectStorageMultipartUploadV1; + +const planByMultipartUploadV1 = new WeakMap(); + +export function defineObjectStorageMultipartUploadV1(input: { + readonly uploadRef: string; + readonly plan: ObjectStorageMultipartPlanV1; +}): ObjectStorageMultipartUploadV1 { + const record = readClosedRecord(input, ['uploadRef', 'plan']); + if ( + record === undefined || + !isSafeReference(record['uploadRef']) || + typeof record['plan'] !== 'object' || + record['plan'] === null || + !multipartPlansV1.has(record['plan']) + ) { + throw new ProviderContractErrorV1(); + } + const plan = record['plan'] as ObjectStorageMultipartPlanV1; + const upload = Object.freeze({ + uploadRef: record['uploadRef'], + plan, + acceptedPartSizeBytes: plan.partSizeBytes, + maximumParts: plan.maximumParts, + }) as ObjectStorageMultipartUploadV1; + planByMultipartUploadV1.set(upload, plan); + return upload; +} + export interface ObjectStorageUploadPartRequestV1 { readonly context: ProviderInvocationContextV1; - readonly uploadRef: string; + readonly upload: ObjectStorageMultipartUploadV1; readonly part: ObjectStoragePartV1; } +const objectStorageUploadedPartBrandV1: unique symbol = Symbol('ObjectStorageUploadedPartV1'); + export interface ObjectStorageUploadedPartV1 { + readonly [objectStorageUploadedPartBrandV1]: true; + readonly partNumber: number; + readonly sha256: string; + readonly byteLength: number; + readonly receiptRef: string; +} + +interface ObjectStorageUploadedPartStateV1 { + readonly upload: ObjectStorageMultipartUploadV1; readonly partNumber: number; readonly sha256: string; readonly byteLength: number; readonly receiptRef: string; } +const uploadedPartStateV1 = new WeakMap(); + +function expectedPartByteLengthV1( + plan: ObjectStorageMultipartPlanV1, + partNumber: number, +): number | undefined { + if (partNumber < 1 || partNumber > plan.maximumParts) return undefined; + if (partNumber < plan.maximumParts) return plan.partSizeBytes; + return plan.expectedByteLength - plan.partSizeBytes * (plan.maximumParts - 1); +} + +function partBelongsToUploadV1( + upload: ObjectStorageMultipartUploadV1, + part: ObjectStoragePartV1, +): boolean { + const plan = planByMultipartUploadV1.get(upload); + if (plan === undefined || !contentByObjectStoragePartV1.has(part)) return false; + return expectedPartByteLengthV1(plan, part.partNumber) === part.byteLength; +} + +export function defineObjectStorageUploadPartRequestV1( + input: ObjectStorageUploadPartRequestV1, +): ObjectStorageUploadPartRequestV1 { + const record = readClosedRecord(input, ['context', 'upload', 'part']); + if ( + record === undefined || + typeof record['upload'] !== 'object' || + record['upload'] === null || + typeof record['part'] !== 'object' || + record['part'] === null || + !partBelongsToUploadV1( + record['upload'] as ObjectStorageMultipartUploadV1, + record['part'] as ObjectStoragePartV1, + ) + ) { + throw new ProviderContractErrorV1(); + } + try { + requireProviderIdempotencyV1(record['context'] as ProviderInvocationContextV1); + } catch { + throw new ProviderContractErrorV1(); + } + return Object.freeze({ + context: record['context'] as ProviderInvocationContextV1, + upload: record['upload'] as ObjectStorageMultipartUploadV1, + part: record['part'] as ObjectStoragePartV1, + }); +} + +export function defineObjectStorageUploadedPartV1(input: { + readonly upload: ObjectStorageMultipartUploadV1; + readonly part: ObjectStoragePartV1; + readonly receiptRef: string; +}): ObjectStorageUploadedPartV1 { + const record = readClosedRecord(input, ['upload', 'part', 'receiptRef']); + if ( + record === undefined || + typeof record['upload'] !== 'object' || + record['upload'] === null || + typeof record['part'] !== 'object' || + record['part'] === null || + !isSafeReference(record['receiptRef']) || + !partBelongsToUploadV1( + record['upload'] as ObjectStorageMultipartUploadV1, + record['part'] as ObjectStoragePartV1, + ) + ) { + throw new ProviderContractErrorV1(); + } + const upload = record['upload'] as ObjectStorageMultipartUploadV1; + const part = record['part'] as ObjectStoragePartV1; + const state: ObjectStorageUploadedPartStateV1 = Object.freeze({ + upload, + partNumber: part.partNumber, + sha256: part.sha256, + byteLength: part.byteLength, + receiptRef: record['receiptRef'], + }); + const receipt = Object.freeze({ + partNumber: state.partNumber, + sha256: state.sha256, + byteLength: state.byteLength, + receiptRef: state.receiptRef, + }) as ObjectStorageUploadedPartV1; + uploadedPartStateV1.set(receipt, state); + return receipt; +} + +export interface ObjectStorageCompleteMultipartRequestInputV1 { + readonly context: ProviderInvocationContextV1; + readonly upload: ObjectStorageMultipartUploadV1; + readonly orderedParts: readonly ObjectStorageUploadedPartV1[]; +} + +const objectStorageCompleteMultipartRequestBrandV1: unique symbol = Symbol( + 'ObjectStorageCompleteMultipartRequestV1', +); + export interface ObjectStorageCompleteMultipartRequestV1 { + readonly [objectStorageCompleteMultipartRequestBrandV1]: true; readonly context: ProviderInvocationContextV1; + readonly upload: ObjectStorageMultipartUploadV1; readonly uploadRef: string; readonly orderedParts: readonly ObjectStorageUploadedPartV1[]; readonly expectedSha256: string; readonly expectedByteLength: number; } +const completeMultipartRequestsV1 = new WeakSet(); + +export function defineObjectStorageCompleteMultipartRequestV1( + input: ObjectStorageCompleteMultipartRequestInputV1, +): ObjectStorageCompleteMultipartRequestV1 { + const record = readClosedRecord(input, ['context', 'upload', 'orderedParts']); + const upload = record?.['upload']; + const plan = + typeof upload === 'object' && upload !== null ? planByMultipartUploadV1.get(upload) : undefined; + const orderedParts = record === undefined ? undefined : readArray(record['orderedParts'], 10_000); + if ( + record === undefined || + plan === undefined || + orderedParts === undefined || + orderedParts.length !== plan.maximumParts + ) { + throw new ProviderContractErrorV1(); + } + try { + requireProviderIdempotencyV1(record['context'] as ProviderInvocationContextV1); + } catch { + throw new ProviderContractErrorV1(); + } + + let totalByteLength = 0; + const validatedParts: ObjectStorageUploadedPartV1[] = []; + for (let index = 0; index < orderedParts.length; index += 1) { + const receipt = orderedParts[index]; + if (typeof receipt !== 'object' || receipt === null) throw new ProviderContractErrorV1(); + const typedReceipt = receipt as ObjectStorageUploadedPartV1; + const state = uploadedPartStateV1.get(typedReceipt); + const expectedPartNumber = index + 1; + if ( + state === undefined || + state.upload !== upload || + state.partNumber !== expectedPartNumber || + state.byteLength !== expectedPartByteLengthV1(plan, expectedPartNumber) || + typedReceipt.partNumber !== state.partNumber || + typedReceipt.sha256 !== state.sha256 || + typedReceipt.byteLength !== state.byteLength || + typedReceipt.receiptRef !== state.receiptRef + ) { + throw new ProviderContractErrorV1(); + } + totalByteLength += state.byteLength; + validatedParts.push(typedReceipt); + } + if (totalByteLength !== plan.expectedByteLength) throw new ProviderContractErrorV1(); + + const request = Object.freeze({ + context: record['context'] as ProviderInvocationContextV1, + upload: upload as ObjectStorageMultipartUploadV1, + uploadRef: (upload as ObjectStorageMultipartUploadV1).uploadRef, + orderedParts: Object.freeze(validatedParts), + expectedSha256: plan.expectedSha256, + expectedByteLength: plan.expectedByteLength, + }) as ObjectStorageCompleteMultipartRequestV1; + completeMultipartRequestsV1.add(request); + return request; +} + +export function assertObjectStorageCompleteMultipartRequestV1( + request: ObjectStorageCompleteMultipartRequestV1, +): ObjectStorageCompleteMultipartRequestV1 { + if ( + typeof request !== 'object' || + request === null || + !completeMultipartRequestsV1.has(request) + ) { + throw new ProviderContractErrorV1(); + } + return request; +} + export interface ObjectStorageAbortMultipartRequestV1 { readonly context: ProviderInvocationContextV1; - readonly uploadRef: string; + readonly upload: ObjectStorageMultipartUploadV1; } export interface ObjectStoragePutResultV1 { diff --git a/packages/provider-ports/test/built-public-api-smoke.mjs b/packages/provider-ports/test/built-public-api-smoke.mjs index 76d7c0d3..ca25ab61 100644 --- a/packages/provider-ports/test/built-public-api-smoke.mjs +++ b/packages/provider-ports/test/built-public-api-smoke.mjs @@ -5,9 +5,14 @@ const ports = await import('../dist/v1.js'); assert.equal(ports.PROVIDER_PORT_SCHEMA_VERSION_V1, 1); assert.equal(typeof ports.defineProviderDescriptorV1, 'function'); assert.equal(typeof ports.createProviderFailureV1, 'function'); -assert.equal(typeof ports.defineSecretReferenceV1, 'function'); +assert.equal(typeof ports.createSecretReferenceCapabilityV1, 'function'); +assert.equal(ports.defineSecretReferenceV1, undefined); assert.equal(typeof ports.defineSecretHandleV1, 'function'); assert.equal(typeof ports.defineObjectStorageMultipartPlanV1, 'function'); +assert.equal(typeof ports.defineObjectStorageMultipartUploadV1, 'function'); +assert.equal(typeof ports.defineObjectStorageUploadPartRequestV1, 'function'); +assert.equal(typeof ports.defineObjectStorageUploadedPartV1, 'function'); +assert.equal(typeof ports.defineObjectStorageCompleteMultipartRequestV1, 'function'); assert.equal(typeof ports.defineSubscriptionMigrationManifestV1, 'function'); assert.equal(ports.secretHandleIdV1, undefined); assert.equal(ports.secretReferenceHandleV1, undefined); diff --git a/packages/provider-ports/test/common-v1.test.mjs b/packages/provider-ports/test/common-v1.test.mjs index f9204adf..2acf26e3 100644 --- a/packages/provider-ports/test/common-v1.test.mjs +++ b/packages/provider-ports/test/common-v1.test.mjs @@ -177,7 +177,7 @@ test('defines provider health with safe reason codes and no raw detail channel', }); test('creates opaque secret handles that redact serialization and expose no material or raw IDs', () => { - const reference = ports.defineSecretReferenceV1({ + const reference = ports.createSecretReferenceCapabilityV1().issuer.issue({ namespace: 'production', pathSegments: ['email', 'credential'], }); diff --git a/packages/provider-ports/test/fixtures/storage-fake-v1.ts b/packages/provider-ports/test/fixtures/storage-fake-v1.ts new file mode 100644 index 00000000..13caffe8 --- /dev/null +++ b/packages/provider-ports/test/fixtures/storage-fake-v1.ts @@ -0,0 +1,214 @@ +import { + ProviderContractErrorV1, + assertMutatingProviderRequestV1, + assertObjectStorageCompleteMultipartRequestV1, + defineObjectStorageExitManifestV1, + defineObjectStorageMultipartUploadV1, + defineObjectStorageUploadedPartV1, + defineProviderDescriptorV1, + defineProviderHealthV1, +} from '../../src/v1.ts'; +import type { + ObjectStorageBeginMultipartResultV1, + ObjectStorageProviderPortV1, + ObjectStoragePutResultV1, + ObjectStorageUploadedPartV1, +} from '../../src/v1.ts'; + +type BackingKindV1 = 'map' | 'record'; + +interface BackingV1 { + get(key: string): T | undefined; + set(key: string, value: T): void; + delete(key: string): boolean; +} + +function createBackingV1(kind: BackingKindV1): BackingV1 { + if (kind === 'map') { + const values = new Map(); + return { + get: (key) => values.get(key), + set: (key, value) => { + values.set(key, value); + }, + delete: (key) => values.delete(key), + }; + } + const values = Object.create(null) as Record; + return { + get: (key) => values[key], + set: (key, value) => { + values[key] = value; + }, + delete: (key) => delete values[key], + }; +} + +function descriptorV1(adapterKey: string) { + return defineProviderDescriptorV1({ + kind: 'object-storage', + adapterKey, + capabilities: ( + [ + 'begin-multipart-upload', + 'upload-part', + 'complete-multipart-upload', + 'abort-multipart-upload', + 'read-range', + 'verify-digest', + 'apply-retention', + 'delete-verified', + 'create-read-grant', + 'export-object-manifest', + ] as const + ).map((operation) => ({ + operation, + idempotency: 'required', + cancellation: 'cooperative', + timeoutMs: 5_000, + maxAttempts: 3, + })), + dataHandling: { + regions: ['local'], + contentRetention: 'durable', + maximumRetentionSeconds: 86_400, + trainingUse: 'not_applicable', + }, + resilience: { failover: 'manual', degradedBehavior: 'fail_closed' }, + exit: { + statePortability: 'full', + exportFormat: 'databreeze-object-storage-exit-v1', + credentialRevocation: 'not_applicable', + }, + }); +} + +export function storageFakeV1( + adapterKey: string, + backingKind: BackingKindV1, + sha256V1: (content: Uint8Array) => string, +): ObjectStorageProviderPortV1 { + const beginReceipts = createBackingV1(backingKind); + const partReceipts = createBackingV1(backingKind); + const parts = createBackingV1(backingKind); + const results = createBackingV1(backingKind); + const objects = createBackingV1(backingKind); + + return { + descriptor: () => descriptorV1(adapterKey), + async checkHealth() { + await Promise.resolve(); + return defineProviderHealthV1({ + status: 'healthy', + checkedAt: '2026-08-01T10:00:00.000Z', + latencyMs: 0, + safeReasonCodes: [], + }); + }, + async beginMultipartUpload(request) { + const key = assertMutatingProviderRequestV1(request.context); + const prior = beginReceipts.get(key); + if (prior !== undefined) return prior; + const upload = defineObjectStorageMultipartUploadV1({ + uploadRef: `upload:${request.plan.objectKey}`, + plan: request.plan, + }); + beginReceipts.set(key, upload); + await Promise.resolve(); + return upload; + }, + async uploadPart(request) { + const key = assertMutatingProviderRequestV1(request.context); + const prior = partReceipts.get(key); + if (prior !== undefined) return prior; + const content = request.part.readContent(); + if (sha256V1(content) !== request.part.sha256) throw new ProviderContractErrorV1(); + const receipt = defineObjectStorageUploadedPartV1({ + upload: request.upload, + part: request.part, + receiptRef: `part:${request.part.partNumber}`, + }); + parts.set(`${request.upload.uploadRef}:${request.part.partNumber}`, content); + partReceipts.set(key, receipt); + await Promise.resolve(); + return receipt; + }, + async completeMultipartUpload(untrustedRequest) { + const request = assertObjectStorageCompleteMultipartRequestV1(untrustedRequest); + const key = assertMutatingProviderRequestV1(request.context); + const prior = results.get(key); + if (prior !== undefined) return prior; + const chunks: Uint8Array[] = []; + let byteLength = 0; + for (const receipt of request.orderedParts) { + const content = parts.get(`${request.upload.uploadRef}:${receipt.partNumber}`); + if (content === undefined || sha256V1(content) !== receipt.sha256) { + throw new ProviderContractErrorV1(); + } + chunks.push(content); + byteLength += content.byteLength; + } + const content = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + content.set(chunk, offset); + offset += chunk.byteLength; + } + const sha256 = sha256V1(content); + if (byteLength !== request.expectedByteLength || sha256 !== request.expectedSha256) { + throw new ProviderContractErrorV1(); + } + const objectRef = `object:${request.upload.plan.objectKey}`; + objects.set(objectRef, content); + const result = Object.freeze({ objectRef, sha256, byteLength }); + results.set(key, result); + await Promise.resolve(); + return result; + }, + async abortMultipartUpload(request) { + assertMutatingProviderRequestV1(request.context); + await Promise.resolve(); + return Object.freeze({ aborted: true }); + }, + async readRange(request) { + await Promise.resolve(); + return ( + objects.get(request.objectRef)?.slice(request.offset, request.offset + request.length) ?? + new Uint8Array() + ); + }, + async verifyDigest(request) { + const content = objects.get(request.objectRef); + await Promise.resolve(); + return Object.freeze({ + verified: content !== undefined && sha256V1(content) === request.expectedSha256, + }); + }, + async applyRetention(request) { + assertMutatingProviderRequestV1(request.context); + await Promise.resolve(); + return Object.freeze({ applied: true }); + }, + async deleteVerified(request) { + assertMutatingProviderRequestV1(request.context); + await Promise.resolve(); + return Object.freeze({ deleted: objects.delete(request.objectRef) }); + }, + async createReadGrant(request) { + assertMutatingProviderRequestV1(request.context); + await Promise.resolve(); + return Object.freeze({ + grantRef: `grant:${request.objectRef}`, + expiresAt: request.expiresAt, + }); + }, + async exportObjectManifest() { + await Promise.resolve(); + return defineObjectStorageExitManifestV1({ + manifestFormat: 'databreeze-object-storage-exit-v1', + entries: [], + complete: true, + }); + }, + }; +} diff --git a/packages/provider-ports/test/interchangeability-v1.test.mjs b/packages/provider-ports/test/interchangeability-v1.test.mjs index 4fff47e7..6125fbbd 100644 --- a/packages/provider-ports/test/interchangeability-v1.test.mjs +++ b/packages/provider-ports/test/interchangeability-v1.test.mjs @@ -1,173 +1,24 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { - assertMutatingProviderRequestV1, + ProviderContractErrorV1, assertProviderInvocationActiveV1, createProviderInvocationContextV1, - defineObjectStorageExitManifestV1, + defineObjectStorageCompleteMultipartRequestV1, defineObjectStorageMultipartPlanV1, defineObjectStoragePartV1, - defineProviderDescriptorV1, - defineProviderHealthV1, + defineObjectStorageUploadPartRequestV1, } from '../src/v1.ts'; +import { storageFakeV1 } from './fixtures/storage-fake-v1.ts'; const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -function descriptor(adapterKey) { - return defineProviderDescriptorV1({ - kind: 'object-storage', - adapterKey, - capabilities: [ - 'begin-multipart-upload', - 'upload-part', - 'complete-multipart-upload', - 'abort-multipart-upload', - 'read-range', - 'verify-digest', - 'apply-retention', - 'delete-verified', - 'create-read-grant', - 'export-object-manifest', - ].map((operation) => ({ - operation, - idempotency: 'required', - cancellation: 'cooperative', - timeoutMs: 5_000, - maxAttempts: 3, - })), - dataHandling: { - regions: ['local'], - contentRetention: 'durable', - maximumRetentionSeconds: 86_400, - trainingUse: 'not_applicable', - }, - resilience: { failover: 'manual', degradedBehavior: 'fail_closed' }, - exit: { - statePortability: 'full', - exportFormat: 'databreeze-object-storage-exit-v1', - credentialRevocation: 'not_applicable', - }, - }); -} - -function createBacking(kind) { - if (kind === 'map') { - const values = new Map(); - return { - get: (key) => values.get(key), - set: (key, value) => values.set(key, value), - delete: (key) => values.delete(key), - }; - } - const values = Object.create(null); - return { - get: (key) => values[key], - set: (key, value) => { - values[key] = value; - return value; - }, - delete: (key) => delete values[key], - }; -} - -function storageFake(adapterKey, backingKind) { - const receipts = createBacking(backingKind); - const parts = createBacking(backingKind); - const results = createBacking(backingKind); - return { - descriptor: () => descriptor(adapterKey), - async checkHealth() { - return defineProviderHealthV1({ - status: 'healthy', - checkedAt: '2026-08-01T10:00:00.000Z', - latencyMs: 0, - safeReasonCodes: [], - }); - }, - async beginMultipartUpload(request) { - const key = assertMutatingProviderRequestV1(request.context); - const prior = receipts.get(key); - if (prior !== undefined) return prior; - const value = Object.freeze({ - uploadRef: `upload:${request.plan.objectKey}`, - acceptedPartSizeBytes: request.plan.partSizeBytes, - maximumParts: request.plan.maximumParts, - }); - receipts.set(key, value); - return value; - }, - async uploadPart(request) { - const key = assertMutatingProviderRequestV1(request.context); - const prior = receipts.get(key); - if (prior !== undefined) return prior; - const part = defineObjectStoragePartV1(request.part); - const value = Object.freeze({ - partNumber: part.partNumber, - sha256: part.sha256, - byteLength: part.content.byteLength, - receiptRef: `part:${part.partNumber}`, - }); - parts.set(`${request.uploadRef}:${part.partNumber}`, part.content); - receipts.set(key, value); - return value; - }, - async completeMultipartUpload(request) { - const key = assertMutatingProviderRequestV1(request.context); - const prior = results.get(key); - if (prior !== undefined) return prior; - const byteLength = request.orderedParts.reduce((total, part) => total + part.byteLength, 0); - assert.equal(byteLength, request.expectedByteLength); - const value = Object.freeze({ - objectRef: `object:${request.uploadRef.slice('upload:'.length)}`, - sha256: request.expectedSha256, - byteLength, - }); - results.set(key, value); - return value; - }, - async abortMultipartUpload(request) { - assertMutatingProviderRequestV1(request.context); - return Object.freeze({ aborted: true }); - }, - async readRange(request) { - return ( - parts - .get(`upload:${request.objectRef.slice('object:'.length)}:1`) - ?.slice(request.offset, request.offset + request.length) ?? new Uint8Array() - ); - }, - async verifyDigest() { - return Object.freeze({ verified: true }); - }, - async applyRetention(request) { - assertMutatingProviderRequestV1(request.context); - return Object.freeze({ applied: true }); - }, - async deleteVerified(request) { - assertMutatingProviderRequestV1(request.context); - results.delete(request.objectRef); - return Object.freeze({ deleted: true }); - }, - async createReadGrant(request) { - assertMutatingProviderRequestV1(request.context); - return Object.freeze({ - grantRef: `grant:${request.objectRef}`, - expiresAt: request.expiresAt, - }); - }, - async exportObjectManifest() { - return defineObjectStorageExitManifestV1({ - manifestFormat: 'databreeze-object-storage-exit-v1', - entries: [], - complete: true, - }); - }, - }; -} +const sha256 = (content) => createHash('sha256').update(content).digest('hex'); function context(operation, idempotencyKey) { return createProviderInvocationContextV1({ @@ -184,7 +35,7 @@ function context(operation, idempotencyKey) { async function storeWithReplay(port) { const plan = defineObjectStorageMultipartPlanV1({ objectKey: 'workspace/object-1', - expectedSha256: 'a'.repeat(64), + expectedSha256: '039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81', expectedByteLength: 3, partSizeBytes: 8 * 1024 * 1024, }); @@ -196,38 +47,70 @@ async function storeWithReplay(port) { ]); assert.equal(upload, replayedUpload); - const uploadedPart = await port.uploadPart({ - context: context('upload-part', 'idem-part-1'), - uploadRef: upload.uploadRef, - part: defineObjectStoragePartV1({ - partNumber: 1, - content: new Uint8Array([1, 2, 3]), - sha256: 'b'.repeat(64), + const uploadedPart = await port.uploadPart( + defineObjectStorageUploadPartRequestV1({ + context: context('upload-part', 'idem-part-1'), + upload, + part: defineObjectStoragePartV1({ + partNumber: 1, + content: new Uint8Array([1, 2, 3]), + sha256: plan.expectedSha256, + }), }), - }); + ); const completeContext = context('complete-multipart-upload', 'idem-complete'); - const request = { + const request = defineObjectStorageCompleteMultipartRequestV1({ context: completeContext, - uploadRef: upload.uploadRef, + upload, orderedParts: [uploadedPart], - expectedSha256: plan.expectedSha256, - expectedByteLength: plan.expectedByteLength, - }; + }); return Promise.all([ port.completeMultipartUpload(request), port.completeMultipartUpload(request), ]); } +test('the typechecked behavioral fake recomputes the completed object digest', async () => { + const port = storageFakeV1('digest-check-memory-v1', 'map', sha256); + const plan = defineObjectStorageMultipartPlanV1({ + objectKey: 'workspace/object-with-wrong-plan-digest', + expectedSha256: 'a'.repeat(64), + expectedByteLength: 3, + partSizeBytes: 8 * 1024 * 1024, + }); + const upload = await port.beginMultipartUpload({ + context: context('begin-multipart-upload', 'idem-begin-wrong-digest'), + plan, + }); + const part = defineObjectStoragePartV1({ + partNumber: 1, + content: new Uint8Array([1, 2, 3]), + sha256: sha256(new Uint8Array([1, 2, 3])), + }); + const receipt = await port.uploadPart( + defineObjectStorageUploadPartRequestV1({ + context: context('upload-part', 'idem-part-wrong-digest'), + upload, + part, + }), + ); + const request = defineObjectStorageCompleteMultipartRequestV1({ + context: context('complete-multipart-upload', 'idem-complete-wrong-digest'), + upload, + orderedParts: [receipt], + }); + await assert.rejects(() => port.completeMultipartUpload(request), ProviderContractErrorV1); +}); + for (const [name, port] of [ - ['map-backed adapter', storageFake('map-memory-v1', 'map')], - ['record-backed adapter', storageFake('record-memory-v1', 'record')], + ['map-backed adapter', storageFakeV1('map-memory-v1', 'map', sha256)], + ['record-backed adapter', storageFakeV1('record-memory-v1', 'record', sha256)], ]) { test(`uses the same resumable object-storage contract with a ${name}`, async () => { const [first, replay] = await storeWithReplay(port); assert.deepEqual(first, { objectRef: 'object:workspace/object-1', - sha256: 'a'.repeat(64), + sha256: '039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81', byteLength: 3, }); assert.equal(first, replay, 'an idempotent replay returns the original receipt'); diff --git a/packages/provider-ports/test/ports-v1.type-test.ts b/packages/provider-ports/test/ports-v1.type-test.ts index 2bd3d88f..67cf7e7d 100644 --- a/packages/provider-ports/test/ports-v1.type-test.ts +++ b/packages/provider-ports/test/ports-v1.type-test.ts @@ -13,23 +13,6 @@ import type { const unavailable = (): Promise => Promise.reject(new Error('compile-only adapter')); -const completeObjectStorageAdapter = { - descriptor: (): never => { - throw new Error('compile-only adapter'); - }, - checkHealth: unavailable, - beginMultipartUpload: unavailable, - uploadPart: unavailable, - completeMultipartUpload: unavailable, - abortMultipartUpload: unavailable, - readRange: unavailable, - verifyDigest: unavailable, - applyRetention: unavailable, - deleteVerified: unavailable, - createReadGrant: unavailable, - exportObjectManifest: unavailable, -} satisfies ObjectStorageProviderPortV1; - const completeEmailAdapter = { descriptor: (): never => { throw new Error('compile-only adapter'); @@ -41,17 +24,10 @@ const completeEmailAdapter = { exportSuppressionManifest: unavailable, } satisfies EmailProviderPortV1; -void completeObjectStorageAdapter; void completeEmailAdapter; // @ts-expect-error -- secret references are branded values created by the validated factory. -const structurallyForgedSecretReference: SecretReferenceV1 = { - kind: 'secret-reference', - namespace: 'production', - pathSegments: ['email'], - toString: () => '[REDACTED_SECRET_REFERENCE]', - toJSON: () => '[REDACTED_SECRET_REFERENCE]', -}; +const structurallyForgedSecretReference: SecretReferenceV1 = {}; void structurallyForgedSecretReference; // @ts-expect-error -- provider operation errors are created only by createProviderFailureV1. diff --git a/packages/provider-ports/test/review-regressions-v1.test.mjs b/packages/provider-ports/test/review-regressions-v1.test.mjs index 9b78975e..07a9f312 100644 --- a/packages/provider-ports/test/review-regressions-v1.test.mjs +++ b/packages/provider-ports/test/review-regressions-v1.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { URL } from 'node:url'; import { inspect } from 'node:util'; import test from 'node:test'; @@ -166,6 +167,27 @@ test('canonical contract timestamp validation rejects impossible calendar dates' ); }); +test('leap-second deadlines expire across UTC date and year rollovers', () => { + for (const [deadlineAt, now] of [ + ['2016-12-31T23:59:60.000Z', '2017-01-01T00:00:00.000Z'], + ['2016-12-31T23:59:60.500Z', '2017-01-01T00:00:00.500Z'], + ]) { + const context = invocation({ deadlineAt }); + assert.throws( + () => ports.assertProviderInvocationActiveV1(context, now), + (error) => + error instanceof ports.ProviderOperationErrorV1 && + error.code === 'TIMEOUT' && + error.retryable === true, + ); + } + + const active = invocation({ deadlineAt: '2016-12-31T23:59:60.500Z' }); + assert.doesNotThrow(() => + ports.assertProviderInvocationActiveV1(active, '2017-01-01T00:00:00.499Z'), + ); +}); + test('invocation validation is closed and revalidates the whole context', () => { assert.throws( () => invocation({ unexpected: 'raw' }), @@ -180,15 +202,38 @@ test('invocation validation is closed and revalidates the whole context', () => ); }); -test('secret references and handles are opaque objects with no raw extractors', () => { - const reference = ports.defineSecretReferenceV1({ +test('secret references and handles reveal no metadata without their scoped capability', () => { + const capability = ports.createSecretReferenceCapabilityV1(); + const foreignCapability = ports.createSecretReferenceCapabilityV1(); + const reference = capability.issuer.issue({ namespace: 'production', pathSegments: ['email', 'credential'], version: 'active', }); const handle = ports.defineSecretHandleV1({ reference, expiresAt: '2026-08-01T10:05:00.000Z' }); + + assert.deepEqual(capability.resolver.resolve(reference), { + namespace: 'production', + pathSegments: ['email', 'credential'], + version: 'active', + }); + assert.throws(() => foreignCapability.resolver.resolve(reference), ports.ProviderContractErrorV1); + assert.deepEqual(Reflect.ownKeys(reference), []); + assert.deepEqual(Reflect.ownKeys(handle), []); + assert.deepEqual(Object.getOwnPropertyDescriptors(reference), {}); + assert.deepEqual(Object.getOwnPropertyDescriptors(handle), {}); + assert.equal(reference.namespace, undefined); + assert.equal(reference.pathSegments, undefined); + assert.equal(reference.version, undefined); + assert.equal(handle.reference, undefined); + assert.equal(handle.expiresAt, undefined); assert.equal(String(reference), '[REDACTED_SECRET_REFERENCE]'); assert.equal(String(handle), '[REDACTED_SECRET_HANDLE]'); + assert.equal(inspect(reference), '[REDACTED_SECRET_REFERENCE]'); + assert.equal(inspect(handle), '[REDACTED_SECRET_HANDLE]'); + assert.equal(JSON.stringify(reference), '"[REDACTED_SECRET_REFERENCE]"'); + assert.equal(JSON.stringify(handle), '"[REDACTED_SECRET_HANDLE]"'); + assert.equal(ports.defineSecretReferenceV1, undefined); assert.equal(ports.secretHandleIdV1, undefined); assert.equal(ports.secretReferenceHandleV1, undefined); assert.doesNotMatch(JSON.stringify({ reference, handle }), /production|email|credential|active/u); @@ -269,12 +314,19 @@ test('object storage uses bounded resumable multipart requests for 20 GiB object }); assert.equal(plan.maximumParts, 2_560); + const original = new Uint8Array([1, 2, 3]); const part = ports.defineObjectStoragePartV1({ partNumber: 1, - content: new Uint8Array(8 * 1024 * 1024), - sha256: 'b'.repeat(64), + content: original, + sha256: '039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81', }); - assert.equal(part.content.byteLength, 8 * 1024 * 1024); + original[0] = 9; + const firstRead = part.readContent(); + firstRead[1] = 9; + assert.deepEqual(part.readContent(), new Uint8Array([1, 2, 3])); + assert.equal(part.byteLength, 3); + assert.equal(part.content, undefined); + assert.equal(createHash('sha256').update(part.readContent()).digest('hex'), part.sha256); assert.throws( () => ports.defineObjectStoragePartV1({ @@ -286,6 +338,119 @@ test('object storage uses bounded resumable multipart requests for 20 GiB object ); }); +test('multipart completion is factory-bound, exact, ordered, contiguous, and content-bound', () => { + const partSizeBytes = ports.OBJECT_STORAGE_MIN_PART_BYTES_V1; + const firstContent = new Uint8Array(partSizeBytes); + const secondContent = new Uint8Array([1, 2, 3]); + const fullContent = new Uint8Array(partSizeBytes + secondContent.byteLength); + fullContent.set(secondContent, partSizeBytes); + const digest = (content) => createHash('sha256').update(content).digest('hex'); + const plan = ports.defineObjectStorageMultipartPlanV1({ + objectKey: 'workspace/object-completion', + expectedSha256: digest(fullContent), + expectedByteLength: fullContent.byteLength, + partSizeBytes, + }); + const upload = ports.defineObjectStorageMultipartUploadV1({ + uploadRef: 'upload-1', + plan, + }); + const firstPart = ports.defineObjectStoragePartV1({ + partNumber: 1, + content: firstContent, + sha256: digest(firstContent), + }); + const secondPart = ports.defineObjectStoragePartV1({ + partNumber: 2, + content: secondContent, + sha256: digest(secondContent), + }); + const firstReceipt = ports.defineObjectStorageUploadedPartV1({ + upload, + part: firstPart, + receiptRef: 'receipt-1', + }); + const secondReceipt = ports.defineObjectStorageUploadedPartV1({ + upload, + part: secondPart, + receiptRef: 'receipt-2', + }); + const context = invocation({ + operation: 'complete-multipart-upload', + idempotencyKey: 'idem-complete', + }); + const request = ports.defineObjectStorageCompleteMultipartRequestV1({ + context, + upload, + orderedParts: [firstReceipt, secondReceipt], + }); + assert.equal(request.expectedSha256, plan.expectedSha256); + assert.equal(request.expectedByteLength, plan.expectedByteLength); + + for (const orderedParts of [ + [secondReceipt, firstReceipt], + [firstReceipt, firstReceipt], + [firstReceipt], + ]) { + assert.throws( + () => ports.defineObjectStorageCompleteMultipartRequestV1({ context, upload, orderedParts }), + ports.ProviderContractErrorV1, + ); + } + + const foreignUpload = ports.defineObjectStorageMultipartUploadV1({ + uploadRef: 'upload-2', + plan, + }); + assert.throws( + () => + ports.defineObjectStorageCompleteMultipartRequestV1({ + context, + upload: foreignUpload, + orderedParts: [firstReceipt, secondReceipt], + }), + ports.ProviderContractErrorV1, + ); + assert.throws( + () => + ports.defineObjectStorageCompleteMultipartRequestV1({ + context, + upload, + orderedParts: [firstReceipt, secondReceipt], + expectedSha256: 'f'.repeat(64), + }), + ports.ProviderContractErrorV1, + ); +}); + +test('contract arrays reject non-index string and symbol properties', () => { + const capabilities = validDescriptor().capabilities.slice(); + capabilities[Symbol('hidden')] = capability('put-object'); + assert.throws( + () => ports.defineProviderDescriptorV1({ ...validDescriptor(), capabilities }), + ports.ProviderContractErrorV1, + ); + + const entries = [ + { + objectKey: 'workspace/object-1', + versionRef: 'version-1', + sha256: 'a'.repeat(64), + byteLength: 1, + }, + ]; + entries.extra = 'must-not-survive'; + assert.throws( + () => + ports.defineObjectStorageExitManifestV1({ + manifestFormat: 'databreeze-object-storage-exit-v1', + entries, + complete: true, + }), + ports.ProviderContractErrorV1, + ); +}); + test('email and push ports include typed suppression operations', async () => { const source = await import('node:fs/promises').then((fs) => fs.readFile(new URL('../src/ports-v1.ts', import.meta.url), 'utf8'), From 95eaedd1272a3859f4295c2b5037e02f5bc000da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 09:08:05 +0700 Subject: [PATCH 21/51] fix(config): bind capabilities and idempotency --- packages/config/README.md | 6 +- .../config/src/runtime-config/loader-v1.ts | 119 ++++++++++----- .../config/src/runtime-config/types-v1.ts | 7 +- .../test/review-regressions-v1.test.mjs | 144 ++++++++++++++++-- packages/config/test/runtime-v1.test.mjs | 6 +- packages/provider-ports/README.md | 3 +- packages/provider-ports/src/common-v1.ts | 58 ++++++- .../test/built-public-api-smoke.mjs | 8 + .../test/fixtures/storage-fake-v1.ts | 91 +++++++++-- .../test/interchangeability-v1.test.mjs | 127 +++++++++++++++ .../test/review-regressions-v1.test.mjs | 44 ++++++ 11 files changed, 547 insertions(+), 66 deletions(-) diff --git a/packages/config/README.md b/packages/config/README.md index 71f32e0c..aaafe187 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -9,7 +9,7 @@ itself, contact a provider, or choose product policy. `@databreeze/config/runtime/v1` exports: - `loadRuntimeConfigV1`, which accepts an explicit environment record/entry list, optional - structured overrides, and the composition-owned secret-reference issuer used by enabled + structured overrides, and the factory-issued secret-reference capability used by enabled credential references, then returns a deeply frozen configuration; - the five explicit profiles: `development`, `test`, `preview`, `staging`, and `production`; - typed object-storage, email, push, OCR, AI, payments, telemetry, and secrets selections; @@ -49,8 +49,8 @@ references, never API keys, passwords, tokens, webhook secrets, or other credent tenant state. - Provider credentials or implicit host-environment reads. -The only runtime dependency is the pure versioned provider-contract package used to accept a -scoped secret-reference issuer and construct the same opaque reference accepted by +The only runtime dependency is the pure versioned provider-contract package used to authenticate a +scoped secret-reference capability and construct the same opaque reference accepted by `SecretsProviderPortV1`. The product-policy precedence `platform default -> plan/region -> organization -> workspace -> diff --git a/packages/config/src/runtime-config/loader-v1.ts b/packages/config/src/runtime-config/loader-v1.ts index 6ad05ef9..44c804f4 100644 --- a/packages/config/src/runtime-config/loader-v1.ts +++ b/packages/config/src/runtime-config/loader-v1.ts @@ -1,4 +1,10 @@ import { ConfigValidationErrorV1, RUNTIME_CONFIG_SCHEMA_VERSION_V1 } from './types-v1.ts'; +import { + isSecretReferenceCapabilityV1, + isSecretReferenceForCapabilityV1, + isSecretReferenceIssuerV1, + isSecretReferenceV1, +} from '@databreeze/provider-ports/v1'; import type { ActiveDocumentProviderConfigV1, AiConfigV1, @@ -13,7 +19,7 @@ import type { PushConfigV1, RuntimeConfigV1, RuntimeProfileV1, - SecretReferenceIssuerV1, + SecretReferenceCapabilityV1, SecretReferenceV1, SecretsConfigV1, TelemetryConfigV1, @@ -248,7 +254,7 @@ function snapshotLoadInput( ): Readonly<{ environment?: EnvironmentEntriesV1; overrides?: unknown; - secretReferenceIssuer?: SecretReferenceIssuerV1; + secretReferenceCapability?: SecretReferenceCapabilityV1; }> { const descriptors = ownDataDescriptors(input); if (descriptors === undefined || isArraySafely(input) !== false) { @@ -258,10 +264,10 @@ function snapshotLoadInput( const result: { environment?: EnvironmentEntriesV1; overrides?: unknown; - secretReferenceIssuer?: SecretReferenceIssuerV1; + secretReferenceCapability?: SecretReferenceCapabilityV1; } = {}; for (const [key, descriptor] of Object.entries(descriptors)) { - if (key !== 'environment' && key !== 'overrides' && key !== 'secretReferenceIssuer') { + if (key !== 'environment' && key !== 'overrides' && key !== 'secretReferenceCapability') { issues.push({ path: 'configuration.unknown_key', code: 'unknown_key' }); continue; } @@ -271,8 +277,8 @@ function snapshotLoadInput( } if (key === 'environment') result.environment = descriptor.value as EnvironmentEntriesV1; if (key === 'overrides') result.overrides = descriptor.value; - if (key === 'secretReferenceIssuer') { - result.secretReferenceIssuer = descriptor.value as SecretReferenceIssuerV1; + if (key === 'secretReferenceCapability') { + result.secretReferenceCapability = descriptor.value as SecretReferenceCapabilityV1; } } return result; @@ -658,16 +664,34 @@ function validEndpoint( return value; } -function isSecretReferenceIssueFunction(value: unknown): value is SecretReferenceIssuerV1['issue'] { +function isSecretReferenceIssueFunction( + value: unknown, +): value is SecretReferenceCapabilityV1['issuer']['issue'] { return typeof value === 'function'; } +function invalidSecretReferenceCapability(issues: ConfigIssueV1[]): undefined { + if ( + !issues.some( + (issue) => + issue.path === 'configuration.secret_reference_capability' && + issue.code === 'invalid_secret_reference_capability', + ) + ) { + issues.push({ + path: 'configuration.secret_reference_capability', + code: 'invalid_secret_reference_capability', + }); + } + return undefined; +} + function secretReference( value: unknown, path: string, issues: ConfigIssueV1[], required: boolean, - issuer: SecretReferenceIssuerV1 | undefined, + capability: SecretReferenceCapabilityV1 | undefined, ): SecretReferenceV1 | undefined { if (value === undefined) { if (required) issues.push({ path, code: 'required' }); @@ -698,25 +722,42 @@ function secretReference( issues.push({ path, code: 'invalid_secret_reference' }); return undefined; } - const issuerDescriptors = ownDataDescriptors(issuer); + if (!isSecretReferenceCapabilityV1(capability)) { + return invalidSecretReferenceCapability(issues); + } + const capabilityDescriptors = ownDataDescriptors(capability); + const issuerDescriptor = capabilityDescriptors?.['issuer']; + const issuerValue: unknown = + issuerDescriptor !== undefined && 'value' in issuerDescriptor + ? issuerDescriptor.value + : undefined; + if (!isSecretReferenceIssuerV1(issuerValue)) { + return invalidSecretReferenceCapability(issues); + } + const issuerDescriptors = ownDataDescriptors(issuerValue); const issueDescriptor = issuerDescriptors?.['issue']; const issueValue: unknown = issueDescriptor !== undefined && 'value' in issueDescriptor ? issueDescriptor.value : undefined; if (!isSecretReferenceIssueFunction(issueValue)) { - issues.push({ path: 'configuration.secret_reference_issuer', code: 'required' }); - return undefined; + return invalidSecretReferenceCapability(issues); } try { - return Reflect.apply(issueValue, undefined, [ + const reference: unknown = Reflect.apply(issueValue, undefined, [ { namespace: match[1] as string, pathSegments, ...(match[3] === undefined ? {} : { version: match[3] }), }, ]); + if ( + !isSecretReferenceV1(reference) || + !isSecretReferenceForCapabilityV1(capability, reference) + ) { + return invalidSecretReferenceCapability(issues); + } + return reference; } catch { - issues.push({ path, code: 'invalid_secret_reference' }); - return undefined; + return invalidSecretReferenceCapability(issues); } } @@ -755,7 +796,7 @@ function validateObjectStorage( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], - issuer: SecretReferenceIssuerV1 | undefined, + capability: SecretReferenceCapabilityV1 | undefined, ): ObjectStorageConfigV1 { const path = 'providers.objectStorage'; const mode = modeOf(record, path, ['local', 'remote'], issues); @@ -783,7 +824,7 @@ function validateObjectStorage( `${path}.credentialRef`, issues, mode === 'remote', - issuer, + capability, ); return { mode: mode === 'remote' ? 'remote' : 'local', @@ -799,7 +840,7 @@ function validateEmail( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], - issuer: SecretReferenceIssuerV1 | undefined, + capability: SecretReferenceCapabilityV1 | undefined, ): EmailConfigV1 { const path = 'providers.email'; const mode = modeOf(record, path, ['disabled', 'local', 'remote'], issues); @@ -827,7 +868,7 @@ function validateEmail( `${path}.credentialRef`, issues, mode === 'remote', - issuer, + capability, ); return { mode: mode === 'remote' ? 'remote' : 'local', @@ -841,7 +882,7 @@ function validatePush( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], - issuer: SecretReferenceIssuerV1 | undefined, + capability: SecretReferenceCapabilityV1 | undefined, ): PushConfigV1 { const path = 'providers.push'; const mode = modeOf(record, path, ['disabled', 'remote'], issues); @@ -863,7 +904,7 @@ function validatePush( `${path}.credentialRef`, issues, true, - issuer, + capability, ); return { mode: 'remote', @@ -878,7 +919,7 @@ function validateDocumentProvider( profile: RuntimeProfileV1, name: 'ocr' | 'ai', issues: ConfigIssueV1[], - issuer: SecretReferenceIssuerV1 | undefined, + capability: SecretReferenceCapabilityV1 | undefined, ): ActiveDocumentProviderConfigV1 | { readonly mode: 'disabled' } { const path = `providers.${name}`; const mode = modeOf(record, path, ['disabled', 'local', 'remote'], issues); @@ -902,7 +943,7 @@ function validateDocumentProvider( `${path}.credentialRef`, issues, mode === 'remote', - issuer, + capability, ); return { mode: mode === 'remote' ? 'remote' : 'local', @@ -915,7 +956,7 @@ function validatePayments( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], - issuer: SecretReferenceIssuerV1 | undefined, + capability: SecretReferenceCapabilityV1 | undefined, ): PaymentsConfigV1 { const path = 'providers.payments'; const mode = modeOf(record, path, ['disabled', 'remote'], issues); @@ -941,14 +982,14 @@ function validatePayments( `${path}.credentialRef`, issues, true, - issuer, + capability, ); const webhookSecretRef = secretReference( record['webhookSecretRef'], `${path}.webhookSecretRef`, issues, true, - issuer, + capability, ); return { mode: 'remote', @@ -962,7 +1003,7 @@ function validateTelemetry( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], - issuer: SecretReferenceIssuerV1 | undefined, + capability: SecretReferenceCapabilityV1 | undefined, ): TelemetryConfigV1 { const path = 'providers.telemetry'; const mode = modeOf(record, path, ['disabled', 'local', 'remote'], issues); @@ -986,7 +1027,7 @@ function validateTelemetry( `${path}.credentialRef`, issues, false, - issuer, + capability, ); return { mode: mode === 'remote' ? 'remote' : 'local', @@ -1030,33 +1071,33 @@ function validateProviders( record: UnknownRecord, profile: RuntimeProfileV1, issues: ConfigIssueV1[], - issuer: SecretReferenceIssuerV1 | undefined, + capability: SecretReferenceCapabilityV1 | undefined, ): ProviderRuntimeConfigV1 { return { objectStorage: validateObjectStorage( recordAt(record, 'objectStorage'), profile, issues, - issuer, + capability, ), - email: validateEmail(recordAt(record, 'email'), profile, issues, issuer), - push: validatePush(recordAt(record, 'push'), profile, issues, issuer), + email: validateEmail(recordAt(record, 'email'), profile, issues, capability), + push: validatePush(recordAt(record, 'push'), profile, issues, capability), ocr: validateDocumentProvider( recordAt(record, 'ocr'), profile, 'ocr', issues, - issuer, + capability, ) as OcrConfigV1, ai: validateDocumentProvider( recordAt(record, 'ai'), profile, 'ai', issues, - issuer, + capability, ) as AiConfigV1, - payments: validatePayments(recordAt(record, 'payments'), profile, issues, issuer), - telemetry: validateTelemetry(recordAt(record, 'telemetry'), profile, issues, issuer), + payments: validatePayments(recordAt(record, 'payments'), profile, issues, capability), + telemetry: validateTelemetry(recordAt(record, 'telemetry'), profile, issues, capability), secrets: validateSecrets(recordAt(record, 'secrets'), profile, issues), }; } @@ -1081,6 +1122,12 @@ function selectedProfile( export function loadRuntimeConfigV1(input: LoadRuntimeConfigInputV1 = {}): RuntimeConfigV1 { const issues: ConfigIssueV1[] = []; const safeInput = snapshotLoadInput(input, issues); + if ( + safeInput.secretReferenceCapability !== undefined && + !isSecretReferenceCapabilityV1(safeInput.secretReferenceCapability) + ) { + invalidSecretReferenceCapability(issues); + } const environment = readEnvironment(safeInput.environment, issues); const overrideRecord = safeInput.overrides === undefined ? {} : snapshotOverrides(safeInput.overrides, issues); @@ -1114,7 +1161,7 @@ export function loadRuntimeConfigV1(input: LoadRuntimeConfigInputV1 = {}): Runti recordAt(merged, 'providers'), profile, issues, - safeInput.secretReferenceIssuer, + safeInput.secretReferenceCapability, ); if (issues.length > 0) { diff --git a/packages/config/src/runtime-config/types-v1.ts b/packages/config/src/runtime-config/types-v1.ts index 6587f350..e55ba94d 100644 --- a/packages/config/src/runtime-config/types-v1.ts +++ b/packages/config/src/runtime-config/types-v1.ts @@ -1,6 +1,6 @@ -import type { SecretReferenceIssuerV1, SecretReferenceV1 } from '@databreeze/provider-ports/v1'; +import type { SecretReferenceCapabilityV1, SecretReferenceV1 } from '@databreeze/provider-ports/v1'; -export type { SecretReferenceIssuerV1, SecretReferenceV1 } from '@databreeze/provider-ports/v1'; +export type { SecretReferenceCapabilityV1, SecretReferenceV1 } from '@databreeze/provider-ports/v1'; export const RUNTIME_CONFIG_SCHEMA_VERSION_V1 = 1 as const; @@ -15,6 +15,7 @@ export type ConfigIssueCodeV1 = | 'invalid_mode' | 'invalid_profile' | 'invalid_secret_reference' + | 'invalid_secret_reference_capability' | 'invalid_secret_namespace' | 'invalid_string' | 'required' @@ -145,5 +146,5 @@ export type EnvironmentEntriesV1 = export interface LoadRuntimeConfigInputV1 { readonly environment?: EnvironmentEntriesV1; readonly overrides?: unknown; - readonly secretReferenceIssuer?: SecretReferenceIssuerV1; + readonly secretReferenceCapability?: SecretReferenceCapabilityV1; } diff --git a/packages/config/test/review-regressions-v1.test.mjs b/packages/config/test/review-regressions-v1.test.mjs index 1c20c7a0..f3486b51 100644 --- a/packages/config/test/review-regressions-v1.test.mjs +++ b/packages/config/test/review-regressions-v1.test.mjs @@ -4,7 +4,7 @@ import test from 'node:test'; import { ConfigValidationErrorV1, loadRuntimeConfigV1 } from '../src/runtime-config/v1.ts'; -const providerPorts = await import('../../provider-ports/src/v1.ts'); +const providerPorts = await import('@databreeze/provider-ports/v1'); function issue(error, path, code) { return ( @@ -343,18 +343,22 @@ for (const namespace of ['', '.', '..', 'team//prod', 'team/', 'team/../prod', ' }); } -test('returns an issuer-created opaque secret reference without a raw extractor', async () => { +function enabledPushEnvironment() { + return { + DATABREEZE_PROFILE: 'development', + DATABREEZE_PUSH_MODE: 'remote', + DATABREEZE_PUSH_ENDPOINT_URL: 'https://push.example.test', + DATABREEZE_PUSH_APPLICATION_ID: 'databreeze', + DATABREEZE_PUSH_CREDENTIAL_REF: 'secret://development/push/credential#active', + }; +} + +test('returns a capability-created opaque secret reference without a raw extractor', async () => { const runtime = await import('../src/runtime-config/v1.ts'); const capability = providerPorts.createSecretReferenceCapabilityV1(); const config = loadRuntimeConfigV1({ - environment: { - DATABREEZE_PROFILE: 'development', - DATABREEZE_PUSH_MODE: 'remote', - DATABREEZE_PUSH_ENDPOINT_URL: 'https://push.example.test', - DATABREEZE_PUSH_APPLICATION_ID: 'databreeze', - DATABREEZE_PUSH_CREDENTIAL_REF: 'secret://development/push/credential#active', - }, - secretReferenceIssuer: capability.issuer, + environment: enabledPushEnvironment(), + secretReferenceCapability: capability, }); const reference = config.providers.push.credentialRef; assert.deepEqual(capability.resolver.resolve(reference), { @@ -370,4 +374,124 @@ test('returns an issuer-created opaque secret reference without a raw extractor' assert.equal(runtime.secretReferenceHandleV1, undefined); assert.equal(runtime.createSecretReferenceV1, undefined); assert.equal(JSON.stringify(reference), '"[REDACTED_SECRET_REFERENCE]"'); + assert.doesNotMatch(JSON.stringify(config), /secretReferenceCapability|secret:\/\//u); +}); + +test('rejects fake, hostile, revoked, and foreign secret capabilities without invoking them', () => { + const realCapability = providerPorts.createSecretReferenceCapabilityV1(); + const foreignCapability = providerPorts.createSecretReferenceCapabilityV1(); + const foreignReference = foreignCapability.issuer.issue({ + namespace: 'foreign', + pathSegments: ['credential'], + }); + let invoked = 0; + const attempts = [ + { + name: 'primitive-return', + marker: 'primitive-return-marker-X9Y8Z7', + value: { + issuer: { + issue() { + invoked += 1; + return 42; + }, + }, + resolver: {}, + }, + }, + { + name: 'plain-return', + marker: 'plain-return-marker-X9Y8Z7', + value: { + issuer: { + issue() { + invoked += 1; + return { raw: 'plain-return-marker-X9Y8Z7' }; + }, + }, + resolver: {}, + }, + }, + { + name: 'throwing-method', + marker: 'throwing-method-marker-X9Y8Z7', + value: { + issuer: { + issue() { + invoked += 1; + throw new Error('throwing-method-marker-X9Y8Z7'); + }, + }, + resolver: {}, + }, + }, + { + name: 'foreign-return', + marker: 'foreign-return-marker-X9Y8Z7', + value: { + issuer: { + issue() { + invoked += 1; + return foreignReference; + }, + }, + resolver: foreignCapability.resolver, + }, + }, + ]; + + const getterCapability = {}; + Object.defineProperty(getterCapability, 'issuer', { + enumerable: true, + get() { + invoked += 1; + throw new Error('capability-getter-marker-X9Y8Z7'); + }, + }); + attempts.push({ + name: 'getter', + marker: 'capability-getter-marker-X9Y8Z7', + value: getterCapability, + }); + + const hostileProxy = new Proxy( + {}, + { + get() { + invoked += 1; + throw new Error('capability-proxy-marker-X9Y8Z7'); + }, + ownKeys() { + invoked += 1; + throw new Error('capability-proxy-marker-X9Y8Z7'); + }, + }, + ); + attempts.push({ + name: 'proxy', + marker: 'capability-proxy-marker-X9Y8Z7', + value: hostileProxy, + }); + + const { proxy: revokedProxy, revoke } = Proxy.revocable(realCapability, {}); + revoke(); + attempts.push({ + name: 'revoked-proxy', + marker: 'revoked-proxy-marker-X9Y8Z7', + value: revokedProxy, + }); + + for (const attempt of attempts) { + expectSafeConfigFailure( + () => + loadRuntimeConfigV1({ + environment: enabledPushEnvironment(), + secretReferenceCapability: attempt.value, + }), + 'configuration.secret_reference_capability', + 'invalid_secret_reference_capability', + [attempt.marker], + ); + } + assert.equal(invoked, 0); }); diff --git a/packages/config/test/runtime-v1.test.mjs b/packages/config/test/runtime-v1.test.mjs index 97eeea67..c400eeea 100644 --- a/packages/config/test/runtime-v1.test.mjs +++ b/packages/config/test/runtime-v1.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { ConfigValidationErrorV1, loadRuntimeConfigV1 } from '../src/runtime-config/v1.ts'; -import { createSecretReferenceCapabilityV1 } from '../../provider-ports/src/v1.ts'; +import { createSecretReferenceCapabilityV1 } from '@databreeze/provider-ports/v1'; const secretReferenceCapability = createSecretReferenceCapabilityV1(); @@ -79,7 +79,7 @@ for (const profile of ['preview', 'staging', 'production']) { test(`loads an explicitly complete ${profile} profile`, () => { const config = loadRuntimeConfigV1({ environment: nonLocalEnvironment(profile), - secretReferenceIssuer: secretReferenceCapability.issuer, + secretReferenceCapability, }); assert.equal(config.profile, profile); @@ -311,7 +311,7 @@ for (const reference of ['', 'super-secret-value', 'secret://production/changeme test('redacts valid secret references during string and JSON serialization', () => { const config = loadRuntimeConfigV1({ environment: nonLocalEnvironment('production'), - secretReferenceIssuer: secretReferenceCapability.issuer, + secretReferenceCapability, }); const reference = config.providers.objectStorage.credentialRef; diff --git a/packages/provider-ports/README.md b/packages/provider-ports/README.md index f0ff92b8..7370aba3 100644 --- a/packages/provider-ports/README.md +++ b/packages/provider-ports/README.md @@ -27,7 +27,8 @@ enforce cancellation/deadlines/idempotency, and create errors only through a red allowlisted operations and code-derived message keys. Raw provider causes are neither accessed nor retained. A composition-owned issuer/resolver capability creates and resolves secret references; references and handles expose no identifier fields or raw public ID and redact string, JSON, and -diagnostic inspection. +diagnostic inspection. Provenance predicates and assertions validate genuine capabilities, +issuers, references, and capability membership without revealing identifier metadata. Object storage is resumable and bounded-memory: begin, upload a validated copy-isolated 8-64 MiB part, complete, or abort. Factory-issued uploads bind their immutable plan; uploaded-part receipts diff --git a/packages/provider-ports/src/common-v1.ts b/packages/provider-ports/src/common-v1.ts index 2fd6b641..a7100309 100644 --- a/packages/provider-ports/src/common-v1.ts +++ b/packages/provider-ports/src/common-v1.ts @@ -833,6 +833,9 @@ export interface SecretReferenceCapabilityV1 { } const secretReferences = new WeakSet(); +const secretReferenceIssuers = new WeakSet(); +const secretReferenceCapabilities = new WeakSet(); +const referencesBySecretCapability = new WeakMap>(); const secretSegmentPattern = /^[a-z0-9][a-z0-9._-]{0,62}$/; const inspectCustomV1 = Symbol.for('nodejs.util.inspect.custom'); @@ -877,6 +880,7 @@ function validatedSecretReferenceMetadataV1(input: unknown): SecretReferenceMeta export function createSecretReferenceCapabilityV1(): SecretReferenceCapabilityV1 { const metadataByReference = new WeakMap(); + const issuedReferences = new WeakSet(); const issuer: SecretReferenceIssuerV1 = Object.freeze({ issue(input: SecretReferenceMetadataV1): SecretReferenceV1 { const metadata = validatedSecretReferenceMetadataV1(input); @@ -884,6 +888,7 @@ export function createSecretReferenceCapabilityV1(): SecretReferenceCapabilityV1 Object.create(secretReferencePrototypeV1) as SecretReferenceV1, ); secretReferences.add(reference); + issuedReferences.add(reference); metadataByReference.set(reference, metadata); return reference; }, @@ -896,7 +901,58 @@ export function createSecretReferenceCapabilityV1(): SecretReferenceCapabilityV1 return metadata; }, }); - return Object.freeze({ issuer, resolver }); + const capability = Object.freeze({ issuer, resolver }); + secretReferenceIssuers.add(issuer); + secretReferenceCapabilities.add(capability); + referencesBySecretCapability.set(capability, issuedReferences); + return capability; +} + +export function isSecretReferenceCapabilityV1( + value: unknown, +): value is SecretReferenceCapabilityV1 { + return isObject(value) && secretReferenceCapabilities.has(value); +} + +export function isSecretReferenceIssuerV1(value: unknown): value is SecretReferenceIssuerV1 { + return isObject(value) && secretReferenceIssuers.has(value); +} + +export function isSecretReferenceV1(value: unknown): value is SecretReferenceV1 { + return isObject(value) && secretReferences.has(value); +} + +export function isSecretReferenceForCapabilityV1( + capability: unknown, + reference: unknown, +): reference is SecretReferenceV1 { + if (!isSecretReferenceCapabilityV1(capability) || !isSecretReferenceV1(reference)) return false; + return referencesBySecretCapability.get(capability)?.has(reference) === true; +} + +export function assertSecretReferenceCapabilityV1(value: unknown): SecretReferenceCapabilityV1 { + if (!isSecretReferenceCapabilityV1(value)) throw new ProviderContractErrorV1(); + return value; +} + +export function assertSecretReferenceIssuerV1(value: unknown): SecretReferenceIssuerV1 { + if (!isSecretReferenceIssuerV1(value)) throw new ProviderContractErrorV1(); + return value; +} + +export function assertSecretReferenceV1(value: unknown): SecretReferenceV1 { + if (!isSecretReferenceV1(value)) throw new ProviderContractErrorV1(); + return value; +} + +export function assertSecretReferenceForCapabilityV1( + capability: unknown, + reference: unknown, +): SecretReferenceV1 { + if (!isSecretReferenceForCapabilityV1(capability, reference)) { + throw new ProviderContractErrorV1(); + } + return reference; } const secretHandleBrandV1: unique symbol = Symbol('SecretHandleV1'); diff --git a/packages/provider-ports/test/built-public-api-smoke.mjs b/packages/provider-ports/test/built-public-api-smoke.mjs index ca25ab61..5637db52 100644 --- a/packages/provider-ports/test/built-public-api-smoke.mjs +++ b/packages/provider-ports/test/built-public-api-smoke.mjs @@ -6,6 +6,14 @@ assert.equal(ports.PROVIDER_PORT_SCHEMA_VERSION_V1, 1); assert.equal(typeof ports.defineProviderDescriptorV1, 'function'); assert.equal(typeof ports.createProviderFailureV1, 'function'); assert.equal(typeof ports.createSecretReferenceCapabilityV1, 'function'); +assert.equal(typeof ports.isSecretReferenceCapabilityV1, 'function'); +assert.equal(typeof ports.isSecretReferenceIssuerV1, 'function'); +assert.equal(typeof ports.isSecretReferenceV1, 'function'); +assert.equal(typeof ports.isSecretReferenceForCapabilityV1, 'function'); +assert.equal(typeof ports.assertSecretReferenceCapabilityV1, 'function'); +assert.equal(typeof ports.assertSecretReferenceIssuerV1, 'function'); +assert.equal(typeof ports.assertSecretReferenceV1, 'function'); +assert.equal(typeof ports.assertSecretReferenceForCapabilityV1, 'function'); assert.equal(ports.defineSecretReferenceV1, undefined); assert.equal(typeof ports.defineSecretHandleV1, 'function'); assert.equal(typeof ports.defineObjectStorageMultipartPlanV1, 'function'); diff --git a/packages/provider-ports/test/fixtures/storage-fake-v1.ts b/packages/provider-ports/test/fixtures/storage-fake-v1.ts index 13caffe8..428630ab 100644 --- a/packages/provider-ports/test/fixtures/storage-fake-v1.ts +++ b/packages/provider-ports/test/fixtures/storage-fake-v1.ts @@ -2,6 +2,7 @@ import { ProviderContractErrorV1, assertMutatingProviderRequestV1, assertObjectStorageCompleteMultipartRequestV1, + createProviderFailureV1, defineObjectStorageExitManifestV1, defineObjectStorageMultipartUploadV1, defineObjectStorageUploadedPartV1, @@ -10,9 +11,12 @@ import { } from '../../src/v1.ts'; import type { ObjectStorageBeginMultipartResultV1, + ObjectStorageMultipartPlanV1, + ObjectStorageMultipartUploadV1, ObjectStorageProviderPortV1, ObjectStoragePutResultV1, ObjectStorageUploadedPartV1, + ProviderOperationV1, } from '../../src/v1.ts'; type BackingKindV1 = 'map' | 'record'; @@ -23,6 +27,29 @@ interface BackingV1 { delete(key: string): boolean; } +interface IdempotencyRecordV1 { + readonly fingerprint: string; + readonly result: T; +} + +function replayV1( + backing: BackingV1>, + key: string, + fingerprint: string, + operation: ProviderOperationV1, +): T | undefined { + const prior = backing.get(key); + if (prior === undefined) return undefined; + if (prior.fingerprint !== fingerprint) { + throw createProviderFailureV1({ + code: 'CONFLICT', + operation, + retryable: false, + }); + } + return prior.result; +} + function createBackingV1(kind: BackingKindV1): BackingV1 { if (kind === 'map') { const values = new Map(); @@ -88,11 +115,36 @@ export function storageFakeV1( backingKind: BackingKindV1, sha256V1: (content: Uint8Array) => string, ): ObjectStorageProviderPortV1 { - const beginReceipts = createBackingV1(backingKind); - const partReceipts = createBackingV1(backingKind); + const beginReceipts = + createBackingV1>(backingKind); + const partReceipts = + createBackingV1>(backingKind); const parts = createBackingV1(backingKind); - const results = createBackingV1(backingKind); + const results = createBackingV1>(backingKind); const objects = createBackingV1(backingKind); + const objectIds = new WeakMap(); + let nextObjectId = 1; + + const objectId = (value: object): number => { + const existing = objectIds.get(value); + if (existing !== undefined) return existing; + const assigned = nextObjectId; + nextObjectId += 1; + objectIds.set(value, assigned); + return assigned; + }; + const planFingerprint = (plan: ObjectStorageMultipartPlanV1): readonly unknown[] => [ + objectId(plan), + plan.objectKey, + plan.expectedSha256, + plan.expectedByteLength, + plan.partSizeBytes, + plan.maximumParts, + ]; + const uploadFingerprint = (upload: ObjectStorageMultipartUploadV1): readonly unknown[] => [ + objectId(upload), + ...planFingerprint(upload.plan), + ]; return { descriptor: () => descriptorV1(adapterKey), @@ -107,19 +159,27 @@ export function storageFakeV1( }, async beginMultipartUpload(request) { const key = assertMutatingProviderRequestV1(request.context); - const prior = beginReceipts.get(key); + const fingerprint = JSON.stringify(['begin', ...planFingerprint(request.plan)]); + const prior = replayV1(beginReceipts, key, fingerprint, 'begin-multipart-upload'); if (prior !== undefined) return prior; const upload = defineObjectStorageMultipartUploadV1({ uploadRef: `upload:${request.plan.objectKey}`, plan: request.plan, }); - beginReceipts.set(key, upload); + beginReceipts.set(key, Object.freeze({ fingerprint, result: upload })); await Promise.resolve(); return upload; }, async uploadPart(request) { const key = assertMutatingProviderRequestV1(request.context); - const prior = partReceipts.get(key); + const fingerprint = JSON.stringify([ + 'upload', + ...uploadFingerprint(request.upload), + request.part.partNumber, + request.part.sha256, + request.part.byteLength, + ]); + const prior = replayV1(partReceipts, key, fingerprint, 'upload-part'); if (prior !== undefined) return prior; const content = request.part.readContent(); if (sha256V1(content) !== request.part.sha256) throw new ProviderContractErrorV1(); @@ -129,14 +189,27 @@ export function storageFakeV1( receiptRef: `part:${request.part.partNumber}`, }); parts.set(`${request.upload.uploadRef}:${request.part.partNumber}`, content); - partReceipts.set(key, receipt); + partReceipts.set(key, Object.freeze({ fingerprint, result: receipt })); await Promise.resolve(); return receipt; }, async completeMultipartUpload(untrustedRequest) { const request = assertObjectStorageCompleteMultipartRequestV1(untrustedRequest); const key = assertMutatingProviderRequestV1(request.context); - const prior = results.get(key); + const fingerprint = JSON.stringify([ + 'complete', + ...uploadFingerprint(request.upload), + request.expectedSha256, + request.expectedByteLength, + request.orderedParts.map((receipt) => [ + objectId(receipt), + receipt.partNumber, + receipt.sha256, + receipt.byteLength, + receipt.receiptRef, + ]), + ]); + const prior = replayV1(results, key, fingerprint, 'complete-multipart-upload'); if (prior !== undefined) return prior; const chunks: Uint8Array[] = []; let byteLength = 0; @@ -161,7 +234,7 @@ export function storageFakeV1( const objectRef = `object:${request.upload.plan.objectKey}`; objects.set(objectRef, content); const result = Object.freeze({ objectRef, sha256, byteLength }); - results.set(key, result); + results.set(key, Object.freeze({ fingerprint, result })); await Promise.resolve(); return result; }, diff --git a/packages/provider-ports/test/interchangeability-v1.test.mjs b/packages/provider-ports/test/interchangeability-v1.test.mjs index 6125fbbd..24462c70 100644 --- a/packages/provider-ports/test/interchangeability-v1.test.mjs +++ b/packages/provider-ports/test/interchangeability-v1.test.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { ProviderContractErrorV1, + ProviderOperationErrorV1, assertProviderInvocationActiveV1, createProviderInvocationContextV1, defineObjectStorageCompleteMultipartRequestV1, @@ -20,6 +21,10 @@ const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url const sha256 = (content) => createHash('sha256').update(content).digest('hex'); +function isIdempotencyConflict(error) { + return error instanceof ProviderOperationErrorV1 && error.code === 'CONFLICT' && !error.retryable; +} + function context(operation, idempotencyKey) { return createProviderInvocationContextV1({ operation, @@ -102,6 +107,128 @@ test('the typechecked behavioral fake recomputes the completed object digest', a await assert.rejects(() => port.completeMultipartUpload(request), ProviderContractErrorV1); }); +test('begin rejects reuse of an idempotency key for a different multipart plan', async () => { + const port = storageFakeV1('begin-conflict-memory-v1', 'map', sha256); + const firstPlan = defineObjectStorageMultipartPlanV1({ + objectKey: 'workspace/begin-conflict-a', + expectedSha256: 'a'.repeat(64), + expectedByteLength: 3, + partSizeBytes: 8 * 1024 * 1024, + }); + const secondPlan = defineObjectStorageMultipartPlanV1({ + objectKey: 'workspace/begin-conflict-b', + expectedSha256: 'b'.repeat(64), + expectedByteLength: 4, + partSizeBytes: 16 * 1024 * 1024, + }); + const firstRequest = { + context: context('begin-multipart-upload', 'idem-begin-conflict'), + plan: firstPlan, + }; + const first = await port.beginMultipartUpload(firstRequest); + assert.equal(await port.beginMultipartUpload(firstRequest), first); + await assert.rejects( + () => + port.beginMultipartUpload({ + context: context('begin-multipart-upload', 'idem-begin-conflict'), + plan: secondPlan, + }), + isIdempotencyConflict, + ); +}); + +test('upload rejects reuse of an idempotency key for another upload or part integrity tuple', async () => { + const port = storageFakeV1('upload-conflict-memory-v1', 'map', sha256); + const plan = defineObjectStorageMultipartPlanV1({ + objectKey: 'workspace/upload-conflict', + expectedSha256: sha256(new Uint8Array([1, 2, 3])), + expectedByteLength: 3, + partSizeBytes: 8 * 1024 * 1024, + }); + const firstUpload = await port.beginMultipartUpload({ + context: context('begin-multipart-upload', 'idem-upload-conflict-begin-a'), + plan, + }); + const secondUpload = await port.beginMultipartUpload({ + context: context('begin-multipart-upload', 'idem-upload-conflict-begin-b'), + plan, + }); + const firstPart = defineObjectStoragePartV1({ + partNumber: 1, + content: new Uint8Array([1, 2, 3]), + sha256: sha256(new Uint8Array([1, 2, 3])), + }); + const secondPart = defineObjectStoragePartV1({ + partNumber: 1, + content: new Uint8Array([1, 2, 4]), + sha256: sha256(new Uint8Array([1, 2, 4])), + }); + const firstRequest = defineObjectStorageUploadPartRequestV1({ + context: context('upload-part', 'idem-upload-conflict'), + upload: firstUpload, + part: firstPart, + }); + const first = await port.uploadPart(firstRequest); + assert.equal(await port.uploadPart(firstRequest), first); + for (const [upload, part] of [ + [secondUpload, firstPart], + [firstUpload, secondPart], + ]) { + await assert.rejects( + () => + port.uploadPart( + defineObjectStorageUploadPartRequestV1({ + context: context('upload-part', 'idem-upload-conflict'), + upload, + part, + }), + ), + isIdempotencyConflict, + ); + } +}); + +test('complete rejects reuse of an idempotency key for another bound upload and receipt list', async () => { + const port = storageFakeV1('complete-conflict-memory-v1', 'map', sha256); + const content = new Uint8Array([1, 2, 3]); + const plan = defineObjectStorageMultipartPlanV1({ + objectKey: 'workspace/complete-conflict', + expectedSha256: sha256(content), + expectedByteLength: content.byteLength, + partSizeBytes: 8 * 1024 * 1024, + }); + const uploads = await Promise.all( + ['a', 'b'].map((suffix) => + port.beginMultipartUpload({ + context: context('begin-multipart-upload', `idem-complete-conflict-begin-${suffix}`), + plan, + }), + ), + ); + const part = defineObjectStoragePartV1({ partNumber: 1, content, sha256: sha256(content) }); + const receipts = await Promise.all( + uploads.map((upload, index) => + port.uploadPart( + defineObjectStorageUploadPartRequestV1({ + context: context('upload-part', `idem-complete-conflict-part-${index}`), + upload, + part, + }), + ), + ), + ); + const requests = uploads.map((upload, index) => + defineObjectStorageCompleteMultipartRequestV1({ + context: context('complete-multipart-upload', 'idem-complete-conflict'), + upload, + orderedParts: [receipts[index]], + }), + ); + const first = await port.completeMultipartUpload(requests[0]); + assert.equal(await port.completeMultipartUpload(requests[0]), first); + await assert.rejects(() => port.completeMultipartUpload(requests[1]), isIdempotencyConflict); +}); + for (const [name, port] of [ ['map-backed adapter', storageFakeV1('map-memory-v1', 'map', sha256)], ['record-backed adapter', storageFakeV1('record-memory-v1', 'record', sha256)], diff --git a/packages/provider-ports/test/review-regressions-v1.test.mjs b/packages/provider-ports/test/review-regressions-v1.test.mjs index 07a9f312..4b6ff193 100644 --- a/packages/provider-ports/test/review-regressions-v1.test.mjs +++ b/packages/provider-ports/test/review-regressions-v1.test.mjs @@ -212,6 +212,13 @@ test('secret references and handles reveal no metadata without their scoped capa }); const handle = ports.defineSecretHandleV1({ reference, expiresAt: '2026-08-01T10:05:00.000Z' }); + assert.equal(ports.isSecretReferenceCapabilityV1(capability), true); + assert.equal(ports.isSecretReferenceIssuerV1(capability.issuer), true); + assert.equal(ports.isSecretReferenceV1(reference), true); + assert.equal(ports.isSecretReferenceForCapabilityV1(capability, reference), true); + assert.equal(ports.isSecretReferenceForCapabilityV1(foreignCapability, reference), false); + assert.equal(ports.assertSecretReferenceCapabilityV1(capability), capability); + assert.equal(ports.assertSecretReferenceForCapabilityV1(capability, reference), reference); assert.deepEqual(capability.resolver.resolve(reference), { namespace: 'production', pathSegments: ['email', 'credential'], @@ -239,6 +246,43 @@ test('secret references and handles reveal no metadata without their scoped capa assert.doesNotMatch(JSON.stringify({ reference, handle }), /production|email|credential|active/u); }); +test('secret provenance checks reject hostile and revoked values without invoking traps', () => { + const marker = 'secret-provenance-marker-X9Y8Z7'; + let trapCalls = 0; + const hostile = new Proxy( + {}, + { + get() { + trapCalls += 1; + throw new Error(marker); + }, + ownKeys() { + trapCalls += 1; + throw new Error(marker); + }, + }, + ); + const { proxy: revoked, revoke } = Proxy.revocable({}, {}); + revoke(); + + for (const value of [undefined, null, 42, {}, hostile, revoked]) { + assert.equal(ports.isSecretReferenceCapabilityV1(value), false); + assert.equal(ports.isSecretReferenceIssuerV1(value), false); + assert.equal(ports.isSecretReferenceV1(value), false); + } + assert.equal(trapCalls, 0); + assert.throws( + () => ports.assertSecretReferenceCapabilityV1(hostile), + (error) => { + assert.ok(error instanceof ports.ProviderContractErrorV1); + assert.doesNotMatch(String(error), new RegExp(marker, 'u')); + assert.doesNotMatch(JSON.stringify(error), new RegExp(marker, 'u')); + assert.doesNotMatch(inspect(error), new RegExp(marker, 'u')); + return true; + }, + ); +}); + test('base ports have no generic arbitrary state export', async () => { const source = await import('node:fs/promises').then((fs) => fs.readFile(new URL('../src/ports-v1.ts', import.meta.url), 'utf8'), From fd6dad78596cce9875c2abc443de27da8d520f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 09:16:36 +0700 Subject: [PATCH 22/51] fix(config): bind multipart replay identity --- .../test/fixtures/storage-fake-v1.ts | 1 + .../test/interchangeability-v1.test.mjs | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/provider-ports/test/fixtures/storage-fake-v1.ts b/packages/provider-ports/test/fixtures/storage-fake-v1.ts index 428630ab..00b9888e 100644 --- a/packages/provider-ports/test/fixtures/storage-fake-v1.ts +++ b/packages/provider-ports/test/fixtures/storage-fake-v1.ts @@ -175,6 +175,7 @@ export function storageFakeV1( const fingerprint = JSON.stringify([ 'upload', ...uploadFingerprint(request.upload), + objectId(request.part), request.part.partNumber, request.part.sha256, request.part.byteLength, diff --git a/packages/provider-ports/test/interchangeability-v1.test.mjs b/packages/provider-ports/test/interchangeability-v1.test.mjs index 24462c70..0fa6f284 100644 --- a/packages/provider-ports/test/interchangeability-v1.test.mjs +++ b/packages/provider-ports/test/interchangeability-v1.test.mjs @@ -188,6 +188,55 @@ test('upload rejects reuse of an idempotency key for another upload or part inte } }); +test('upload binds an idempotency key to the exact immutable part object', async () => { + const port = storageFakeV1('upload-part-identity-memory-v1', 'map', sha256); + const content = new Uint8Array([1, 2, 3]); + const declaredSha256 = sha256(content); + const plan = defineObjectStorageMultipartPlanV1({ + objectKey: 'workspace/upload-part-identity', + expectedSha256: declaredSha256, + expectedByteLength: content.byteLength, + partSizeBytes: 8 * 1024 * 1024, + }); + const upload = await port.beginMultipartUpload({ + context: context('begin-multipart-upload', 'idem-upload-part-identity-begin'), + plan, + }); + const originalPart = defineObjectStoragePartV1({ + partNumber: 1, + content, + sha256: declaredSha256, + }); + const originalRequest = defineObjectStorageUploadPartRequestV1({ + context: context('upload-part', 'idem-upload-part-identity'), + upload, + part: originalPart, + }); + const receipt = await port.uploadPart(originalRequest); + assert.equal(await port.uploadPart(originalRequest), receipt); + + for (const replacementPart of [ + defineObjectStoragePartV1({ partNumber: 1, content, sha256: declaredSha256 }), + defineObjectStoragePartV1({ + partNumber: 1, + content: new Uint8Array([9, 8, 7]), + sha256: declaredSha256, + }), + ]) { + await assert.rejects( + () => + port.uploadPart( + defineObjectStorageUploadPartRequestV1({ + context: context('upload-part', 'idem-upload-part-identity'), + upload, + part: replacementPart, + }), + ), + isIdempotencyConflict, + ); + } +}); + test('complete rejects reuse of an idempotency key for another bound upload and receipt list', async () => { const port = storageFakeV1('complete-conflict-memory-v1', 'map', sha256); const content = new Uint8Array([1, 2, 3]); From 262de95e4746a6c2bd968a6ca57989afee22e0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 09:45:28 +0700 Subject: [PATCH 23/51] feat(i18n): establish complete bilingual catalogs --- packages/i18n/README.md | 23 +- packages/i18n/package.json | 17 + packages/i18n/src/catalogs-v1.ts | 297 ++++++++++++++++++ packages/i18n/src/errors-v1.ts | 34 ++ packages/i18n/src/formatting-v1.ts | 258 +++++++++++++++ packages/i18n/src/locale-v1.ts | 132 ++++++++ packages/i18n/src/messages-v1.ts | 69 ++++ packages/i18n/src/safe-input-v1.ts | 34 ++ packages/i18n/src/v1.ts | 5 + packages/i18n/test/built-public-api-smoke.mjs | 9 + packages/i18n/test/catalogs-v1.test.mjs | 211 +++++++++++++ packages/i18n/test/formatting-v1.test.mjs | 129 ++++++++ packages/i18n/test/locale-v1.test.mjs | 94 ++++++ packages/i18n/test/messages-v1.test.mjs | 82 +++++ packages/i18n/test/public-api-v1.test.mjs | 40 +++ packages/i18n/tsconfig.build.json | 11 + packages/i18n/tsconfig.json | 9 + packages/i18n/turbo.json | 9 + pnpm-lock.yaml | 2 + 19 files changed, 1464 insertions(+), 1 deletion(-) create mode 100644 packages/i18n/package.json create mode 100644 packages/i18n/src/catalogs-v1.ts create mode 100644 packages/i18n/src/errors-v1.ts create mode 100644 packages/i18n/src/formatting-v1.ts create mode 100644 packages/i18n/src/locale-v1.ts create mode 100644 packages/i18n/src/messages-v1.ts create mode 100644 packages/i18n/src/safe-input-v1.ts create mode 100644 packages/i18n/src/v1.ts create mode 100644 packages/i18n/test/built-public-api-smoke.mjs create mode 100644 packages/i18n/test/catalogs-v1.test.mjs create mode 100644 packages/i18n/test/formatting-v1.test.mjs create mode 100644 packages/i18n/test/locale-v1.test.mjs create mode 100644 packages/i18n/test/messages-v1.test.mjs create mode 100644 packages/i18n/test/public-api-v1.test.mjs create mode 100644 packages/i18n/tsconfig.build.json create mode 100644 packages/i18n/tsconfig.json create mode 100644 packages/i18n/turbo.json diff --git a/packages/i18n/README.md b/packages/i18n/README.md index 40bdc280..3e6133f4 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -1,3 +1,24 @@ # Internationalization -Vietnamese-default and complete English catalogs, message identifiers, formatting rules, and cross-platform locale fixtures. +`@databreeze/i18n/v1` is the canonical TypeScript foundation for DataBreeze product language. It provides a bounded bilingual vocabulary, strict text interpolation, locale negotiation, and validated `Intl` formatting helpers. Vietnamese (`vi-VN`) is the exact default and English (`en`) is complete for every published v1 key. + +This package provides partial foundation coverage for IAM-016, WEB-013, WEB-021, WEB-022, DSK-021, AND-017, and NCO-017. It does not claim that future screens or notification templates are already translated. + +## Public contract + +- `MESSAGE_CATALOGS_V1` and `MESSAGE_KEYS_V1` contain stable, versioned keys with identical placeholder schemas in both locales. +- `negotiateLocaleV1` gives a supported user preference priority over `Accept-Language` and safely falls back to `vi-VN`. +- `formatMessageV1` requires every declared `{name}` parameter, rejects undeclared parameters, and performs literal text substitution without parsing HTML. Callers must render the returned string through their platform's text API, never an HTML injection API. +- Date/time formatting always requires an explicit IANA time zone. Decimal, currency, percent, list, relative-time, and plural helpers reject unsupported locales and malformed values instead of silently changing business values. + +## Boundaries + +The package has no runtime dependencies and must not depend on UI frameworks, Web/Desktop shells, Android resources, persistence, IAM services, notification delivery, feature modules, remote translation systems, or provider adapters. Android will consume generated terminology and fixtures in its own implementation task; it does not import this TypeScript package. + +## Expanding the catalogs + +1. Add a stable key to both `vi-VN` and `en` in the same change. Prefer domain-neutral foundation language here; feature-specific copy belongs with the feature registration. +2. Use natural professional Vietnamese, then complete English copy. Do not add a partial fallback, placeholder copy, HTML, controls, or bidirectional formatting characters. +3. Declare every interpolation parameter and its `string` or finite `number` type identically in both locales. +4. Add behavior-focused tests for the new message or formatter case. Run the package tests, typecheck, build self-import, and root repository checks. +5. Preserve existing v1 key semantics. Breaking key, placeholder, or meaning changes require a new versioned export rather than mutating a released contract. diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 00000000..22e5322a --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,17 @@ +{ + "name": "@databreeze/i18n", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./v1": { + "types": "./src/v1.ts", + "import": "./dist/v1.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json && node test/built-public-api-smoke.mjs", + "test": "node --test test/**/*.test.mjs", + "typecheck": "tsc --noEmit --project tsconfig.json" + } +} diff --git a/packages/i18n/src/catalogs-v1.ts b/packages/i18n/src/catalogs-v1.ts new file mode 100644 index 00000000..b34c174a --- /dev/null +++ b/packages/i18n/src/catalogs-v1.ts @@ -0,0 +1,297 @@ +export const I18N_SCHEMA_VERSION_V1 = 1 as const; + +export const SUPPORTED_LOCALES_V1 = Object.freeze(['vi-VN', 'en'] as const); +export type SupportedLocaleV1 = (typeof SUPPORTED_LOCALES_V1)[number]; +export const DEFAULT_LOCALE_V1: SupportedLocaleV1 = 'vi-VN'; + +export type MessageParameterTypeV1 = 'number' | 'string'; + +export interface CatalogMessageV1 { + readonly message: string; + readonly parameters: Readonly>; +} + +function entry( + message: string, + parameters: Readonly> = {}, +): CatalogMessageV1 { + return Object.freeze({ message, parameters: Object.freeze({ ...parameters }) }); +} + +const vietnameseCatalogV1 = { + 'product.name': entry('DataBreeze'), + 'common.yes': entry('Có'), + 'common.no': entry('Không'), + 'common.notAvailable': entry('Không có sẵn'), + 'common.unknown': entry('Chưa xác định'), + 'action.add': entry('Thêm'), + 'action.approve': entry('Phê duyệt'), + 'action.assign': entry('Giao việc'), + 'action.cancel': entry('Hủy'), + 'action.close': entry('Đóng'), + 'action.confirm': entry('Xác nhận'), + 'action.continue': entry('Tiếp tục'), + 'action.create': entry('Tạo'), + 'action.delete': entry('Xóa'), + 'action.edit': entry('Chỉnh sửa'), + 'action.open': entry('Mở'), + 'action.reject': entry('Từ chối'), + 'action.retry': entry('Thử lại'), + 'action.save': entry('Lưu'), + 'action.search': entry('Tìm kiếm'), + 'action.submit': entry('Gửi'), + 'nav.home': entry('Trang chủ'), + 'nav.inbox': entry('Hộp thư đến'), + 'nav.datasets': entry('Bộ dữ liệu'), + 'nav.jobs': entry('Tác vụ'), + 'nav.reviews': entry('Nội dung cần xem xét'), + 'nav.approvals': entry('Nội dung cần phê duyệt'), + 'nav.reports': entry('Báo cáo'), + 'nav.devices': entry('Thiết bị'), + 'nav.audit': entry('Nhật ký kiểm toán'), + 'nav.settings': entry('Cài đặt'), + 'role.owner': entry('Chủ sở hữu'), + 'role.admin': entry('Quản trị viên'), + 'role.analyst': entry('Chuyên viên phân tích'), + 'role.operator': entry('Nhân viên vận hành'), + 'role.approver': entry('Người phê duyệt'), + 'role.viewer': entry('Người xem'), + 'scope.organization': entry('Tổ chức'), + 'scope.workspace': entry('Không gian làm việc'), + 'scope.project': entry('Dự án'), + 'dataMode.local.label': entry('Cục bộ'), + 'dataMode.local.description': entry( + 'Dữ liệu gốc ở lại trên thiết bị được cho phép; chỉ thông tin đã được chính sách cho phép mới đồng bộ.', + ), + 'dataMode.hybrid.label': entry('Kết hợp'), + 'dataMode.hybrid.description': entry( + 'Dữ liệu gốc có thể ở lại trên thiết bị, còn dữ liệu có cấu trúc và kết quả đã chọn có thể đồng bộ.', + ), + 'dataMode.cloud.label': entry('Đám mây'), + 'dataMode.cloud.description': entry( + 'Dữ liệu gốc được cho phép có thể lưu trữ và xử lý an toàn trên hạ tầng đám mây.', + ), + 'job.status.created': entry('Đã tạo'), + 'job.status.queued': entry('Đang chờ xử lý'), + 'job.status.waitingForDevice': entry('Đang chờ thiết bị'), + 'job.status.dispatched': entry('Đã chuyển đến bộ xử lý'), + 'job.status.running': entry('Đang chạy'), + 'job.status.needsReview': entry('Cần xem xét'), + 'job.status.awaitingApproval': entry('Đang chờ phê duyệt'), + 'job.status.succeeded': entry('Đã hoàn thành'), + 'job.status.partiallySucceeded': entry('Hoàn thành một phần'), + 'job.status.failed': entry('Không thành công'), + 'job.status.cancelRequested': entry('Đang yêu cầu hủy'), + 'job.status.cancelled': entry('Đã hủy'), + 'job.status.expired': entry('Đã hết hạn'), + 'review.status.open': entry('Cần xử lý'), + 'review.status.acknowledged': entry('Đã ghi nhận'), + 'review.status.inReview': entry('Đang xem xét'), + 'review.status.resolved': entry('Đã giải quyết'), + 'review.status.dismissed': entry('Đã bỏ qua'), + 'review.status.suppressed': entry('Đã tạm ẩn'), + 'approval.status.pending': entry('Đang chờ quyết định'), + 'approval.status.approved': entry('Đã phê duyệt'), + 'approval.status.rejected': entry('Đã từ chối'), + 'approval.status.expired': entry('Yêu cầu đã hết hạn'), + 'approval.status.invalidated': entry('Phê duyệt không còn hiệu lực'), + 'approval.status.cancelled': entry('Yêu cầu đã hủy'), + 'offline.available': entry('Có thể làm việc ngoại tuyến'), + 'offline.working': entry('Đang làm việc ngoại tuyến'), + 'offline.changesQueued': entry('Thay đổi đang chờ đồng bộ'), + 'offline.requiresConnection': entry('Thao tác này cần kết nối mạng'), + 'sync.idle': entry('Chưa có thay đổi cần đồng bộ'), + 'sync.inProgress': entry('Đang đồng bộ'), + 'sync.complete': entry('Đồng bộ hoàn tất'), + 'sync.paused': entry('Đã tạm dừng đồng bộ'), + 'sync.conflict': entry('Có xung đột cần xử lý'), + 'sync.failed': entry('Đồng bộ chưa thành công'), + 'sync.waitingForNetwork': entry('Đang chờ kết nối mạng'), + 'sync.lastCompletedAt': entry('Đồng bộ gần nhất lúc {time}.', { time: 'string' }), + 'error.generic': entry('Đã xảy ra lỗi. Dữ liệu của bạn vẫn được giữ nguyên.'), + 'error.genericWithCorrelationId': entry('Đã xảy ra lỗi. Mã đối chiếu: {correlationId}.', { + correlationId: 'string', + }), + 'error.invalidRequest': entry('Yêu cầu không hợp lệ. Hãy kiểm tra thông tin và thử lại.'), + 'error.unauthorized': entry('Phiên đăng nhập không hợp lệ. Hãy đăng nhập lại.'), + 'error.forbidden': entry('Bạn không có quyền thực hiện thao tác này.'), + 'error.notFound': entry('Không tìm thấy mục này hoặc bạn không có quyền truy cập.'), + 'error.conflict': entry('Dữ liệu đã thay đổi. Hãy tải lại trước khi tiếp tục.'), + 'error.rateLimited': entry('Đang có quá nhiều yêu cầu. Hãy thử lại sau.'), + 'error.serviceUnavailable': entry( + 'Dịch vụ tạm thời chưa sẵn sàng. Dữ liệu của bạn vẫn được giữ nguyên.', + ), + 'error.networkUnavailable': entry('Không có kết nối mạng. Thay đổi được phép sẽ chờ để đồng bộ.'), + 'error.sourceOffline': entry('Thiết bị chứa dữ liệu gốc hiện không trực tuyến.'), + 'error.sessionExpired': entry('Phiên làm việc đã hết hạn. Hãy đăng nhập lại để tiếp tục.'), + 'retry.now': entry('Thử lại ngay'), + 'retry.later': entry('Hãy thử lại sau. Dữ liệu đã nhập vẫn được giữ nguyên.'), + 'retry.afterSeconds': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), + 'module.folderAutopilot': entry('Folder Autopilot'), + 'module.spreadsheetAuditor': entry('Spreadsheet Auditor'), + 'module.quoteIntelligence': entry('Quote Intelligence'), + 'module.operationsCapture': entry('Operations Capture'), + 'module.invoiceLeakDetector': entry('Invoice Leak Detector'), + 'module.clientReportFactory': entry('Client Report Factory'), + 'module.privateDataAnalyst': entry('Private Data Analyst'), + 'module.migrationReady': entry('Migration Ready'), + 'module.dataQualityGuard': entry('Data Quality Guard'), + 'module.embeddedImporter': entry('Embedded Importer'), + 'accessibility.mainNavigation': entry('Điều hướng chính'), + 'accessibility.loading': entry('Đang tải nội dung'), + 'accessibility.requiredField': entry('Trường bắt buộc'), + 'accessibility.progressLabel': entry('Tiến độ: {current} trên {total}.', { + current: 'number', + total: 'number', + }), + 'status.ready': entry('Sẵn sàng'), + 'status.inProgress': entry('Đang thực hiện'), + 'status.completed': entry('Đã hoàn tất'), +} as const; + +export type MessageKeyV1 = keyof typeof vietnameseCatalogV1; +export type MessageCatalogV1 = Readonly>; + +const englishCatalogV1: MessageCatalogV1 = { + 'product.name': entry('DataBreeze'), + 'common.yes': entry('Yes'), + 'common.no': entry('No'), + 'common.notAvailable': entry('Not available'), + 'common.unknown': entry('Unknown'), + 'action.add': entry('Add'), + 'action.approve': entry('Approve'), + 'action.assign': entry('Assign'), + 'action.cancel': entry('Cancel'), + 'action.close': entry('Close'), + 'action.confirm': entry('Confirm'), + 'action.continue': entry('Continue'), + 'action.create': entry('Create'), + 'action.delete': entry('Delete'), + 'action.edit': entry('Edit'), + 'action.open': entry('Open'), + 'action.reject': entry('Reject'), + 'action.retry': entry('Retry'), + 'action.save': entry('Save'), + 'action.search': entry('Search'), + 'action.submit': entry('Submit'), + 'nav.home': entry('Home'), + 'nav.inbox': entry('Inbox'), + 'nav.datasets': entry('Datasets'), + 'nav.jobs': entry('Jobs'), + 'nav.reviews': entry('Reviews'), + 'nav.approvals': entry('Approvals'), + 'nav.reports': entry('Reports'), + 'nav.devices': entry('Devices'), + 'nav.audit': entry('Audit log'), + 'nav.settings': entry('Settings'), + 'role.owner': entry('Owner'), + 'role.admin': entry('Admin'), + 'role.analyst': entry('Analyst'), + 'role.operator': entry('Operator'), + 'role.approver': entry('Approver'), + 'role.viewer': entry('Viewer'), + 'scope.organization': entry('Organization'), + 'scope.workspace': entry('Workspace'), + 'scope.project': entry('Project'), + 'dataMode.local.label': entry('Local'), + 'dataMode.local.description': entry( + 'Original data stays on an approved device; only policy-approved information synchronizes.', + ), + 'dataMode.hybrid.label': entry('Hybrid'), + 'dataMode.hybrid.description': entry( + 'Original data can remain on a device while selected structured data and results synchronize.', + ), + 'dataMode.cloud.label': entry('Cloud'), + 'dataMode.cloud.description': entry( + 'Authorized original data can be stored and processed securely in cloud infrastructure.', + ), + 'job.status.created': entry('Created'), + 'job.status.queued': entry('Queued'), + 'job.status.waitingForDevice': entry('Waiting for device'), + 'job.status.dispatched': entry('Sent to processor'), + 'job.status.running': entry('Running'), + 'job.status.needsReview': entry('Needs review'), + 'job.status.awaitingApproval': entry('Awaiting approval'), + 'job.status.succeeded': entry('Succeeded'), + 'job.status.partiallySucceeded': entry('Partially succeeded'), + 'job.status.failed': entry('Failed'), + 'job.status.cancelRequested': entry('Cancellation requested'), + 'job.status.cancelled': entry('Cancelled'), + 'job.status.expired': entry('Expired'), + 'review.status.open': entry('Open'), + 'review.status.acknowledged': entry('Acknowledged'), + 'review.status.inReview': entry('In review'), + 'review.status.resolved': entry('Resolved'), + 'review.status.dismissed': entry('Dismissed'), + 'review.status.suppressed': entry('Suppressed'), + 'approval.status.pending': entry('Decision pending'), + 'approval.status.approved': entry('Approved'), + 'approval.status.rejected': entry('Rejected'), + 'approval.status.expired': entry('Request expired'), + 'approval.status.invalidated': entry('Approval invalidated'), + 'approval.status.cancelled': entry('Request cancelled'), + 'offline.available': entry('Available offline'), + 'offline.working': entry('Working offline'), + 'offline.changesQueued': entry('Changes are queued for sync'), + 'offline.requiresConnection': entry('This action requires a network connection'), + 'sync.idle': entry('No changes to synchronize'), + 'sync.inProgress': entry('Synchronizing'), + 'sync.complete': entry('Sync complete'), + 'sync.paused': entry('Sync paused'), + 'sync.conflict': entry('Conflict needs attention'), + 'sync.failed': entry('Sync did not complete'), + 'sync.waitingForNetwork': entry('Waiting for a network connection'), + 'sync.lastCompletedAt': entry('Last synchronized at {time}.', { time: 'string' }), + 'error.generic': entry('Something went wrong. Your data has been preserved.'), + 'error.genericWithCorrelationId': entry( + 'Something went wrong. Reference code: {correlationId}.', + { + correlationId: 'string', + }, + ), + 'error.invalidRequest': entry('The request is invalid. Check the information and try again.'), + 'error.unauthorized': entry('Your sign-in is no longer valid. Sign in again.'), + 'error.forbidden': entry('You do not have permission to perform this action.'), + 'error.notFound': entry('This item was not found or you do not have access.'), + 'error.conflict': entry('The data changed. Reload before continuing.'), + 'error.rateLimited': entry('There are too many requests. Try again later.'), + 'error.serviceUnavailable': entry( + 'The service is temporarily unavailable. Your data has been preserved.', + ), + 'error.networkUnavailable': entry( + 'There is no network connection. Allowed changes will wait to sync.', + ), + 'error.sourceOffline': entry('The device containing the original data is offline.'), + 'error.sessionExpired': entry('Your session expired. Sign in again to continue.'), + 'retry.now': entry('Try again now'), + 'retry.later': entry('Try again later. Your entered data has been preserved.'), + 'retry.afterSeconds': entry('Try again in {seconds} seconds.', { seconds: 'number' }), + 'module.folderAutopilot': entry('Folder Autopilot'), + 'module.spreadsheetAuditor': entry('Spreadsheet Auditor'), + 'module.quoteIntelligence': entry('Quote Intelligence'), + 'module.operationsCapture': entry('Operations Capture'), + 'module.invoiceLeakDetector': entry('Invoice Leak Detector'), + 'module.clientReportFactory': entry('Client Report Factory'), + 'module.privateDataAnalyst': entry('Private Data Analyst'), + 'module.migrationReady': entry('Migration Ready'), + 'module.dataQualityGuard': entry('Data Quality Guard'), + 'module.embeddedImporter': entry('Embedded Importer'), + 'accessibility.mainNavigation': entry('Main navigation'), + 'accessibility.loading': entry('Loading content'), + 'accessibility.requiredField': entry('Required field'), + 'accessibility.progressLabel': entry('Progress: {current} of {total}.', { + current: 'number', + total: 'number', + }), + 'status.ready': entry('Ready'), + 'status.inProgress': entry('In progress'), + 'status.completed': entry('Completed'), +}; + +export const MESSAGE_KEYS_V1 = Object.freeze(Object.keys(vietnameseCatalogV1) as MessageKeyV1[]); + +export const MESSAGE_CATALOGS_V1: Readonly> = + Object.freeze({ + 'vi-VN': Object.freeze(vietnameseCatalogV1), + en: Object.freeze(englishCatalogV1), + }); diff --git a/packages/i18n/src/errors-v1.ts b/packages/i18n/src/errors-v1.ts new file mode 100644 index 00000000..565b645b --- /dev/null +++ b/packages/i18n/src/errors-v1.ts @@ -0,0 +1,34 @@ +export type I18nErrorCodeV1 = + | 'EXTRA_PARAMETER' + | 'INVALID_ARGUMENT' + | 'INVALID_CURRENCY' + | 'INVALID_DATE' + | 'INVALID_LOCALE' + | 'INVALID_NUMBER' + | 'INVALID_PARAMETER' + | 'INVALID_TIME_ZONE' + | 'MISSING_MESSAGE' + | 'MISSING_PARAMETER'; + +const ERROR_MESSAGES_V1: Readonly> = Object.freeze({ + EXTRA_PARAMETER: 'The message received an undeclared parameter.', + INVALID_ARGUMENT: 'The internationalization argument is invalid.', + INVALID_CURRENCY: 'The currency code is not supported.', + INVALID_DATE: 'The date value is invalid.', + INVALID_LOCALE: 'The locale is not supported.', + INVALID_NUMBER: 'The numeric value must be finite.', + INVALID_PARAMETER: 'The message parameter has an invalid value.', + INVALID_TIME_ZONE: 'An explicit supported time zone is required.', + MISSING_MESSAGE: 'The message key is not present in the catalog.', + MISSING_PARAMETER: 'A required message parameter is missing.', +}); + +export class I18nErrorV1 extends Error { + readonly code: I18nErrorCodeV1; + + constructor(code: I18nErrorCodeV1) { + super(ERROR_MESSAGES_V1[code]); + this.name = 'I18nErrorV1'; + this.code = code; + } +} diff --git a/packages/i18n/src/formatting-v1.ts b/packages/i18n/src/formatting-v1.ts new file mode 100644 index 00000000..c17ec023 --- /dev/null +++ b/packages/i18n/src/formatting-v1.ts @@ -0,0 +1,258 @@ +import type { SupportedLocaleV1 } from './catalogs-v1.ts'; +import { I18nErrorV1 } from './errors-v1.ts'; +import { assertSupportedLocaleV1 } from './locale-v1.ts'; +import { readClosedDataObjectV1 } from './safe-input-v1.ts'; + +const FRACTION_KEYS_V1 = new Set([ + 'locale', + 'maximumFractionDigits', + 'minimumFractionDigits', + 'useGrouping', +]); +const CURRENCY_KEYS_V1 = new Set([...FRACTION_KEYS_V1, 'currency', 'currencyDisplay']); +const DATE_TIME_KEYS_V1 = new Set(['dateStyle', 'hour12', 'locale', 'timeStyle', 'timeZone']); +const LIST_KEYS_V1 = new Set(['locale', 'style', 'type']); +const RELATIVE_TIME_KEYS_V1 = new Set(['locale', 'numeric', 'style']); +const PLURAL_KEYS_V1 = new Set(['locale', 'type']); +const CURRENCY_CODES_V1 = new Set(Intl.supportedValuesOf('currency')); +const RELATIVE_TIME_UNITS_V1 = new Set([ + 'day', + 'hour', + 'minute', + 'month', + 'quarter', + 'second', + 'week', + 'year', +]); + +interface FractionOptionsV1 { + readonly locale: SupportedLocaleV1; + readonly minimumFractionDigits?: number; + readonly maximumFractionDigits?: number; + readonly useGrouping?: boolean; +} + +export type DecimalFormatOptionsV1 = FractionOptionsV1; + +export interface CurrencyFormatOptionsV1 extends FractionOptionsV1 { + readonly currency: string; + readonly currencyDisplay?: 'code' | 'name' | 'narrowSymbol' | 'symbol'; +} + +export type PercentFormatOptionsV1 = FractionOptionsV1; + +export interface DateTimeFormatOptionsV1 { + readonly locale: SupportedLocaleV1; + readonly timeZone: string; + readonly dateStyle?: 'full' | 'long' | 'medium' | 'short'; + readonly timeStyle?: 'full' | 'long' | 'medium' | 'short'; + readonly hour12?: boolean; +} + +export interface ListFormatOptionsV1 { + readonly locale: SupportedLocaleV1; + readonly type?: 'conjunction' | 'disjunction' | 'unit'; + readonly style?: 'long' | 'narrow' | 'short'; +} + +export interface RelativeTimeFormatOptionsV1 { + readonly locale: SupportedLocaleV1; + readonly numeric?: 'always' | 'auto'; + readonly style?: 'long' | 'narrow' | 'short'; +} + +export interface PluralFormatOptionsV1 { + readonly locale: SupportedLocaleV1; + readonly type?: 'cardinal' | 'ordinal'; +} + +function requiredLocale(options: Readonly>): SupportedLocaleV1 { + const locale = options['locale']; + assertSupportedLocaleV1(locale); + return locale; +} + +function finiteNumber(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new I18nErrorV1('INVALID_NUMBER'); + } + return value; +} + +function optionalEnum(value: unknown, values: readonly T[]): T | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string' || !values.includes(value as T)) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + return value as T; +} + +function optionalBoolean(value: unknown): boolean | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'boolean') { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + return value; +} + +function optionalFractionDigit(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 20) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + return value; +} + +function isStringListValue(value: unknown): boolean { + return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string'); +} + +function numberFormatOptions( + input: unknown, + allowedKeys: ReadonlySet, +): { readonly locale: SupportedLocaleV1; readonly options: Intl.NumberFormatOptions } { + const source = readClosedDataObjectV1(input, allowedKeys); + const locale = requiredLocale(source); + const minimumFractionDigits = optionalFractionDigit(source['minimumFractionDigits']); + const maximumFractionDigits = optionalFractionDigit(source['maximumFractionDigits']); + if ( + minimumFractionDigits !== undefined && + maximumFractionDigits !== undefined && + minimumFractionDigits > maximumFractionDigits + ) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + const useGrouping = optionalBoolean(source['useGrouping']); + return { + locale, + options: { + ...(minimumFractionDigits === undefined ? {} : { minimumFractionDigits }), + ...(maximumFractionDigits === undefined ? {} : { maximumFractionDigits }), + ...(useGrouping === undefined ? {} : { useGrouping }), + }, + }; +} + +export function formatDateTimeV1(value: number | Date, input: DateTimeFormatOptionsV1): string { + const source = readClosedDataObjectV1(input, DATE_TIME_KEYS_V1); + const locale = requiredLocale(source); + const rawTimeZone = source['timeZone']; + if (typeof rawTimeZone !== 'string' || rawTimeZone.trim() === '') { + throw new I18nErrorV1('INVALID_TIME_ZONE'); + } + const timestamp = value instanceof Date ? value.getTime() : value; + if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { + throw new I18nErrorV1('INVALID_DATE'); + } + const date = new Date(timestamp); + if (!Number.isFinite(date.getTime())) { + throw new I18nErrorV1('INVALID_DATE'); + } + const dateStyle = optionalEnum(source['dateStyle'], ['full', 'long', 'medium', 'short']); + const timeStyle = optionalEnum(source['timeStyle'], ['full', 'long', 'medium', 'short']); + const hour12 = optionalBoolean(source['hour12']); + try { + return new Intl.DateTimeFormat(locale, { + timeZone: rawTimeZone, + dateStyle: dateStyle ?? 'medium', + timeStyle: timeStyle ?? 'short', + ...(hour12 === undefined ? {} : { hour12 }), + }).format(date); + } catch { + throw new I18nErrorV1('INVALID_TIME_ZONE'); + } +} + +export function formatDecimalV1(value: number, input: DecimalFormatOptionsV1): string { + const { locale, options } = numberFormatOptions(input, FRACTION_KEYS_V1); + return new Intl.NumberFormat(locale, { ...options, style: 'decimal' }).format( + finiteNumber(value), + ); +} + +export function formatCurrencyV1(value: number, input: CurrencyFormatOptionsV1): string { + const source = readClosedDataObjectV1(input, CURRENCY_KEYS_V1); + const { locale, options } = numberFormatOptions(source, CURRENCY_KEYS_V1); + const currency = source['currency']; + if ( + typeof currency !== 'string' || + !/^[A-Z]{3}$/u.test(currency) || + !CURRENCY_CODES_V1.has(currency) + ) { + throw new I18nErrorV1('INVALID_CURRENCY'); + } + const currencyDisplay = optionalEnum(source['currencyDisplay'], [ + 'code', + 'name', + 'narrowSymbol', + 'symbol', + ]); + return new Intl.NumberFormat(locale, { + ...options, + style: 'currency', + currency, + ...(currencyDisplay === undefined ? {} : { currencyDisplay }), + }).format(finiteNumber(value)); +} + +export function formatPercentV1(value: number, input: PercentFormatOptionsV1): string { + const { locale, options } = numberFormatOptions(input, FRACTION_KEYS_V1); + const maximumFractionDigits = + options.maximumFractionDigits ?? Math.max(options.minimumFractionDigits ?? 0, 3); + return new Intl.NumberFormat(locale, { + ...options, + maximumFractionDigits, + style: 'percent', + }).format(finiteNumber(value)); +} + +export function formatListV1(values: readonly string[], input: ListFormatOptionsV1): string { + const source = readClosedDataObjectV1(input, LIST_KEYS_V1); + const locale = requiredLocale(source); + if (!isStringListValue(values)) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + const type = optionalEnum(source['type'], ['conjunction', 'disjunction', 'unit']); + const style = optionalEnum(source['style'], ['long', 'narrow', 'short']); + return new Intl.ListFormat(locale, { + ...(type === undefined ? {} : { type }), + ...(style === undefined ? {} : { style }), + }).format(Array.from(values)); +} + +export function formatRelativeTimeV1( + value: number, + unit: Intl.RelativeTimeFormatUnit, + input: RelativeTimeFormatOptionsV1, +): string { + const source = readClosedDataObjectV1(input, RELATIVE_TIME_KEYS_V1); + const locale = requiredLocale(source); + if (typeof unit !== 'string' || !RELATIVE_TIME_UNITS_V1.has(unit)) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + const numeric = optionalEnum(source['numeric'], ['always', 'auto']); + const style = optionalEnum(source['style'], ['long', 'narrow', 'short']); + return new Intl.RelativeTimeFormat(locale, { + ...(numeric === undefined ? {} : { numeric }), + ...(style === undefined ? {} : { style }), + }).format(finiteNumber(value), unit); +} + +export function selectPluralCategoryV1( + value: number, + input: PluralFormatOptionsV1, +): Intl.LDMLPluralRule { + const source = readClosedDataObjectV1(input, PLURAL_KEYS_V1); + const locale = requiredLocale(source); + const type = optionalEnum(source['type'], ['cardinal', 'ordinal']); + return new Intl.PluralRules(locale, type === undefined ? {} : { type }).select( + finiteNumber(value), + ); +} diff --git a/packages/i18n/src/locale-v1.ts b/packages/i18n/src/locale-v1.ts new file mode 100644 index 00000000..a5f999a8 --- /dev/null +++ b/packages/i18n/src/locale-v1.ts @@ -0,0 +1,132 @@ +import { DEFAULT_LOCALE_V1, SUPPORTED_LOCALES_V1, type SupportedLocaleV1 } from './catalogs-v1.ts'; +import { I18nErrorV1 } from './errors-v1.ts'; +import { readClosedDataObjectV1 } from './safe-input-v1.ts'; + +const NEGOTIATION_KEYS_V1 = new Set(['acceptLanguage', 'userLocale']); +const Q_VALUE_V1 = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u; +const LANGUAGE_TAG_V1 = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u; + +interface CandidateV1 { + readonly locale: SupportedLocaleV1 | '*'; + readonly quality: number; + readonly order: number; +} + +function supportedLocaleForTag(tag: string): SupportedLocaleV1 | undefined { + const trimmed = tag.trim(); + if (!LANGUAGE_TAG_V1.test(trimmed)) { + return undefined; + } + const language = trimmed.split('-', 1)[0]?.toLowerCase(); + if (language === 'vi') { + return 'vi-VN'; + } + if (language === 'en') { + return 'en'; + } + return undefined; +} + +function parseCandidate(part: string, order: number): CandidateV1 | undefined { + const sections = part.split(';').map((section) => section.trim()); + if (sections.length > 2 || sections[0] === '') { + return undefined; + } + const tag = sections[0]; + let quality = 1; + if (sections.length === 2) { + const match = /^q=(.+)$/iu.exec(sections[1] ?? ''); + if (match === null || !Q_VALUE_V1.test(match[1] ?? '')) { + return undefined; + } + quality = Number(match[1]); + } + if (tag === '*') { + return { locale: '*', quality, order }; + } + const locale = supportedLocaleForTag(tag ?? ''); + return locale === undefined ? undefined : { locale, quality, order }; +} + +function negotiateHeader(header: unknown): SupportedLocaleV1 { + if (typeof header !== 'string' || header.length === 0 || header.length > 8_192) { + return DEFAULT_LOCALE_V1; + } + + const candidates = header + .split(',') + .slice(0, 64) + .map(parseCandidate) + .filter((candidate): candidate is CandidateV1 => candidate !== undefined); + const bestByLocale = new Map(); + for (const candidate of candidates) { + const current = bestByLocale.get(candidate.locale); + if ( + current === undefined || + candidate.quality > current.quality || + (candidate.quality === current.quality && candidate.order < current.order) + ) { + bestByLocale.set(candidate.locale, candidate); + } + } + + const excluded = new Set( + [...bestByLocale.values()] + .filter( + (candidate): candidate is CandidateV1 & { readonly locale: SupportedLocaleV1 } => + candidate.locale !== '*' && candidate.quality === 0, + ) + .map((candidate) => candidate.locale), + ); + const ranked = [...bestByLocale.values()] + .filter((candidate) => candidate.quality > 0) + .sort((left, right) => right.quality - left.quality || left.order - right.order); + for (const candidate of ranked) { + if (candidate.locale !== '*') { + if (!excluded.has(candidate.locale)) { + return candidate.locale; + } + continue; + } + const wildcardLocale = SUPPORTED_LOCALES_V1.find((locale) => !excluded.has(locale)); + if (wildcardLocale !== undefined) { + return wildcardLocale; + } + } + return DEFAULT_LOCALE_V1; +} + +export interface LocaleNegotiationInputV1 { + readonly userLocale?: unknown; + readonly acceptLanguage?: unknown; +} + +export function negotiateLocaleV1(input?: unknown): SupportedLocaleV1 { + if (typeof input === 'string') { + return negotiateHeader(input); + } + if (input === undefined || input === null) { + return DEFAULT_LOCALE_V1; + } + + let negotiation: Readonly>; + try { + negotiation = readClosedDataObjectV1(input, NEGOTIATION_KEYS_V1); + } catch { + return DEFAULT_LOCALE_V1; + } + const userLocale = negotiation['userLocale']; + if (typeof userLocale === 'string') { + const preferred = supportedLocaleForTag(userLocale); + if (preferred !== undefined) { + return preferred; + } + } + return negotiateHeader(negotiation['acceptLanguage']); +} + +export function assertSupportedLocaleV1(locale: unknown): asserts locale is SupportedLocaleV1 { + if (locale !== 'vi-VN' && locale !== 'en') { + throw new I18nErrorV1('INVALID_LOCALE'); + } +} diff --git a/packages/i18n/src/messages-v1.ts b/packages/i18n/src/messages-v1.ts new file mode 100644 index 00000000..3e9a0a67 --- /dev/null +++ b/packages/i18n/src/messages-v1.ts @@ -0,0 +1,69 @@ +import { + MESSAGE_CATALOGS_V1, + MESSAGE_KEYS_V1, + type MessageKeyV1, + type MessageParameterTypeV1, + type SupportedLocaleV1, +} from './catalogs-v1.ts'; +import { I18nErrorV1 } from './errors-v1.ts'; +import { assertSupportedLocaleV1 } from './locale-v1.ts'; +import { readClosedDataObjectV1 } from './safe-input-v1.ts'; + +const MESSAGE_KEY_SET_V1 = new Set(MESSAGE_KEYS_V1); +const PLACEHOLDER_V1 = /\{([A-Za-z][A-Za-z0-9]*)\}/gu; + +function assertParameterValue(type: MessageParameterTypeV1, value: unknown): void { + if (type === 'string' && typeof value === 'string') { + return; + } + if (type === 'number' && typeof value === 'number' && Number.isFinite(value)) { + return; + } + throw new I18nErrorV1('INVALID_PARAMETER'); +} + +export function formatMessageV1( + locale: SupportedLocaleV1, + key: MessageKeyV1, + parameters: unknown = {}, +): string { + assertSupportedLocaleV1(locale); + if (typeof key !== 'string' || !MESSAGE_KEY_SET_V1.has(key)) { + throw new I18nErrorV1('MISSING_MESSAGE'); + } + const catalogMessage = MESSAGE_CATALOGS_V1[locale][key]; + const parameterNames = Object.keys(catalogMessage.parameters); + let safeParameters: Readonly>; + try { + safeParameters = readClosedDataObjectV1(parameters, new Set(parameterNames)); + } catch (error) { + if ( + error instanceof I18nErrorV1 && + error.code === 'INVALID_ARGUMENT' && + parameters !== null && + typeof parameters === 'object' + ) { + let keys: PropertyKey[] = []; + try { + keys = Reflect.ownKeys(parameters); + } catch { + throw error; + } + if (keys.some((parameterName) => !parameterNames.includes(String(parameterName)))) { + throw new I18nErrorV1('EXTRA_PARAMETER'); + } + } + throw error; + } + + for (const parameterName of parameterNames) { + if (!Object.hasOwn(safeParameters, parameterName)) { + throw new I18nErrorV1('MISSING_PARAMETER'); + } + assertParameterValue(catalogMessage.parameters[parameterName]!, safeParameters[parameterName]); + } + + return catalogMessage.message.replace(PLACEHOLDER_V1, (_placeholder, parameterName: string) => + String(safeParameters[parameterName]), + ); +} diff --git a/packages/i18n/src/safe-input-v1.ts b/packages/i18n/src/safe-input-v1.ts new file mode 100644 index 00000000..4fab4566 --- /dev/null +++ b/packages/i18n/src/safe-input-v1.ts @@ -0,0 +1,34 @@ +import { I18nErrorV1 } from './errors-v1.ts'; + +export function readClosedDataObjectV1( + value: unknown, + allowedKeys: ReadonlySet, +): Readonly> { + try { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + + const result: Record = Object.create(null) as Record; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !allowedKeys.has(key)) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !Object.hasOwn(descriptor, 'value')) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + result[key] = descriptor.value; + } + return result; + } catch (error) { + if (error instanceof I18nErrorV1) { + throw error; + } + throw new I18nErrorV1('INVALID_ARGUMENT'); + } +} diff --git a/packages/i18n/src/v1.ts b/packages/i18n/src/v1.ts new file mode 100644 index 00000000..c8daaca6 --- /dev/null +++ b/packages/i18n/src/v1.ts @@ -0,0 +1,5 @@ +export * from './catalogs-v1.ts'; +export * from './errors-v1.ts'; +export * from './formatting-v1.ts'; +export * from './locale-v1.ts'; +export * from './messages-v1.ts'; diff --git a/packages/i18n/test/built-public-api-smoke.mjs b/packages/i18n/test/built-public-api-smoke.mjs new file mode 100644 index 00000000..2a57a45c --- /dev/null +++ b/packages/i18n/test/built-public-api-smoke.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; + +const api = await import('../dist/v1.js'); + +assert.equal(api.I18N_SCHEMA_VERSION_V1, 1); +assert.equal(api.DEFAULT_LOCALE_V1, 'vi-VN'); +assert.equal(api.negotiateLocaleV1('en-US'), 'en'); +assert.equal(api.formatMessageV1('vi-VN', 'action.save'), 'Lưu'); +assert.equal(typeof api.formatCurrencyV1, 'function'); diff --git a/packages/i18n/test/catalogs-v1.test.mjs b/packages/i18n/test/catalogs-v1.test.mjs new file mode 100644 index 00000000..cc68cc72 --- /dev/null +++ b/packages/i18n/test/catalogs-v1.test.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +const REQUIRED_KEYS = Object.freeze([ + 'product.name', + 'common.yes', + 'common.no', + 'common.notAvailable', + 'common.unknown', + 'action.add', + 'action.approve', + 'action.assign', + 'action.cancel', + 'action.close', + 'action.confirm', + 'action.continue', + 'action.create', + 'action.delete', + 'action.edit', + 'action.open', + 'action.reject', + 'action.retry', + 'action.save', + 'action.search', + 'action.submit', + 'nav.home', + 'nav.inbox', + 'nav.datasets', + 'nav.jobs', + 'nav.reviews', + 'nav.approvals', + 'nav.reports', + 'nav.devices', + 'nav.audit', + 'nav.settings', + 'role.owner', + 'role.admin', + 'role.analyst', + 'role.operator', + 'role.approver', + 'role.viewer', + 'scope.organization', + 'scope.workspace', + 'scope.project', + 'dataMode.local.label', + 'dataMode.local.description', + 'dataMode.hybrid.label', + 'dataMode.hybrid.description', + 'dataMode.cloud.label', + 'dataMode.cloud.description', + 'job.status.created', + 'job.status.queued', + 'job.status.waitingForDevice', + 'job.status.dispatched', + 'job.status.running', + 'job.status.needsReview', + 'job.status.awaitingApproval', + 'job.status.succeeded', + 'job.status.partiallySucceeded', + 'job.status.failed', + 'job.status.cancelRequested', + 'job.status.cancelled', + 'job.status.expired', + 'review.status.open', + 'review.status.acknowledged', + 'review.status.inReview', + 'review.status.resolved', + 'review.status.dismissed', + 'review.status.suppressed', + 'approval.status.pending', + 'approval.status.approved', + 'approval.status.rejected', + 'approval.status.expired', + 'approval.status.invalidated', + 'approval.status.cancelled', + 'offline.available', + 'offline.working', + 'offline.changesQueued', + 'offline.requiresConnection', + 'sync.idle', + 'sync.inProgress', + 'sync.complete', + 'sync.paused', + 'sync.conflict', + 'sync.failed', + 'sync.waitingForNetwork', + 'sync.lastCompletedAt', + 'error.generic', + 'error.genericWithCorrelationId', + 'error.invalidRequest', + 'error.unauthorized', + 'error.forbidden', + 'error.notFound', + 'error.conflict', + 'error.rateLimited', + 'error.serviceUnavailable', + 'error.networkUnavailable', + 'error.sourceOffline', + 'error.sessionExpired', + 'retry.now', + 'retry.later', + 'retry.afterSeconds', + 'module.folderAutopilot', + 'module.spreadsheetAuditor', + 'module.quoteIntelligence', + 'module.operationsCapture', + 'module.invoiceLeakDetector', + 'module.clientReportFactory', + 'module.privateDataAnalyst', + 'module.migrationReady', + 'module.dataQualityGuard', + 'module.embeddedImporter', + 'accessibility.mainNavigation', + 'accessibility.loading', + 'accessibility.requiredField', + 'accessibility.progressLabel', + 'status.ready', + 'status.inProgress', + 'status.completed', +]); + +function placeholders(message) { + return [...message.matchAll(/\{([A-Za-z][A-Za-z0-9]*)\}/gu)].map((match) => match[1]); +} + +function assertDeeplyFrozen(value) { + assert.equal(Object.isFrozen(value), true); + for (const child of Object.values(value)) { + if (child !== null && typeof child === 'object') { + assertDeeplyFrozen(child); + } + } +} + +test('[IAM-016, WEB-013, DSK-021, AND-017, NCO-017] catalogs contain the complete bounded v1 vocabulary without fallback gaps', async () => { + const { MESSAGE_CATALOGS_V1, MESSAGE_KEYS_V1 } = await import('../src/v1.ts'); + const viKeys = Object.keys(MESSAGE_CATALOGS_V1['vi-VN']); + const enKeys = Object.keys(MESSAGE_CATALOGS_V1.en); + + assert.deepEqual(MESSAGE_KEYS_V1, REQUIRED_KEYS); + assert.deepEqual(viKeys, REQUIRED_KEYS); + assert.deepEqual(enKeys, REQUIRED_KEYS); +}); + +test('catalog messages and placeholder schemas are equivalent across locales', async () => { + const { MESSAGE_CATALOGS_V1, MESSAGE_KEYS_V1 } = await import('../src/v1.ts'); + + for (const key of MESSAGE_KEYS_V1) { + const vi = MESSAGE_CATALOGS_V1['vi-VN'][key]; + const en = MESSAGE_CATALOGS_V1.en[key]; + assert.deepEqual(vi.parameters, en.parameters, `${key} must use the same parameter types`); + assert.deepEqual( + [...new Set(placeholders(vi.message))].sort(), + Object.keys(vi.parameters).sort(), + `${key} Vietnamese placeholders must be declared`, + ); + assert.deepEqual( + [...new Set(placeholders(en.message))].sort(), + Object.keys(en.parameters).sort(), + `${key} English placeholders must be declared`, + ); + } +}); + +test('catalogs are deeply immutable and resist mutation', async () => { + const { MESSAGE_CATALOGS_V1, MESSAGE_KEYS_V1 } = await import('../src/v1.ts'); + + assertDeeplyFrozen(MESSAGE_CATALOGS_V1); + assertDeeplyFrozen(MESSAGE_KEYS_V1); + const before = MESSAGE_CATALOGS_V1['vi-VN']['action.save'].message; + assert.throws(() => { + MESSAGE_CATALOGS_V1['vi-VN']['action.save'].message = 'Thay đổi'; + }, TypeError); + assert.equal(MESSAGE_CATALOGS_V1['vi-VN']['action.save'].message, before); +}); + +test('catalog copy is normalized, non-empty, plain text, and free of placeholders for unfinished work', async () => { + const { MESSAGE_CATALOGS_V1, MESSAGE_KEYS_V1 } = await import('../src/v1.ts'); + const forbiddenText = + /(?:<|>|\p{Cc}|[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]|\b(?:TODO|TBD|FIXME|lorem ipsum)\b)/iu; + + for (const locale of ['vi-VN', 'en']) { + for (const key of MESSAGE_KEYS_V1) { + const message = MESSAGE_CATALOGS_V1[locale][key].message; + assert.equal(message, message.normalize('NFC'), `${locale}:${key} must be NFC`); + assert.equal(message.trim().length > 0, true, `${locale}:${key} must not be empty`); + assert.doesNotMatch(message, forbiddenText, `${locale}:${key} must remain safe plain text`); + } + } +}); + +test('Vietnamese foundation copy is primary professional copy rather than an English fallback', async () => { + const { MESSAGE_CATALOGS_V1, MESSAGE_KEYS_V1 } = await import('../src/v1.ts'); + const canonicalNames = new Set([ + 'product.name', + ...MESSAGE_KEYS_V1.filter((key) => key.startsWith('module.')), + ]); + + for (const key of MESSAGE_KEYS_V1) { + if (!canonicalNames.has(key)) { + assert.notEqual( + MESSAGE_CATALOGS_V1['vi-VN'][key].message, + MESSAGE_CATALOGS_V1.en[key].message, + `${key} must not silently fall back to English`, + ); + } + } + assert.equal(MESSAGE_CATALOGS_V1['vi-VN']['role.approver'].message, 'Người phê duyệt'); + assert.match(MESSAGE_CATALOGS_V1['vi-VN']['dataMode.hybrid.description'].message, /dữ liệu/u); + assert.equal(MESSAGE_CATALOGS_V1['vi-VN']['sync.complete'].message, 'Đồng bộ hoàn tất'); +}); diff --git a/packages/i18n/test/formatting-v1.test.mjs b/packages/i18n/test/formatting-v1.test.mjs new file mode 100644 index 00000000..01c45f69 --- /dev/null +++ b/packages/i18n/test/formatting-v1.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +function visibleSpaces(value) { + return value.replace(/[\u00a0\u202f]/gu, ' '); +} + +test('[WEB-013] formats one instant in an explicit time zone without using the host time zone', async () => { + const { formatDateTimeV1 } = await import('../src/v1.ts'); + const instant = Date.parse('2026-08-01T17:30:00.000Z'); + const vietnamese = visibleSpaces( + formatDateTimeV1(instant, { + locale: 'vi-VN', + timeZone: 'Asia/Ho_Chi_Minh', + dateStyle: 'short', + timeStyle: 'short', + }), + ); + const englishUtc = visibleSpaces( + formatDateTimeV1(instant, { + locale: 'en', + timeZone: 'UTC', + dateStyle: 'short', + timeStyle: 'short', + hour12: false, + }), + ); + + assert.match(vietnamese, /2\/8\/(?:26|2026)/u); + assert.match(vietnamese, /00:30/u); + assert.match(englishUtc, /8\/1\/(?:26|2026)/u); + assert.match(englishUtc, /17:30/u); +}); + +test('formats decimal, VND and other currencies, and percentages without changing input values', async () => { + const { formatCurrencyV1, formatDecimalV1, formatPercentV1 } = await import('../src/v1.ts'); + const amount = 1234.5; + + assert.equal(visibleSpaces(formatDecimalV1(amount, { locale: 'vi-VN' })), '1.234,5'); + assert.equal(visibleSpaces(formatDecimalV1(amount, { locale: 'en' })), '1,234.5'); + assert.match( + visibleSpaces( + formatCurrencyV1(amount, { + locale: 'vi-VN', + currency: 'VND', + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }), + ), + /1\.234,5.*₫/u, + ); + assert.match( + visibleSpaces(formatCurrencyV1(amount, { locale: 'en', currency: 'USD' })), + /\$1,234\.50/u, + ); + assert.match(visibleSpaces(formatPercentV1(0.125, { locale: 'vi-VN' })), /12,5.*%/u); + assert.equal(amount, 1234.5); +}); + +test('formats lists, relative time, and plural categories for both locales', async () => { + const { formatListV1, formatRelativeTimeV1, selectPluralCategoryV1 } = await import( + '../src/v1.ts' + ); + + const viList = formatListV1(['Web', 'Máy tính', 'Android'], { locale: 'vi-VN' }); + const enList = formatListV1(['Web', 'Desktop', 'Android'], { locale: 'en' }); + assert.match(viList, /Web.*Máy tính.*Android/u); + assert.match(enList, /Web.*Desktop.*and.*Android/u); + assert.match(formatRelativeTimeV1(-2, 'day', { locale: 'vi-VN' }), /2 ngày trước/u); + assert.match(formatRelativeTimeV1(-2, 'day', { locale: 'en' }), /2 days ago/u); + assert.equal(selectPluralCategoryV1(1, { locale: 'en' }), 'one'); + assert.equal(selectPluralCategoryV1(2, { locale: 'en' }), 'other'); + assert.equal(selectPluralCategoryV1(1, { locale: 'vi-VN' }), 'other'); +}); + +test('rejects invalid locale, time zone, date, currency, numeric values, units, and option keys', async () => { + const { formatCurrencyV1, formatDateTimeV1, formatDecimalV1, formatRelativeTimeV1, I18nErrorV1 } = + await import('../src/v1.ts'); + const cases = [ + [() => formatDecimalV1(1, { locale: 'fr' }), 'INVALID_LOCALE'], + [() => formatDecimalV1(Infinity, { locale: 'en' }), 'INVALID_NUMBER'], + [() => formatDateTimeV1(Number.NaN, { locale: 'en', timeZone: 'UTC' }), 'INVALID_DATE'], + [() => formatDateTimeV1(8.64e15 + 1, { locale: 'en', timeZone: 'UTC' }), 'INVALID_DATE'], + [() => formatDateTimeV1(0, { locale: 'en' }), 'INVALID_TIME_ZONE'], + [() => formatDateTimeV1(0, { locale: 'en', timeZone: 'Moon/Base' }), 'INVALID_TIME_ZONE'], + [() => formatCurrencyV1(1, { locale: 'en', currency: 'usd' }), 'INVALID_CURRENCY'], + [() => formatRelativeTimeV1(1, 'fortnight', { locale: 'en' }), 'INVALID_ARGUMENT'], + [() => formatDecimalV1(1, { locale: 'en', rawProviderOption: true }), 'INVALID_ARGUMENT'], + ]; + + for (const [operation, code] of cases) { + assert.throws(operation, (error) => error instanceof I18nErrorV1 && error.code === code); + } +}); + +test('rejects impossible fraction ranges and accessor-backed formatter options safely', async () => { + const { formatDecimalV1, I18nErrorV1 } = await import('../src/v1.ts'); + let getterCalls = 0; + const options = {}; + Object.defineProperty(options, 'locale', { + enumerable: true, + get() { + getterCalls += 1; + return 'en'; + }, + }); + + assert.throws( + () => + formatDecimalV1(1, { + locale: 'en', + minimumFractionDigits: 3, + maximumFractionDigits: 2, + }), + (error) => error instanceof I18nErrorV1 && error.code === 'INVALID_ARGUMENT', + ); + assert.throws( + () => formatDecimalV1(1, options), + (error) => error instanceof I18nErrorV1 && error.code === 'INVALID_ARGUMENT', + ); + assert.equal(getterCalls, 0); + + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + assert.throws( + () => formatDecimalV1(1, proxy), + (error) => error instanceof I18nErrorV1 && error.code === 'INVALID_ARGUMENT', + ); +}); diff --git a/packages/i18n/test/locale-v1.test.mjs b/packages/i18n/test/locale-v1.test.mjs new file mode 100644 index 00000000..4e5f6513 --- /dev/null +++ b/packages/i18n/test/locale-v1.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +test('[IAM-016] defaults exactly to Vietnamese for absent, empty, unsupported, and malformed preferences', async () => { + const { negotiateLocaleV1 } = await import('../src/v1.ts'); + + for (const input of [ + undefined, + null, + '', + ' ', + 'fr-FR', + 'en;q=bogus', + 'en;q=1.1', + 'en;q=.5', + 'en;q=0', + 42, + [], + ]) { + assert.equal(negotiateLocaleV1(input), 'vi-VN', `input ${String(input)} must fail safely`); + } +}); + +test('canonicalizes supported language and region variants', async () => { + const { negotiateLocaleV1 } = await import('../src/v1.ts'); + + for (const input of ['vi', 'VI', 'vi-vn', 'vi-VN', 'vi-US']) { + assert.equal(negotiateLocaleV1(input), 'vi-VN'); + } + for (const input of ['en', 'EN', 'en-us', 'en-GB']) { + assert.equal(negotiateLocaleV1(input), 'en'); + } +}); + +test('uses quality weights, stable source order, and the highest duplicate weight', async () => { + const { negotiateLocaleV1 } = await import('../src/v1.ts'); + + assert.equal(negotiateLocaleV1('en;q=0.8, vi-VN;q=0.9'), 'vi-VN'); + assert.equal(negotiateLocaleV1('en;q=0.9, vi;q=0.9'), 'en'); + assert.equal(negotiateLocaleV1('en;q=0.2, en-US;q=0.8, vi;q=0.7'), 'en'); + assert.equal(negotiateLocaleV1('en;q=0, en-US;q=0.8, vi;q=0.7'), 'en'); + assert.equal(negotiateLocaleV1('fr;q=1, en;q=0.5'), 'en'); +}); + +test('applies wildcard policy without reviving an explicitly excluded locale', async () => { + const { negotiateLocaleV1 } = await import('../src/v1.ts'); + + assert.equal(negotiateLocaleV1('*;q=0.5'), 'vi-VN'); + assert.equal(negotiateLocaleV1('vi;q=0, *;q=0.5'), 'en'); + assert.equal(negotiateLocaleV1('vi;q=0, en;q=0, *;q=1'), 'vi-VN'); + assert.equal(negotiateLocaleV1('*;q=0, en;q=0.4'), 'en'); +}); + +test('gives a supported explicit user preference priority over Accept-Language', async () => { + const { negotiateLocaleV1 } = await import('../src/v1.ts'); + + assert.equal(negotiateLocaleV1({ userLocale: 'EN-gb', acceptLanguage: 'vi;q=1' }), 'en'); + assert.equal(negotiateLocaleV1({ userLocale: 'fr', acceptLanguage: 'en;q=0.8' }), 'en'); + assert.equal(negotiateLocaleV1({ userLocale: '', acceptLanguage: 'en' }), 'en'); +}); + +test('does not execute hostile locale accessors and falls back safely for hostile objects', async () => { + const { negotiateLocaleV1 } = await import('../src/v1.ts'); + let getterCalls = 0; + const hostileAccessor = {}; + Object.defineProperties(hostileAccessor, { + userLocale: { + enumerable: true, + get() { + getterCalls += 1; + throw new Error('must not run'); + }, + }, + acceptLanguage: { + enumerable: true, + get() { + getterCalls += 1; + return 'en'; + }, + }, + }); + const hostileProxy = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error('must fail closed'); + }, + }, + ); + + assert.equal(negotiateLocaleV1(hostileAccessor), 'vi-VN'); + assert.equal(getterCalls, 0); + assert.equal(negotiateLocaleV1(hostileProxy), 'vi-VN'); +}); diff --git a/packages/i18n/test/messages-v1.test.mjs b/packages/i18n/test/messages-v1.test.mjs new file mode 100644 index 00000000..86a394f8 --- /dev/null +++ b/packages/i18n/test/messages-v1.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +test('interpolates declared string and numeric parameters deterministically', async () => { + const { formatMessageV1 } = await import('../src/v1.ts'); + + assert.equal( + formatMessageV1('vi-VN', 'error.genericWithCorrelationId', { correlationId: 'corr-123' }), + 'Đã xảy ra lỗi. Mã đối chiếu: corr-123.', + ); + assert.equal( + formatMessageV1('en', 'retry.afterSeconds', { seconds: 15 }), + 'Try again in 15 seconds.', + ); + assert.equal( + formatMessageV1('vi-VN', 'accessibility.progressLabel', { current: 2, total: 5 }), + 'Tiến độ: 2 trên 5.', + ); +}); + +test('[WEB-021, NCO-017] requires every declared parameter and rejects extras', async () => { + const { formatMessageV1, I18nErrorV1 } = await import('../src/v1.ts'); + + assert.throws( + () => formatMessageV1('en', 'retry.afterSeconds', {}), + (error) => error instanceof I18nErrorV1 && error.code === 'MISSING_PARAMETER', + ); + assert.throws( + () => formatMessageV1('en', 'retry.afterSeconds', { seconds: 2, undeclared: 'no' }), + (error) => error instanceof I18nErrorV1 && error.code === 'EXTRA_PARAMETER', + ); + assert.throws( + () => formatMessageV1('en', 'action.save', { unexpected: 'no' }), + (error) => error instanceof I18nErrorV1 && error.code === 'EXTRA_PARAMETER', + ); +}); + +test('rejects wrong parameter types, non-finite numbers, missing keys, and unsupported locales', async () => { + const { formatMessageV1, I18nErrorV1 } = await import('../src/v1.ts'); + + const cases = [ + [() => formatMessageV1('en', 'retry.afterSeconds', { seconds: '2' }), 'INVALID_PARAMETER'], + [ + () => formatMessageV1('en', 'retry.afterSeconds', { seconds: Number.NaN }), + 'INVALID_PARAMETER', + ], + [() => formatMessageV1('en', 'missing.key', {}), 'MISSING_MESSAGE'], + [() => formatMessageV1('fr', 'action.save', {}), 'INVALID_LOCALE'], + ]; + for (const [operation, code] of cases) { + assert.throws(operation, (error) => error instanceof I18nErrorV1 && error.code === code); + } +}); + +test('performs literal text interpolation without interpreting HTML', async () => { + const { formatMessageV1 } = await import('../src/v1.ts'); + const marker = '& customer'; + + assert.equal( + formatMessageV1('en', 'error.genericWithCorrelationId', { correlationId: marker }), + `Something went wrong. Reference code: ${marker}.`, + ); +}); + +test('rejects accessor-backed parameter bags without invoking them', async () => { + const { formatMessageV1, I18nErrorV1 } = await import('../src/v1.ts'); + let getterCalls = 0; + const parameters = {}; + Object.defineProperty(parameters, 'seconds', { + enumerable: true, + get() { + getterCalls += 1; + return 5; + }, + }); + + assert.throws( + () => formatMessageV1('en', 'retry.afterSeconds', parameters), + (error) => error instanceof I18nErrorV1 && error.code === 'INVALID_ARGUMENT', + ); + assert.equal(getterCalls, 0); +}); diff --git a/packages/i18n/test/public-api-v1.test.mjs b/packages/i18n/test/public-api-v1.test.mjs new file mode 100644 index 00000000..acbc5996 --- /dev/null +++ b/packages/i18n/test/public-api-v1.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function loadSourceApi() { + try { + return await import('../src/v1.ts'); + } catch { + return undefined; + } +} + +test('publishes the versioned bilingual foundation API', async () => { + const api = await loadSourceApi(); + + assert.ok(api, 'the i18n v1 source entry point must exist'); + assert.equal(api.I18N_SCHEMA_VERSION_V1, 1); + assert.equal(api.DEFAULT_LOCALE_V1, 'vi-VN'); + assert.deepEqual(api.SUPPORTED_LOCALES_V1, ['vi-VN', 'en']); + assert.equal(typeof api.negotiateLocaleV1, 'function'); + assert.equal(typeof api.formatMessageV1, 'function'); + assert.equal(typeof api.formatDateTimeV1, 'function'); + assert.equal(typeof api.formatDecimalV1, 'function'); + assert.equal(typeof api.formatCurrencyV1, 'function'); + assert.equal(typeof api.formatPercentV1, 'function'); + assert.equal(typeof api.formatListV1, 'function'); + assert.equal(typeof api.formatRelativeTimeV1, 'function'); + assert.equal(typeof api.selectPluralCategoryV1, 'function'); +}); + +test('exposes only the versioned entry point', async () => { + const manifest = JSON.parse(readFileSync(path.join(packageDirectory, 'package.json'), 'utf8')); + + assert.deepEqual(Object.keys(manifest.exports), ['./v1']); + await assert.rejects(import('@databreeze/i18n'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); +}); diff --git a/packages/i18n/tsconfig.build.json b/packages/i18n/tsconfig.build.json new file mode 100644 index 00000000..ffb43181 --- /dev/null +++ b/packages/i18n/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "dist", + "rewriteRelativeImportExtensions": true, + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 00000000..28754dda --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/i18n/turbo.json b/packages/i18n/turbo.json new file mode 100644 index 00000000..25e76c6b --- /dev/null +++ b/packages/i18n/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test": { + "outputs": [] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87ed62fc..d938c5f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,8 @@ importers: specifier: workspace:* version: link:../contracts + packages/i18n: {} + packages/provider-ports: dependencies: '@databreeze/contracts': From dbab21f31e2b4bfbf37bd6062fa064868def9604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 10:13:59 +0700 Subject: [PATCH 24/51] fix(i18n): harden locale and formatting semantics --- packages/i18n/README.md | 5 +- packages/i18n/src/catalogs-v1.ts | 34 ++-- packages/i18n/src/formatting-v1.ts | 92 +++++++++-- packages/i18n/src/locale-v1.ts | 144 ++++++++++------- packages/i18n/src/messages-v1.ts | 32 +++- packages/i18n/src/text-v1.ts | 45 ++++++ packages/i18n/test/built-public-api-smoke.mjs | 1 + packages/i18n/test/catalogs-v1.test.mjs | 19 ++- packages/i18n/test/messages-v1.test.mjs | 19 ++- packages/i18n/test/public-api-v1.test.mjs | 1 + .../i18n/test/review-regressions-v1.test.mjs | 150 ++++++++++++++++++ 11 files changed, 439 insertions(+), 103 deletions(-) create mode 100644 packages/i18n/src/text-v1.ts create mode 100644 packages/i18n/test/review-regressions-v1.test.mjs diff --git a/packages/i18n/README.md b/packages/i18n/README.md index 3e6133f4..ffe4ac97 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -8,7 +8,8 @@ This package provides partial foundation coverage for IAM-016, WEB-013, WEB-021, - `MESSAGE_CATALOGS_V1` and `MESSAGE_KEYS_V1` contain stable, versioned keys with identical placeholder schemas in both locales. - `negotiateLocaleV1` gives a supported user preference priority over `Accept-Language` and safely falls back to `vi-VN`. -- `formatMessageV1` requires every declared `{name}` parameter, rejects undeclared parameters, and performs literal text substitution without parsing HTML. Callers must render the returned string through their platform's text API, never an HTML injection API. +- `formatMessageV1` requires every declared `{name}` parameter, rejects undeclared parameters, bounds and NFC-normalizes safe Unicode text, and applies stricter syntax to identifier-like values such as correlation IDs. It performs literal text substitution without parsing HTML. Callers must render the returned string through their platform's text API, never an HTML injection API. +- `formatRetryAfterSecondsV1` chooses an explicit singular or plural catalog variant through `Intl.PluralRules` and rejects negative, fractional, or unsafe integer values. - Date/time formatting always requires an explicit IANA time zone. Decimal, currency, percent, list, relative-time, and plural helpers reject unsupported locales and malformed values instead of silently changing business values. ## Boundaries @@ -19,6 +20,6 @@ The package has no runtime dependencies and must not depend on UI frameworks, We 1. Add a stable key to both `vi-VN` and `en` in the same change. Prefer domain-neutral foundation language here; feature-specific copy belongs with the feature registration. 2. Use natural professional Vietnamese, then complete English copy. Do not add a partial fallback, placeholder copy, HTML, controls, or bidirectional formatting characters. -3. Declare every interpolation parameter and its `string` or finite `number` type identically in both locales. +3. Declare every interpolation parameter and its `identifier`, `text`, or finite `number` type identically in both locales. 4. Add behavior-focused tests for the new message or formatter case. Run the package tests, typecheck, build self-import, and root repository checks. 5. Preserve existing v1 key semantics. Breaking key, placeholder, or meaning changes require a new versioned export rather than mutating a released contract. diff --git a/packages/i18n/src/catalogs-v1.ts b/packages/i18n/src/catalogs-v1.ts index b34c174a..89e268bf 100644 --- a/packages/i18n/src/catalogs-v1.ts +++ b/packages/i18n/src/catalogs-v1.ts @@ -4,7 +4,7 @@ export const SUPPORTED_LOCALES_V1 = Object.freeze(['vi-VN', 'en'] as const); export type SupportedLocaleV1 = (typeof SUPPORTED_LOCALES_V1)[number]; export const DEFAULT_LOCALE_V1: SupportedLocaleV1 = 'vi-VN'; -export type MessageParameterTypeV1 = 'number' | 'string'; +export type MessageParameterTypeV1 = 'identifier' | 'number' | 'text'; export interface CatalogMessageV1 { readonly message: string; @@ -61,7 +61,7 @@ const vietnameseCatalogV1 = { 'scope.project': entry('Dự án'), 'dataMode.local.label': entry('Cục bộ'), 'dataMode.local.description': entry( - 'Dữ liệu gốc ở lại trên thiết bị được cho phép; chỉ thông tin đã được chính sách cho phép mới đồng bộ.', + 'Dữ liệu gốc được giữ trên thiết bị đã được cấp quyền; chỉ thông tin được chính sách cho phép mới được đồng bộ.', ), 'dataMode.hybrid.label': entry('Kết hợp'), 'dataMode.hybrid.description': entry( @@ -69,7 +69,7 @@ const vietnameseCatalogV1 = { ), 'dataMode.cloud.label': entry('Đám mây'), 'dataMode.cloud.description': entry( - 'Dữ liệu gốc được cho phép có thể lưu trữ và xử lý an toàn trên hạ tầng đám mây.', + 'Dữ liệu gốc đã được phê duyệt có thể được lưu trữ và xử lý an toàn trên hạ tầng đám mây.', ), 'job.status.created': entry('Đã tạo'), 'job.status.queued': entry('Đang chờ xử lý'), @@ -95,22 +95,22 @@ const vietnameseCatalogV1 = { 'approval.status.rejected': entry('Đã từ chối'), 'approval.status.expired': entry('Yêu cầu đã hết hạn'), 'approval.status.invalidated': entry('Phê duyệt không còn hiệu lực'), - 'approval.status.cancelled': entry('Yêu cầu đã hủy'), - 'offline.available': entry('Có thể làm việc ngoại tuyến'), + 'approval.status.cancelled': entry('Yêu cầu đã bị hủy'), + 'offline.available': entry('Có thể sử dụng khi ngoại tuyến'), 'offline.working': entry('Đang làm việc ngoại tuyến'), 'offline.changesQueued': entry('Thay đổi đang chờ đồng bộ'), 'offline.requiresConnection': entry('Thao tác này cần kết nối mạng'), - 'sync.idle': entry('Chưa có thay đổi cần đồng bộ'), + 'sync.idle': entry('Không có thay đổi cần đồng bộ'), 'sync.inProgress': entry('Đang đồng bộ'), 'sync.complete': entry('Đồng bộ hoàn tất'), 'sync.paused': entry('Đã tạm dừng đồng bộ'), 'sync.conflict': entry('Có xung đột cần xử lý'), 'sync.failed': entry('Đồng bộ chưa thành công'), 'sync.waitingForNetwork': entry('Đang chờ kết nối mạng'), - 'sync.lastCompletedAt': entry('Đồng bộ gần nhất lúc {time}.', { time: 'string' }), + 'sync.lastCompletedAt': entry('Đồng bộ gần nhất lúc {time}.', { time: 'text' }), 'error.generic': entry('Đã xảy ra lỗi. Dữ liệu của bạn vẫn được giữ nguyên.'), 'error.genericWithCorrelationId': entry('Đã xảy ra lỗi. Mã đối chiếu: {correlationId}.', { - correlationId: 'string', + correlationId: 'identifier', }), 'error.invalidRequest': entry('Yêu cầu không hợp lệ. Hãy kiểm tra thông tin và thử lại.'), 'error.unauthorized': entry('Phiên đăng nhập không hợp lệ. Hãy đăng nhập lại.'), @@ -119,14 +119,17 @@ const vietnameseCatalogV1 = { 'error.conflict': entry('Dữ liệu đã thay đổi. Hãy tải lại trước khi tiếp tục.'), 'error.rateLimited': entry('Đang có quá nhiều yêu cầu. Hãy thử lại sau.'), 'error.serviceUnavailable': entry( - 'Dịch vụ tạm thời chưa sẵn sàng. Dữ liệu của bạn vẫn được giữ nguyên.', + 'Dịch vụ tạm thời không khả dụng. Dữ liệu của bạn vẫn được giữ nguyên.', ), - 'error.networkUnavailable': entry('Không có kết nối mạng. Thay đổi được phép sẽ chờ để đồng bộ.'), - 'error.sourceOffline': entry('Thiết bị chứa dữ liệu gốc hiện không trực tuyến.'), + 'error.networkUnavailable': entry( + 'Không có kết nối mạng. Các thay đổi được phép sẽ được lưu và đồng bộ sau.', + ), + 'error.sourceOffline': entry('Thiết bị chứa dữ liệu gốc hiện đang ngoại tuyến.'), 'error.sessionExpired': entry('Phiên làm việc đã hết hạn. Hãy đăng nhập lại để tiếp tục.'), 'retry.now': entry('Thử lại ngay'), 'retry.later': entry('Hãy thử lại sau. Dữ liệu đã nhập vẫn được giữ nguyên.'), - 'retry.afterSeconds': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), + 'retry.afterSeconds.one': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), + 'retry.afterSeconds.other': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), 'module.folderAutopilot': entry('Folder Autopilot'), 'module.spreadsheetAuditor': entry('Spreadsheet Auditor'), 'module.quoteIntelligence': entry('Quote Intelligence'), @@ -241,12 +244,12 @@ const englishCatalogV1: MessageCatalogV1 = { 'sync.conflict': entry('Conflict needs attention'), 'sync.failed': entry('Sync did not complete'), 'sync.waitingForNetwork': entry('Waiting for a network connection'), - 'sync.lastCompletedAt': entry('Last synchronized at {time}.', { time: 'string' }), + 'sync.lastCompletedAt': entry('Last synchronized at {time}.', { time: 'text' }), 'error.generic': entry('Something went wrong. Your data has been preserved.'), 'error.genericWithCorrelationId': entry( 'Something went wrong. Reference code: {correlationId}.', { - correlationId: 'string', + correlationId: 'identifier', }, ), 'error.invalidRequest': entry('The request is invalid. Check the information and try again.'), @@ -265,7 +268,8 @@ const englishCatalogV1: MessageCatalogV1 = { 'error.sessionExpired': entry('Your session expired. Sign in again to continue.'), 'retry.now': entry('Try again now'), 'retry.later': entry('Try again later. Your entered data has been preserved.'), - 'retry.afterSeconds': entry('Try again in {seconds} seconds.', { seconds: 'number' }), + 'retry.afterSeconds.one': entry('Try again in {seconds} second.', { seconds: 'number' }), + 'retry.afterSeconds.other': entry('Try again in {seconds} seconds.', { seconds: 'number' }), 'module.folderAutopilot': entry('Folder Autopilot'), 'module.spreadsheetAuditor': entry('Spreadsheet Auditor'), 'module.quoteIntelligence': entry('Quote Intelligence'), diff --git a/packages/i18n/src/formatting-v1.ts b/packages/i18n/src/formatting-v1.ts index c17ec023..193f5ca7 100644 --- a/packages/i18n/src/formatting-v1.ts +++ b/packages/i18n/src/formatting-v1.ts @@ -15,6 +15,9 @@ const LIST_KEYS_V1 = new Set(['locale', 'style', 'type']); const RELATIVE_TIME_KEYS_V1 = new Set(['locale', 'numeric', 'style']); const PLURAL_KEYS_V1 = new Set(['locale', 'type']); const CURRENCY_CODES_V1 = new Set(Intl.supportedValuesOf('currency')); +// eslint-disable-next-line @typescript-eslint/unbound-method -- Capture the intrinsic so Date subclasses cannot replace it. +const dateGetTimeV1 = Date.prototype.getTime; +const MAX_LIST_ITEMS_V1 = 1_000; const RELATIVE_TIME_UNITS_V1 = new Set([ 'day', 'hour', @@ -110,8 +113,68 @@ function optionalFractionDigit(value: unknown): number | undefined { return value; } -function isStringListValue(value: unknown): boolean { - return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string'); +function snapshotDateV1(value: unknown): Date { + try { + const timestamp = typeof value === 'number' ? value : Reflect.apply(dateGetTimeV1, value, []); + if (!Number.isFinite(timestamp)) { + throw new I18nErrorV1('INVALID_DATE'); + } + const date = new Date(timestamp); + if (!Number.isFinite(Reflect.apply(dateGetTimeV1, date, []))) { + throw new I18nErrorV1('INVALID_DATE'); + } + return date; + } catch { + throw new I18nErrorV1('INVALID_DATE'); + } +} + +function snapshotStringListV1(value: unknown): readonly string[] { + try { + if (!Array.isArray(value)) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if ( + lengthDescriptor === undefined || + !Object.hasOwn(lengthDescriptor, 'value') || + typeof lengthDescriptor.value !== 'number' || + !Number.isInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 || + lengthDescriptor.value > MAX_LIST_ITEMS_V1 + ) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + const length = lengthDescriptor.value; + for (const key of Reflect.ownKeys(value)) { + if (key === 'length') { + continue; + } + if (typeof key !== 'string') { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || index >= length || String(index) !== key) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + } + + const snapshot: string[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if ( + descriptor === undefined || + !Object.hasOwn(descriptor, 'value') || + typeof descriptor.value !== 'string' + ) { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + snapshot.push(descriptor.value); + } + return snapshot; + } catch { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } } function numberFormatOptions( @@ -147,14 +210,7 @@ export function formatDateTimeV1(value: number | Date, input: DateTimeFormatOpti if (typeof rawTimeZone !== 'string' || rawTimeZone.trim() === '') { throw new I18nErrorV1('INVALID_TIME_ZONE'); } - const timestamp = value instanceof Date ? value.getTime() : value; - if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { - throw new I18nErrorV1('INVALID_DATE'); - } - const date = new Date(timestamp); - if (!Number.isFinite(date.getTime())) { - throw new I18nErrorV1('INVALID_DATE'); - } + const date = snapshotDateV1(value); const dateStyle = optionalEnum(source['dateStyle'], ['full', 'long', 'medium', 'short']); const timeStyle = optionalEnum(source['timeStyle'], ['full', 'long', 'medium', 'short']); const hour12 = optionalBoolean(source['hour12']); @@ -216,15 +272,17 @@ export function formatPercentV1(value: number, input: PercentFormatOptionsV1): s export function formatListV1(values: readonly string[], input: ListFormatOptionsV1): string { const source = readClosedDataObjectV1(input, LIST_KEYS_V1); const locale = requiredLocale(source); - if (!isStringListValue(values)) { - throw new I18nErrorV1('INVALID_ARGUMENT'); - } + const snapshot = snapshotStringListV1(values); const type = optionalEnum(source['type'], ['conjunction', 'disjunction', 'unit']); const style = optionalEnum(source['style'], ['long', 'narrow', 'short']); - return new Intl.ListFormat(locale, { - ...(type === undefined ? {} : { type }), - ...(style === undefined ? {} : { style }), - }).format(Array.from(values)); + try { + return new Intl.ListFormat(locale, { + ...(type === undefined ? {} : { type }), + ...(style === undefined ? {} : { style }), + }).format(snapshot); + } catch { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } } export function formatRelativeTimeV1( diff --git a/packages/i18n/src/locale-v1.ts b/packages/i18n/src/locale-v1.ts index a5f999a8..7e6dbd95 100644 --- a/packages/i18n/src/locale-v1.ts +++ b/packages/i18n/src/locale-v1.ts @@ -4,27 +4,44 @@ import { readClosedDataObjectV1 } from './safe-input-v1.ts'; const NEGOTIATION_KEYS_V1 = new Set(['acceptLanguage', 'userLocale']); const Q_VALUE_V1 = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u; -const LANGUAGE_TAG_V1 = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u; +const canonicalizeLocalesV1 = Intl.getCanonicalLocales.bind(Intl); +const LocaleV1 = Intl.Locale; + +interface CanonicalRangeV1 { + readonly canonical: string; + readonly locale: SupportedLocaleV1; + readonly specificity: number; +} interface CandidateV1 { + readonly canonical: string; readonly locale: SupportedLocaleV1 | '*'; readonly quality: number; readonly order: number; + readonly specificity: number; } -function supportedLocaleForTag(tag: string): SupportedLocaleV1 | undefined { +function canonicalSupportedRange(tag: string): CanonicalRangeV1 | undefined { const trimmed = tag.trim(); - if (!LANGUAGE_TAG_V1.test(trimmed)) { + if (trimmed === '' || trimmed.length > 255) { return undefined; } - const language = trimmed.split('-', 1)[0]?.toLowerCase(); - if (language === 'vi') { - return 'vi-VN'; - } - if (language === 'en') { - return 'en'; + + try { + const canonicalLocales = canonicalizeLocalesV1([trimmed]); + if (canonicalLocales.length !== 1 || canonicalLocales[0] === undefined) { + return undefined; + } + const canonical = canonicalLocales[0]; + const language = new LocaleV1(canonical).language.toLowerCase(); + const locale = language === 'vi' ? 'vi-VN' : language === 'en' ? 'en' : undefined; + if (locale === undefined) { + return undefined; + } + return { canonical, locale, specificity: canonical.split('-').length }; + } catch { + return undefined; } - return undefined; } function parseCandidate(part: string, order: number): CandidateV1 | undefined { @@ -32,7 +49,6 @@ function parseCandidate(part: string, order: number): CandidateV1 | undefined { if (sections.length > 2 || sections[0] === '') { return undefined; } - const tag = sections[0]; let quality = 1; if (sections.length === 2) { const match = /^q=(.+)$/iu.exec(sections[1] ?? ''); @@ -41,59 +57,81 @@ function parseCandidate(part: string, order: number): CandidateV1 | undefined { } quality = Number(match[1]); } - if (tag === '*') { - return { locale: '*', quality, order }; - } - const locale = supportedLocaleForTag(tag ?? ''); - return locale === undefined ? undefined : { locale, quality, order }; -} -function negotiateHeader(header: unknown): SupportedLocaleV1 { - if (typeof header !== 'string' || header.length === 0 || header.length > 8_192) { - return DEFAULT_LOCALE_V1; + if (sections[0] === '*') { + return { canonical: '*', locale: '*', quality, order, specificity: 0 }; } + const range = canonicalSupportedRange(sections[0] ?? ''); + return range === undefined ? undefined : { ...range, quality, order }; +} - const candidates = header - .split(',') - .slice(0, 64) - .map(parseCandidate) - .filter((candidate): candidate is CandidateV1 => candidate !== undefined); - const bestByLocale = new Map(); +function consolidateDuplicateRanges(candidates: readonly CandidateV1[]): readonly CandidateV1[] { + const bestByRange = new Map(); for (const candidate of candidates) { - const current = bestByLocale.get(candidate.locale); + const key = candidate.locale === '*' ? '*' : candidate.canonical; + const current = bestByRange.get(key); if ( current === undefined || candidate.quality > current.quality || (candidate.quality === current.quality && candidate.order < current.order) ) { - bestByLocale.set(candidate.locale, candidate); + bestByRange.set(key, candidate); } } + return [...bestByRange.values()]; +} - const excluded = new Set( - [...bestByLocale.values()] - .filter( - (candidate): candidate is CandidateV1 & { readonly locale: SupportedLocaleV1 } => - candidate.locale !== '*' && candidate.quality === 0, - ) - .map((candidate) => candidate.locale), - ); - const ranked = [...bestByLocale.values()] - .filter((candidate) => candidate.quality > 0) - .sort((left, right) => right.quality - left.quality || left.order - right.order); - for (const candidate of ranked) { - if (candidate.locale !== '*') { - if (!excluded.has(candidate.locale)) { - return candidate.locale; - } - continue; - } - const wildcardLocale = SUPPORTED_LOCALES_V1.find((locale) => !excluded.has(locale)); - if (wildcardLocale !== undefined) { - return wildcardLocale; - } +function mostSpecificExplicit( + candidates: readonly CandidateV1[], + locale: SupportedLocaleV1, +): CandidateV1 | undefined { + return candidates + .filter((candidate) => candidate.locale === locale) + .sort( + (left, right) => + right.specificity - left.specificity || + right.quality - left.quality || + left.order - right.order, + )[0]; +} + +function bestWildcard(candidates: readonly CandidateV1[]): CandidateV1 | undefined { + return candidates + .filter((candidate) => candidate.locale === '*') + .sort((left, right) => right.quality - left.quality || left.order - right.order)[0]; +} + +function negotiateHeader(header: unknown): SupportedLocaleV1 { + if (typeof header !== 'string' || header.length === 0 || header.length > 8_192) { + return DEFAULT_LOCALE_V1; } - return DEFAULT_LOCALE_V1; + + const candidates = consolidateDuplicateRanges( + header + .split(',') + .slice(0, 64) + .map(parseCandidate) + .filter((candidate): candidate is CandidateV1 => candidate !== undefined), + ); + const wildcard = bestWildcard(candidates); + const scores = SUPPORTED_LOCALES_V1.map((locale, localeOrder) => { + const explicit = mostSpecificExplicit(candidates, locale); + const candidate = explicit ?? wildcard; + return { + locale, + localeOrder, + quality: candidate?.quality ?? 0, + order: candidate?.order ?? Number.MAX_SAFE_INTEGER, + }; + }).filter((score) => score.quality > 0); + + scores.sort( + (left, right) => + right.quality - left.quality || + left.order - right.order || + left.localeOrder - right.localeOrder, + ); + return scores[0]?.locale ?? DEFAULT_LOCALE_V1; } export interface LocaleNegotiationInputV1 { @@ -117,9 +155,9 @@ export function negotiateLocaleV1(input?: unknown): SupportedLocaleV1 { } const userLocale = negotiation['userLocale']; if (typeof userLocale === 'string') { - const preferred = supportedLocaleForTag(userLocale); + const preferred = canonicalSupportedRange(userLocale); if (preferred !== undefined) { - return preferred; + return preferred.locale; } } return negotiateHeader(negotiation['acceptLanguage']); diff --git a/packages/i18n/src/messages-v1.ts b/packages/i18n/src/messages-v1.ts index 3e9a0a67..0d0556e7 100644 --- a/packages/i18n/src/messages-v1.ts +++ b/packages/i18n/src/messages-v1.ts @@ -8,16 +8,17 @@ import { import { I18nErrorV1 } from './errors-v1.ts'; import { assertSupportedLocaleV1 } from './locale-v1.ts'; import { readClosedDataObjectV1 } from './safe-input-v1.ts'; +import { sanitizeTextParameterV1 } from './text-v1.ts'; const MESSAGE_KEY_SET_V1 = new Set(MESSAGE_KEYS_V1); const PLACEHOLDER_V1 = /\{([A-Za-z][A-Za-z0-9]*)\}/gu; -function assertParameterValue(type: MessageParameterTypeV1, value: unknown): void { - if (type === 'string' && typeof value === 'string') { - return; - } +function normalizeParameterValue(type: MessageParameterTypeV1, value: unknown): number | string { if (type === 'number' && typeof value === 'number' && Number.isFinite(value)) { - return; + return value; + } + if (type === 'identifier' || type === 'text') { + return sanitizeTextParameterV1(value, type); } throw new I18nErrorV1('INVALID_PARAMETER'); } @@ -56,14 +57,31 @@ export function formatMessageV1( throw error; } + const normalizedParameters: Record = Object.create(null) as Record< + string, + number | string + >; for (const parameterName of parameterNames) { if (!Object.hasOwn(safeParameters, parameterName)) { throw new I18nErrorV1('MISSING_PARAMETER'); } - assertParameterValue(catalogMessage.parameters[parameterName]!, safeParameters[parameterName]); + normalizedParameters[parameterName] = normalizeParameterValue( + catalogMessage.parameters[parameterName]!, + safeParameters[parameterName], + ); } return catalogMessage.message.replace(PLACEHOLDER_V1, (_placeholder, parameterName: string) => - String(safeParameters[parameterName]), + String(normalizedParameters[parameterName]), ); } + +export function formatRetryAfterSecondsV1(locale: SupportedLocaleV1, seconds: number): string { + assertSupportedLocaleV1(locale); + if (!Number.isFinite(seconds) || !Number.isSafeInteger(seconds) || seconds < 0) { + throw new I18nErrorV1('INVALID_NUMBER'); + } + const category = new Intl.PluralRules(locale).select(seconds); + const key = category === 'one' ? 'retry.afterSeconds.one' : 'retry.afterSeconds.other'; + return formatMessageV1(locale, key, { seconds }); +} diff --git a/packages/i18n/src/text-v1.ts b/packages/i18n/src/text-v1.ts new file mode 100644 index 00000000..d079711b --- /dev/null +++ b/packages/i18n/src/text-v1.ts @@ -0,0 +1,45 @@ +import { I18nErrorV1 } from './errors-v1.ts'; + +const MAX_IDENTIFIER_LENGTH_V1 = 128; +const MAX_TEXT_LENGTH_V1 = 512; +const UNSAFE_TEXT_V1 = /(?:\p{Cc}|[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069])/u; +const IDENTIFIER_V1 = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u; + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) { + return true; + } + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return true; + } + } + return false; +} + +export function sanitizeTextParameterV1(value: unknown, kind: 'identifier' | 'text'): string { + if (typeof value !== 'string' || hasUnpairedSurrogate(value)) { + throw new I18nErrorV1('INVALID_PARAMETER'); + } + + let normalized: string; + try { + normalized = value.normalize('NFC'); + } catch { + throw new I18nErrorV1('INVALID_PARAMETER'); + } + const maximumLength = kind === 'identifier' ? MAX_IDENTIFIER_LENGTH_V1 : MAX_TEXT_LENGTH_V1; + if ( + normalized.length === 0 || + normalized.length > maximumLength || + UNSAFE_TEXT_V1.test(normalized) || + (kind === 'identifier' && !IDENTIFIER_V1.test(normalized)) + ) { + throw new I18nErrorV1('INVALID_PARAMETER'); + } + return normalized; +} diff --git a/packages/i18n/test/built-public-api-smoke.mjs b/packages/i18n/test/built-public-api-smoke.mjs index 2a57a45c..2717c331 100644 --- a/packages/i18n/test/built-public-api-smoke.mjs +++ b/packages/i18n/test/built-public-api-smoke.mjs @@ -6,4 +6,5 @@ assert.equal(api.I18N_SCHEMA_VERSION_V1, 1); assert.equal(api.DEFAULT_LOCALE_V1, 'vi-VN'); assert.equal(api.negotiateLocaleV1('en-US'), 'en'); assert.equal(api.formatMessageV1('vi-VN', 'action.save'), 'Lưu'); +assert.equal(api.formatRetryAfterSecondsV1('en', 1), 'Try again in 1 second.'); assert.equal(typeof api.formatCurrencyV1, 'function'); diff --git a/packages/i18n/test/catalogs-v1.test.mjs b/packages/i18n/test/catalogs-v1.test.mjs index cc68cc72..444f3872 100644 --- a/packages/i18n/test/catalogs-v1.test.mjs +++ b/packages/i18n/test/catalogs-v1.test.mjs @@ -99,7 +99,8 @@ const REQUIRED_KEYS = Object.freeze([ 'error.sessionExpired', 'retry.now', 'retry.later', - 'retry.afterSeconds', + 'retry.afterSeconds.one', + 'retry.afterSeconds.other', 'module.folderAutopilot', 'module.spreadsheetAuditor', 'module.quoteIntelligence', @@ -208,4 +209,20 @@ test('Vietnamese foundation copy is primary professional copy rather than an Eng assert.equal(MESSAGE_CATALOGS_V1['vi-VN']['role.approver'].message, 'Người phê duyệt'); assert.match(MESSAGE_CATALOGS_V1['vi-VN']['dataMode.hybrid.description'].message, /dữ liệu/u); assert.equal(MESSAGE_CATALOGS_V1['vi-VN']['sync.complete'].message, 'Đồng bộ hoàn tất'); + assert.equal( + MESSAGE_CATALOGS_V1['vi-VN']['approval.status.cancelled'].message, + 'Yêu cầu đã bị hủy', + ); + assert.match( + MESSAGE_CATALOGS_V1['vi-VN']['dataMode.local.description'].message, + /thiết bị đã được cấp quyền/u, + ); + assert.match( + MESSAGE_CATALOGS_V1['vi-VN']['dataMode.cloud.description'].message, + /Dữ liệu gốc đã được phê duyệt/u, + ); + assert.equal( + MESSAGE_CATALOGS_V1['vi-VN']['error.networkUnavailable'].message, + 'Không có kết nối mạng. Các thay đổi được phép sẽ được lưu và đồng bộ sau.', + ); }); diff --git a/packages/i18n/test/messages-v1.test.mjs b/packages/i18n/test/messages-v1.test.mjs index 86a394f8..b853e0bc 100644 --- a/packages/i18n/test/messages-v1.test.mjs +++ b/packages/i18n/test/messages-v1.test.mjs @@ -9,7 +9,7 @@ test('interpolates declared string and numeric parameters deterministically', as 'Đã xảy ra lỗi. Mã đối chiếu: corr-123.', ); assert.equal( - formatMessageV1('en', 'retry.afterSeconds', { seconds: 15 }), + formatMessageV1('en', 'retry.afterSeconds.other', { seconds: 15 }), 'Try again in 15 seconds.', ); assert.equal( @@ -22,11 +22,11 @@ test('[WEB-021, NCO-017] requires every declared parameter and rejects extras', const { formatMessageV1, I18nErrorV1 } = await import('../src/v1.ts'); assert.throws( - () => formatMessageV1('en', 'retry.afterSeconds', {}), + () => formatMessageV1('en', 'retry.afterSeconds.other', {}), (error) => error instanceof I18nErrorV1 && error.code === 'MISSING_PARAMETER', ); assert.throws( - () => formatMessageV1('en', 'retry.afterSeconds', { seconds: 2, undeclared: 'no' }), + () => formatMessageV1('en', 'retry.afterSeconds.other', { seconds: 2, undeclared: 'no' }), (error) => error instanceof I18nErrorV1 && error.code === 'EXTRA_PARAMETER', ); assert.throws( @@ -39,9 +39,12 @@ test('rejects wrong parameter types, non-finite numbers, missing keys, and unsup const { formatMessageV1, I18nErrorV1 } = await import('../src/v1.ts'); const cases = [ - [() => formatMessageV1('en', 'retry.afterSeconds', { seconds: '2' }), 'INVALID_PARAMETER'], [ - () => formatMessageV1('en', 'retry.afterSeconds', { seconds: Number.NaN }), + () => formatMessageV1('en', 'retry.afterSeconds.other', { seconds: '2' }), + 'INVALID_PARAMETER', + ], + [ + () => formatMessageV1('en', 'retry.afterSeconds.other', { seconds: Number.NaN }), 'INVALID_PARAMETER', ], [() => formatMessageV1('en', 'missing.key', {}), 'MISSING_MESSAGE'], @@ -57,8 +60,8 @@ test('performs literal text interpolation without interpreting HTML', async () = const marker = '& customer'; assert.equal( - formatMessageV1('en', 'error.genericWithCorrelationId', { correlationId: marker }), - `Something went wrong. Reference code: ${marker}.`, + formatMessageV1('en', 'sync.lastCompletedAt', { time: marker }), + `Last synchronized at ${marker}.`, ); }); @@ -75,7 +78,7 @@ test('rejects accessor-backed parameter bags without invoking them', async () => }); assert.throws( - () => formatMessageV1('en', 'retry.afterSeconds', parameters), + () => formatMessageV1('en', 'retry.afterSeconds.other', parameters), (error) => error instanceof I18nErrorV1 && error.code === 'INVALID_ARGUMENT', ); assert.equal(getterCalls, 0); diff --git a/packages/i18n/test/public-api-v1.test.mjs b/packages/i18n/test/public-api-v1.test.mjs index acbc5996..3855f98b 100644 --- a/packages/i18n/test/public-api-v1.test.mjs +++ b/packages/i18n/test/public-api-v1.test.mjs @@ -23,6 +23,7 @@ test('publishes the versioned bilingual foundation API', async () => { assert.deepEqual(api.SUPPORTED_LOCALES_V1, ['vi-VN', 'en']); assert.equal(typeof api.negotiateLocaleV1, 'function'); assert.equal(typeof api.formatMessageV1, 'function'); + assert.equal(typeof api.formatRetryAfterSecondsV1, 'function'); assert.equal(typeof api.formatDateTimeV1, 'function'); assert.equal(typeof api.formatDecimalV1, 'function'); assert.equal(typeof api.formatCurrencyV1, 'function'); diff --git a/packages/i18n/test/review-regressions-v1.test.mjs b/packages/i18n/test/review-regressions-v1.test.mjs new file mode 100644 index 00000000..55f84bae --- /dev/null +++ b/packages/i18n/test/review-regressions-v1.test.mjs @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { inspect } from 'node:util'; +import test from 'node:test'; + +test('canonicalizes full BCP 47 locales and ignores structurally malformed ranges', async () => { + const { negotiateLocaleV1 } = await import('../src/v1.ts'); + + assert.equal(negotiateLocaleV1('en-US-u-ca-gregory'), 'en'); + assert.equal(negotiateLocaleV1({ userLocale: 'EN-us-u-CA-gregory', acceptLanguage: 'vi' }), 'en'); + assert.equal(negotiateLocaleV1('en-US-u-ca-gregory;q=0.8,vi;q=0.7'), 'en'); + assert.equal(negotiateLocaleV1('en-1a'), 'vi-VN'); + assert.equal(negotiateLocaleV1('en-1a;q=1,vi;q=0.5'), 'vi-VN'); +}); + +test('scores explicit locale ranges before wildcard fallback', async () => { + const { negotiateLocaleV1 } = await import('../src/v1.ts'); + + assert.equal(negotiateLocaleV1('vi;q=0.1,*;q=0.9,en;q=0.4'), 'en'); + assert.equal(negotiateLocaleV1('vi;q=0,*;q=0.9'), 'en'); + assert.equal(negotiateLocaleV1('vi;q=0,en;q=0,*;q=1'), 'vi-VN'); + assert.equal(negotiateLocaleV1('en;q=0.2,en-US;q=0.8,vi;q=0.7'), 'en'); + assert.equal(negotiateLocaleV1('en;q=0.9,en-US;q=0.2,vi;q=0.5'), 'vi-VN'); + assert.equal(negotiateLocaleV1('vi;q=0.8,en;q=0.8'), 'vi-VN'); + assert.equal(negotiateLocaleV1('en;q=0.8,vi;q=0.8'), 'en'); +}); + +test('reads Date instances through the built-in intrinsic and bounds hostile failures', async () => { + const { formatDateTimeV1, I18nErrorV1 } = await import('../src/v1.ts'); + let overrideCalls = 0; + class HostileDate extends Date { + getTime() { + overrideCalls += 1; + throw new Error('date-marker-must-not-run'); + } + } + + assert.match( + formatDateTimeV1(new HostileDate('2026-08-01T17:30:00.000Z'), { + locale: 'en', + timeZone: 'UTC', + }), + /2026/u, + ); + assert.equal(overrideCalls, 0); + + const hostileProxy = new Proxy(new Date(0), { + get() { + throw new Error('date-proxy-marker'); + }, + }); + const { proxy: revokedDate, revoke } = Proxy.revocable(new Date(0), {}); + revoke(); + for (const value of [hostileProxy, revokedDate]) { + assert.throws( + () => formatDateTimeV1(value, { locale: 'en', timeZone: 'UTC' }), + (error) => { + assert.equal(error instanceof I18nErrorV1, true); + assert.equal(error.code, 'INVALID_DATE'); + assert.doesNotMatch(inspect(error), /date-(?:proxy-)?marker/u); + return true; + }, + ); + } +}); + +test('snapshots list items without holes, accessors, extra keys, or caller iterators', async () => { + const { formatListV1, I18nErrorV1 } = await import('../src/v1.ts'); + let accessorCalls = 0; + const accessorList = ['safe']; + Object.defineProperty(accessorList, '0', { + enumerable: true, + get() { + accessorCalls += 1; + throw new Error('list-accessor-marker'); + }, + }); + const sparse = new Array(2); + sparse[1] = 'Android'; + const extra = ['Web']; + extra.metadata = 'must not survive'; + const symbolExtra = ['Web']; + symbolExtra[Symbol('hidden')] = 'must not survive'; + + for (const value of [accessorList, sparse, extra, symbolExtra]) { + assert.throws( + () => formatListV1(value, { locale: 'en' }), + (error) => error instanceof I18nErrorV1 && error.code === 'INVALID_ARGUMENT', + ); + } + assert.equal(accessorCalls, 0); + + let iteratorCalls = 0; + class HostileList extends Array { + [Symbol.iterator]() { + iteratorCalls += 1; + throw new Error('list-iterator-marker'); + } + } + const list = new HostileList(); + list.push('Web', 'Android'); + assert.match(formatListV1(list, { locale: 'en' }), /Web.*and.*Android/u); + assert.equal(iteratorCalls, 0); +}); + +test('normalizes safe text and rejects unsafe or unbounded interpolation', async () => { + const { formatMessageV1, I18nErrorV1 } = await import('../src/v1.ts'); + + assert.equal( + formatMessageV1('vi-VN', 'sync.lastCompletedAt', { time: 'Nguye\u0302\u0303n A\u0301nh' }), + 'Đồng bộ gần nhất lúc Nguyễn Ánh.', + ); + assert.equal( + formatMessageV1('en', 'error.genericWithCorrelationId', { correlationId: 'corr-123_ABC.9' }), + 'Something went wrong. Reference code: corr-123_ABC.9.', + ); + + for (const correlationId of [ + 'corr 123', + 'corr\n123', + 'corr\u202e123', + `corr-${String.fromCharCode(0xd800)}`, + 'x'.repeat(129), + ]) { + assert.throws( + () => formatMessageV1('en', 'error.genericWithCorrelationId', { correlationId }), + (error) => error instanceof I18nErrorV1 && error.code === 'INVALID_PARAMETER', + ); + } + assert.throws( + () => formatMessageV1('en', 'sync.lastCompletedAt', { time: 'x'.repeat(513) }), + (error) => error instanceof I18nErrorV1 && error.code === 'INVALID_PARAMETER', + ); +}); + +test('selects grammatically correct retry messages with Intl plural rules', async () => { + const { formatRetryAfterSecondsV1, I18nErrorV1 } = await import('../src/v1.ts'); + + assert.equal(formatRetryAfterSecondsV1('en', 0), 'Try again in 0 seconds.'); + assert.equal(formatRetryAfterSecondsV1('en', 1), 'Try again in 1 second.'); + assert.equal(formatRetryAfterSecondsV1('en', 2), 'Try again in 2 seconds.'); + assert.equal(formatRetryAfterSecondsV1('vi-VN', 0), 'Thử lại sau 0 giây.'); + assert.equal(formatRetryAfterSecondsV1('vi-VN', 1), 'Thử lại sau 1 giây.'); + assert.equal(formatRetryAfterSecondsV1('vi-VN', 2), 'Thử lại sau 2 giây.'); + for (const seconds of [-1, 1.5, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws( + () => formatRetryAfterSecondsV1('en', seconds), + (error) => error instanceof I18nErrorV1 && error.code === 'INVALID_NUMBER', + ); + } +}); From 5acd154f3b9ed0e31f8d4a0cfb04e8481fa9de32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 10:35:44 +0700 Subject: [PATCH 25/51] fix(i18n): capture locale formatting intrinsics --- packages/i18n/src/errors-v1.ts | 2 +- packages/i18n/src/formatting-v1.ts | 127 +++++++--- packages/i18n/src/intrinsics-v1.ts | 154 ++++++++++++ packages/i18n/src/locale-v1.ts | 23 +- packages/i18n/src/messages-v1.ts | 8 +- packages/i18n/src/text-v1.ts | 10 +- .../i18n/test/intrinsic-capture-v1.test.mjs | 237 ++++++++++++++++++ 7 files changed, 509 insertions(+), 52 deletions(-) create mode 100644 packages/i18n/src/intrinsics-v1.ts create mode 100644 packages/i18n/test/intrinsic-capture-v1.test.mjs diff --git a/packages/i18n/src/errors-v1.ts b/packages/i18n/src/errors-v1.ts index 565b645b..041c5d90 100644 --- a/packages/i18n/src/errors-v1.ts +++ b/packages/i18n/src/errors-v1.ts @@ -16,7 +16,7 @@ const ERROR_MESSAGES_V1: Readonly> = Object.free INVALID_CURRENCY: 'The currency code is not supported.', INVALID_DATE: 'The date value is invalid.', INVALID_LOCALE: 'The locale is not supported.', - INVALID_NUMBER: 'The numeric value must be finite.', + INVALID_NUMBER: 'The numeric value is invalid or outside the supported range.', INVALID_PARAMETER: 'The message parameter has an invalid value.', INVALID_TIME_ZONE: 'An explicit supported time zone is required.', MISSING_MESSAGE: 'The message key is not present in the catalog.', diff --git a/packages/i18n/src/formatting-v1.ts b/packages/i18n/src/formatting-v1.ts index 193f5ca7..cff331cb 100644 --- a/packages/i18n/src/formatting-v1.ts +++ b/packages/i18n/src/formatting-v1.ts @@ -1,5 +1,16 @@ import type { SupportedLocaleV1 } from './catalogs-v1.ts'; import { I18nErrorV1 } from './errors-v1.ts'; +import { + createDateIntrinsicV1, + dateTimestampIntrinsicV1, + formatDateTimeIntrinsicV1, + formatListIntrinsicV1, + formatNumberIntrinsicV1, + formatRelativeTimeIntrinsicV1, + selectPluralIntrinsicV1, + supportedValuesIntrinsicV1, + trimStringIntrinsicV1, +} from './intrinsics-v1.ts'; import { assertSupportedLocaleV1 } from './locale-v1.ts'; import { readClosedDataObjectV1 } from './safe-input-v1.ts'; @@ -14,9 +25,7 @@ const DATE_TIME_KEYS_V1 = new Set(['dateStyle', 'hour12', 'locale', 'timeStyle', const LIST_KEYS_V1 = new Set(['locale', 'style', 'type']); const RELATIVE_TIME_KEYS_V1 = new Set(['locale', 'numeric', 'style']); const PLURAL_KEYS_V1 = new Set(['locale', 'type']); -const CURRENCY_CODES_V1 = new Set(Intl.supportedValuesOf('currency')); -// eslint-disable-next-line @typescript-eslint/unbound-method -- Capture the intrinsic so Date subclasses cannot replace it. -const dateGetTimeV1 = Date.prototype.getTime; +const CURRENCY_CODES_V1 = new Set(supportedValuesIntrinsicV1('currency')); const MAX_LIST_ITEMS_V1 = 1_000; const RELATIVE_TIME_UNITS_V1 = new Set([ 'day', @@ -115,12 +124,12 @@ function optionalFractionDigit(value: unknown): number | undefined { function snapshotDateV1(value: unknown): Date { try { - const timestamp = typeof value === 'number' ? value : Reflect.apply(dateGetTimeV1, value, []); + const timestamp = typeof value === 'number' ? value : dateTimestampIntrinsicV1(value); if (!Number.isFinite(timestamp)) { throw new I18nErrorV1('INVALID_DATE'); } - const date = new Date(timestamp); - if (!Number.isFinite(Reflect.apply(dateGetTimeV1, date, []))) { + const date = createDateIntrinsicV1(timestamp); + if (!Number.isFinite(dateTimestampIntrinsicV1(date))) { throw new I18nErrorV1('INVALID_DATE'); } return date; @@ -207,7 +216,7 @@ export function formatDateTimeV1(value: number | Date, input: DateTimeFormatOpti const source = readClosedDataObjectV1(input, DATE_TIME_KEYS_V1); const locale = requiredLocale(source); const rawTimeZone = source['timeZone']; - if (typeof rawTimeZone !== 'string' || rawTimeZone.trim() === '') { + if (typeof rawTimeZone !== 'string' || trimStringIntrinsicV1(rawTimeZone) === '') { throw new I18nErrorV1('INVALID_TIME_ZONE'); } const date = snapshotDateV1(value); @@ -215,12 +224,16 @@ export function formatDateTimeV1(value: number | Date, input: DateTimeFormatOpti const timeStyle = optionalEnum(source['timeStyle'], ['full', 'long', 'medium', 'short']); const hour12 = optionalBoolean(source['hour12']); try { - return new Intl.DateTimeFormat(locale, { - timeZone: rawTimeZone, - dateStyle: dateStyle ?? 'medium', - timeStyle: timeStyle ?? 'short', - ...(hour12 === undefined ? {} : { hour12 }), - }).format(date); + return formatDateTimeIntrinsicV1( + locale, + { + timeZone: rawTimeZone, + dateStyle: dateStyle ?? 'medium', + timeStyle: timeStyle ?? 'short', + ...(hour12 === undefined ? {} : { hour12 }), + }, + date, + ); } catch { throw new I18nErrorV1('INVALID_TIME_ZONE'); } @@ -228,9 +241,12 @@ export function formatDateTimeV1(value: number | Date, input: DateTimeFormatOpti export function formatDecimalV1(value: number, input: DecimalFormatOptionsV1): string { const { locale, options } = numberFormatOptions(input, FRACTION_KEYS_V1); - return new Intl.NumberFormat(locale, { ...options, style: 'decimal' }).format( - finiteNumber(value), - ); + const number = finiteNumber(value); + try { + return formatNumberIntrinsicV1(locale, { ...options, style: 'decimal' }, number); + } catch { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } } export function formatCurrencyV1(value: number, input: CurrencyFormatOptionsV1): string { @@ -250,23 +266,41 @@ export function formatCurrencyV1(value: number, input: CurrencyFormatOptionsV1): 'narrowSymbol', 'symbol', ]); - return new Intl.NumberFormat(locale, { - ...options, - style: 'currency', - currency, - ...(currencyDisplay === undefined ? {} : { currencyDisplay }), - }).format(finiteNumber(value)); + const number = finiteNumber(value); + try { + return formatNumberIntrinsicV1( + locale, + { + ...options, + style: 'currency', + currency, + ...(currencyDisplay === undefined ? {} : { currencyDisplay }), + }, + number, + ); + } catch { + throw new I18nErrorV1('INVALID_CURRENCY'); + } } export function formatPercentV1(value: number, input: PercentFormatOptionsV1): string { const { locale, options } = numberFormatOptions(input, FRACTION_KEYS_V1); const maximumFractionDigits = options.maximumFractionDigits ?? Math.max(options.minimumFractionDigits ?? 0, 3); - return new Intl.NumberFormat(locale, { - ...options, - maximumFractionDigits, - style: 'percent', - }).format(finiteNumber(value)); + const number = finiteNumber(value); + try { + return formatNumberIntrinsicV1( + locale, + { + ...options, + maximumFractionDigits, + style: 'percent', + }, + number, + ); + } catch { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } } export function formatListV1(values: readonly string[], input: ListFormatOptionsV1): string { @@ -276,10 +310,14 @@ export function formatListV1(values: readonly string[], input: ListFormatOptions const type = optionalEnum(source['type'], ['conjunction', 'disjunction', 'unit']); const style = optionalEnum(source['style'], ['long', 'narrow', 'short']); try { - return new Intl.ListFormat(locale, { - ...(type === undefined ? {} : { type }), - ...(style === undefined ? {} : { style }), - }).format(snapshot); + return formatListIntrinsicV1( + locale, + { + ...(type === undefined ? {} : { type }), + ...(style === undefined ? {} : { style }), + }, + snapshot, + ); } catch { throw new I18nErrorV1('INVALID_ARGUMENT'); } @@ -297,10 +335,20 @@ export function formatRelativeTimeV1( } const numeric = optionalEnum(source['numeric'], ['always', 'auto']); const style = optionalEnum(source['style'], ['long', 'narrow', 'short']); - return new Intl.RelativeTimeFormat(locale, { - ...(numeric === undefined ? {} : { numeric }), - ...(style === undefined ? {} : { style }), - }).format(finiteNumber(value), unit); + const number = finiteNumber(value); + try { + return formatRelativeTimeIntrinsicV1( + locale, + { + ...(numeric === undefined ? {} : { numeric }), + ...(style === undefined ? {} : { style }), + }, + number, + unit, + ); + } catch { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } } export function selectPluralCategoryV1( @@ -310,7 +358,10 @@ export function selectPluralCategoryV1( const source = readClosedDataObjectV1(input, PLURAL_KEYS_V1); const locale = requiredLocale(source); const type = optionalEnum(source['type'], ['cardinal', 'ordinal']); - return new Intl.PluralRules(locale, type === undefined ? {} : { type }).select( - finiteNumber(value), - ); + const number = finiteNumber(value); + try { + return selectPluralIntrinsicV1(locale, type === undefined ? {} : { type }, number); + } catch { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } } diff --git a/packages/i18n/src/intrinsics-v1.ts b/packages/i18n/src/intrinsics-v1.ts new file mode 100644 index 00000000..af03ddbe --- /dev/null +++ b/packages/i18n/src/intrinsics-v1.ts @@ -0,0 +1,154 @@ +import { I18nErrorV1 } from './errors-v1.ts'; + +type IntrinsicFunctionV1 = (...arguments_: never[]) => unknown; + +const reflectApplyV1 = Reflect.apply; +const DateConstructorV1 = Date; +const IntlObjectV1 = Intl; +const LocaleConstructorV1 = Intl.Locale; +const DateTimeFormatConstructorV1 = Intl.DateTimeFormat; +const NumberFormatConstructorV1 = Intl.NumberFormat; +const ListFormatConstructorV1 = Intl.ListFormat; +const RelativeTimeFormatConstructorV1 = Intl.RelativeTimeFormat; +const PluralRulesConstructorV1 = Intl.PluralRules; + +function captureMethodV1(target: object, key: PropertyKey): IntrinsicFunctionV1 { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + if (descriptor === undefined || typeof descriptor.value !== 'function') { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + return descriptor.value as IntrinsicFunctionV1; +} + +function captureGetterV1(target: object, key: PropertyKey): IntrinsicFunctionV1 { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + if (descriptor === undefined || typeof descriptor.get !== 'function') { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } + // eslint-disable-next-line @typescript-eslint/unbound-method -- The getter is intentionally captured with its receiver supplied by Reflect.apply. + return descriptor.get as IntrinsicFunctionV1; +} + +function applyIntrinsicV1( + intrinsic: IntrinsicFunctionV1, + thisArgument: unknown, + argumentsList: readonly unknown[], +): TResult { + return reflectApplyV1(intrinsic, thisArgument, argumentsList) as TResult; +} + +const getCanonicalLocalesV1 = captureMethodV1(Intl, 'getCanonicalLocales'); +const supportedValuesOfV1 = captureMethodV1(Intl, 'supportedValuesOf'); +const localeLanguageGetterV1 = captureGetterV1( + LocaleConstructorV1.prototype as unknown as object, + 'language', +); +const dateGetTimeV1 = captureMethodV1(DateConstructorV1.prototype, 'getTime'); +const dateTimeFormatGetterV1 = captureGetterV1(DateTimeFormatConstructorV1.prototype, 'format'); +const numberFormatGetterV1 = captureGetterV1(NumberFormatConstructorV1.prototype, 'format'); +const listFormatMethodV1 = captureMethodV1(ListFormatConstructorV1.prototype, 'format'); +const relativeTimeFormatMethodV1 = captureMethodV1( + RelativeTimeFormatConstructorV1.prototype as unknown as object, + 'format', +); +const pluralSelectMethodV1 = captureMethodV1( + PluralRulesConstructorV1.prototype as unknown as object, + 'select', +); +const stringCharCodeAtV1 = captureMethodV1(String.prototype, 'charCodeAt'); +const stringNormalizeV1 = captureMethodV1(String.prototype, 'normalize'); +const stringTrimV1 = captureMethodV1(String.prototype, 'trim'); +const stringSplitV1 = captureMethodV1(String.prototype, 'split'); + +export function canonicalizeLocalesIntrinsicV1(locales: readonly string[]): readonly string[] { + return applyIntrinsicV1(getCanonicalLocalesV1, IntlObjectV1, [locales]); +} + +export function localeLanguageIntrinsicV1(tag: string): string { + const locale = new LocaleConstructorV1(tag); + return applyIntrinsicV1(localeLanguageGetterV1, locale, []); +} + +export function supportedValuesIntrinsicV1(key: 'currency'): readonly string[] { + return applyIntrinsicV1(supportedValuesOfV1, IntlObjectV1, [key]); +} + +export function dateTimestampIntrinsicV1(value: unknown): number { + return applyIntrinsicV1(dateGetTimeV1, value, []); +} + +export function createDateIntrinsicV1(timestamp: number): Date { + return new DateConstructorV1(timestamp); +} + +export function formatDateTimeIntrinsicV1( + locale: string, + options: Intl.DateTimeFormatOptions, + value: Date, +): string { + const formatter = new DateTimeFormatConstructorV1(locale, options); + const format = applyIntrinsicV1<(date?: Date | number) => string>( + dateTimeFormatGetterV1, + formatter, + [], + ); + return format(value); +} + +export function formatNumberIntrinsicV1( + locale: string, + options: Intl.NumberFormatOptions, + value: number, +): string { + const formatter = new NumberFormatConstructorV1(locale, options); + const format = applyIntrinsicV1<(number?: number | bigint) => string>( + numberFormatGetterV1, + formatter, + [], + ); + return format(value); +} + +export function formatListIntrinsicV1( + locale: string, + options: Intl.ListFormatOptions, + values: readonly string[], +): string { + const formatter = new ListFormatConstructorV1(locale, options); + return applyIntrinsicV1(listFormatMethodV1, formatter, [values]); +} + +export function formatRelativeTimeIntrinsicV1( + locale: string, + options: Intl.RelativeTimeFormatOptions, + value: number, + unit: Intl.RelativeTimeFormatUnit, +): string { + const formatter = new RelativeTimeFormatConstructorV1(locale, options); + return applyIntrinsicV1(relativeTimeFormatMethodV1, formatter, [value, unit]); +} + +export function selectPluralIntrinsicV1( + locale: string, + options: Intl.PluralRulesOptions, + value: number, +): Intl.LDMLPluralRule { + const formatter = new PluralRulesConstructorV1(locale, options); + return applyIntrinsicV1(pluralSelectMethodV1, formatter, [value]); +} + +export function stringCodeUnitAtIntrinsicV1(value: string, index: number): number { + return applyIntrinsicV1(stringCharCodeAtV1, value, [index]); +} + +export function normalizeStringIntrinsicV1(value: string): string { + return applyIntrinsicV1(stringNormalizeV1, value, ['NFC']); +} + +export function trimStringIntrinsicV1(value: string): string { + return applyIntrinsicV1(stringTrimV1, value, []); +} + +export function splitStringIntrinsicV1(value: string, separator: string): readonly string[] { + return applyIntrinsicV1(stringSplitV1, value, [separator]); +} diff --git a/packages/i18n/src/locale-v1.ts b/packages/i18n/src/locale-v1.ts index 7e6dbd95..f6ac5e47 100644 --- a/packages/i18n/src/locale-v1.ts +++ b/packages/i18n/src/locale-v1.ts @@ -1,11 +1,15 @@ import { DEFAULT_LOCALE_V1, SUPPORTED_LOCALES_V1, type SupportedLocaleV1 } from './catalogs-v1.ts'; import { I18nErrorV1 } from './errors-v1.ts'; +import { + canonicalizeLocalesIntrinsicV1, + localeLanguageIntrinsicV1, + splitStringIntrinsicV1, + trimStringIntrinsicV1, +} from './intrinsics-v1.ts'; import { readClosedDataObjectV1 } from './safe-input-v1.ts'; const NEGOTIATION_KEYS_V1 = new Set(['acceptLanguage', 'userLocale']); const Q_VALUE_V1 = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u; -const canonicalizeLocalesV1 = Intl.getCanonicalLocales.bind(Intl); -const LocaleV1 = Intl.Locale; interface CanonicalRangeV1 { readonly canonical: string; @@ -22,30 +26,32 @@ interface CandidateV1 { } function canonicalSupportedRange(tag: string): CanonicalRangeV1 | undefined { - const trimmed = tag.trim(); + const trimmed = trimStringIntrinsicV1(tag); if (trimmed === '' || trimmed.length > 255) { return undefined; } try { - const canonicalLocales = canonicalizeLocalesV1([trimmed]); + const canonicalLocales = canonicalizeLocalesIntrinsicV1([trimmed]); if (canonicalLocales.length !== 1 || canonicalLocales[0] === undefined) { return undefined; } const canonical = canonicalLocales[0]; - const language = new LocaleV1(canonical).language.toLowerCase(); + const language = localeLanguageIntrinsicV1(canonical); const locale = language === 'vi' ? 'vi-VN' : language === 'en' ? 'en' : undefined; if (locale === undefined) { return undefined; } - return { canonical, locale, specificity: canonical.split('-').length }; + return { canonical, locale, specificity: splitStringIntrinsicV1(canonical, '-').length }; } catch { return undefined; } } function parseCandidate(part: string, order: number): CandidateV1 | undefined { - const sections = part.split(';').map((section) => section.trim()); + const sections = splitStringIntrinsicV1(part, ';').map((section) => + trimStringIntrinsicV1(section), + ); if (sections.length > 2 || sections[0] === '') { return undefined; } @@ -107,8 +113,7 @@ function negotiateHeader(header: unknown): SupportedLocaleV1 { } const candidates = consolidateDuplicateRanges( - header - .split(',') + splitStringIntrinsicV1(header, ',') .slice(0, 64) .map(parseCandidate) .filter((candidate): candidate is CandidateV1 => candidate !== undefined), diff --git a/packages/i18n/src/messages-v1.ts b/packages/i18n/src/messages-v1.ts index 0d0556e7..af09f15f 100644 --- a/packages/i18n/src/messages-v1.ts +++ b/packages/i18n/src/messages-v1.ts @@ -6,6 +6,7 @@ import { type SupportedLocaleV1, } from './catalogs-v1.ts'; import { I18nErrorV1 } from './errors-v1.ts'; +import { selectPluralIntrinsicV1 } from './intrinsics-v1.ts'; import { assertSupportedLocaleV1 } from './locale-v1.ts'; import { readClosedDataObjectV1 } from './safe-input-v1.ts'; import { sanitizeTextParameterV1 } from './text-v1.ts'; @@ -81,7 +82,12 @@ export function formatRetryAfterSecondsV1(locale: SupportedLocaleV1, seconds: nu if (!Number.isFinite(seconds) || !Number.isSafeInteger(seconds) || seconds < 0) { throw new I18nErrorV1('INVALID_NUMBER'); } - const category = new Intl.PluralRules(locale).select(seconds); + let category: Intl.LDMLPluralRule; + try { + category = selectPluralIntrinsicV1(locale, {}, seconds); + } catch { + throw new I18nErrorV1('INVALID_ARGUMENT'); + } const key = category === 'one' ? 'retry.afterSeconds.one' : 'retry.afterSeconds.other'; return formatMessageV1(locale, key, { seconds }); } diff --git a/packages/i18n/src/text-v1.ts b/packages/i18n/src/text-v1.ts index d079711b..6a8b669a 100644 --- a/packages/i18n/src/text-v1.ts +++ b/packages/i18n/src/text-v1.ts @@ -1,4 +1,5 @@ import { I18nErrorV1 } from './errors-v1.ts'; +import { normalizeStringIntrinsicV1, stringCodeUnitAtIntrinsicV1 } from './intrinsics-v1.ts'; const MAX_IDENTIFIER_LENGTH_V1 = 128; const MAX_TEXT_LENGTH_V1 = 512; @@ -7,9 +8,12 @@ const IDENTIFIER_V1 = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u; function hasUnpairedSurrogate(value: string): boolean { for (let index = 0; index < value.length; index += 1) { - const codeUnit = value.charCodeAt(index); + const codeUnit = stringCodeUnitAtIntrinsicV1(value, index); if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - const next = value.charCodeAt(index + 1); + if (index + 1 >= value.length) { + return true; + } + const next = stringCodeUnitAtIntrinsicV1(value, index + 1); if (next < 0xdc00 || next > 0xdfff) { return true; } @@ -28,7 +32,7 @@ export function sanitizeTextParameterV1(value: unknown, kind: 'identifier' | 'te let normalized: string; try { - normalized = value.normalize('NFC'); + normalized = normalizeStringIntrinsicV1(value); } catch { throw new I18nErrorV1('INVALID_PARAMETER'); } diff --git a/packages/i18n/test/intrinsic-capture-v1.test.mjs b/packages/i18n/test/intrinsic-capture-v1.test.mjs new file mode 100644 index 00000000..9269e5de --- /dev/null +++ b/packages/i18n/test/intrinsic-capture-v1.test.mjs @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import { inspect } from 'node:util'; +import test from 'node:test'; + +function replaceProperty(target, key, replacement) { + const original = Object.getOwnPropertyDescriptor(target, key); + assert.notEqual(original, undefined, `missing intrinsic ${String(key)}`); + Object.defineProperty(target, key, replacement(original)); + return () => Object.defineProperty(target, key, original); +} + +function replaceValue(target, key, value) { + return replaceProperty(target, key, (original) => ({ ...original, value })); +} + +function restoreAll(restorations) { + for (let index = restorations.length - 1; index >= 0; index -= 1) { + restorations[index](); + } +} + +function hostileFunction(calls, marker) { + return function hostileIntrinsic() { + calls.push(marker); + throw new Error(marker); + }; +} + +test('rejects every lone surrogate without leaking input and accepts valid astral text', async () => { + const { formatMessageV1, I18nErrorV1 } = await import('../src/v1.ts'); + const high = String.fromCharCode(0xd800); + const low = String.fromCharCode(0xdc00); + const invalidValues = [ + `${high}surrogate-marker-leading-high`, + `surrogate-marker-${high}-middle-high`, + `surrogate-marker-trailing-high${high}`, + `${low}surrogate-marker-leading-low`, + `surrogate-marker-${low}-middle-low`, + `surrogate-marker-trailing-low${low}`, + ]; + + for (const time of invalidValues) { + assert.throws( + () => formatMessageV1('en', 'sync.lastCompletedAt', { time }), + (error) => { + assert.equal(error instanceof I18nErrorV1, true); + assert.equal(error.code, 'INVALID_PARAMETER'); + assert.doesNotMatch(inspect(error), /surrogate-marker/u); + return true; + }, + ); + } + assert.equal( + formatMessageV1('en', 'sync.lastCompletedAt', { time: 'Launch \u{1f680}' }), + 'Last synchronized at Launch 🚀.', + ); +}); + +test('uses captured locale canonicalization, constructor, and language getter intrinsics', async () => { + const { negotiateLocaleV1 } = await import('../src/v1.ts'); + const calls = []; + const restorations = []; + const LocaleIntrinsic = Intl.Locale; + + try { + restorations.push( + replaceValue(Intl, 'getCanonicalLocales', hostileFunction(calls, 'canonical-marker')), + ); + restorations.push( + replaceValue(Intl, 'Locale', hostileFunction(calls, 'locale-constructor-marker')), + ); + restorations.push( + replaceProperty(LocaleIntrinsic.prototype, 'language', (original) => ({ + ...original, + get: hostileFunction(calls, 'locale-language-marker'), + })), + ); + + assert.equal(negotiateLocaleV1('en-US-u-ca-gregory'), 'en'); + assert.deepEqual(calls, []); + } finally { + restoreAll(restorations); + } +}); + +test('uses captured Date and DateTimeFormat constructor and method intrinsics', async () => { + const { formatDateTimeV1 } = await import('../src/v1.ts'); + const calls = []; + const restorations = []; + const DateIntrinsic = Date; + const DateTimeFormatIntrinsic = Intl.DateTimeFormat; + + try { + restorations.push( + replaceValue(globalThis, 'Date', hostileFunction(calls, 'date-constructor-marker')), + ); + restorations.push( + replaceValue( + DateIntrinsic.prototype, + 'getTime', + hostileFunction(calls, 'date-get-time-marker'), + ), + ); + restorations.push( + replaceValue(Intl, 'DateTimeFormat', hostileFunction(calls, 'date-time-constructor-marker')), + ); + restorations.push( + replaceProperty(DateTimeFormatIntrinsic.prototype, 'format', (original) => ({ + ...original, + get: hostileFunction(calls, 'date-time-format-marker'), + })), + ); + + assert.match(formatDateTimeV1(0, { locale: 'en', timeZone: 'UTC' }), /1970/u); + assert.deepEqual(calls, []); + } finally { + restoreAll(restorations); + } +}); + +test('uses captured NumberFormat constructor and format getter intrinsics', async () => { + const { formatDecimalV1 } = await import('../src/v1.ts'); + const calls = []; + const restorations = []; + const NumberFormatIntrinsic = Intl.NumberFormat; + + try { + restorations.push( + replaceValue(Intl, 'NumberFormat', hostileFunction(calls, 'number-constructor-marker')), + ); + restorations.push( + replaceProperty(NumberFormatIntrinsic.prototype, 'format', (original) => ({ + ...original, + get: hostileFunction(calls, 'number-format-marker'), + })), + ); + + assert.equal(formatDecimalV1(1234.5, { locale: 'en' }), '1,234.5'); + assert.deepEqual(calls, []); + } finally { + restoreAll(restorations); + } +}); + +test('uses captured ListFormat constructor and format method intrinsics', async () => { + const { formatListV1 } = await import('../src/v1.ts'); + const calls = []; + const restorations = []; + const ListFormatIntrinsic = Intl.ListFormat; + + try { + restorations.push( + replaceValue(Intl, 'ListFormat', hostileFunction(calls, 'list-constructor-marker')), + ); + restorations.push( + replaceValue( + ListFormatIntrinsic.prototype, + 'format', + hostileFunction(calls, 'list-format-marker'), + ), + ); + + assert.equal(formatListV1(['Web', 'Android'], { locale: 'en' }), 'Web and Android'); + assert.deepEqual(calls, []); + } finally { + restoreAll(restorations); + } +}); + +test('uses captured RelativeTimeFormat constructor and format method intrinsics', async () => { + const { formatRelativeTimeV1 } = await import('../src/v1.ts'); + const calls = []; + const restorations = []; + const RelativeTimeFormatIntrinsic = Intl.RelativeTimeFormat; + + try { + restorations.push( + replaceValue( + Intl, + 'RelativeTimeFormat', + hostileFunction(calls, 'relative-constructor-marker'), + ), + ); + restorations.push( + replaceValue( + RelativeTimeFormatIntrinsic.prototype, + 'format', + hostileFunction(calls, 'relative-format-marker'), + ), + ); + + assert.equal(formatRelativeTimeV1(-2, 'day', { locale: 'en' }), '2 days ago'); + assert.deepEqual(calls, []); + } finally { + restoreAll(restorations); + } +}); + +test('uses captured PluralRules constructor and select method for both plural APIs', async () => { + const { formatRetryAfterSecondsV1, selectPluralCategoryV1 } = await import('../src/v1.ts'); + const calls = []; + const restorations = []; + const PluralRulesIntrinsic = Intl.PluralRules; + + try { + restorations.push( + replaceValue(Intl, 'PluralRules', hostileFunction(calls, 'plural-constructor-marker')), + ); + restorations.push( + replaceValue( + PluralRulesIntrinsic.prototype, + 'select', + hostileFunction(calls, 'plural-select-marker'), + ), + ); + + assert.equal(selectPluralCategoryV1(1, { locale: 'en' }), 'one'); + assert.equal(formatRetryAfterSecondsV1('en', 2), 'Try again in 2 seconds.'); + assert.deepEqual(calls, []); + } finally { + restoreAll(restorations); + } +}); + +test('describes invalid retry seconds without claiming every failure is non-finite', async () => { + const { formatRetryAfterSecondsV1, I18nErrorV1 } = await import('../src/v1.ts'); + + for (const seconds of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws( + () => formatRetryAfterSecondsV1('en', seconds), + (error) => + error instanceof I18nErrorV1 && + error.code === 'INVALID_NUMBER' && + error.message === 'The numeric value is invalid or outside the supported range.', + ); + } +}); From 94a18463f4a00b3b3ccc97b35181a6c64b1d559d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 10:43:56 +0700 Subject: [PATCH 26/51] fix(i18n): capture message string intrinsics --- packages/i18n/src/formatting-v1.ts | 19 ++++- packages/i18n/src/intrinsics-v1.ts | 14 ++++ packages/i18n/src/messages-v1.ts | 22 +++-- .../i18n/test/intrinsic-capture-v1.test.mjs | 80 +++++++++++++++++++ 4 files changed, 126 insertions(+), 9 deletions(-) diff --git a/packages/i18n/src/formatting-v1.ts b/packages/i18n/src/formatting-v1.ts index cff331cb..44104385 100644 --- a/packages/i18n/src/formatting-v1.ts +++ b/packages/i18n/src/formatting-v1.ts @@ -1,6 +1,7 @@ import type { SupportedLocaleV1 } from './catalogs-v1.ts'; import { I18nErrorV1 } from './errors-v1.ts'; import { + convertToStringIntrinsicV1, createDateIntrinsicV1, dateTimestampIntrinsicV1, formatDateTimeIntrinsicV1, @@ -96,10 +97,15 @@ function optionalEnum(value: unknown, values: readonly T[]): T if (value === undefined) { return undefined; } - if (typeof value !== 'string' || !values.includes(value as T)) { + if (typeof value !== 'string') { throw new I18nErrorV1('INVALID_ARGUMENT'); } - return value as T; + for (let index = 0; index < values.length; index += 1) { + if (value === values[index]) { + return value as T; + } + } + throw new I18nErrorV1('INVALID_ARGUMENT'); } function optionalBoolean(value: unknown): boolean | undefined { @@ -163,14 +169,19 @@ function snapshotStringListV1(value: unknown): readonly string[] { throw new I18nErrorV1('INVALID_ARGUMENT'); } const index = Number(key); - if (!Number.isSafeInteger(index) || index < 0 || index >= length || String(index) !== key) { + if ( + !Number.isSafeInteger(index) || + index < 0 || + index >= length || + convertToStringIntrinsicV1(index) !== key + ) { throw new I18nErrorV1('INVALID_ARGUMENT'); } } const snapshot: string[] = []; for (let index = 0; index < length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + const descriptor = Object.getOwnPropertyDescriptor(value, convertToStringIntrinsicV1(index)); if ( descriptor === undefined || !Object.hasOwn(descriptor, 'value') || diff --git a/packages/i18n/src/intrinsics-v1.ts b/packages/i18n/src/intrinsics-v1.ts index af03ddbe..418d758f 100644 --- a/packages/i18n/src/intrinsics-v1.ts +++ b/packages/i18n/src/intrinsics-v1.ts @@ -55,8 +55,10 @@ const pluralSelectMethodV1 = captureMethodV1( PluralRulesConstructorV1.prototype as unknown as object, 'select', ); +const stringConversionV1 = captureMethodV1(globalThis, 'String'); const stringCharCodeAtV1 = captureMethodV1(String.prototype, 'charCodeAt'); const stringNormalizeV1 = captureMethodV1(String.prototype, 'normalize'); +const stringReplaceV1 = captureMethodV1(String.prototype, 'replace'); const stringTrimV1 = captureMethodV1(String.prototype, 'trim'); const stringSplitV1 = captureMethodV1(String.prototype, 'split'); @@ -141,10 +143,22 @@ export function stringCodeUnitAtIntrinsicV1(value: string, index: number): numbe return applyIntrinsicV1(stringCharCodeAtV1, value, [index]); } +export function convertToStringIntrinsicV1(value: unknown): string { + return applyIntrinsicV1(stringConversionV1, undefined, [value]); +} + export function normalizeStringIntrinsicV1(value: string): string { return applyIntrinsicV1(stringNormalizeV1, value, ['NFC']); } +export function replaceStringIntrinsicV1( + value: string, + pattern: RegExp, + replacement: (substring: string, capture: string) => string, +): string { + return applyIntrinsicV1(stringReplaceV1, value, [pattern, replacement]); +} + export function trimStringIntrinsicV1(value: string): string { return applyIntrinsicV1(stringTrimV1, value, []); } diff --git a/packages/i18n/src/messages-v1.ts b/packages/i18n/src/messages-v1.ts index af09f15f..cec34e53 100644 --- a/packages/i18n/src/messages-v1.ts +++ b/packages/i18n/src/messages-v1.ts @@ -6,7 +6,11 @@ import { type SupportedLocaleV1, } from './catalogs-v1.ts'; import { I18nErrorV1 } from './errors-v1.ts'; -import { selectPluralIntrinsicV1 } from './intrinsics-v1.ts'; +import { + convertToStringIntrinsicV1, + replaceStringIntrinsicV1, + selectPluralIntrinsicV1, +} from './intrinsics-v1.ts'; import { assertSupportedLocaleV1 } from './locale-v1.ts'; import { readClosedDataObjectV1 } from './safe-input-v1.ts'; import { sanitizeTextParameterV1 } from './text-v1.ts'; @@ -35,9 +39,10 @@ export function formatMessageV1( } const catalogMessage = MESSAGE_CATALOGS_V1[locale][key]; const parameterNames = Object.keys(catalogMessage.parameters); + const parameterNameSet = new Set(parameterNames); let safeParameters: Readonly>; try { - safeParameters = readClosedDataObjectV1(parameters, new Set(parameterNames)); + safeParameters = readClosedDataObjectV1(parameters, parameterNameSet); } catch (error) { if ( error instanceof I18nErrorV1 && @@ -51,7 +56,11 @@ export function formatMessageV1( } catch { throw error; } - if (keys.some((parameterName) => !parameterNames.includes(String(parameterName)))) { + if ( + keys.some( + (parameterName) => !parameterNameSet.has(convertToStringIntrinsicV1(parameterName)), + ) + ) { throw new I18nErrorV1('EXTRA_PARAMETER'); } } @@ -72,8 +81,11 @@ export function formatMessageV1( ); } - return catalogMessage.message.replace(PLACEHOLDER_V1, (_placeholder, parameterName: string) => - String(normalizedParameters[parameterName]), + return replaceStringIntrinsicV1( + catalogMessage.message, + PLACEHOLDER_V1, + (_placeholder, parameterName) => + convertToStringIntrinsicV1(normalizedParameters[parameterName]), ); } diff --git a/packages/i18n/test/intrinsic-capture-v1.test.mjs b/packages/i18n/test/intrinsic-capture-v1.test.mjs index 9269e5de..cb6e5ea6 100644 --- a/packages/i18n/test/intrinsic-capture-v1.test.mjs +++ b/packages/i18n/test/intrinsic-capture-v1.test.mjs @@ -235,3 +235,83 @@ test('describes invalid retry seconds without claiming every failure is non-fini ); } }); + +test('preserves valid i18n outputs after global String and prototype methods are replaced', async () => { + const { formatListV1, formatMessageV1, formatRetryAfterSecondsV1, negotiateLocaleV1 } = + await import('../src/v1.ts'); + const calls = []; + const restorations = []; + const StringIntrinsic = String; + let actual; + + try { + for (const method of ['replace', 'replaceAll', 'startsWith', 'includes']) { + restorations.push( + replaceValue( + StringIntrinsic.prototype, + method, + hostileFunction(calls, `string-${method}-marker`), + ), + ); + } + restorations.push( + replaceValue(globalThis, 'String', hostileFunction(calls, 'string-constructor-marker')), + ); + + actual = { + error: formatMessageV1('en', 'error.generic'), + list: formatListV1(['Web', 'Android'], { locale: 'en' }), + message: formatMessageV1('en', 'accessibility.progressLabel', { current: 2, total: 5 }), + negotiated: negotiateLocaleV1('en-US-u-ca-gregory'), + plural: formatRetryAfterSecondsV1('en', 1), + }; + } finally { + restoreAll(restorations); + } + + assert.deepEqual(actual, { + error: 'Something went wrong. Your data has been preserved.', + list: 'Web and Android', + message: 'Progress: 2 of 5.', + negotiated: 'en', + plural: 'Try again in 1 second.', + }); + assert.deepEqual(calls, []); +}); + +test('keeps extra-parameter errors stable when global String conversion is replaced', async () => { + const { formatMessageV1, I18nErrorV1 } = await import('../src/v1.ts'); + const calls = []; + const restorations = []; + const StringIntrinsic = String; + const parameters = { [Symbol('extra-parameter-marker')]: 'hidden' }; + let caught; + + try { + restorations.push( + replaceValue( + StringIntrinsic.prototype, + 'replace', + hostileFunction(calls, 'string-replace-marker'), + ), + ); + restorations.push( + replaceValue(globalThis, 'String', hostileFunction(calls, 'string-constructor-marker')), + ); + try { + formatMessageV1('en', 'action.save', parameters); + } catch (error) { + caught = error; + } + } finally { + restoreAll(restorations); + } + + assert.equal(caught instanceof I18nErrorV1, true); + assert.equal(caught.code, 'EXTRA_PARAMETER'); + assert.doesNotMatch( + inspect(caught), + /(?:extra-parameter|string-(?:constructor|replace))-marker/u, + ); + assert.deepEqual(calls, []); +}); From e5d88e9ec32b2e7f785c048e427ab8b955af02ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 10:54:32 +0700 Subject: [PATCH 27/51] feat(brand): preserve canonical databreeze assets --- packages/design-tokens/brand/manifest.json | 29 +++++++ .../brand/source/databreeze-mark-dark.png | Bin 0 -> 93346 bytes .../source/databreeze-wordmark-black.png | Bin 0 -> 107312 bytes .../brand/source/databreeze-wordmark-blue.png | Bin 0 -> 112700 bytes packages/design-tokens/package.json | 13 +++ .../design-tokens/test/brand-sources.test.mjs | 74 ++++++++++++++++++ packages/design-tokens/turbo.json | 9 +++ pnpm-lock.yaml | 2 + 8 files changed, 127 insertions(+) create mode 100644 packages/design-tokens/brand/manifest.json create mode 100644 packages/design-tokens/brand/source/databreeze-mark-dark.png create mode 100644 packages/design-tokens/brand/source/databreeze-wordmark-black.png create mode 100644 packages/design-tokens/brand/source/databreeze-wordmark-blue.png create mode 100644 packages/design-tokens/package.json create mode 100644 packages/design-tokens/test/brand-sources.test.mjs create mode 100644 packages/design-tokens/turbo.json diff --git a/packages/design-tokens/brand/manifest.json b/packages/design-tokens/brand/manifest.json new file mode 100644 index 00000000..14fb1d1d --- /dev/null +++ b/packages/design-tokens/brand/manifest.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "assets": [ + { + "file": "databreeze-mark-dark.png", + "height": 1973, + "intendedUse": "Standalone application and product mark on dark backgrounds", + "mediaType": "image/png", + "sha256": "5EE10842AD090F2BB980B51DDCF8BB4F8738C87B9659BE10387FE0B2D845B7A4", + "width": 1974 + }, + { + "file": "databreeze-wordmark-black.png", + "height": 1155, + "intendedUse": "Monochrome DataBreeze wordmark on light backgrounds", + "mediaType": "image/png", + "sha256": "4F37835E9648E7035DE9BCB6ADA05C1203A1C05A1D0DB81DF1D1AEA01D46FC98", + "width": 4710 + }, + { + "file": "databreeze-wordmark-blue.png", + "height": 1155, + "intendedUse": "Primary DataBreeze wordmark on light backgrounds", + "mediaType": "image/png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D", + "width": 4710 + } + ] +} diff --git a/packages/design-tokens/brand/source/databreeze-mark-dark.png b/packages/design-tokens/brand/source/databreeze-mark-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e3e6e55f429bfe7d0c77009549f5c40af763a784 GIT binary patch literal 93346 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

dELeNOU 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 u(pSryGPoTBe$2)A={-ZmlRrh$NoP|rQ@Ce literal 0 HcmV?d00001 diff --git a/packages/design-tokens/brand/source/databreeze-wordmark-black.png b/packages/design-tokens/brand/source/databreeze-wordmark-black.png new file mode 100644 index 0000000000000000000000000000000000000000..aa1b762c4241ad83df983c0c3edf75a00e4fb522 GIT binary patch literal 107312 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 z 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 V+NfA=TC1LLTspr)?inO=#5b{mq<5A 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 Wc1$l7*A)5^wc1)8+JBGg5%+)gvIIW> literal 0 HcmV?d00001 diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json new file mode 100644 index 00000000..e70a06f0 --- /dev/null +++ b/packages/design-tokens/package.json @@ -0,0 +1,13 @@ +{ + "name": "@databreeze/design-tokens", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./brand/manifest.json": "./brand/manifest.json", + "./brand/source/*": "./brand/source/*" + }, + "scripts": { + "test": "node --test test/**/*.test.mjs" + } +} diff --git a/packages/design-tokens/test/brand-sources.test.mjs b/packages/design-tokens/test/brand-sources.test.mjs new file mode 100644 index 00000000..ed9ca0d1 --- /dev/null +++ b/packages/design-tokens/test/brand-sources.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import { readdir, readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import { URL } from 'node:url'; + +const brandDirectory = new URL('../brand/', import.meta.url); +const sourceDirectory = new URL('source/', brandDirectory); +const manifestUrl = new URL('manifest.json', brandDirectory); + +const approvedAssets = [ + { + file: 'databreeze-mark-dark.png', + height: 1973, + intendedUse: 'Standalone application and product mark on dark backgrounds', + mediaType: 'image/png', + sha256: '5EE10842AD090F2BB980B51DDCF8BB4F8738C87B9659BE10387FE0B2D845B7A4', + width: 1974, + }, + { + file: 'databreeze-wordmark-black.png', + height: 1155, + intendedUse: 'Monochrome DataBreeze wordmark on light backgrounds', + mediaType: 'image/png', + sha256: '4F37835E9648E7035DE9BCB6ADA05C1203A1C05A1D0DB81DF1D1AEA01D46FC98', + width: 4710, + }, + { + file: 'databreeze-wordmark-blue.png', + height: 1155, + intendedUse: 'Primary DataBreeze wordmark on light backgrounds', + mediaType: 'image/png', + sha256: 'B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D', + width: 4710, + }, +]; + +const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +async function readManifest() { + return JSON.parse(await readFile(manifestUrl, 'utf8')); +} + +test('the manifest records the complete approved immutable source set', async () => { + const manifest = await readManifest(); + + assert.deepEqual(manifest, { + schemaVersion: 1, + assets: approvedAssets, + }); +}); + +test('the source directory contains only the three canonical named PNGs', async () => { + const entries = await readdir(sourceDirectory, { withFileTypes: true }); + const sourceFiles = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(); + + assert.deepEqual(sourceFiles, approvedAssets.map(({ file }) => file).sort()); +}); + +for (const asset of approvedAssets) { + test(`${asset.file} retains its approved bytes and PNG dimensions`, async () => { + const bytes = await readFile(new URL(asset.file, sourceDirectory)); + + assert.deepEqual(bytes.subarray(0, pngSignature.length), pngSignature); + assert.equal(bytes.subarray(12, 16).toString('ascii'), 'IHDR'); + assert.equal(bytes.readUInt32BE(16), asset.width); + assert.equal(bytes.readUInt32BE(20), asset.height); + assert.equal(createHash('sha256').update(bytes).digest('hex').toUpperCase(), asset.sha256); + }); +} diff --git a/packages/design-tokens/turbo.json b/packages/design-tokens/turbo.json new file mode 100644 index 00000000..25e76c6b --- /dev/null +++ b/packages/design-tokens/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test": { + "outputs": [] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d938c5f1..704a4b92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,8 @@ importers: specifier: 3.0.1 version: 3.0.1(ajv@8.17.1) + packages/design-tokens: {} + packages/domain: dependencies: '@databreeze/contracts': From 98a0b6fc2c2172d73f3204cd533d0618dc26e27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 11:03:34 +0700 Subject: [PATCH 28/51] fix(brand): reject non-file source entries --- .../design-tokens/test/brand-sources.test.mjs | 65 +++++++++++++++++-- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/design-tokens/test/brand-sources.test.mjs b/packages/design-tokens/test/brand-sources.test.mjs index ed9ca0d1..9675fb8c 100644 --- a/packages/design-tokens/test/brand-sources.test.mjs +++ b/packages/design-tokens/test/brand-sources.test.mjs @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; -import { readdir, readFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { test } from 'node:test'; import { URL } from 'node:url'; @@ -42,6 +44,29 @@ async function readManifest() { return JSON.parse(await readFile(manifestUrl, 'utf8')); } +async function sourceFileNames(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)); + + for (const entry of entries) { + if (!entry.isFile()) { + throw new Error(`Unexpected brand source entry "${entry.name}": expected a regular file`); + } + } + + return entries.map((entry) => entry.name); +} + +async function createTemporarySourceDirectory() { + const root = await mkdtemp(join(tmpdir(), 'databreeze-brand-source-')); + const directory = join(root, 'source'); + await mkdir(directory); + await Promise.all( + approvedAssets.map(({ file }) => writeFile(join(directory, file), Buffer.alloc(0))), + ); + return { directory, root }; +} + test('the manifest records the complete approved immutable source set', async () => { const manifest = await readManifest(); @@ -52,15 +77,43 @@ test('the manifest records the complete approved immutable source set', async () }); test('the source directory contains only the three canonical named PNGs', async () => { - const entries = await readdir(sourceDirectory, { withFileTypes: true }); - const sourceFiles = entries - .filter((entry) => entry.isFile()) - .map((entry) => entry.name) - .sort(); + const sourceFiles = await sourceFileNames(sourceDirectory); assert.deepEqual(sourceFiles, approvedAssets.map(({ file }) => file).sort()); }); +test('source-set validation rejects an unexpected nested directory', async () => { + const temporary = await createTemporarySourceDirectory(); + + try { + await mkdir(join(temporary.directory, 'legacy')); + + await assert.rejects( + sourceFileNames(temporary.directory), + /Unexpected brand source entry "legacy": expected a regular file/, + ); + } finally { + await rm(temporary.root, { force: true, recursive: true }); + } +}); + +test('source-set validation rejects an unexpected symbolic link', async () => { + const temporary = await createTemporarySourceDirectory(); + + try { + const legacyDirectory = join(temporary.root, 'legacy'); + await mkdir(legacyDirectory); + await symlink(legacyDirectory, join(temporary.directory, 'asset-4.png'), 'junction'); + + await assert.rejects( + sourceFileNames(temporary.directory), + /Unexpected brand source entry "asset-4\.png": expected a regular file/, + ); + } finally { + await rm(temporary.root, { force: true, recursive: true }); + } +}); + for (const asset of approvedAssets) { test(`${asset.file} retains its approved bytes and PNG dimensions`, async () => { const bytes = await readFile(new URL(asset.file, sourceDirectory)); From 422798e140a7909f65beb1f33120b53d17d41a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 11:28:05 +0700 Subject: [PATCH 29/51] feat(brand): generate platform logo derivatives --- .gitattributes | 3 + .prettierignore | 1 + packages/design-tokens/README.md | 12 + .../design-tokens/brand/derivative-plan.json | 316 +++++++ packages/design-tokens/brand/derivatives.json | 884 ++++++++++++++++++ .../android/adaptive-foreground-432.png | Bin 0 -> 12626 bytes .../generated/android/launcher-hdpi-72.png | Bin 0 -> 2658 bytes .../generated/android/launcher-mdpi-48.png | Bin 0 -> 1817 bytes .../generated/android/launcher-xhdpi-96.png | Bin 0 -> 3403 bytes .../generated/android/launcher-xxhdpi-144.png | Bin 0 -> 5112 bytes .../android/launcher-xxxhdpi-192.png | Bin 0 -> 6756 bytes .../android/notification-hdpi-36.png | Bin 0 -> 1184 bytes .../android/notification-mdpi-24.png | Bin 0 -> 782 bytes .../android/notification-xhdpi-48.png | Bin 0 -> 1582 bytes .../android/notification-xxhdpi-72.png | Bin 0 -> 2264 bytes .../android/notification-xxxhdpi-96.png | Bin 0 -> 3015 bytes .../generated/desktop/application-256.png | Bin 0 -> 7512 bytes .../brand/generated/desktop/application.ico | Bin 0 -> 20526 bytes .../brand/generated/desktop/installer.ico | Bin 0 -> 20526 bytes .../generated/desktop/notification-32.png | Bin 0 -> 1122 bytes .../brand/generated/desktop/updater.ico | Bin 0 -> 20526 bytes .../generated/web/apple-touch-icon-180.png | Bin 0 -> 6335 bytes .../brand/generated/web/favicon-16.png | Bin 0 -> 542 bytes .../brand/generated/web/favicon-32.png | Bin 0 -> 1173 bytes .../brand/generated/web/install-icon-192.png | Bin 0 -> 6756 bytes .../brand/generated/web/install-icon-512.png | Bin 0 -> 18761 bytes .../web/navigation-wordmark-black-204x50.png | Bin 0 -> 3946 bytes .../web/navigation-wordmark-blue-204x50.png | Bin 0 -> 6632 bytes .../generated/web/social-card-1200x630.png | Bin 0 -> 33448 bytes packages/design-tokens/package.json | 8 + .../scripts/generate-brand-derivatives.mjs | 419 +++++++++ .../test/brand-derivatives.test.mjs | 275 ++++++ .../test/fixtures/brand-visual-golden.json | 29 + packages/design-tokens/turbo.json | 3 + pnpm-lock.yaml | 335 ++++++- 35 files changed, 2284 insertions(+), 1 deletion(-) create mode 100644 packages/design-tokens/brand/derivative-plan.json create mode 100644 packages/design-tokens/brand/derivatives.json create mode 100644 packages/design-tokens/brand/generated/android/adaptive-foreground-432.png create mode 100644 packages/design-tokens/brand/generated/android/launcher-hdpi-72.png create mode 100644 packages/design-tokens/brand/generated/android/launcher-mdpi-48.png create mode 100644 packages/design-tokens/brand/generated/android/launcher-xhdpi-96.png create mode 100644 packages/design-tokens/brand/generated/android/launcher-xxhdpi-144.png create mode 100644 packages/design-tokens/brand/generated/android/launcher-xxxhdpi-192.png create mode 100644 packages/design-tokens/brand/generated/android/notification-hdpi-36.png create mode 100644 packages/design-tokens/brand/generated/android/notification-mdpi-24.png create mode 100644 packages/design-tokens/brand/generated/android/notification-xhdpi-48.png create mode 100644 packages/design-tokens/brand/generated/android/notification-xxhdpi-72.png create mode 100644 packages/design-tokens/brand/generated/android/notification-xxxhdpi-96.png create mode 100644 packages/design-tokens/brand/generated/desktop/application-256.png create mode 100644 packages/design-tokens/brand/generated/desktop/application.ico create mode 100644 packages/design-tokens/brand/generated/desktop/installer.ico create mode 100644 packages/design-tokens/brand/generated/desktop/notification-32.png create mode 100644 packages/design-tokens/brand/generated/desktop/updater.ico create mode 100644 packages/design-tokens/brand/generated/web/apple-touch-icon-180.png create mode 100644 packages/design-tokens/brand/generated/web/favicon-16.png create mode 100644 packages/design-tokens/brand/generated/web/favicon-32.png create mode 100644 packages/design-tokens/brand/generated/web/install-icon-192.png create mode 100644 packages/design-tokens/brand/generated/web/install-icon-512.png create mode 100644 packages/design-tokens/brand/generated/web/navigation-wordmark-black-204x50.png create mode 100644 packages/design-tokens/brand/generated/web/navigation-wordmark-blue-204x50.png create mode 100644 packages/design-tokens/brand/generated/web/social-card-1200x630.png create mode 100644 packages/design-tokens/scripts/generate-brand-derivatives.mjs create mode 100644 packages/design-tokens/test/brand-derivatives.test.mjs create mode 100644 packages/design-tokens/test/fixtures/brand-visual-golden.json diff --git a/.gitattributes b/.gitattributes index cda27fc6..639ded72 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ * text=auto .gitattributes text eol=lf .gitignore text eol=lf +.prettierignore text eol=lf *.sha256 text eol=lf *.md text eol=lf *.json text eol=lf @@ -10,6 +11,7 @@ *.toml text eol=lf *.ts text eol=lf *.tsx text eol=lf +*.mjs text eol=lf *.kt text eol=lf *.kts text eol=lf *.py text eol=lf @@ -17,6 +19,7 @@ *.bat text eol=crlf *.cmd text eol=crlf *.png binary +*.ico binary *.jpg binary *.jpeg binary *.gif binary diff --git a/.prettierignore b/.prettierignore index 680dcaab..cfd45e6d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,4 @@ docs/ **/README.md pnpm-lock.yaml packages/contracts/generated/ +packages/design-tokens/brand/derivatives.json diff --git a/packages/design-tokens/README.md b/packages/design-tokens/README.md index d008e8b0..bbd4508e 100644 --- a/packages/design-tokens/README.md +++ b/packages/design-tokens/README.md @@ -1,3 +1,15 @@ # Design Tokens Platform-neutral DataBreeze color, typography, spacing, motion, and icon tokens with generated outputs for React and Android. + +## Canonical brand assets + +`brand/source/` contains the three immutable legacy assets. Never edit those files. Their approved dimensions and SHA-256 values live in `brand/manifest.json` and are checked before any derivative is created. + +The declarative `brand/derivative-plan.json` records every Web, Windows Desktop, and Android output, including its source, purpose, dimensions, content box, and safe-zone policy. The generator permits only source cropping, aspect-preserving resizing, transparent padding, and PNG/ICO container conversion. It does not redraw, recolor, or distort the logo. + +Run `pnpm brand:generate` after an approved plan or pipeline change. Run `pnpm brand:check` to regenerate into a temporary clean directory and byte-compare the result with `brand/generated/` and `brand/derivatives.json`. `pnpm build` performs the same drift check. + +Wordmark derivatives already contain the DataBreeze name and must not be placed beside duplicate visible “DataBreeze” text. Standalone-mark derivatives may be paired with product text only when the surrounding interface or accessible name requires it. + +Android notification PNGs are full-color, transparent reference sources. The native Android shell owns the later platform-specific monochrome/tint resource so this foundation pipeline never recolors the approved source. diff --git a/packages/design-tokens/brand/derivative-plan.json b/packages/design-tokens/brand/derivative-plan.json new file mode 100644 index 00000000..2a5fc716 --- /dev/null +++ b/packages/design-tokens/brand/derivative-plan.json @@ -0,0 +1,316 @@ +{ + "schemaVersion": 1, + "pipeline": { + "engine": "sharp", + "engineVersion": "0.35.3", + "pixelPolicy": "sRGB source colors are preserved; only crop, aspect-preserving contain resize, transparent padding, and PNG/ICO container conversion are allowed", + "png": { + "adaptiveFiltering": false, + "compressionLevel": 9, + "effort": 10, + "palette": false + } + }, + "sources": { + "blueMark": { + "crop": { "height": 1155, "left": 0, "top": 0, "width": 1155 }, + "file": "databreeze-wordmark-blue.png" + }, + "blueWordmark": { "file": "databreeze-wordmark-blue.png" }, + "blackWordmark": { "file": "databreeze-wordmark-black.png" }, + "darkMark": { "file": "databreeze-mark-dark.png" } + }, + "assets": [ + { + "file": "android/adaptive-foreground-432.png", + "platform": "android", + "purpose": "Adaptive launcher foreground source", + "source": "blueMark", + "width": 432, + "height": 432, + "contentBox": { "x": 84, "y": 84, "width": 264, "height": 264 }, + "safeZone": "android-adaptive-66dp-within-108dp", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "android/launcher-hdpi-72.png", + "platform": "android", + "purpose": "Legacy launcher source (hdpi)", + "source": "blueMark", + "width": 72, + "height": 72, + "contentBox": { "x": 7, "y": 7, "width": 58, "height": 58 }, + "safeZone": "10-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "android/launcher-mdpi-48.png", + "platform": "android", + "purpose": "Legacy launcher source (mdpi)", + "source": "blueMark", + "width": 48, + "height": 48, + "contentBox": { "x": 5, "y": 5, "width": 38, "height": 38 }, + "safeZone": "10-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "android/launcher-xhdpi-96.png", + "platform": "android", + "purpose": "Legacy launcher source (xhdpi)", + "source": "blueMark", + "width": 96, + "height": 96, + "contentBox": { "x": 10, "y": 10, "width": 76, "height": 76 }, + "safeZone": "10-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "android/launcher-xxhdpi-144.png", + "platform": "android", + "purpose": "Legacy launcher source (xxhdpi)", + "source": "blueMark", + "width": 144, + "height": 144, + "contentBox": { "x": 14, "y": 14, "width": 116, "height": 116 }, + "safeZone": "10-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "android/launcher-xxxhdpi-192.png", + "platform": "android", + "purpose": "Legacy launcher source (xxxhdpi)", + "source": "blueMark", + "width": 192, + "height": 192, + "contentBox": { "x": 19, "y": 19, "width": 154, "height": 154 }, + "safeZone": "10-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "android/notification-hdpi-36.png", + "platform": "android", + "purpose": "Full-color notification reference source (hdpi); platform tint asset is created in the Android shell", + "source": "blueMark", + "width": 36, + "height": 36, + "contentBox": { "x": 6, "y": 6, "width": 24, "height": 24 }, + "safeZone": "one-sixth-per-edge", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "android/notification-mdpi-24.png", + "platform": "android", + "purpose": "Full-color notification reference source (mdpi); platform tint asset is created in the Android shell", + "source": "blueMark", + "width": 24, + "height": 24, + "contentBox": { "x": 4, "y": 4, "width": 16, "height": 16 }, + "safeZone": "one-sixth-per-edge", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "android/notification-xhdpi-48.png", + "platform": "android", + "purpose": "Full-color notification reference source (xhdpi); platform tint asset is created in the Android shell", + "source": "blueMark", + "width": 48, + "height": 48, + "contentBox": { "x": 8, "y": 8, "width": 32, "height": 32 }, + "safeZone": "one-sixth-per-edge", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "android/notification-xxhdpi-72.png", + "platform": "android", + "purpose": "Full-color notification reference source (xxhdpi); platform tint asset is created in the Android shell", + "source": "blueMark", + "width": 72, + "height": 72, + "contentBox": { "x": 12, "y": 12, "width": 48, "height": 48 }, + "safeZone": "one-sixth-per-edge", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "android/notification-xxxhdpi-96.png", + "platform": "android", + "purpose": "Full-color notification reference source (xxxhdpi); platform tint asset is created in the Android shell", + "source": "blueMark", + "width": 96, + "height": 96, + "contentBox": { "x": 16, "y": 16, "width": 64, "height": 64 }, + "safeZone": "one-sixth-per-edge", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "desktop/application-256.png", + "platform": "desktop", + "purpose": "Windows application icon PNG", + "source": "darkMark", + "width": 256, + "height": 256, + "contentBox": { "x": 13, "y": 13, "width": 230, "height": 230 }, + "safeZone": "5-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "desktop/application.ico", + "platform": "desktop", + "purpose": "Windows application multi-resolution icon", + "source": "blueMark", + "width": 256, + "height": 256, + "contentBox": { "x": 26, "y": 26, "width": 204, "height": 204 }, + "safeZone": "10-percent-minimum", + "frames": [16, 24, 32, 48, 64, 128, 256], + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "desktop/installer.ico", + "platform": "desktop", + "purpose": "Windows installer multi-resolution icon", + "source": "blueMark", + "width": 256, + "height": 256, + "contentBox": { "x": 26, "y": 26, "width": 204, "height": 204 }, + "safeZone": "10-percent-minimum", + "frames": [16, 24, 32, 48, 64, 128, 256], + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "desktop/notification-32.png", + "platform": "desktop", + "purpose": "Windows notification icon PNG", + "source": "blueMark", + "width": 32, + "height": 32, + "contentBox": { "x": 5, "y": 5, "width": 22, "height": 22 }, + "safeZone": "15-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "desktop/updater.ico", + "platform": "desktop", + "purpose": "Windows updater multi-resolution icon", + "source": "blueMark", + "width": 256, + "height": 256, + "contentBox": { "x": 26, "y": 26, "width": 204, "height": 204 }, + "safeZone": "10-percent-minimum", + "frames": [16, 24, 32, 48, 64, 128, 256], + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "web/apple-touch-icon-180.png", + "platform": "web", + "purpose": "Apple touch install icon", + "source": "blueMark", + "width": 180, + "height": 180, + "contentBox": { "x": 18, "y": 18, "width": 144, "height": 144 }, + "safeZone": "10-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "web/favicon-16.png", + "platform": "web", + "purpose": "Browser favicon (16px)", + "source": "blueMark", + "width": 16, + "height": 16, + "contentBox": { "x": 2, "y": 2, "width": 12, "height": 12 }, + "safeZone": "12.5-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "web/favicon-32.png", + "platform": "web", + "purpose": "Browser favicon (32px)", + "source": "blueMark", + "width": 32, + "height": 32, + "contentBox": { "x": 4, "y": 4, "width": 24, "height": 24 }, + "safeZone": "12.5-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "web/install-icon-192.png", + "platform": "web", + "purpose": "Web application install icon (192px)", + "source": "blueMark", + "width": 192, + "height": 192, + "contentBox": { "x": 19, "y": 19, "width": 154, "height": 154 }, + "safeZone": "10-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "web/install-icon-512.png", + "platform": "web", + "purpose": "Web application install icon (512px)", + "source": "blueMark", + "width": 512, + "height": 512, + "contentBox": { "x": 51, "y": 51, "width": 410, "height": 410 }, + "safeZone": "10-percent-minimum", + "containsWordmark": false, + "adjacentProductNamePolicy": "accessible-context-only" + }, + { + "file": "web/navigation-wordmark-black-204x50.png", + "platform": "web", + "purpose": "Black navigation wordmark for light surfaces", + "source": "blackWordmark", + "width": 204, + "height": 50, + "contentBox": { "x": 0, "y": 0, "width": 204, "height": 50 }, + "safeZone": "source-clear-space-only", + "containsWordmark": true, + "adjacentProductNamePolicy": "forbidden" + }, + { + "file": "web/navigation-wordmark-blue-204x50.png", + "platform": "web", + "purpose": "Primary blue navigation wordmark for light surfaces", + "source": "blueWordmark", + "width": 204, + "height": 50, + "contentBox": { "x": 0, "y": 0, "width": 204, "height": 50 }, + "safeZone": "source-clear-space-only", + "containsWordmark": true, + "adjacentProductNamePolicy": "forbidden" + }, + { + "file": "web/social-card-1200x630.png", + "platform": "web", + "purpose": "Social metadata image without adjacent duplicate product text", + "source": "blueWordmark", + "width": 1200, + "height": 630, + "contentBox": { "x": 120, "y": 126, "width": 960, "height": 378 }, + "safeZone": "10-percent-horizontal-and-20-percent-vertical", + "containsWordmark": true, + "adjacentProductNamePolicy": "forbidden" + } + ] +} diff --git a/packages/design-tokens/brand/derivatives.json b/packages/design-tokens/brand/derivatives.json new file mode 100644 index 00000000..fa082c62 --- /dev/null +++ b/packages/design-tokens/brand/derivatives.json @@ -0,0 +1,884 @@ +{ + "schemaVersion": 1, + "generator": { + "engine": "sharp", + "engineVersion": "0.35.3", + "icoContainer": "png-frame-ico-v1", + "png": { + "adaptiveFiltering": false, + "compressionLevel": 9, + "effort": 10, + "palette": false + } + }, + "sourceManifestSha256": "78F718C9B32303F5C2C746BFF5F7698740CCC1565F5D0242AF0E4B83263BF7A0", + "assets": [ + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 84, + "y": 84, + "width": 264, + "height": 264 + }, + "file": "android/adaptive-foreground-432.png", + "fittedBox": { + "x": 84, + "y": 84, + "width": 264, + "height": 264 + }, + "height": 432, + "mediaType": "image/png", + "platform": "android", + "purpose": "Adaptive launcher foreground source", + "safeZone": "android-adaptive-66dp-within-108dp", + "sha256": "2342B1B56C9D16E009EFA5D285D030C8BD37DBB2135223A9D819DB098B86732B", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "C16B2F8154035627EE4BC1970B6BFC21C557AE8D44F1A33F85DE3C3377A7E645", + "width": 432 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 7, + "y": 7, + "width": 58, + "height": 58 + }, + "file": "android/launcher-hdpi-72.png", + "fittedBox": { + "x": 7, + "y": 7, + "width": 58, + "height": 58 + }, + "height": 72, + "mediaType": "image/png", + "platform": "android", + "purpose": "Legacy launcher source (hdpi)", + "safeZone": "10-percent-minimum", + "sha256": "37FB331CA337961A759DF4D937D780CA291305B3B0FB0DDC9A61306127278448", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "C850208368B49DD40EB5206145DC8044A0AD97AD5A15222B5AAA14458ACBD626", + "width": 72 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 5, + "y": 5, + "width": 38, + "height": 38 + }, + "file": "android/launcher-mdpi-48.png", + "fittedBox": { + "x": 5, + "y": 5, + "width": 38, + "height": 38 + }, + "height": 48, + "mediaType": "image/png", + "platform": "android", + "purpose": "Legacy launcher source (mdpi)", + "safeZone": "10-percent-minimum", + "sha256": "DCF3ACFC20775B7F23EB70383032AEC11B0CFAC9D7149B6CE3E945EC570AB295", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "2249A9C233228CDF3E7CD648AD7E011F1369837C00BA34F4407B71FA2C134216", + "width": 48 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 10, + "y": 10, + "width": 76, + "height": 76 + }, + "file": "android/launcher-xhdpi-96.png", + "fittedBox": { + "x": 10, + "y": 10, + "width": 76, + "height": 76 + }, + "height": 96, + "mediaType": "image/png", + "platform": "android", + "purpose": "Legacy launcher source (xhdpi)", + "safeZone": "10-percent-minimum", + "sha256": "7F03E51CD277518B9DBA1BEAC381F68E29B9385BBCF76630E8C373249DC12961", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "D8C304EDE847F7F8B370F3477EBE155E951644F7406FE77FA44596CBB4497EBB", + "width": 96 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 14, + "y": 14, + "width": 116, + "height": 116 + }, + "file": "android/launcher-xxhdpi-144.png", + "fittedBox": { + "x": 14, + "y": 14, + "width": 116, + "height": 116 + }, + "height": 144, + "mediaType": "image/png", + "platform": "android", + "purpose": "Legacy launcher source (xxhdpi)", + "safeZone": "10-percent-minimum", + "sha256": "25FDB53E6BDF4E433B8F9A4036E86A94BE457BE6BDD65BDD06B83037395CF93A", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "56AAE8DD4F4DDA38B333325F56D2CF0064E7866DF22C15C7564D294B8E08377E", + "width": 144 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 19, + "y": 19, + "width": 154, + "height": 154 + }, + "file": "android/launcher-xxxhdpi-192.png", + "fittedBox": { + "x": 19, + "y": 19, + "width": 154, + "height": 154 + }, + "height": 192, + "mediaType": "image/png", + "platform": "android", + "purpose": "Legacy launcher source (xxxhdpi)", + "safeZone": "10-percent-minimum", + "sha256": "8AD6B9B8D6441E24A97F28ED8B6492779AAB24639DBAB972CF67A04F05170C2B", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "B83E179BC03B00A0C6B9ED6CD14B89BDBB792730799FD1890DC1B6769DE1367B", + "width": 192 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 6, + "y": 6, + "width": 24, + "height": 24 + }, + "file": "android/notification-hdpi-36.png", + "fittedBox": { + "x": 6, + "y": 6, + "width": 24, + "height": 24 + }, + "height": 36, + "mediaType": "image/png", + "platform": "android", + "purpose": "Full-color notification reference source (hdpi); platform tint asset is created in the Android shell", + "safeZone": "one-sixth-per-edge", + "sha256": "703BCC1E42F25D5AA205DB7612AB4E17E9CED5057A8992E29E54F21449C092D0", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "52210F51643C72E92152F2DA156CC8B18EA0C215BE49452C4E1F905B05A944B3", + "width": 36 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 4, + "y": 4, + "width": 16, + "height": 16 + }, + "file": "android/notification-mdpi-24.png", + "fittedBox": { + "x": 4, + "y": 4, + "width": 16, + "height": 16 + }, + "height": 24, + "mediaType": "image/png", + "platform": "android", + "purpose": "Full-color notification reference source (mdpi); platform tint asset is created in the Android shell", + "safeZone": "one-sixth-per-edge", + "sha256": "F64E241F0B86D328A10C616F933066CBFADDBB7AF41C29C91C3F8662477A8410", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "1FAB0BBDBE6BB698871E7CC7A53F67309C430800AF8A67DA8145C6A0E080A629", + "width": 24 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 8, + "y": 8, + "width": 32, + "height": 32 + }, + "file": "android/notification-xhdpi-48.png", + "fittedBox": { + "x": 8, + "y": 8, + "width": 32, + "height": 32 + }, + "height": 48, + "mediaType": "image/png", + "platform": "android", + "purpose": "Full-color notification reference source (xhdpi); platform tint asset is created in the Android shell", + "safeZone": "one-sixth-per-edge", + "sha256": "7BC721DDBD0F1D18E6039F06F01F2CB5585B1DCC1A24E1FF0D8A11CDC1E5564B", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "C2A801F109B8819723F889AD3D37A64CA9CE88EAE3C1C19E74A79AA0443F4842", + "width": 48 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 12, + "y": 12, + "width": 48, + "height": 48 + }, + "file": "android/notification-xxhdpi-72.png", + "fittedBox": { + "x": 12, + "y": 12, + "width": 48, + "height": 48 + }, + "height": 72, + "mediaType": "image/png", + "platform": "android", + "purpose": "Full-color notification reference source (xxhdpi); platform tint asset is created in the Android shell", + "safeZone": "one-sixth-per-edge", + "sha256": "C369CB8CB832B282F8878E650141C46F3C32D3E9FCDDDEB2FDCBF6ED015E1EF6", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "72DBB2A440698170F2FCA82C15C1F21CB6A0A5DD754B16F301B467234540A7ED", + "width": 72 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 16, + "y": 16, + "width": 64, + "height": 64 + }, + "file": "android/notification-xxxhdpi-96.png", + "fittedBox": { + "x": 16, + "y": 16, + "width": 64, + "height": 64 + }, + "height": 96, + "mediaType": "image/png", + "platform": "android", + "purpose": "Full-color notification reference source (xxxhdpi); platform tint asset is created in the Android shell", + "safeZone": "one-sixth-per-edge", + "sha256": "6145C0AF5B8BEBA206CCDF2948A560402570EE044199C75AA0A979C1071AC227", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "2B5CB8E94058FE42C45B43D9E21933A41816FBB91A82F123E2AF460BFA405477", + "width": 96 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 13, + "y": 13, + "width": 230, + "height": 230 + }, + "file": "desktop/application-256.png", + "fittedBox": { + "x": 13, + "y": 13, + "width": 230, + "height": 230 + }, + "height": 256, + "mediaType": "image/png", + "platform": "desktop", + "purpose": "Windows application icon PNG", + "safeZone": "5-percent-minimum", + "sha256": "E8E5118585FCAC39C63A85A537B6F6930F5675D0F2960CD9186197FD7CBC417E", + "source": { + "file": "databreeze-mark-dark.png", + "sha256": "5EE10842AD090F2BB980B51DDCF8BB4F8738C87B9659BE10387FE0B2D845B7A4" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "E38BD7B30191F57F9399D39972649B252AD8C30708FCB476A77C77A0A4E4E373", + "width": 256 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 26, + "y": 26, + "width": 204, + "height": 204 + }, + "file": "desktop/application.ico", + "fittedBox": { + "x": 26, + "y": 26, + "width": 204, + "height": 204 + }, + "frames": [ + 16, + 24, + 32, + 48, + 64, + 128, + 256 + ], + "height": 256, + "mediaType": "image/x-icon", + "platform": "desktop", + "purpose": "Windows application multi-resolution icon", + "safeZone": "10-percent-minimum", + "sha256": "01151CE213CEF964112854C3E09195A38C6AA4457359B89F90EE63ECD62DB00C", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "DFE2F8828A981E686B2EAF8BB7E2C960AB7575B88081D59B79469374021B0058", + "width": 256 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 26, + "y": 26, + "width": 204, + "height": 204 + }, + "file": "desktop/installer.ico", + "fittedBox": { + "x": 26, + "y": 26, + "width": 204, + "height": 204 + }, + "frames": [ + 16, + 24, + 32, + 48, + 64, + 128, + 256 + ], + "height": 256, + "mediaType": "image/x-icon", + "platform": "desktop", + "purpose": "Windows installer multi-resolution icon", + "safeZone": "10-percent-minimum", + "sha256": "01151CE213CEF964112854C3E09195A38C6AA4457359B89F90EE63ECD62DB00C", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "DFE2F8828A981E686B2EAF8BB7E2C960AB7575B88081D59B79469374021B0058", + "width": 256 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 5, + "y": 5, + "width": 22, + "height": 22 + }, + "file": "desktop/notification-32.png", + "fittedBox": { + "x": 5, + "y": 5, + "width": 22, + "height": 22 + }, + "height": 32, + "mediaType": "image/png", + "platform": "desktop", + "purpose": "Windows notification icon PNG", + "safeZone": "15-percent-minimum", + "sha256": "F6A0FC62CBD49DEDC1702F17D42B2D65448A8FDE98770069A5A6D312802246A4", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "38903201463667946F1232BF5235841B937208520E39568C564F0BC2E3F64ADD", + "width": 32 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 26, + "y": 26, + "width": 204, + "height": 204 + }, + "file": "desktop/updater.ico", + "fittedBox": { + "x": 26, + "y": 26, + "width": 204, + "height": 204 + }, + "frames": [ + 16, + 24, + 32, + 48, + 64, + 128, + 256 + ], + "height": 256, + "mediaType": "image/x-icon", + "platform": "desktop", + "purpose": "Windows updater multi-resolution icon", + "safeZone": "10-percent-minimum", + "sha256": "01151CE213CEF964112854C3E09195A38C6AA4457359B89F90EE63ECD62DB00C", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "DFE2F8828A981E686B2EAF8BB7E2C960AB7575B88081D59B79469374021B0058", + "width": 256 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 18, + "y": 18, + "width": 144, + "height": 144 + }, + "file": "web/apple-touch-icon-180.png", + "fittedBox": { + "x": 18, + "y": 18, + "width": 144, + "height": 144 + }, + "height": 180, + "mediaType": "image/png", + "platform": "web", + "purpose": "Apple touch install icon", + "safeZone": "10-percent-minimum", + "sha256": "2809E173878DB399BCFB974799F05B4930F85DFE6C198E64F7A2117DD20E0927", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "4049EC9E808406AB767E07EC5F11A37021453D1ED601E04ED2C910612B8248CB", + "width": 180 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 2, + "y": 2, + "width": 12, + "height": 12 + }, + "file": "web/favicon-16.png", + "fittedBox": { + "x": 2, + "y": 2, + "width": 12, + "height": 12 + }, + "height": 16, + "mediaType": "image/png", + "platform": "web", + "purpose": "Browser favicon (16px)", + "safeZone": "12.5-percent-minimum", + "sha256": "F471D3E2CA6BF8A3ACA2E95A3FBA5FA97E6ABF56E713D280361A7B15993600E9", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "A7DC5A14885ABC87316C39E803D9C8BEE2FB560189C1096F2CC3E323CE175327", + "width": 16 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 4, + "y": 4, + "width": 24, + "height": 24 + }, + "file": "web/favicon-32.png", + "fittedBox": { + "x": 4, + "y": 4, + "width": 24, + "height": 24 + }, + "height": 32, + "mediaType": "image/png", + "platform": "web", + "purpose": "Browser favicon (32px)", + "safeZone": "12.5-percent-minimum", + "sha256": "267894B71A948F7673004ACF910D7673AEE925635E84078BB5967161EC2FFE39", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "C01AA26FD1B9DDC0CAE0175EB7AC7A16E0CF802C354AB1DE7A76D922EB17702C", + "width": 32 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 19, + "y": 19, + "width": 154, + "height": 154 + }, + "file": "web/install-icon-192.png", + "fittedBox": { + "x": 19, + "y": 19, + "width": 154, + "height": 154 + }, + "height": 192, + "mediaType": "image/png", + "platform": "web", + "purpose": "Web application install icon (192px)", + "safeZone": "10-percent-minimum", + "sha256": "8AD6B9B8D6441E24A97F28ED8B6492779AAB24639DBAB972CF67A04F05170C2B", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "B83E179BC03B00A0C6B9ED6CD14B89BDBB792730799FD1890DC1B6769DE1367B", + "width": 192 + }, + { + "adjacentProductNamePolicy": "accessible-context-only", + "containsWordmark": false, + "contentBox": { + "x": 51, + "y": 51, + "width": 410, + "height": 410 + }, + "file": "web/install-icon-512.png", + "fittedBox": { + "x": 51, + "y": 51, + "width": 410, + "height": 410 + }, + "height": 512, + "mediaType": "image/png", + "platform": "web", + "purpose": "Web application install icon (512px)", + "safeZone": "10-percent-minimum", + "sha256": "8ED8E34F3D7867B028485FF75F23802B23200DEC2AB2823C5A0C9C4082BF36C6", + "source": { + "crop": { + "height": 1155, + "left": 0, + "top": 0, + "width": 1155 + }, + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "F3FC6E0C0F32F40C26961A5570321D025540313693793C44B58B7B8718621792", + "width": 512 + }, + { + "adjacentProductNamePolicy": "forbidden", + "containsWordmark": true, + "contentBox": { + "x": 0, + "y": 0, + "width": 204, + "height": 50 + }, + "file": "web/navigation-wordmark-black-204x50.png", + "fittedBox": { + "x": 0, + "y": 0, + "width": 204, + "height": 50 + }, + "height": 50, + "mediaType": "image/png", + "platform": "web", + "purpose": "Black navigation wordmark for light surfaces", + "safeZone": "source-clear-space-only", + "sha256": "7A77C7D071DFD45D73A9782F7BEC69407D06CAD99D9E08D1B289D48C25E28625", + "source": { + "file": "databreeze-wordmark-black.png", + "sha256": "4F37835E9648E7035DE9BCB6ADA05C1203A1C05A1D0DB81DF1D1AEA01D46FC98" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "AD5EE9CB6E4738BE1EDF118B2DB3BC7ADEA4785373E0D053E5822E733B8D40B5", + "width": 204 + }, + { + "adjacentProductNamePolicy": "forbidden", + "containsWordmark": true, + "contentBox": { + "x": 0, + "y": 0, + "width": 204, + "height": 50 + }, + "file": "web/navigation-wordmark-blue-204x50.png", + "fittedBox": { + "x": 0, + "y": 0, + "width": 204, + "height": 50 + }, + "height": 50, + "mediaType": "image/png", + "platform": "web", + "purpose": "Primary blue navigation wordmark for light surfaces", + "safeZone": "source-clear-space-only", + "sha256": "18FBE61BD29BF5D43A93A041A0493908B7E1E22AF1912652B1461D551CDE2A13", + "source": { + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "F0A731AF0F0366BE5A9ABED49913EDE32B67A67D333496B04B68BDAB54DC5FEC", + "width": 204 + }, + { + "adjacentProductNamePolicy": "forbidden", + "containsWordmark": true, + "contentBox": { + "x": 120, + "y": 126, + "width": 960, + "height": 378 + }, + "file": "web/social-card-1200x630.png", + "fittedBox": { + "x": 120, + "y": 197, + "width": 960, + "height": 235 + }, + "height": 630, + "mediaType": "image/png", + "platform": "web", + "purpose": "Social metadata image without adjacent duplicate product text", + "safeZone": "10-percent-horizontal-and-20-percent-vertical", + "sha256": "97B2323193D284D3DD4750276B3939B4EA6F5123E31DB7E00E5241D7FD9391CF", + "source": { + "file": "databreeze-wordmark-blue.png", + "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + }, + "transform": "aspect-preserving-contain", + "visualSha256": "EC86B2F83795CBD4F6267A704B87A47EA672702E615661CCC0DECB16455DCD96", + "width": 1200 + } + ] +} diff --git a/packages/design-tokens/brand/generated/android/adaptive-foreground-432.png b/packages/design-tokens/brand/generated/android/adaptive-foreground-432.png new file mode 100644 index 0000000000000000000000000000000000000000..131ce949a053737ec81cdb34e5b1c3b90758efca GIT binary patch literal 12626 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 Hu0mjfK&xCp literal 0 HcmV?d00001 diff --git a/packages/design-tokens/brand/generated/android/launcher-xhdpi-96.png b/packages/design-tokens/brand/generated/android/launcher-xhdpi-96.png new file mode 100644 index 0000000000000000000000000000000000000000..c9ca398e4da3f9c480dff512cd8ca60f7bbb057f GIT binary patch literal 3403 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 h`N&5;@{y1EgpdBt{eQPw{;tQK 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 HI{g0uorlk@ literal 0 HcmV?d00001 diff --git a/packages/design-tokens/brand/generated/android/launcher-xxxhdpi-192.png b/packages/design-tokens/brand/generated/android/launcher-xxxhdpi-192.png new file mode 100644 index 0000000000000000000000000000000000000000..bd7fc5acb2ab7d19535b10243d6c561d4f3d1c21 GIT binary patch literal 6756 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 WpquJ@!Nh-SBcLRwCR;9J?EhaTR@Q3( literal 0 HcmV?d00001 diff --git a/packages/design-tokens/brand/generated/android/notification-hdpi-36.png b/packages/design-tokens/brand/generated/android/notification-hdpi-36.png new file mode 100644 index 0000000000000000000000000000000000000000..4decb86f6426f85b2edf23449b510033f9ea81bd GIT binary patch literal 1184 zcmV;R1Yi4!P)Bds<4^nW^O*d62xN{*c6m_H6x+zrbqAe7Pv~(qsmKsQrruyE!@9+AZ zd*7RxOlE|65!}p$12ga4bLYF~eCIo7`ucjSw|eU(EyL#Xrp>=M?cl6Y5hIN^at*A% zlV70PbX4wUTA z!I)lf#Hx~CkZR*n89=G$uK2loKu!?S0frMqI4mtqNy%Ih8WsP9+At>v8lYsq28qZx z;uGdZhy-5_21aMm1q~;>(0$4j5Rn}gq)0(*I$>Oz!3`_?yv9L*8j%{Y{gcd#Qi`Z) z`^&&6K(?Pnh)=H)5U&Hnvv8DdX^SuN==9Ca4S*k4ReIepYK=^uF5(oZaNnr>y|Dlw z&Q}5=Q#Vg25{4b_qrLAQlv^w>SwN}?bh6SXOV^a*x(SEJ*ovreV1;8ABx(6$}5Js;ePr7$iyOZlNLbu3^2M_@VaQjQ7v92SvvkcJxd>tRmE-qnAg?EV}N=9pyy0H&(9dF-Csw-ncJ#B4LeVFAQiK) z!!0%oqJG2!+UDgBl5ym7Glc|c0#2;WiCHNW!q%4NXy52_Q}^8WYs=+ zH;hP~Vh%sfZ2nnph}`Gk+tI?&87wbsGTi9L@eh!mX{kt>SxTuZ&7}X54$*?;5+7&-N zM;NJ-lPIR97nT)g4*27^!0(;FWPOl&oj+6+?{u%!UKAO2=e}g&hb=GuHs={MXkK3+ zh&uK%Eu@zVPK<$Q+gvk{AZ;*Q3CC1(os?U!_YrR2wwKYx5KZ{RkB1ySSPW46@gt!W zX1qF{@3kh<%>cTvvX?2N^u^f(Ia^q<+YV@$1Qi&xgT^SE1L7@!;Da-e%EZ!KJfVHA ztSJ;BDr;&3n57Ar8exf$0CTKiX0X1UKO0Y;N1dQZnXYV4yftV03yE}Uq zSDT2j5iBjOg8l=Bs3fS3mNr@nHuehIm_|`FBq3^o5^raBXLrZ%?cSN#c#Dk|85s7* zd-Hwoeeb;~m3G#Dw+uU2VYdI&aO9<-(bYcsH#4JmckGP4?9Kcvzh>AnfWI~@8FLY9 z?u0Ad2^>wloJix;SdvQ$#hcRVKAI+~`^Xf*=A(nje&kBTCa%tHbq; zfq~kGy?FmZ#1wVr0}}2+9#<`&m}njCN&tX}iwFdMMgp=Zi>{&O8PmNWUFSm4|Flh# zn2;0MOcg3}S;%QfAh8KRQ5J?10x(8Be45??=v2K^4jLidk|j?=IczdAg@7c&(=QwA=7J(!R?rd6V1z zEZT7%fTK*v>&WH?v;0$kH3yw2P`rzf)#zqzt0J2&rsK1P8fC#(-aLmJZTo0$oc6{R7A=p1> z1n@(xVghF(d^T8HJ5Y##i!{INNCw+~U3OdSSAEWG^fM8Er2&IIMO_lK^1`6k0oS{V zT-WcQ{}ar?g{t>y-}cru$J2w5?k(h4Y`3ag*LeZ(o%K)FPc&)g+uj3kI{*Lx M07*qoM6N<$f`s&F%>V!Z literal 0 HcmV?d00001 diff --git a/packages/design-tokens/brand/generated/android/notification-xhdpi-48.png b/packages/design-tokens/brand/generated/android/notification-xhdpi-48.png new file mode 100644 index 0000000000000000000000000000000000000000..8afee3571e44fc57f6b23fbaa5866c463d2ad52a GIT binary patch literal 1582 zcmV+}2GRM6P)+YSIO3cn{0M=>dy9QE?lN`Gk3o4obP`3oSVyKBOBSsMmDk$Gl=2*Np$BD(e00j<(OJI z7?r>9p;~Qz7@vt3JI8n7?4J1fL=-_K9mkKymN8XwN_BqR_#A1akD?uPfjFIG8+pfms^@0dUWzX|`o+BX4M z#K8p>gEEYcYWV?>fyLcB0O0LXiSHJ{4Wrv7^t+9f@HF{(k8Ga%^)Wx zKji+V>GbhV5>i@6O#T2d+^-S5BEVDLod6Bw_gtFS-NS<;(E1tJreY%iKlYCr}4h328meJ>h zrHVe#%5kIyd}B0;yhe}R4tDai#?4JfWKesBtQc=KYSqP6=zZNZe7vsS3=#V3qCq& zQI`O0Nfnt8ULJIrY~m!^pno|+pDUR3`Ry~W+vU7zA5VWX8}tp-ILM{3T|SY7CTl`M-$w66 zjZaI4_A$j^aK51B`hjXT_}1x*wee6)7kp*oI4nxxT#&BP#f8EO_2Vth5TCJ?h4Jv$Yx9AWGi?kB2cKufglc*69<+N~)MRJ0V3C+V3&Wcs7S$_0vW`qP`4I6Ft9yTBa0;V9?i_fVbR_wiBQ$(%0dAxdyb5@_G`YPL&G%;0fdAzJWC~piLJK zAlZmu8R0Au-F>&H%j?9QFLzr6RKs5t*DV%D(9nein918juYX3&jq$dN-@!CAkn(Hr zu?uj(ya(BMfmHaO*7bU~h)DuR4ZaUDa23ktWr*~T@H}5I+|%mx=0mN^)A@wA+u6uQ gHnNe8Y;=zQ0GA0^rOnkApa1{>07*qoM6N<$g6<{yL;wH) literal 0 HcmV?d00001 diff --git a/packages/design-tokens/brand/generated/android/notification-xxhdpi-72.png b/packages/design-tokens/brand/generated/android/notification-xxhdpi-72.png new file mode 100644 index 0000000000000000000000000000000000000000..574dedd80e69c12e1ed9ad2555fef76fe5125524 GIT binary patch literal 2264 zcmV;}2q*W6P)-03uGKeO9Jqr2N}7G&c)vEaIx-^3_hb11rJvkYWsDLI)FD_$*>lSL_5>T?{N63;!y+Mq3{2diU<;T7iQNST7cen1{{p zh8?6u3?S9~cIr(`(scr*ya3&y-DGO4m`tlg)hgk;&vL7W=ko{LH-noZ z5bo`I8x$TOtc^BMl(p@30LT~(7Hr-qc^Y*AyY>`a*UdDkI*6(DTgX%-viwCekGS9RW#Nm+N$J4f!+8i2@D>NMq(Db{1c5P=FXn%uzCsRAZ-~ zFO3I|&_1{rdCmbA%2m^@)*+8IFPNrk{{yh+H!whD2hMUQ$$F97liOSg&XSeqZoD#+HgKm^nwq2-#6a|28aesMKx(h2U`o45G0fyK)DU>9NhafbE=A7iUW?XJ-FBT z%vx>1wR~TL0OTcYlq(giAIb~%09x$;ApNvhaNJsHkEobi64M?AkgRKQ0ICED5pGju zzC!@P|MbNLh=Id5VP9pPn1}$J;kdgS)JCfPOFr+;^6y;>fCzlbnUEe_$gI&HESqsc zf}G@Pu25nNsf8AasIO`T5G!C+(?LmMwaSCi$Uu;gv`9ufwCF6$kXii|4mnG;A>$tBaKDvx z1!pu!EU{C}JyAgl08;c_RcVmdUZG-`H_wc{OI7`zh5)CcG?>mE>qX=)_Wm3zNVQ_s z(X7Dy5bN|USKM0w+L^Hc(cga0$tWU7_zk*#LDir0*U~`XRJ2tgi_sVpRO@&S4~7S{ zSGLnduAjpTsn7SD=HF0{qTtz~ocp-1$(_{6|%71{Xo zX$>jfu4PqiIr18fq8l3O4mH(74E{T{RLXXl8A=%O?|#)o~XE+5A$F z%=M(TK?dH_sKAT^k|0|zWo2{`CF3MM{|Yfqd81#HtVig9;nViP@gBx^dtgGJqJG;d zcId01UGv6v8~r-2no&QW)Kr`6C%F&0lDu@d9<*kHHYxd$C}Z4*hWXGvdDJWeNZ$!k z@9cbc->hneApJeNu3=wLhTev0oQ1DA2J<*^tsyAdahTJe#H)z~{r<`8CT1ejxf#Or z$TTJW2(JAt#5fAGKY?rZCy$QbT$hHrM6^gpINuOXnmhH<0JP}N$xJL@I27ZRJYy(a zmLh~2_$^(7U~s%Hn$yx@E=8*UFNNo9bz$CSEm3HL@Vq;7LVyC|&{%_cERdHDBP~PE zV^}QTQq1p@k6e;=x2V|1JbI@XsbE#Cz204$CL|6E!vfm`PX9PjGIjvGBUzub>cSmp zX&&O~zE+IV*rGfK`O`b&Ro?+O)-m`nwDWY19ayNI{wepS&1a|H<#>zEtkD)=(UHON z$_M7FP^u^+0V)%Rf;7~MY`ZUthK}eOD5jQ?%_WIwP^Nu2i%CyLU$oa$N8=6m>0cBi zfjl$zfkNbIC(4=lvB>jy-Agp)eop9okhTen9*DK*3PKOs^T}v-gZUgudljZ<$6;0d zd3XdXzLrqSwWzUTGRC4k^CC*d5mfuJk>`K3yXXiF2hWVfs`4V&rsn-6(AWzTYTDy` zP%OTxB6=09J(Z!ot?kr}@HdCaaq~`POzOwiQq|s>So1zEFUB5PCjUcP$UA@m?E~Ua zfJ4qwJ{6Za@lJI>gRb~3-RlCNAmpm%7v;vAPaIyXJ#nboZzf9|#sLTsg(4RgeTLG6 z%EOs8B@SU)oV~Ss7qy+gIWgKf?%@Tb{3XhSTNR^|XPmp6M^n!=AP!56+b4?ZqZ=?D$m0hRX)!E=Gn%3+6@2>D>Y<&*gskCX-mLi0k|Q2_qovS zzq1jR&>MBaOl7Yd*ghcSW6+c<&jX?nK<>!N`n}c7lSr}Z>eK^T))l;;Vc@T1sicxh9Qhx1BG$Rjd!O+D0000--PuBY$w>+}5ZdcWWQ!TXhFZ)XAF5aj>>K&&iH9sjiNzp%6Z$#0LNwg1$K zFiVd}0GJp5u(`@7*SCi+|P#$*a!}7P!E~y!tQY0>ET04|O9OR&Ph3cSp z6TeIr$KW+qes?l@^pJtAPJsy87fkb4T-V_3PK^!PZgJf5?oP3us)Sj(fSltq%dE5i zGuHXapd@jDNsJ|R30cz-ceVZ+T;KB?qII8FGKo20JzcAtytpSw>G63YeVnoMjiaO8 z`OTUOCL^YtjQ8ZEJ-YD)s04mE04O*%KKRh>CBS&nBK<~cG z^d!MYvJZIYJzuD~aM?6o=bW5 zZ0CH#1!)3$e!@g-%_sVN4Vu-ccu6XS@s(wkdXXUmAfw0oRWGX_m{F@>-=T7++v7Y5WpR(pdELw2 z3m@W{GU9r|eSkz>i?t8mlq?cPwg&F6*K;SgmC+;(vN^tOJtlRDe`r-GlAqK$$_qCA zn{>Qr1FjA5@wYatx!D+8;BI}2sX}#7rO!@U?iNKG?{N{Vp2RKiq-Giu<~%E5C-!Hk zWz8}TdTj3Ap*tp8KrB7m{O59>QHf#er{&L@M_v2medkA^+Fw+1(zu5Q4 zwD`@5`GTgymmnzZLexs$e2oN??>-BdK0{VCc6=2d$b%xWX}CSK;{=B>XU^z0=4R(3 zS)Q_B%jrzLc+U3p8XISe4o=)2A6ENE@0+7%R7l+!P&N0ewr{YlgbIw3D+^h~;Std) zb)^*$5SjFKYd->|jSdLBK+Ajhcv-2Ua$o3Z&&Wwd@)lTRd@5p6UL7wA4~Ee;)7Sw3BnB#>#C|s044himni5g7O+viLjWfL1*M!FCS1h>|ycRW;{PePM5Mq-srR<@l8F^1e!-i%a?Lj zQzrO93&)1Ny>tU6+Pbepa4%TooNwKPAp%p% zsuo>g)Try>cVqr4ObrXiBaZIZ~(dV3ZsI^nnR@(DUCY}|~xYkH1$1KJiTdhp@m)}|F%+-N-% z?4Ne(5*Zmkpf5KS(J_s3eWyhikwj}Aj0a^L?~OemB1h zqKeN=T@_J_V5&_m7lzrf!&u598EUlv?z@l zX3|Qu{+o+{F0bKSp{|Nl4S5UlHe8U4-HMgn=X5`TSA@bux@R&X%(wLS8PgF9$CdnQ zuVy4v|K+Fjb=r~ev2iL3V&!x2Lh>YmzL2c`_`~dSKx4OL*GV^^;xvY@TxJ%VF=)e$ zhbr4f_f(54`|K30<>uWp%TC=JBS5Jh{Z+q+KSw({<>QRbe&K9o5Ns4+isY^tT9{t} zGDq|NubFj~j_lBZnV}*4lpK5$jah?O_LfvI5`&)8ZmJqhUA`E>pkVxM3Gw-9gu&Hx(Rg|aMDZvqwbJ8qz0jNz({Oau3pVz7iZ|AF zv9ux6G~C|#3K>6^Z#9pcw6=S*LplggI#&=PtDy6k=H%Z;cjmRds@mY(!Vx%4R(A9M z^%v3OMTlF#;1X84CiB7Lhh69C#8g(Z{?&xBrggGA@p-i=Aw9MXEpt-*n^t}`#LeLl z!@8rvkTJ~tbnO<7SUG|09RXZ7=kiuOgOpr%$8IFvPpi^k%kK3HxlwRJEBZbzGMK0U zndgKVd`-nsx^LcO?=(qNdYV&o#kYBTF)l}7QT~^R?FwlSg)i!9gNQBjO9eyBH9P!> z#BGI4MElMcFO&nRK_Ly-Qe=+QnQyPE%cuF8Qrzt$cjV&FT*~fWcrBZj zv0c6;FyR0C;PD|&`{B&yof|yOAsHhdD+p-bz1gRDH`_g>^RubX3GYMa#ExvzvYLwU z*M7rE!`PJ0Ol6g+h0jQq){E42wpqc&!A~3FW6riA<~-jecS>d-gnA0;2Gg@6*M_x! z0Pgs+2s4v#TQGd=L6+5P&uqGbR6aD3KKc6vbU4Ts)ir*5o_UWM}TjEO2GNOqv3P%Nbj0mtk4t^g@1R-O|ykpMlCA{CGK-LT%%ojNlMk+ z<0Kc?qa&p_4JJ7V-HftGJ9M(Os^G>Bu{GCCBnCQDZAk3Wh86N$ChOcQP`EZ@PN-EB zgsHpbc*F8N-uq{RMk0GgL~MwPJi)rByT9&rvdyfG#LB}#yMLrl0I?IVpPpWAQJ&|$ zr7nZ#0x#jyJG_`RTH9hiIWV3NXy!cXz`$Ddpvix(!S&~w)Ca@HCFVJo0@rxsf2hZE zwAem0%wVPQKM4>^Zk_oY8m7tCI00(nppvu}J)~M$G0X`sHd_6Bu?=vr0 zqo)o2P-@^vI5H!<6nZ%1kfp#{rujet-*)-S$|iY>;Cfxq@ge3nDyYyw=o~Xszxde0 zB(2i>Wdl-&$as}M@*~YkWll6FsMN8_39Jl91k;%ic%9;GkmrgO-b8G zc|w2x;|dorI0_!BbplM`%5w1VQ|Lk$3xuar5=tLAfp<}^>ykV3X>o%rU1SP-njXl3 z4?QKettb#KuDJTccw@X!x;nfRW$mc-(_(xzTIZ(cbyCLX5V0%ng*j zL70>$#)SDm+5Om~QiUxIT|ujI_O|lkFGKCkta++91fT7^4$za^BZVbcoIibRhaYuN z=ig!2`w>p0%Tr1#x~>NcwsiHRto>i9{jcU?B#ziBhig;$hc(EnYQP7x3R3B{sC38i)wkOnCcq`n~1-MxS!Eh$K&gfvPwigZa!EQ^G& zbV+RSF8{&rJ-_!k=iX<}nYs6wnR{pEGqaJ;G?nP6*{J~lbSlaUIsib3KOumUocJ-Oi$N$v1HAX?<5C(YICMY1ItY5KPV;iSrpmeQ`Y zSC@F(IJvJ{WK7x}Cz>{4Tb*CmBzk!ct@}F@DmTpTn?Ii&-9&9pBP+2jhgJXkXWm4> zo!!*zBZpo-)geo0Zo%kj|NN!kz$*a3Lsatrcxor5Bmq4CCxa*)59R;clzg_AD<&XN zIXeB##tZ-&UD@LAIQNIPOmBv610Eg7yp`c<{{5U1z490O_5-_IAg#W)x0+^_c7Jp7 zK!4J1rvI|eSEFo`JjutJVK{$7U&b?P9zi`ilIf);?~=rh)%<~gLN$F`duwLqX}zzo88={y}#=91CPk{YmeDya(%ltf9|o7uZc20*B?6< ziFaXwym{1wXOuEg({r4wwOg;!fnv3aL!irTI_HEWyHkq-J0m%b_QM-=L7+nsOq9%v z<4gQc?CtjAb;2laPh1M*EQ~FdT(g~QUK5&Z+-Bv!@AJ+fLVS{9qU47cgv5DewbX8D znjEn>?@d^pS+&!cgiN-JJPr5CY>Pt&A17drxpc!QQc7%EjDmVF-ZbDL72+BR++$kJ z?ee~esl8s?@vgLRQ3yr&W9ZxU;k7>9hL`$JcK5pHwx z-0=yO(^bsbPb0(x7G3ovVe+6m4UD7!paN>XHem4^Lz>pYs8FLV`*1IK#OVg~zX;99 zh3JLnLmY&Z3R>>e&Cp}o{y|w*9_38Jb&LbQ{_?M^gBj6Rcsm?slj`7@zJH{w(igzV zNz4oBK85@mv0w;+cVsl3d_qN7KfB!UB-D6`CC%sY?J)Qbo*7nbdlnO*iEooVYens< zVDMZ8Jd0;b$m^7pAX7ivyJMQNk^kDY)D8>J!#n!J8(&_rocdecpuCAt<*Q9>8dyJk zGWR@uLF`w*N%1H<|iw*Nuk?9G=!oZ>$$o#|NGaa_xOH)#UW`AVp6ZQyA zSGCVE-MAXcot~R>9t)!f2lKz}tMiEgYflmkgYOW!(7K`bUp{SgHm!&S3Aq#u);|ul zHrDIvUO`2i(;5I(?GI!qdo}7ypb84IyKkNkiH3&Y@90&nz3z*3H);8|0M91OoGox~ zwCYorI6OLI`rgNsi0l(V3R?X!ZYOF4_*})$u=`Xqwad2r(rpSY5Gtt|*WJv&D-3EYTAf+R7Vb6m@2O?cOTa~jZaUPpk$u7&nQIAMk(=|pMcIcQv1o~Xv^^Jg|rI6UEQmq^ahfaTF2&s%Kw1;2LHd3;6*Deo^Vy$Tjd zfDEO}HlUy^&44}T_$p|swp)LLJ7~W8h9q40aJ&2Ty`dd0k|dYt&JVH;pi(3dpjOXD zu`a+t^ysHj|CO%f4;}PH=3{yFv~$Z8#dFT#I@u_lWi7XJj;3=>ehDG90TQ4I;o;)B zmec^}{dZ%rLZQi&XDck6%ZfkMRIbk1R}j4db856W*lKP#K2Z;OewAoa+#=n2e@*RX z8^^9ijabHmn?+0C$}IeD`@i5C_^q=bJ$Ga_9ar?__UEp`BW|!L1AfYKPF{KuoU@Q- z{5s<)r4Rn{*-E7Y<}O}cO7qW~{W~e=FD>2L!ES^)>@ffW^ZI`B9Vy<$5fr_}S~+w%;qxa|&LbHIUf)?#gPYc2 zW{wTab7M+|0A2`qxUS}Fyd1~ObXC3>pozM^!u+qa@Q;7(fY#2}=x$WIS*8({T{)=aNcDVek@Q|8~De}v_c-RTLOd#lh8jF{#ZW9p&I z9d<>)s9izX1=eB+fQjA{MTH`&bET>IBHFzZk5;etzFE8Qn#+%Ul zWFRR$s7%nafK33zqk(eJ_;B&}Rd)y56e3R8cXCP?jP54=kop$ax3(? z?kqbANVLCm>@N>1_dlQ^605ykeXF{R* z_KeTt9N+7*ey*M9E?cOzRVR|~)%M;}81c5ATbMNgX(%*dS*4=g{Q(=%g611gD^0gxpV<_T8GZc$M7qxkQh%<%)9ZG~037i|Us7L{ce+UVi-@sf;aIOc7t;qqp zEYG*Z-fG8Iu*1MTXYlN$kZ{)a44$1VQd8=5NA|Y)X5*ed1Ub`hiiE%ik4~ivR5vTd z7NZ*+aG;}w!f5PpMo^We)_70e7mC_7Kco@4(xDqZJazPg4y;>nn`~f3=Q08SKsS>A zs*1(kX4%5053X8j<&NaV)tl0{1P%n>OlfbTM5G0=sI+&^*0m4bT&4h5rl2CsyS`?l zr#&_MD6cBWYxVB;mBo+5EUC(n;GS+Wz3+DGm*eY_TXsLWfiiL;<8p1&(vzrbuq?6N z;VgT&{0VCKDs2rbc!k3rQPyIH+Y6PRtqK=m0b+uoRnXv3X|vuyIJfe4!?wH#6IaI@ zgk#~T!E=(1pAJWJ@7ixPSmp0SK{V{R>G{sUAA)nb%0tUIxVbUQw8@Qdw?bBP4scUb zUIcr(tB1s`byrhaf4bo3W~Ed!M(hrR42z%_bAkCF!5+kNvKMZ?exmh|EPd6RXZ#Z$z%gNA zesZRY%abck+06Ch!{5_X7~b$dEleiIa)E`woS?c7G(k6S&13kP`aFnxkEL9{%MkcX zs-Kcn@QAd<9&WYrSvURdDI1^_a$0$$ycEtAAiLZndoKv^l(U6e8FrC|oZOLt&`?BH zVXtwWe)uUFunXBMc0Ryv9i);k+%ov4kV)Rn0P-KncVxpAm*1_5rBAs0_*9t3IT!Tj z@G7Eh^U!)w(J&V3{miaTn$L$VbQB8Is6s4k1+seSe+p*2Q#vG7lhm|h{mTk`P^tF! zn!IG>Ipa{g3)Drg8MmMyfFz{Khs2X}dd5K1rUkLoQIItIR!9g~+&`EX*g((uC+}b+ zzijWhM*y}TBgnmPW_|*=(=d7zTp1s zx^glm(8X(Uw4l}{*g(8%`py)+@zkpI&s?_YkVbnjLT5v!F7<8F^NsJ&rM?-f~Q-;pLDs%lNtOx&#GIWMpBN8~<{J z&4Re@(ivQV}XZ?AG~Uia3#@vPXa&nL7p!oUB(n%(C_ z&}{$e^P3Z6Zu452)qP9bIh`B}qzY#M4>_4vVzUievZeM3szr6{tx_COITKo4@OjeO zn=XnnEbG1?;gA1BllNWQ>)qdfJa$iKV3X&*k8Wy{buM1-tu8sk?dlfgRVIEvPsrc( zIbQg-bw8vpDwF-HEQpUZ2+{F-um_o}-V_&2T<<+>)0vL5E@r`SOh{mnDqM&P`!7Y= zekOA{I^`U}H!63gDzL)7Wc3LNsC>KGij@27OS7niaarkhLm4g!uMe3A^R_d8f5KPuZcx4y4Sz*LY*xPqn}i;%Q#ylNO%Yjr7ZpzD1%6QxSG&R z0df`c;w!~7+U0vmsX7z4e|2+76iLTiBo<=*g?Rl?*3s>JpY3dmcv@weR2%*^!L<{z ztgSv_&f=x^moMVI%gKnaO{zBuL2q*`%gr~>NAyxEFILwcz2-nl#L&JDPEnbX^f=yv zSyO{i&LB0YoYHo!X`x}rdmYQ6g=$S&P5t!b@mR%4CB3!dJFAQ=-ggg@W{ch3l8xeB zd4Idd>R&+d2MeUhy|0Xfi&ibzO(<9HWF;Po7H$udYzf2 zT#%rrvEQ95lZ2h*oj#&D?s>Yer6)P=Nx0hM<(@~=RoR~AtO#ev4Q(T_1_nC?OvhhF zUY7Vn_2Ne7!ngl2G7;BgE-#pkAJ{g&#J2Oslf&hYZZ9u{>!QRX`@;>&<4P(LiqLVg ze-8Upo7~+--a0FzLOm!{Ab^G=B$-rry7P%(8q=aUS-BITT1=s#HQ7d19D2qo0N8fZ z6lSQ(tZ|XekO}d*J69j@TF4kH^Jaw#rf0ACDyL|0 zf3-b&2;wVbj#YK#Q2_+#lm^*>g!cnvPXVhre+-ILQSHl2ZwF!dIlcDk=KMxUYtn2X z^a3Etoo%ijIe)4%bSpwWC~>&Pz?EA8E57TpAJZNoMefRK?!@_8-uRVW&q^xVkG1v| zu_Vi1T^X6|<`y+^$2z7{_;ee0jD?q8kp=5k3Q;z_N~mnfRp#yL{!=Che8=5J8(ad) zo*i07W^Bvcb(5#dNg=^P8fvWWVFOt?T7>lI0>isngs(%VNsvqH4GSM60f6^^#xIde zDAyZr-iBzoW9l6Po|)JEb_#agzBPNgx>ApQaG;71O4lO=`TUbTT|7f;!6PM99oA~j zWKxx>$0!;Yvc>jEmq&pQL*RRDUt619Yv!cs#L+Af)yYqaC&7S6Us3Lny`SD(;k)vW zIOz-l(2S;$MSeT*7!Z^@J9A|)cs$Fxmw}G`iQIpVwxRmq3Mjk63}seAqXWvku*+j- z=b9TQB3VvuYdPnvow*LZ2l^>)2Cq{M#{#k4WrP?Rp!WvmpvH6wn13A)XlcCl&cwTT zqo9Y!_@_nt{++Sl@7Wow$pp)X6}i){xJUa{@&6U?aa0SBzz(6gW}_Vw|IOVeUY<`1 zm@_@z=`^!{t`8VW!wetHJ4f3sh-#gn4rBWDoc1s2!{t^YtUbc9?K`F~7tV>01buuc zl4U*Xn`PG$QN`5ld}A(aE>Rl1{kw8ty&sqp8rbETs+&*T4*>#on6E`%_vs-R( z1Lg$YmW}*a1MG*M#2a|gcM|np=k{|j*xIbu_Q7<7-JD#J>}P0E=6!rnk_24{Y9QbdLL7ndT@Ali=!*vdYWWVZ9io!%ja3U|02mYl)?HHZ-R7D-1#5=SO?{rdd5pxfMQg@>#^-eBAL3@@D+ z6VPtwC$mQf4B*8emsI`!IMoYc3xp(4emMVh*b9@f zMcHavBZj#E_77Okl{YIf-b4H+yD9%9%A1wSn_u)fKqcvd56~m6)CMaPzvVWr5jhLd z`1bvtYA%wE+C6H9`5nEubK}uqO-JFRP3;S3{&{V7o~fo$B;()TDK0qGkVL~c=ABk` z&`CQX2zi3q8Rx#(WY{Jq=sXbRWN02tyY zhjOYKH!c)=9fx!Ow{YMqW8=yRviIFzD~!myltj{j_&%^V8z@e{Ah#POQ4d+vz{#q9 zO<=73u~Tv(N!r3cE%jo3coeKT z(T2F7YMamAq=)s&eD4nx(EDYV3Vk%tZXJnfUd2GcM{0!6cZM8?u}k|`0$5G z3JU%0k=t3p5$i?@z)@6lHoN~yhXK3yqsOvJ=&2ei1_Knt!|VEXSC)0C^ZWFjp!-188YD0jjGv^{(I|HSZN3h z@Kl3)SKgndzLR@3KO12*IJI5}<+=v7uE33zp257!O`Rz#JefjM*~t|halg7Q4i72CYBBG zI&m(m{>+0>={G(^s7}9m)eeOlz%_rZdFbhjnF?hmNY2WJ#S$A!oZ#D6zV;Dnk03); ze?zo~Z6F<@Rn*q!zIss*5p?MNx2pKp(D@-#6tM=hhQKq@B_*1xkz(0pH(h*}^wiwx z=PJQ37nxf*+RGh|{LNKD<=KqH$m7kAWx(utf$Jbb8_N3a8mBa|E$nuAR_!I0rnhcQDItDC>s@q}KZ} zzXkfk1{lC^N<^#QuR^XSHr>31m$!bU-rX^rg-?w^JA4$y3bUYjM1-hUKxYuyJc6fR zJb;4dBnZjco!`>ei`W@H8=KS`(87=*a=|1#w>=r~uTF7+{=xzBleK0=-)PbUp-LBy zF>O1gG-_Y*$9ma$?x>2tJh*>XLz|_GgzCB8Y{r4{|$q25Mum9Fm!AL zA^vucq~oZi)s}`$qNt#Q^!T)<=y-nC=c)eq9!!l*M5(;?4@F?$rviHLRmJ?8jM(*X zZcblKGO3Cprr@lrK{zt*&;WpFhb%g-kDkW(F8m5;-_7sP&pnJT$#p1gI2-t8BUV2` z2NMSoDt%7#>uBl~{Kp}VHZ8tZhQ4J^uYU$BX-qJNlK{}+4I(t|`?#9$z3Add=bhZ@ zeyWsh@TblB3$YWd=S47TEnXF)dAZs@%nc%$+1~eEw;T%?Vm61=pNcAkqw3e{kB)KLlb2exjdA*Kx5wPl zwfuKr)|<8xEZ{R0VCOyIXO*=)8S|R=&!I`b8f82@Uyb$j8`(psFY%TA=F6c8OF#y2 zLPP#QfgA)b3_}SIpT<&AlqXr*gFcL{_u*U&5 zdZFtD%t-kX%em#6$lW56tjf~#DSJM*b5lQ$Z#^-y6}K!!n4qH%R>L%};ypkF?LtBL z`@8P%xbNeII^Jc(BuGt4xySusHim zVD;7X=bnw_9PO#(5z42O;6>a1*T|Mq1fQPjhXA?LmvQok?)rd(sf<1M1$R*-G663$#oKZjiH5Nht=U%YJXNq`^fXo(zBqeEO wlq7ik>oVUdHF~HlGyL61=a2vOSo4Fm}81a}YaY&2+af(8g~!QCZD(BQ5KuEG80f6jX! zUe!JKetA7J)w8LtHow&a03ZMfz{Upt^$=001y-ZH(dY+0L}l#Advs2 z$q@iR9}xgJIscm`K>`3TGys4?|C^>l0e}Qt0D!^%n~uN%fLgMD0{^xD$iJlq00A}t zh*Ew376X+O_1`dtg1n6Szy99}0Bq!cvH{A=SJQtTii^CS8vwxV|K|cV`l;LhlX0ma zBdIAkI}rpr#`l%WTgXZNb6E_d%Z}Ya~k|9dr>$XmesD`S=QNey3v_&rS1%e>3$CJ6lv~T znjlf2)fn0P9oe*|xORB5-cIc+m$WM1=uww)#5$U#@mGWE5?`^PSg!nAq}sBZ!jDG# z->fLJCC0Gv2|}eb*19o}WUKGR)o(tJ9cpK`MWs3}S^L1Xu%9X843D)grm)fSBfTH# zF*?CoVWS@)2eH7lvTslpdbU9P$Z!*L&5?#aN0k|Ey_haqcO8Zt6d0~Z_*qcVC}(A( zI$$BvU7^(Fj>f{pa7Yo{hMzNhMxVI6kdk=y5uQ#SA6{f&T%8#$_lBqkaZ#jvnb>ep z85G!;Dx?ocTxL;M*%rbb4hR!9=(s}KP58MmH-#}P|BJs3o-Q?c9~}J-4R`8uruR8s-J`Z0}-R($n1CI59=RBgkW~Fp&yBMAr;QblOTO} zK_s8_&^4FbR-9xiF|gjJ12b#(AjVCFuw%qtPoy6rGZ^W9gQ!U!@hA=%cR$omiS}MY zzB#L&6aHu)+$(yzG!qzX2qy~aAEajcuJWAxYW0kegDO|yn66RxPbUBc+4nNvrA$Kp zHwyGmWdY*Q9)0GCAM7Nt@~KWgz^JcVefwIj)hhHQ*^yWEN2KD!*M&8~@7?h0gXaW&srWX2-0O z4xj-POHESi4j$mao7TkIB<>lf2yu^YOqYY(kx^wxe!u37%}GeS1I22DMy`{S?xpd~ z(SNET*a-*nyvO-_d6kP4&m*AM42Oh{dov!D=w!ZgY@%;D(%D|22!{(41T7w`lqhz4 zS%YhWGcghn)-a?Hx4j9oo-=w7Lwd>`@_N41!-YA6~X2q1fpAkx`OgYvXEvcUr zieV*8B|YYxKS4e&$n%bx-HInk3o6kqs?~L5d7y3rvhyKmBUKRVKem!MYY2DSS zo91_jR#)yY>E=UB@@Be4RK9A;cx6L4^vD)5ttE~7>stf(9iax8-nNf=#KO;!%IdTQ zn=w~0i~7opb=++7zP#M}R10Cc*}x^3YYkEDgmjWoUZL$H*3n0D6O6qeE_t=I$W4qx z>4(9NW)f!owvb*rM%%{dX_}tRt}1z5zPO7`do%u)u6+KtWf)-;4e*@DQ=!b;bK)-4 z{q%G28sq4pnIMlUuet=pLEUP>rR9-bUxtFO!Q42v-S^V^@x8CTd5kwJHz^Frm$T<4#@H zU7cNNupO#aPQExbg4mjT8j4y=Sf6mHy8UiRTYw6mU+TRaq3uD~iltC|ta^v}pTw^o znSlevUGVK>?)jy6_<0|Hq2Dzp%%PjD=XD!bdJkn*3g|w)*8jRhI%WC}tS@Mf{a;KJ z@^2pUUnc72>wEP-m?-?@e@s-j|3qifc~u7gJvf*;u&Ln>cw4;;IjZOT$y!*`aZyU- zB877%+f)NHLp1b_kYppcEQIE5o3I`1-ld^BM5AzO29{xguC zvSZm5H>lG*;4pr2yQo8fQ46WR2@=kSvL zCwi0_`u5#*MfsZh4H7R<+1S8SF$h%S8j`U?@*)MP?C=jPD2&>+|IPFc^j zb2=XEv={6U)@T(E`~v73xi1NN@4To3jaEA8tc58<2r)nGxClScY9Hq&uc$R?jmDrr zvysvlEquOd!g8Bwwmq?A&a+Jo(`9^vW&m$dZ>PxHv<14F z{H+HzeTli$>CqYy3g}arI|K=S&P{)g=fPz1SjCH!-FTEcK}}Gzh&w~KLXfQ9Zc8mh zU^#Op?~OEJB#z+R6rxTo4c~hP(yON%1$s(oO@)4gl*crI0npQMZ_VWRcKN=hCU4z& z>`kI;F`aq(n1kftqY+G>eW1ch@wAGAR!4zh7UX$H0KC+gJ@VLgQaX=_Aot|O0n(rG z63X!w(~dYYo%Un_U#3RpH8m%D;EmKA1Xb)R*K zh(UK=9-vh4h09mY+GNr1aH+AKZP568OYadP+I#S3#m{s>JSTi$ygzwOI5H4a>e+XaKBmQH_dO3p|=ow>$pTb)J%|TJ`EGH^u zvNcvbt;@V9*B*a*`2`2ue^wJ0*TIB%QS3LH(+Mj1XE*Mot%6ipg3b`61K*L~XHbkY zyj(!d3gJ-DzTCF`U9YrQr^KS}u@i^AKtj>k&N7~#ySgS~5g{XT+?}C+%8FOTN>8#t zg>+LhH5m_FFBsz(28d^TRNsY;G&Xg|T?Xt|UXk6~OExC2rQiYag?)5vv60E+d()=8 z`>nAV+rgi^uooybf>1?|5+LJ3C?cF5NY=1&64jf74IBriPAk1Dd8WqAb4xG8y0#aW zI{%7k*!Q_hX44BpWj0zu>1v}hYTxhf89A5E=EZg__WDXh^3gf*#$X{R;28nw<)oxu zB_;U(;vD~_)BxvyIY%Av{ipxIIUd#GlY`&Y|;W+v zi?8*QXHHd}ldXHJyAz#$xg?a)OEp4X>3+9H8~+abfTOD`*4Xe`&X*u*t{jv(mu@vB zSDD6aoh$v-FVA1t4@nAQVyZE_KM8S5ovEYmg;{Bo6Mx6zv6E0hR=LB>AY7NKr}?(P z$aYr9CB8M(Ofd5-#j|>-YbF}>jms4dW2nR~vA;%~l(LLIzg|TE(eM0nvM;OW%LjsA zSxzuA8!PCj2Rt5Cda~UdrK$0ol1|{8&b)Wg;pu#<8V>0q3UEw$=z*I1%t6N}6KUtm zR?^d(fGzfINz$-fm=fKg^P@3hA&+C`AlbxdIXQey;Oe+HB5*^#9tDI*y8#JsV1{q$ zMx0Mz_IK|w=5iXd)!$mT5;X`EttA|El`om*=^3r&2s;dBOHuin9f<Kg^wO)_V{@D%gQ_U`>fJyv{kr`Dy@^KM1Uu!=lOfWrf^V; zI#4%*T0HynYS2CB#W9&BnEwlxURi`7Kq%};coH~$+)ZgyRIEyI{(y(TKjB-?AU zdyXmQX0+81F5qI!(PyJo-m+4plIzoh>Qswv=poI-k+;D5+N6KM|2K^9Iu9S{kK&T4 z-?vIy$l#LwNmw*J)ti9kKn2H<8%6|c)@KupJVO8cUW^tMu!ZwO9QleQvPX!6z2aOQ z5#GI9LWKz45%I-DzXvUNw@K;1tiO9A_9U6-N!NiV-oE>>l$F!rUp1G!m;LuCcQlxg z;jTU(w3x&;ePE;sp6Gt3`O_4I(_Xqb2ajRw-@dXVENS^;dME4>e*cpwbK-5kk zHd|3)y;~IH9LcKO6?i`C019!mQ!C4>3f^{XY_`93PreaA>-%`|Q# z9Cn%a%7klr^j=C2B#!Ivo)ZpGA}JWPW~>+axSb=KPv)M(2#kSF%GC`S*!Y?pa*T>} zI>s}%#G+aYR#>fkG-Q3+RWm&Du}W0qQhSgV&u*sZqFn9E)N{EHo}op~`P$#?=427} z8*^0WX?u6#`<^yBLeb4U-}jlax<(O4kB0P zXdAPT+o&|wSCR{1WcUWV$8MM9NS(sF4_Abxr3-)iii3`K)r$`O$HbUC|FUH+qEA#& z#+?x&0^xJCH;xvudsirG-Rp~nwxXF)1#%=WLG(F zmk>E;w$MzQkU7z*AM#98Sl$6CzxOiLb^P^(@CK5ZdmbYKhsUiyL z`PG$=yS1QriZQZNwUmumQ@aG0ZNdgxH{7;N*m01x0GQ&z%Pl@qsMIwUvfqi8b z6AW;RVw>(!9Vf7!Goy4?C{D^TI`k|Bcx!)77p@R)M@8{1o2S-M-(Xn@GG1HOPeFp= zATi<D75*J z&c7e0T!jXh$D7`qAOd3YxV9uNwzdf~nX-tNl6mK<6;B9A4ueL|n?+Jg$V6xY!bVL2 zbPGcJj<2LlSP(OX*yddyJunGB*J`_7d6Ga4h;uL5xy!6>lR!f(;0M0BZdTa6-iS-C zV%*VFG!p{Mr>DJ7nmX>MA)dyKYro4R=*8MBW^?<}#nrgiwo`|@gGhke zCmo%Fp2#aGap)50;)Fj`4z9H}=`1++q2!{rIuxxRivb@%D3gu$XIL8 zr@1j3J2gz=)ruivbqr7)Nc*PZQA_Xx31=3SIz%%(GdeHin`Q-d6INA~W}*?4>&J4Q z?Tp1<;Kl1?#!`kw^T_GgQbQjFv8UTo#;gD9^W!8x@2pP1+<%>^tnX((yCuD_G1xj^ zB;Cs}Tcb96`s#do-Qv5s_2ao{)aIUg#M8S$75j=*jcDj8`=!tzyRwi~@>X;6R=8#E=G93c zPkFrc2ITcY6o-A|O3>-StYU(p(W_w2x<9T$*ANDh=T$;y&r&x2d29!x;j#+&AJgzl z5VCSapa#qkrwyn5W)gu`nMOYz60Hy0Fu0>|I{jwVlIPme*I!{7&S7g(=dgDK?T2>V!Q>?s~AIk(|O$m ziyhh_TKlr5fZjQr&I4ynoh?!iQP?2-kC)=Iqk-(wdAMd+x3j^zO7t5z@nrAImp^~& zY^6aS)?9qXm_Uf3lE2#Y<*zs*x6V=tWxJ>_afx5I2qQ}xW*%SQU}nPVm9?7lHW*>d z^LI&IeEq0E>7x&+U&QhNnsU_GhBI{bH*lvPtX%AO9DJdSZvaGx$2&N`Q;(JP1r_vA zGyd|kDJ9PuRqIB7K+*<);ek0wOh>ZrWiKwio8U)Db^WwcQw#v5sm14qXpO`8lKr<{ zek#hmbx`Y717%@lp*E}$w0gD?TgisHqr?sLLF_4v=6QUjw|fLW@_FU9<52wN8<`0t zO6Qewt9$l#1#Gg`C4X}Vjzt!r;TCm_EKh%D_rkRQlS1Ss>E7tU9@Z zFpClktPd}GS8xYWy$vRuQyg7?mt^buaKi*Z;L+1YaKO7F!>W#zdZkRl+R-j=Emz`ExXNl!yQ2WM}`YXrtCyyeM?)Wjk{ z+TEHG(S02A@TSDZK0iJvOk);FNN-K`SNLm1X}nq7Rx(7{U;7KZLZ{;Xyaun}`zzb( z#1&U>er^$DXm$l+v8tjn0Lb>nsCCtDz0=>1@6b%>k)ix-4hpF(`IbYqVv0bX|B<5LwdnA+XMDMLe7bNx@@-l~0oQZUK-N$M9Q;OZ15w_xKzv&K zp^^DVVfX`ro{%`Q&k}i6Ux|O*7fD%#25`Zx2kY@7$BiLLV%`bR1$;B>|-D8ct zS#Xx&#cCjQT94bZ!mDzG-^CE1%ljcm7&;hOEXhd4v~2*@uNzc}rN&V9dmnzJf! z<1Bs^#MmeiKZ*PY6_uAoT8TJ6k07loSr7$v=FOV7;!U52H|jt$ws+cLUH<0rIL3vL zB)?nR#}9c9Cz*?b+jr=^fgyuLYwz9TWVtlH+wgRb?G^T|}Nssp|B z5LdiOq;#cEBUA{NaLkH~=zf3;yUjMbYs#pRJdbHqat@Yi6hGvU5-%?yvlOEVfS6Zm zoa~|VyY*Ty$e3KB?wTAa8QlI@8MO^12Woueb8Ku+zSYJs>ERv4VyAaj-`DpAAiSu9 zYe4|B?38E~<*bhMb{dYK<~36-=UR>(-rFzAR?!Neyp+}hi(v&-M{8v(__-WBUPp__ z9->74&cnfCFqiB*%%~b42->N! z{X+QuA%Fg~R#Uk|`p*QadBb(P)E9m-%=t6pus-y$O4?^l0JypQQA?aM`aNjg205n0 zL|%PH2=tY3519m{K}HQ3yV_(mMk06Y-GBF*gg4H&HBucM&8G$iLf&DTne!B8U3Fkx zA$Ph{Aa{$v)rod%UJ>Hohw8@6d#(TjG8zpOewalDgZ-DHZSBo=*z zM-%KDSr!*n|5V?j`Nb=MNz5x8L;kR$>%Y&JjUx%oPb*dC_J95r&Hj&-57@tH*ncHR z{ooo0Xwv>gjm*Pu#9o_P%W37QS43egWOK4UHE%mn zM<<<%#Dabs3_|{iC_{~kj4T%pm4p=G(rC(~gVhqx8Ey8})e%s6k^H;nI+DxDMYck(;T zRvAZj|ESx3Th*WtkxlCCr++;@uUX*Y{mnLE(II=%PUG&VHzy*gJ}&ULd~4UTANE|U z|56&kR2BjEw1~Ij8e_lqtIOl-Li>bUOz2mgQf~^@)e)8l`y>Ykkg8$E%rQ@qR>51D z*ELnEOEc7n8)|qVA>zzu$Z-?{lNDRFzL9AB0UcP)Q`YB%v;s_egK9rQwKWeW+i# zh%5`S01K48kX@n%uc1ZEaan*1XkiZlHt=v=yoso(nTm1r=5LLva?YGtH|#(g_UW*O zJmIY2#|mHSu@ZG{FEkrC%`rYE5EPF15IM}O==4TC>Ri)n_^^k z(U?Gzx%g7hHcI-v;zf8nt3pwsW;#D{h`I!OdI5&$*7SoZXx5fLF*M8JddECO^=6D*p&y^X?;Kh(-`tx8-m#~lJ=XSl zWv!3`fs1%QD2P8*zpJ=P&O{B_TqgbQk$MPmIA;7z=Wa4>Tpv* zA#kt8#y^n4EEmRLu>2^CEdQK*=ohnU^>^66ZqMSZN-QDWO`Qi5`T#&Fv-Eav<&wE~ zX&&fWeSNyj&e&Yb&Pw=SdLCYt%@Wn$1JHEY4>Fh0_r8ed4&=V=pqQ9$cK86sQ78)zc~bm~ov4)qz2|#FzJA04K^r zX%Ken)2W&%80dKev`@Y*P~nr4hlU&EY(jon~#jwUlEHifQT3-50jD(vf3vi6hH~}GpE+Mem=M7*jQEy zI{+V*OYq3e?n>JN51o}Q7hIU^P&)v@av5oC8yTd6B(~gzj6l#(+5u(K$uCti#R4hQ zV1V5G8F|09GU(5!bmp6GAW|MQ-o2go$lam^4qPnZ?_XyAdo9XQKVo$VtM4v-X!G z>5=|UPFpqpCJ{heQ#2Pf`iV$$b^loo(5+Uzuvj)!1RdfS)>ps$#s}^&fmNv=W$);A zvbC+n$ForB&b$?BU&gNIn4-|I&0XH^sTLw8Y5}PXA4CjTi zU{Vuys(7GBtpB*c>_=@8E{1tYp}MeAUdp>UBbRp1T3!FR3SwUbx|Hkp8?BV&+i_~6$T9ATqIg&b4!hm)v9)4WoblfKIsfYpX@h49@_0HQMp!DpQi&baXOOt=z!+|D`1bnZRw$jBf zHOTbKSO0@6B{BCT062)*gvIQA+d}7y7x2W4dy_<+Bk}0U31WHd$j!fB$~e3cUMQ`>3$ z1DNEf=2cOgWYUw7`5|_g-=Bp{v-Yb}h@sH-VkWs~m?R?D7RpSQ9kc8J0Qh??!si!f zLG+3c#hfkPD@i+Ce@)eODRWbtb~p60RVc6o%Fz7NWoM#&W?3B`$^(&HGkx{AE>tv`HHb*ZD9Re z$`7n27b%>;g=f_*+}}_xOtzvm#wNbxPxF7Q=9d6!*sX`%yGiIhc+`$1;EpNiCE zUA+?FGW*!cZpcSkJ{|k&@(tBTcR6?hoTUwYh}w(CNuCl2=gYJ;GAmocj5ixV@dj(^ ziP=?M*)y5NWP9zmXRv*xe7oV$RRVo#&lE%vc|D3C!hvSX%%*YIi?7H8<}XT6ZPouW z)WAGc-gn!9Ea3;Y8d`Zw_E&tKn1aDhg)`81(u5jGZd!1D$F%uk-NV7n;0fBjy&R39 zt8rEp3kEtuPwsBd^=>m_+p-%1wUpuB-v#9dZS4@Z1oKu=QA^TCdDJz*_0~D-Z;)F) zNeRjtphnlChzkr|)NC{&=#Tv__@Xyu_Qwu+WF7Q+7Lo0MSx}XvQF-~dsFq+UsjI=B z&)!@_c8`;4mnaW&+NwX#hKQjvitI2quO+uk6YpKfJ|3rV?RppzjCm6QsH*W;_Hab1 zM8&qZKSY#d&o6()U#$_jNle_ z^w6$3HXlq@>&!(|L-7SI@|6DW$mnCvqmk*bNY@f@{=@k_PmuQm_D4EXM<;*#{^vK9 zXS;z-n4V2NW*E5AI_@G(5bRhTngTn`XNi1+g*O2F1;!6?xOpp+QPQAvZ9{vS5B>N% zmfx?bKPQAv9Vk}bn^7k>_G|=|wU*=tsw^CO#fLWjGLq&h8Jh8jn`S(!Hbe@7R6an} zTSH8Z1KboZ-&H*wZ*O6y6Wwu+2A0#MULxb}vpYG1w`4yhkKtS$!J%e)xYgrwKA*uV z*Nti0n)197?rA1s#XT_$=Q##a82S0elzC|)F|^M&9P{n_+n$YllXVqq?3qu<3~MKL z*l`Nqe%00J36561lyhE4V#5YWyZn)&gZB8BZiF_@rT^NaU*>z$_N^yp(6#G**SA~{ z*$Yl>7am)O1$h(xW&dfN*LFy3kBOt7jyVFyE^&|WS|OQvaKN=y^Dg-a#--nHyWroT zr2AeJuhkpbLOZdA*hb0m7rmY*ERq^~QQkgSqu3z9WiLlSJYfv|hSS=@D}>%Tqx$Z4 z0Wr1R8hrV-74U4h(`u~V4!LjryUt@8vF2oyz9DEZsV6}R!1u`2#xuv^?ILMNt04?K z1#S1^Q<=*}%+!9V!1!AcqG_+Ge-vQn^gtOnVIA1oM$z?lPxAPz;R+QwTNQ=!%e9`3&c^nrip znd#x7C);|?Nv}KrQ0m@Q_GPlMwQw~Kz1AHOuZ*3&@{I_h|D(ixKnRq!wd1q)i2Z}L z)i3+W$^CKnURG*O3d$WPZIjSD2Di9%)U|G6Hyq!052&0sp67xp&e5%HSjQYM5~6Rk ztZjz%ov&O)#p(BbRBN4-cdsfYUP~>jE=4Ru^@erR_-E*49}qoYSzZXlzJz=hKID{m zW9VT?yzK&s;vuSLjaf%>iIPGsmxfk^Zg6ePgJmvSPqZxil^4S7?8{SaM#$YuR#|f=>#w5#Vvx*Rd@V9cJ7rD25;+MTY`S&*5#|AejqE%asF-Rge@@BzqD#H6(=HnA-MVr6F{zf ziRPIW!as3m2e9`8ItatVR9INzC%IO>xB+?VL%+BvEOu=OM+5o3V_a`b2{8Hq2`M}_oXQ;4S1ei0O`aeaSfjC~(y-oxQMk3VRf9hX z9>^h=DUO8Ft9KcSNtm6K6pZRBf_jP_uYNsskiW<`PVJ9cOrrwa3IjUUW!7|`!1JNphqJua#3Y!1c9RO|j(c2o}ED|Q(e{8rRcOnFae`BEgVJ0bJA;soze8tofs>&^& z^9AngY3`#4Rw5doN!udHX9)57|JA3M4=*d5Fg`K%?TQ9pe$vZ$kACT)|1XE)_x@(@ z_r{ODVQ!$N6%ODKeA;@Krl=xI?y)uFvt)>ujmRC~-a^xaLaz)0$~p1S6oyqRsSU_J zhqI;BFpn4;kc|_jlLOzBN*a%OKlbywscJ44kXD4u0J#_RLhSg!*0MZ(T{r(+-DWrC z$QQDOL1pT<_8=&KIHvA550AM1RD5%!yUPWs!BZk`4SY+hq;ykWdjNZ*lcK>!YYcWE zNP|89NXj1-VOy4?!8d6m0;aqP&0}CEsVht_GZgQSid>jF=aMrq4@AlGoTvlcPK_1% zfJUMQ5P=T$E5GMjVwqtZekikU)+pjzA;vi)gqRn8(UZ*~4irDd9b^aLB=rD!N}9c!fPA`D!IY4?QK{}Gn>4`LxY{R%>V*ck~A zzj6Nuto$GLpMw6)f`I=*VGLbMt^NlT1_J!QC=5!9p;m@d!Z1C=Ml&H4W& zikC*5M5XA}*?Bk~bQVxu-ZrDiR$X4P{@mq8v8LQ6Qr33+`ttmW^qC(*W zs92{D#fLYo(OZ=|mqlac9Uf!nE|#w(K{l+bUN(%Y&Nll^&x?EBM1Le}iMy!V1YXmA z{X@Ku*UGI_3ZF=HE}N7ob5+)Ycjfv^kYY^JSFqC#PH|v-Coo9H zTeFRx_Vc*9yvLWedi`G0TqxxbWAXRhu%+HnO?a6=%=OQ{`Qeoxe?K2W(|F!@64J<9 zsPLOCJXIxF^Ofq5AJX8hP@;^2f8w;qWtyVGa>9-mbeUvu_5R8lZj5tjTt+&9=rJH3 znCCsiQe2Nc*y4eG3inF^M6Gz1(kK!?p7}B#OA=Oy4{pOsot3x}uFnRJA2sBGq7ojf za2$Z><$>tE6a{13$>k@?CIi!r(WtH1NPPDNZI7e)t9oPWOezCL^3G-tXeJFed9ZkD zkR}zr?uPsGK9rn?;fZy7E zFUg@-tVmGdoQ4~^n#ZC1T6r4FWuK7K$=YX32i&yr?fE6)^{^IVt}h`qfXl;Za_eI5>|Z1F)0jrmu;h3kCL#LLx?Ykjb-;QuaJsQD1dug?X4Z` zKZbf)&sb6OsR*Bd$KS0}7K+IfnHSSny_@5KKV=!h@nOn&TAH0T8LO>elBuNApBOo> zKs%UV;1u)&h4m-I-(2erCpt|Gpcnc%>siRrUtu2E)551Vp04`*?MqPFrmc)de$o5awB@|e@q$VyF zI30-^!qYGo1@&rT);|DSnz!T?S7B4iGj30A4AI#)Q7Cmny#5qoJ)Go#3;*86hXT9F z$fd}1sD$g)ne(cKUrG5Oy=*o#YXXDFuyw_`D}Ji&ZZCA0o;1nK#6RXQ$Umtm4f3z@ zjrG2#c6EN^d=bWJX>*h)#4x~nStm9xoQvjsf(DHbQtC~>-^kE)D~0Nae>+qr4o8bngU z!10%csi)5z9&OJ0uNxP#f68sZ?<~(-B~bFTrJBnlZn29U&iVgK;jxuM(<* z*uFh0djd&{87rIY!3YV_AK$MRybGL+s!M=Y)*>P`zmX@pCrsSi>%#&$^S)O5Va-0h zqzoobzXuW(A=}`HTU<6tACaK7ckj?d4w0R-K2AzaTX^O5Rr6gM^R00dEf@^;2?QduXnCT(~0 z_mB$R*2Y&nwoXu3o5JPBZ@oeZtQgIr3c$H_u{-U_+^;;m zFQV2F6&mEvUowI6GK#mA{?nE5*SU16I`fJe{x)VO&I3xah4;9TFR!)5_ZmjB%p4%4 zCiqgW-TnY+@SG&$?=ubU(4Sv(`|)#S#$QJZIbVwyV3~BPp9ziq^A;5H0y&xIu8hZ? zo*k%Q-MPbJCjAnkF=$MJUA-`xf5mhC^gAMCLa6{SN=#o^%hiO!$m$1nklH+aT;7)=oHT(kJs>zoC!O(#2uvu~hNB~$bv7q24+1l!YB85h#Nxeh!OY2iJmc94 z60%+#ns-(JgEqGU;7B|r$4tJ|Ir)F|+6E=wS1&ckJo{sY1dv}n7nMIEff^Di`8)k| zX}d1P&3=PzCdl>Fvf;t|2{(JcoxIy;aUSnl=E3kV>s@cymhqa8G6=k%c_Sg6lurqP z!GDpFOAX0s=Y+scZC+vbFDrH7)4!%=UnGGl%;?gZ2N>Jv)yZ3r(`gvc_UCaXdswk( z{@?o3G~`~{%RPj-tlq#+<#=rL=4y1F!@*D0qS0C*r%(6^7l=WD4=9I+r+!5X|M=&1 zhuY1>Tze&=U>%^QoNP83-zBGK!k9y_5XDI zZa_tb_{`+8J#3oK%tIeTfg~*C%omM{NmMKqxq28NtoB2J_Sq6y0y)o3mah1A#Dbmp zKTk+_yC4_;8|=3%;$ZSQO26SCL5B3&PL2t?MImcT{u3lDt{$zfq|+Gm3koRcTg|?I z=F-#d`{&Br8)vV)n2VoxgPZgfC4rHu+Z%qW4J`2fO_6z=84*DPb|=4@g;hI^qXM9Y)U$=PioE+QpwmYs1Q1pww`mJnS(b$dHR$iD?94cq zAv`CXIU`$ei;WY~n4J3e*%vFlb|0L`qUYJ?WW1cERWA8*Mh9dz0)HoTWX0Nb1}zmi zA@YTpbI)G<&Im9dv%DMIe)XjipZ@djpOj49gz_*#S3gEJH`c90Y;xL8*fRuPfZog{Cy>kvbpG4a48^m$Y zY972ZL-nTa92FTDw9?M(c>9U4BbTTHx?c zHQ%*ER5Ke&1zz7qJqcKw1&YLviEC5yVeYCL03CmrA2oZ|x6vG!p(k;H2>c^UnML>Vpi$zjaCO9s`ekq?8B=2ms5X5cS~N_}g*f^o)+~I$$Qj z0NvHdL9eJmo?9l=8w{=IfQHMP5<2LYCteCX6C(29UXmR^Z$>1*Qvfm!-?MweWzSgp zTaIPRweyOth?oK~NP;a5X1gcr*;AGG-sC|LI)Bcdvo@s608+ZSUbEIJDEt8@YVx;a z7&k985X;yx)YhR^EkSDTM^AS~F1>H_oFQ`rp^fXHN77%iHlgrl zEU@Z|8?AyUxEk*3zu8v5ht})@qDm|V>X2OJF0~%~E#A%DH+=XO|S8N#P zZ>`utv|9jr(0v*<*!~0?shOMUQZ>&dT4A^+Aka^U+x+k zbgA#5_`zP|-5)V|u9oNX=Yc ztRMb{ZWUNFo~ygZl(m>t^n}Q z?1LE0M7Q&3YjEW_?6~;q;{{*-|`qVwfTuH1G$939w4j;E#%= zyij_+Yr0)GR6uQfB*xT~LT(vV4cHTVVy;%@gFAV?XqOPpu?nW&LtRrtGW{?)h|*YD zuvNP97dk&aQ01TC!m!;bT4EafI%a~i{{4xvv_Y9__htXAHS6X0auoeYbJ^T`c(!b6 zcngl4RA3?`WWBMD0A&ujCnKN5MGjm#i{_=(a}?ImG&@%S`%uJuEUGez)U;?ZFXA;l z85l*_<&Za+ZC)%w_Tr36bp`#j17b~5`rbq1KVEO;Gl!P5`?fhpzNR|6AKa1?Wc(~g z-*0X?Ks)9jn>MwrMI|BaZxJN7ai%u&iwD1DW$Z?%M&BRzX0GAsIx>S^T*nIvKn1E` ze|Ui;HtINbw<6y%jfVu)$BK!(Gh9ulBkb|Vp85AiWr`=u~~y zr*ry-pSfm^2CToeB;&u>0?_UjxG~z)113UeUs;;vEHt<0egu)g{}0k)l1B+%`e>y8 zy<{WQ`(uK76B6!9@KUfh%pqKBsL?}8#o@^a1CK!ikB#Jl6%}e65 zYeD0g1Oa;%!7$&Ck&7P?gFRpQkL`gHaeY4(gh@Nf6-qyK8-rnumer>v7HgNo0>T$d z=r;rjzda*@Y>Ff^ZOqkvOQd`hlK0?C-!&Auw5wSc5%}SbF3zh--5s>-R?tb9uuJ$U zkd-O6o82BhUUBbMg$GNOt8>&h2Qb?*ZVH@KX!vC-lEqYNmo`h5Q%Yz8_q+8znrptl zi=*gsptvA&?yKOMyo)~OLs!N^=&gF<{EhUmSvzIShk|3}-ce|=0U7AUvJ$!47MK69 z!V3kmemJVpxG|{^ zkMSG7B-iofHwRfgr|Ito2Tm>BAr=DWH%D~kS7@FeFBSS_%Z+H+{xQ=%@p+At7Q00bFL9wV@MU$Cqa~J*r^@pu-|ErX9jc3AbgsbGAV;f8cq!O_Rk<~(3_H>6qaq%pl5y0@(VCzE zDREo)xK$$i#XDMufAFgRhJDQ)#|ooOkfV#;}rJ^$DwsYwn14B5%{af%;%b)bz=&(L>4` zMZ>>Wdo~9|0b#9^KjPgPin}VY7AAX*Nk}37EOaJ7rpc-xp9b)HzddFQszU|y_~~D0 z@;k@{R$E%^__?yaP=2+qdEym$v$7?-Y1=KRk?@sisYG*@BrgEpA5^>^l#%|S`_7AP zR6B9K^}z9=X4g@%uVUw{BdYScHN9%-3`7<0bO;WV8g*H^+BNV+H{h3z&gH{`P6G1^ zpDeIt9B$TbOl^lpMn!_tzUH54{Je)bhm8@L!~B$N3m-~XXS<{K=XNnUk2b#S{G0Pr z?T>3|2s{}##!b-Vm-YImJO)J&tHK!z!UoLw)TMyx)K0}j1IME;`s>A7WM>J>;lvD3 zeTr3=>zT%bKlLPl4b7uK#foj1=SewQ9%_z~zAJ3@SVtH9Qiqm)&eXU(SG*!BShhsv z9X#puZt8qUrT-ASq`#eLKOvR*wW?7>Hn>mpg3>5E<}HeJgf0|_YwF%$ez<-0Oyf;R z+pk6Voge$vFl6K1Q{A3JyEDFDJ^v!SV^d3VqR~2Qf5y~*)bXtHjC~H@Y#KpT<<_{LDPQaaw7%xx0)I&NF0*ySZ`HGY^gO>{F=PA`^0JXB zxeD75G}L_g%>srL;$*ctqtuP5K{2L<>pf8BJbH!^Y5wz>Uq4fk*IV}EbUfK}a6*dS z0|M#@1M{9t)z91XSqmA!p7hS^VX6DqN*O8`7mCcmwVCf$Tk!60b*RD8i;46`;-@Zp zaE8i`eF?#B(Q(WiVX_=^Kxk&GxTl4ccrYN`oZTHqic!!1k%4o|-+q51&s~cIcRKyx zIhQ&pubmM1!#}N$2R?f6L^th|GT)k~TmW*^c0cbL%tmeeW=wDbzl&<)x7Yk2 z`?eNv`QAdZ_mvX~HZNa_T|}qde9`U+n{{D`NLE(oJP)Dux|Gp@&;Wv63UzlQ9;X1g}LsO96)g}~JyX?GMC@JF>sUD})3KJqOHjfiW zqX@HYv-jA1m7N=w*6IUf1WQEvdpks?jExV==i-vM$Ee}RkGN01*P?SQcCV;~RFzWq zpdg&o>rtnvan5AUR0pVS=+3O?wsw$TS`V`yO`Z*`<@4K9RDF8P&T4*tajnT=YmZyI zufX(Mq|Qwm%JZ%k92cPdj;XjA9f>h&sT$jWUHqf|>B>JXWxqrxM5o+GVdLgJ|^ z$s|{tXM`ntUMHj&=ZHZ72ygcS;P3p&Qs0M%;-6s`Io58gf*{nbInRGNVGj2&Py`bP z&j`REma@6Nhv&vz-Y(}%B%8DFJ~#-ZP_oOdoylBt)=RE%86@xW0CCifbc;zCm~w+r z;Ow|`-fZUy7e0hDy7zg`&KswD^{GP&ZDv|6YmkcDb%IbtF1XN$*CmBp_)b>%MUn4( zeCzd798yREINWmFYw&;~VBo-kDQAplTLTP=ko1G6#8IgwOU*_adR*4oBqbmk9z49; zz}Ee~c?g6K8BbA5K+40lrNNXh39y{4$IhNIv3gd>+ zy=)wYTe23f0C>Tv_p6Wy1CCdKM~OTH0H&BX!USau3KmxIico}A!UOc$n8uzNAjson z4udIK2V<@V(jj2V!&wz-{GuQT{WT63`B4ySOZ}Q%&cz<-v#r1DOlreeUGYg=%Hp

X*V_!h+ZAv+`w=Z&lz#pdH=k(WvqC$r5W_uLb?BD@8$R5*5&?k5!Hix|J07#G?EKo93e2p zJIga$wb5CpJC=)D&^7M15!J7VHee9iffZ0p(49Z|7x6fgXiw~#$pX9B>A9KvtQ478 zd00#^E_JqYtvP^8Rs3Q)2+rKsNG9%9>mw`fE)Fgplhbqm0cqZfA_%59G?lZPSLn~r z&cp9=P2c&{Enl=bpN=yFJjmBB1;Q{F{zPB>A$jjQ6frYDx1X$PZ#6zY=Ty$U;~ z1^*rY; zmVP{XTlgX1*LaLG)55jk=7Z`b+F6zvfMgqAgn&_btlBYtBUIWVS5~i@?C}rEIz8Hj z1M0`hFR@Mxa8>tKv}kB^k5U+Jxhs#U8aYk8nlJ%|mibncM09JBB&tWY{#p}Yvq#L+ z6b>;&`q<0DU(NwGZ%fb~KJz1rVohXXOYLI(`2{Q}J^Qy?K$S$bv^a1ZnMCibyjvAE z^Q9}O=FwlD^`t(=Ye_UQO7|uCPKW?gM}6I+Z=JV*V;(iL5QAFJqKFdQj~)?m)HPYWtMjn~&lZ+BJy^pKYvR^ID9CI5cofUj zl8-VtPwn%czP(#=ab6B*%8mR+UfPrkt5f=CtM>U-&1n?I6^#*&l?jw*WoY#F244Cz zx-{NYlnDcCWPK?&xS_@}#?vOsUOg8=W+nI?F~C{Dg#gOGh2rwDklfZ*D+^}8{J~J} z=>WN7KbG3T1-i7<1N7)9IO3xQmv=W0F5)mG<1T^}yNF2_`hp_}-oV{6mj z?B&Drt%$mvrvxP6WQGE{N;vtKvTxq~-DS~_FRS4C==;z%TvnpsoJQa7pCofA`9n9{Bz2ZMeVhE;d)M`Xy;4Hri07S`v1A1xJyb+!*N|ZRA@edyAsCk?!7LeDyRB0))UCU~9NHtnI;e#z)jQ+_@ZGY|&r|9xBu$y(YWzAM%B zQVUN5oK9Eyh??4N!XoF9@s~E3CN|V)k4q6xZJtJpoj1{cHv1w05Laj=>X9CwZ`%O!{6wAx&wul-86IGiO*yHu5Az zXY2Zgm_#noe8dl#Q`Laq8-4E)TUKwMDD}=QA6F|Er=yBP|IC4<@+C#W{M&@;Me(Z? z#xdoxOAIock~&;eq%`VkH{^&=?`*Q>5nd~EIqDi_s={v*8-NwBz%K*&zV9g(kGTwT zjIwOm&hL_^+ZRh$oa{Oy=S(Sjz*G%t)1#X9O0aEt)2yzW!i0HSJW~Awjd9#{Wje0f zs8LAsemZLA&9iGY9<`HEjt{2p`uB?ZsA{4?%ZcAr^dvr?>9sZM!ckjxRNQiPuNPY3 zsK0)NWdbgX2=LPRPYtJ}Yrt&>OkNriut}`$4PPp0VT(v9@H_{Kk#-G{9eWhrt3?PK mrcksG^>5_f4*nn9@c%A)o^THNYLD+|0&NQZ+WPOSo4Fm}81a}YaY&2+af(8g~!QCZD(BQ5KuEG80f6jX! zUe!JKetA7J)w8LtHow&a03ZMfz{Upt^$=001y-ZH(dY+0L}l#Advs2 z$q@iR9}xgJIscm`K>`3TGys4?|C^>l0e}Qt0D!^%n~uN%fLgMD0{^xD$iJlq00A}t zh*Ew376X+O_1`dtg1n6Szy99}0Bq!cvH{A=SJQtTii^CS8vwxV|K|cV`l;LhlX0ma zBdIAkI}rpr#`l%WTgXZNb6E_d%Z}Ya~k|9dr>$XmesD`S=QNey3v_&rS1%e>3$CJ6lv~T znjlf2)fn0P9oe*|xORB5-cIc+m$WM1=uww)#5$U#@mGWE5?`^PSg!nAq}sBZ!jDG# z->fLJCC0Gv2|}eb*19o}WUKGR)o(tJ9cpK`MWs3}S^L1Xu%9X843D)grm)fSBfTH# zF*?CoVWS@)2eH7lvTslpdbU9P$Z!*L&5?#aN0k|Ey_haqcO8Zt6d0~Z_*qcVC}(A( zI$$BvU7^(Fj>f{pa7Yo{hMzNhMxVI6kdk=y5uQ#SA6{f&T%8#$_lBqkaZ#jvnb>ep z85G!;Dx?ocTxL;M*%rbb4hR!9=(s}KP58MmH-#}P|BJs3o-Q?c9~}J-4R`8uruR8s-J`Z0}-R($n1CI59=RBgkW~Fp&yBMAr;QblOTO} zK_s8_&^4FbR-9xiF|gjJ12b#(AjVCFuw%qtPoy6rGZ^W9gQ!U!@hA=%cR$omiS}MY zzB#L&6aHu)+$(yzG!qzX2qy~aAEajcuJWAxYW0kegDO|yn66RxPbUBc+4nNvrA$Kp zHwyGmWdY*Q9)0GCAM7Nt@~KWgz^JcVefwIj)hhHQ*^yWEN2KD!*M&8~@7?h0gXaW&srWX2-0O z4xj-POHESi4j$mao7TkIB<>lf2yu^YOqYY(kx^wxe!u37%}GeS1I22DMy`{S?xpd~ z(SNET*a-*nyvO-_d6kP4&m*AM42Oh{dov!D=w!ZgY@%;D(%D|22!{(41T7w`lqhz4 zS%YhWGcghn)-a?Hx4j9oo-=w7Lwd>`@_N41!-YA6~X2q1fpAkx`OgYvXEvcUr zieV*8B|YYxKS4e&$n%bx-HInk3o6kqs?~L5d7y3rvhyKmBUKRVKem!MYY2DSS zo91_jR#)yY>E=UB@@Be4RK9A;cx6L4^vD)5ttE~7>stf(9iax8-nNf=#KO;!%IdTQ zn=w~0i~7opb=++7zP#M}R10Cc*}x^3YYkEDgmjWoUZL$H*3n0D6O6qeE_t=I$W4qx z>4(9NW)f!owvb*rM%%{dX_}tRt}1z5zPO7`do%u)u6+KtWf)-;4e*@DQ=!b;bK)-4 z{q%G28sq4pnIMlUuet=pLEUP>rR9-bUxtFO!Q42v-S^V^@x8CTd5kwJHz^Frm$T<4#@H zU7cNNupO#aPQExbg4mjT8j4y=Sf6mHy8UiRTYw6mU+TRaq3uD~iltC|ta^v}pTw^o znSlevUGVK>?)jy6_<0|Hq2Dzp%%PjD=XD!bdJkn*3g|w)*8jRhI%WC}tS@Mf{a;KJ z@^2pUUnc72>wEP-m?-?@e@s-j|3qifc~u7gJvf*;u&Ln>cw4;;IjZOT$y!*`aZyU- zB877%+f)NHLp1b_kYppcEQIE5o3I`1-ld^BM5AzO29{xguC zvSZm5H>lG*;4pr2yQo8fQ46WR2@=kSvL zCwi0_`u5#*MfsZh4H7R<+1S8SF$h%S8j`U?@*)MP?C=jPD2&>+|IPFc^j zb2=XEv={6U)@T(E`~v73xi1NN@4To3jaEA8tc58<2r)nGxClScY9Hq&uc$R?jmDrr zvysvlEquOd!g8Bwwmq?A&a+Jo(`9^vW&m$dZ>PxHv<14F z{H+HzeTli$>CqYy3g}arI|K=S&P{)g=fPz1SjCH!-FTEcK}}Gzh&w~KLXfQ9Zc8mh zU^#Op?~OEJB#z+R6rxTo4c~hP(yON%1$s(oO@)4gl*crI0npQMZ_VWRcKN=hCU4z& z>`kI;F`aq(n1kftqY+G>eW1ch@wAGAR!4zh7UX$H0KC+gJ@VLgQaX=_Aot|O0n(rG z63X!w(~dYYo%Un_U#3RpH8m%D;EmKA1Xb)R*K zh(UK=9-vh4h09mY+GNr1aH+AKZP568OYadP+I#S3#m{s>JSTi$ygzwOI5H4a>e+XaKBmQH_dO3p|=ow>$pTb)J%|TJ`EGH^u zvNcvbt;@V9*B*a*`2`2ue^wJ0*TIB%QS3LH(+Mj1XE*Mot%6ipg3b`61K*L~XHbkY zyj(!d3gJ-DzTCF`U9YrQr^KS}u@i^AKtj>k&N7~#ySgS~5g{XT+?}C+%8FOTN>8#t zg>+LhH5m_FFBsz(28d^TRNsY;G&Xg|T?Xt|UXk6~OExC2rQiYag?)5vv60E+d()=8 z`>nAV+rgi^uooybf>1?|5+LJ3C?cF5NY=1&64jf74IBriPAk1Dd8WqAb4xG8y0#aW zI{%7k*!Q_hX44BpWj0zu>1v}hYTxhf89A5E=EZg__WDXh^3gf*#$X{R;28nw<)oxu zB_;U(;vD~_)BxvyIY%Av{ipxIIUd#GlY`&Y|;W+v zi?8*QXHHd}ldXHJyAz#$xg?a)OEp4X>3+9H8~+abfTOD`*4Xe`&X*u*t{jv(mu@vB zSDD6aoh$v-FVA1t4@nAQVyZE_KM8S5ovEYmg;{Bo6Mx6zv6E0hR=LB>AY7NKr}?(P z$aYr9CB8M(Ofd5-#j|>-YbF}>jms4dW2nR~vA;%~l(LLIzg|TE(eM0nvM;OW%LjsA zSxzuA8!PCj2Rt5Cda~UdrK$0ol1|{8&b)Wg;pu#<8V>0q3UEw$=z*I1%t6N}6KUtm zR?^d(fGzfINz$-fm=fKg^P@3hA&+C`AlbxdIXQey;Oe+HB5*^#9tDI*y8#JsV1{q$ zMx0Mz_IK|w=5iXd)!$mT5;X`EttA|El`om*=^3r&2s;dBOHuin9f<Kg^wO)_V{@D%gQ_U`>fJyv{kr`Dy@^KM1Uu!=lOfWrf^V; zI#4%*T0HynYS2CB#W9&BnEwlxURi`7Kq%};coH~$+)ZgyRIEyI{(y(TKjB-?AU zdyXmQX0+81F5qI!(PyJo-m+4plIzoh>Qswv=poI-k+;D5+N6KM|2K^9Iu9S{kK&T4 z-?vIy$l#LwNmw*J)ti9kKn2H<8%6|c)@KupJVO8cUW^tMu!ZwO9QleQvPX!6z2aOQ z5#GI9LWKz45%I-DzXvUNw@K;1tiO9A_9U6-N!NiV-oE>>l$F!rUp1G!m;LuCcQlxg z;jTU(w3x&;ePE;sp6Gt3`O_4I(_Xqb2ajRw-@dXVENS^;dME4>e*cpwbK-5kk zHd|3)y;~IH9LcKO6?i`C019!mQ!C4>3f^{XY_`93PreaA>-%`|Q# z9Cn%a%7klr^j=C2B#!Ivo)ZpGA}JWPW~>+axSb=KPv)M(2#kSF%GC`S*!Y?pa*T>} zI>s}%#G+aYR#>fkG-Q3+RWm&Du}W0qQhSgV&u*sZqFn9E)N{EHo}op~`P$#?=427} z8*^0WX?u6#`<^yBLeb4U-}jlax<(O4kB0P zXdAPT+o&|wSCR{1WcUWV$8MM9NS(sF4_Abxr3-)iii3`K)r$`O$HbUC|FUH+qEA#& z#+?x&0^xJCH;xvudsirG-Rp~nwxXF)1#%=WLG(F zmk>E;w$MzQkU7z*AM#98Sl$6CzxOiLb^P^(@CK5ZdmbYKhsUiyL z`PG$=yS1QriZQZNwUmumQ@aG0ZNdgxH{7;N*m01x0GQ&z%Pl@qsMIwUvfqi8b z6AW;RVw>(!9Vf7!Goy4?C{D^TI`k|Bcx!)77p@R)M@8{1o2S-M-(Xn@GG1HOPeFp= zATi<D75*J z&c7e0T!jXh$D7`qAOd3YxV9uNwzdf~nX-tNl6mK<6;B9A4ueL|n?+Jg$V6xY!bVL2 zbPGcJj<2LlSP(OX*yddyJunGB*J`_7d6Ga4h;uL5xy!6>lR!f(;0M0BZdTa6-iS-C zV%*VFG!p{Mr>DJ7nmX>MA)dyKYro4R=*8MBW^?<}#nrgiwo`|@gGhke zCmo%Fp2#aGap)50;)Fj`4z9H}=`1++q2!{rIuxxRivb@%D3gu$XIL8 zr@1j3J2gz=)ruivbqr7)Nc*PZQA_Xx31=3SIz%%(GdeHin`Q-d6INA~W}*?4>&J4Q z?Tp1<;Kl1?#!`kw^T_GgQbQjFv8UTo#;gD9^W!8x@2pP1+<%>^tnX((yCuD_G1xj^ zB;Cs}Tcb96`s#do-Qv5s_2ao{)aIUg#M8S$75j=*jcDj8`=!tzyRwi~@>X;6R=8#E=G93c zPkFrc2ITcY6o-A|O3>-StYU(p(W_w2x<9T$*ANDh=T$;y&r&x2d29!x;j#+&AJgzl z5VCSapa#qkrwyn5W)gu`nMOYz60Hy0Fu0>|I{jwVlIPme*I!{7&S7g(=dgDK?T2>V!Q>?s~AIk(|O$m ziyhh_TKlr5fZjQr&I4ynoh?!iQP?2-kC)=Iqk-(wdAMd+x3j^zO7t5z@nrAImp^~& zY^6aS)?9qXm_Uf3lE2#Y<*zs*x6V=tWxJ>_afx5I2qQ}xW*%SQU}nPVm9?7lHW*>d z^LI&IeEq0E>7x&+U&QhNnsU_GhBI{bH*lvPtX%AO9DJdSZvaGx$2&N`Q;(JP1r_vA zGyd|kDJ9PuRqIB7K+*<);ek0wOh>ZrWiKwio8U)Db^WwcQw#v5sm14qXpO`8lKr<{ zek#hmbx`Y717%@lp*E}$w0gD?TgisHqr?sLLF_4v=6QUjw|fLW@_FU9<52wN8<`0t zO6Qewt9$l#1#Gg`C4X}Vjzt!r;TCm_EKh%D_rkRQlS1Ss>E7tU9@Z zFpClktPd}GS8xYWy$vRuQyg7?mt^buaKi*Z;L+1YaKO7F!>W#zdZkRl+R-j=Emz`ExXNl!yQ2WM}`YXrtCyyeM?)Wjk{ z+TEHG(S02A@TSDZK0iJvOk);FNN-K`SNLm1X}nq7Rx(7{U;7KZLZ{;Xyaun}`zzb( z#1&U>er^$DXm$l+v8tjn0Lb>nsCCtDz0=>1@6b%>k)ix-4hpF(`IbYqVv0bX|B<5LwdnA+XMDMLe7bNx@@-l~0oQZUK-N$M9Q;OZ15w_xKzv&K zp^^DVVfX`ro{%`Q&k}i6Ux|O*7fD%#25`Zx2kY@7$BiLLV%`bR1$;B>|-D8ct zS#Xx&#cCjQT94bZ!mDzG-^CE1%ljcm7&;hOEXhd4v~2*@uNzc}rN&V9dmnzJf! z<1Bs^#MmeiKZ*PY6_uAoT8TJ6k07loSr7$v=FOV7;!U52H|jt$ws+cLUH<0rIL3vL zB)?nR#}9c9Cz*?b+jr=^fgyuLYwz9TWVtlH+wgRb?G^T|}Nssp|B z5LdiOq;#cEBUA{NaLkH~=zf3;yUjMbYs#pRJdbHqat@Yi6hGvU5-%?yvlOEVfS6Zm zoa~|VyY*Ty$e3KB?wTAa8QlI@8MO^12Woueb8Ku+zSYJs>ERv4VyAaj-`DpAAiSu9 zYe4|B?38E~<*bhMb{dYK<~36-=UR>(-rFzAR?!Neyp+}hi(v&-M{8v(__-WBUPp__ z9->74&cnfCFqiB*%%~b42->N! z{X+QuA%Fg~R#Uk|`p*QadBb(P)E9m-%=t6pus-y$O4?^l0JypQQA?aM`aNjg205n0 zL|%PH2=tY3519m{K}HQ3yV_(mMk06Y-GBF*gg4H&HBucM&8G$iLf&DTne!B8U3Fkx zA$Ph{Aa{$v)rod%UJ>Hohw8@6d#(TjG8zpOewalDgZ-DHZSBo=*z zM-%KDSr!*n|5V?j`Nb=MNz5x8L;kR$>%Y&JjUx%oPb*dC_J95r&Hj&-57@tH*ncHR z{ooo0Xwv>gjm*Pu#9o_P%W37QS43egWOK4UHE%mn zM<<<%#Dabs3_|{iC_{~kj4T%pm4p=G(rC(~gVhqx8Ey8})e%s6k^H;nI+DxDMYck(;T zRvAZj|ESx3Th*WtkxlCCr++;@uUX*Y{mnLE(II=%PUG&VHzy*gJ}&ULd~4UTANE|U z|56&kR2BjEw1~Ij8e_lqtIOl-Li>bUOz2mgQf~^@)e)8l`y>Ykkg8$E%rQ@qR>51D z*ELnEOEc7n8)|qVA>zzu$Z-?{lNDRFzL9AB0UcP)Q`YB%v;s_egK9rQwKWeW+i# zh%5`S01K48kX@n%uc1ZEaan*1XkiZlHt=v=yoso(nTm1r=5LLva?YGtH|#(g_UW*O zJmIY2#|mHSu@ZG{FEkrC%`rYE5EPF15IM}O==4TC>Ri)n_^^k z(U?Gzx%g7hHcI-v;zf8nt3pwsW;#D{h`I!OdI5&$*7SoZXx5fLF*M8JddECO^=6D*p&y^X?;Kh(-`tx8-m#~lJ=XSl zWv!3`fs1%QD2P8*zpJ=P&O{B_TqgbQk$MPmIA;7z=Wa4>Tpv* zA#kt8#y^n4EEmRLu>2^CEdQK*=ohnU^>^66ZqMSZN-QDWO`Qi5`T#&Fv-Eav<&wE~ zX&&fWeSNyj&e&Yb&Pw=SdLCYt%@Wn$1JHEY4>Fh0_r8ed4&=V=pqQ9$cK86sQ78)zc~bm~ov4)qz2|#FzJA04K^r zX%Ken)2W&%80dKev`@Y*P~nr4hlU&EY(jon~#jwUlEHifQT3-50jD(vf3vi6hH~}GpE+Mem=M7*jQEy zI{+V*OYq3e?n>JN51o}Q7hIU^P&)v@av5oC8yTd6B(~gzj6l#(+5u(K$uCti#R4hQ zV1V5G8F|09GU(5!bmp6GAW|MQ-o2go$lam^4qPnZ?_XyAdo9XQKVo$VtM4v-X!G z>5=|UPFpqpCJ{heQ#2Pf`iV$$b^loo(5+Uzuvj)!1RdfS)>ps$#s}^&fmNv=W$);A zvbC+n$ForB&b$?BU&gNIn4-|I&0XH^sTLw8Y5}PXA4CjTi zU{Vuys(7GBtpB*c>_=@8E{1tYp}MeAUdp>UBbRp1T3!FR3SwUbx|Hkp8?BV&+i_~6$T9ATqIg&b4!hm)v9)4WoblfKIsfYpX@h49@_0HQMp!DpQi&baXOOt=z!+|D`1bnZRw$jBf zHOTbKSO0@6B{BCT062)*gvIQA+d}7y7x2W4dy_<+Bk}0U31WHd$j!fB$~e3cUMQ`>3$ z1DNEf=2cOgWYUw7`5|_g-=Bp{v-Yb}h@sH-VkWs~m?R?D7RpSQ9kc8J0Qh??!si!f zLG+3c#hfkPD@i+Ce@)eODRWbtb~p60RVc6o%Fz7NWoM#&W?3B`$^(&HGkx{AE>tv`HHb*ZD9Re z$`7n27b%>;g=f_*+}}_xOtzvm#wNbxPxF7Q=9d6!*sX`%yGiIhc+`$1;EpNiCE zUA+?FGW*!cZpcSkJ{|k&@(tBTcR6?hoTUwYh}w(CNuCl2=gYJ;GAmocj5ixV@dj(^ ziP=?M*)y5NWP9zmXRv*xe7oV$RRVo#&lE%vc|D3C!hvSX%%*YIi?7H8<}XT6ZPouW z)WAGc-gn!9Ea3;Y8d`Zw_E&tKn1aDhg)`81(u5jGZd!1D$F%uk-NV7n;0fBjy&R39 zt8rEp3kEtuPwsBd^=>m_+p-%1wUpuB-v#9dZS4@Z1oKu=QA^TCdDJz*_0~D-Z;)F) zNeRjtphnlChzkr|)NC{&=#Tv__@Xyu_Qwu+WF7Q+7Lo0MSx}XvQF-~dsFq+UsjI=B z&)!@_c8`;4mnaW&+NwX#hKQjvitI2quO+uk6YpKfJ|3rV?RppzjCm6QsH*W;_Hab1 zM8&qZKSY#d&o6()U#$_jNle_ z^w6$3HXlq@>&!(|L-7SI@|6DW$mnCvqmk*bNY@f@{=@k_PmuQm_D4EXM<;*#{^vK9 zXS;z-n4V2NW*E5AI_@G(5bRhTngTn`XNi1+g*O2F1;!6?xOpp+QPQAvZ9{vS5B>N% zmfx?bKPQAv9Vk}bn^7k>_G|=|wU*=tsw^CO#fLWjGLq&h8Jh8jn`S(!Hbe@7R6an} zTSH8Z1KboZ-&H*wZ*O6y6Wwu+2A0#MULxb}vpYG1w`4yhkKtS$!J%e)xYgrwKA*uV z*Nti0n)197?rA1s#XT_$=Q##a82S0elzC|)F|^M&9P{n_+n$YllXVqq?3qu<3~MKL z*l`Nqe%00J36561lyhE4V#5YWyZn)&gZB8BZiF_@rT^NaU*>z$_N^yp(6#G**SA~{ z*$Yl>7am)O1$h(xW&dfN*LFy3kBOt7jyVFyE^&|WS|OQvaKN=y^Dg-a#--nHyWroT zr2AeJuhkpbLOZdA*hb0m7rmY*ERq^~QQkgSqu3z9WiLlSJYfv|hSS=@D}>%Tqx$Z4 z0Wr1R8hrV-74U4h(`u~V4!LjryUt@8vF2oyz9DEZsV6}R!1u`2#xuv^?ILMNt04?K z1#S1^Q<=*}%+!9V!1!AcqG_+Ge-vQn^gtOnVIA1oM$z?lPxAPz;R+QwTNQ=!%e9`3&c^nrip znd#x7C);|?Nv}KrQ0m@Q_GPlMwQw~Kz1AHOuZ*3&@{I_h|D(ixKnRq!wd1q)i2Z}L z)i3+W$^CKnURG*O3d$WPZIjSD2Di9%)U|G6Hyq!052&0sp67xp&e5%HSjQYM5~6Rk ztZjz%ov&O)#p(BbRBN4-cdsfYUP~>jE=4Ru^@erR_-E*49}qoYSzZXlzJz=hKID{m zW9VT?yzK&s;vuSLjaf%>iIPGsmxfk^Zg6ePgJmvSPqZxil^4S7?8{SaM#$YuR#|f=>#w5#Vvx*Rd@V9cJ7rD25;+MTY`S&*5#|AejqE%asF-Rge@@BzqD#H6(=HnA-MVr6F{zf ziRPIW!as3m2e9`8ItatVR9INzC%IO>xB+?VL%+BvEOu=OM+5o3V_a`b2{8Hq2`M}_oXQ;4S1ei0O`aeaSfjC~(y-oxQMk3VRf9hX z9>^h=DUO8Ft9KcSNtm6K6pZRBf_jP_uYNsskiW<`PVJ9cOrrwa3IjUUW!7|`!1JNphqJua#3Y!1c9RO|j(c2o}ED|Q(e{8rRcOnFae`BEgVJ0bJA;soze8tofs>&^& z^9AngY3`#4Rw5doN!udHX9)57|JA3M4=*d5Fg`K%?TQ9pe$vZ$kACT)|1XE)_x@(@ z_r{ODVQ!$N6%ODKeA;@Krl=xI?y)uFvt)>ujmRC~-a^xaLaz)0$~p1S6oyqRsSU_J zhqI;BFpn4;kc|_jlLOzBN*a%OKlbywscJ44kXD4u0J#_RLhSg!*0MZ(T{r(+-DWrC z$QQDOL1pT<_8=&KIHvA550AM1RD5%!yUPWs!BZk`4SY+hq;ykWdjNZ*lcK>!YYcWE zNP|89NXj1-VOy4?!8d6m0;aqP&0}CEsVht_GZgQSid>jF=aMrq4@AlGoTvlcPK_1% zfJUMQ5P=T$E5GMjVwqtZekikU)+pjzA;vi)gqRn8(UZ*~4irDd9b^aLB=rD!N}9c!fPA`D!IY4?QK{}Gn>4`LxY{R%>V*ck~A zzj6Nuto$GLpMw6)f`I=*VGLbMt^NlT1_J!QC=5!9p;m@d!Z1C=Ml&H4W& zikC*5M5XA}*?Bk~bQVxu-ZrDiR$X4P{@mq8v8LQ6Qr33+`ttmW^qC(*W zs92{D#fLYo(OZ=|mqlac9Uf!nE|#w(K{l+bUN(%Y&Nll^&x?EBM1Le}iMy!V1YXmA z{X@Ku*UGI_3ZF=HE}N7ob5+)Ycjfv^kYY^JSFqC#PH|v-Coo9H zTeFRx_Vc*9yvLWedi`G0TqxxbWAXRhu%+HnO?a6=%=OQ{`Qeoxe?K2W(|F!@64J<9 zsPLOCJXIxF^Ofq5AJX8hP@;^2f8w;qWtyVGa>9-mbeUvu_5R8lZj5tjTt+&9=rJH3 znCCsiQe2Nc*y4eG3inF^M6Gz1(kK!?p7}B#OA=Oy4{pOsot3x}uFnRJA2sBGq7ojf za2$Z><$>tE6a{13$>k@?CIi!r(WtH1NPPDNZI7e)t9oPWOezCL^3G-tXeJFed9ZkD zkR}zr?uPsGK9rn?;fZy7E zFUg@-tVmGdoQ4~^n#ZC1T6r4FWuK7K$=YX32i&yr?fE6)^{^IVt}h`qfXl;Za_eI5>|Z1F)0jrmu;h3kCL#LLx?Ykjb-;QuaJsQD1dug?X4Z` zKZbf)&sb6OsR*Bd$KS0}7K+IfnHSSny_@5KKV=!h@nOn&TAH0T8LO>elBuNApBOo> zKs%UV;1u)&h4m-I-(2erCpt|Gpcnc%>siRrUtu2E)551Vp04`*?MqPFrmc)de$o5awB@|e@q$VyF zI30-^!qYGo1@&rT);|DSnz!T?S7B4iGj30A4AI#)Q7Cmny#5qoJ)Go#3;*86hXT9F z$fd}1sD$g)ne(cKUrG5Oy=*o#YXXDFuyw_`D}Ji&ZZCA0o;1nK#6RXQ$Umtm4f3z@ zjrG2#c6EN^d=bWJX>*h)#4x~nStm9xoQvjsf(DHbQtC~>-^kE)D~0Nae>+qr4o8bngU z!10%csi)5z9&OJ0uNxP#f68sZ?<~(-B~bFTrJBnlZn29U&iVgK;jxuM(<* z*uFh0djd&{87rIY!3YV_AK$MRybGL+s!M=Y)*>P`zmX@pCrsSi>%#&$^S)O5Va-0h zqzoobzXuW(A=}`HTU<6tACaK7ckj?d4w0R-K2AzaTX^O5Rr6gM^R00dEf@^;2?QduXnCT(~0 z_mB$R*2Y&nwoXu3o5JPBZ@oeZtQgIr3c$H_u{-U_+^;;m zFQV2F6&mEvUowI6GK#mA{?nE5*SU16I`fJe{x)VO&I3xah4;9TFR!)5_ZmjB%p4%4 zCiqgW-TnY+@SG&$?=ubU(4Sv(`|)#S#$QJZIbVwyV3~BPp9ziq^A;5H0y&xIu8hZ? zo*k%Q-MPbJCjAnkF=$MJUA-`xf5mhC^gAMCLa6{SN=#o^%hiO!$m$1nklH+aT;7)=oHT(kJs>zoC!O(#2uvu~hNB~$bv7q24+1l!YB85h#Nxeh!OY2iJmc94 z60%+#ns-(JgEqGU;7B|r$4tJ|Ir)F|+6E=wS1&ckJo{sY1dv}n7nMIEff^Di`8)k| zX}d1P&3=PzCdl>Fvf;t|2{(JcoxIy;aUSnl=E3kV>s@cymhqa8G6=k%c_Sg6lurqP z!GDpFOAX0s=Y+scZC+vbFDrH7)4!%=UnGGl%;?gZ2N>Jv)yZ3r(`gvc_UCaXdswk( z{@?o3G~`~{%RPj-tlq#+<#=rL=4y1F!@*D0qS0C*r%(6^7l=WD4=9I+r+!5X|M=&1 zhuY1>Tze&=U>%^QoNP83-zBGK!k9y_5XDI zZa_tb_{`+8J#3oK%tIeTfg~*C%omM{NmMKqxq28NtoB2J_Sq6y0y)o3mah1A#Dbmp zKTk+_yC4_;8|=3%;$ZSQO26SCL5B3&PL2t?MImcT{u3lDt{$zfq|+Gm3koRcTg|?I z=F-#d`{&Br8)vV)n2VoxgPZgfC4rHu+Z%qW4J`2fO_6z=84*DPb|=4@g;hI^qXM9Y)U$=PioE+QpwmYs1Q1pww`mJnS(b$dHR$iD?94cq zAv`CXIU`$ei;WY~n4J3e*%vFlb|0L`qUYJ?WW1cERWA8*Mh9dz0)HoTWX0Nb1}zmi zA@YTpbI)G<&Im9dv%DMIe)XjipZ@djpOj49gz_*#S3gEJH`c90Y;xL8*fRuPfZog{Cy>kvbpG4a48^m$Y zY972ZL-nTa92FTDw9?M(c>9U4BbTTHx?c zHQ%*ER5Ke&1zz7qJqcKw1&YLviEC5yVeYCL03CmrA2oZ|x6vG!p(k;H2>c^UnML>Vpi$zjaCO9s`ekq?8B=2ms5X5cS~N_}g*f^o)+~I$$Qj z0NvHdL9eJmo?9l=8w{=IfQHMP5<2LYCteCX6C(29UXmR^Z$>1*Qvfm!-?MweWzSgp zTaIPRweyOth?oK~NP;a5X1gcr*;AGG-sC|LI)Bcdvo@s608+ZSUbEIJDEt8@YVx;a z7&k985X;yx)YhR^EkSDTM^AS~F1>H_oFQ`rp^fXHN77%iHlgrl zEU@Z|8?AyUxEk*3zu8v5ht})@qDm|V>X2OJF0~%~E#A%DH+=XO|S8N#P zZ>`utv|9jr(0v*<*!~0?shOMUQZ>&dT4A^+Aka^U+x+k zbgA#5_`zP|-5)V|u9oNX=Yc ztRMb{ZWUNFo~ygZl(m>t^n}Q z?1LE0M7Q&3YjEW_?6~;q;{{*-|`qVwfTuH1G$939w4j;E#%= zyij_+Yr0)GR6uQfB*xT~LT(vV4cHTVVy;%@gFAV?XqOPpu?nW&LtRrtGW{?)h|*YD zuvNP97dk&aQ01TC!m!;bT4EafI%a~i{{4xvv_Y9__htXAHS6X0auoeYbJ^T`c(!b6 zcngl4RA3?`WWBMD0A&ujCnKN5MGjm#i{_=(a}?ImG&@%S`%uJuEUGez)U;?ZFXA;l z85l*_<&Za+ZC)%w_Tr36bp`#j17b~5`rbq1KVEO;Gl!P5`?fhpzNR|6AKa1?Wc(~g z-*0X?Ks)9jn>MwrMI|BaZxJN7ai%u&iwD1DW$Z?%M&BRzX0GAsIx>S^T*nIvKn1E` ze|Ui;HtINbw<6y%jfVu)$BK!(Gh9ulBkb|Vp85AiWr`=u~~y zr*ry-pSfm^2CToeB;&u>0?_UjxG~z)113UeUs;;vEHt<0egu)g{}0k)l1B+%`e>y8 zy<{WQ`(uK76B6!9@KUfh%pqKBsL?}8#o@^a1CK!ikB#Jl6%}e65 zYeD0g1Oa;%!7$&Ck&7P?gFRpQkL`gHaeY4(gh@Nf6-qyK8-rnumer>v7HgNo0>T$d z=r;rjzda*@Y>Ff^ZOqkvOQd`hlK0?C-!&Auw5wSc5%}SbF3zh--5s>-R?tb9uuJ$U zkd-O6o82BhUUBbMg$GNOt8>&h2Qb?*ZVH@KX!vC-lEqYNmo`h5Q%Yz8_q+8znrptl zi=*gsptvA&?yKOMyo)~OLs!N^=&gF<{EhUmSvzIShk|3}-ce|=0U7AUvJ$!47MK69 z!V3kmemJVpxG|{^ zkMSG7B-iofHwRfgr|Ito2Tm>BAr=DWH%D~kS7@FeFBSS_%Z+H+{xQ=%@p+At7Q00bFL9wV@MU$Cqa~J*r^@pu-|ErX9jc3AbgsbGAV;f8cq!O_Rk<~(3_H>6qaq%pl5y0@(VCzE zDREo)xK$$i#XDMufAFgRhJDQ)#|ooOkfV#;}rJ^$DwsYwn14B5%{af%;%b)bz=&(L>4` zMZ>>Wdo~9|0b#9^KjPgPin}VY7AAX*Nk}37EOaJ7rpc-xp9b)HzddFQszU|y_~~D0 z@;k@{R$E%^__?yaP=2+qdEym$v$7?-Y1=KRk?@sisYG*@BrgEpA5^>^l#%|S`_7AP zR6B9K^}z9=X4g@%uVUw{BdYScHN9%-3`7<0bO;WV8g*H^+BNV+H{h3z&gH{`P6G1^ zpDeIt9B$TbOl^lpMn!_tzUH54{Je)bhm8@L!~B$N3m-~XXS<{K=XNnUk2b#S{G0Pr z?T>3|2s{}##!b-Vm-YImJO)J&tHK!z!UoLw)TMyx)K0}j1IME;`s>A7WM>J>;lvD3 zeTr3=>zT%bKlLPl4b7uK#foj1=SewQ9%_z~zAJ3@SVtH9Qiqm)&eXU(SG*!BShhsv z9X#puZt8qUrT-ASq`#eLKOvR*wW?7>Hn>mpg3>5E<}HeJgf0|_YwF%$ez<-0Oyf;R z+pk6Voge$vFl6K1Q{A3JyEDFDJ^v!SV^d3VqR~2Qf5y~*)bXtHjC~H@Y#KpT<<_{LDPQaaw7%xx0)I&NF0*ySZ`HGY^gO>{F=PA`^0JXB zxeD75G}L_g%>srL;$*ctqtuP5K{2L<>pf8BJbH!^Y5wz>Uq4fk*IV}EbUfK}a6*dS z0|M#@1M{9t)z91XSqmA!p7hS^VX6DqN*O8`7mCcmwVCf$Tk!60b*RD8i;46`;-@Zp zaE8i`eF?#B(Q(WiVX_=^Kxk&GxTl4ccrYN`oZTHqic!!1k%4o|-+q51&s~cIcRKyx zIhQ&pubmM1!#}N$2R?f6L^th|GT)k~TmW*^c0cbL%tmeeW=wDbzl&<)x7Yk2 z`?eNv`QAdZ_mvX~HZNa_T|}qde9`U+n{{D`NLE(oJP)Dux|Gp@&;Wv63UzlQ9;X1g}LsO96)g}~JyX?GMC@JF>sUD})3KJqOHjfiW zqX@HYv-jA1m7N=w*6IUf1WQEvdpks?jExV==i-vM$Ee}RkGN01*P?SQcCV;~RFzWq zpdg&o>rtnvan5AUR0pVS=+3O?wsw$TS`V`yO`Z*`<@4K9RDF8P&T4*tajnT=YmZyI zufX(Mq|Qwm%JZ%k92cPdj;XjA9f>h&sT$jWUHqf|>B>JXWxqrxM5o+GVdLgJ|^ z$s|{tXM`ntUMHj&=ZHZ72ygcS;P3p&Qs0M%;-6s`Io58gf*{nbInRGNVGj2&Py`bP z&j`REma@6Nhv&vz-Y(}%B%8DFJ~#-ZP_oOdoylBt)=RE%86@xW0CCifbc;zCm~w+r z;Ow|`-fZUy7e0hDy7zg`&KswD^{GP&ZDv|6YmkcDb%IbtF1XN$*CmBp_)b>%MUn4( zeCzd798yREINWmFYw&;~VBo-kDQAplTLTP=ko1G6#8IgwOU*_adR*4oBqbmk9z49; zz}Ee~c?g6K8BbA5K+40lrNNXh39y{4$IhNIv3gd>+ zy=)wYTe23f0C>Tv_p6Wy1CCdKM~OTH0H&BX!USau3KmxIico}A!UOc$n8uzNAjson z4udIK2V<@V(jj2V!&wz-{GuQT{WT63`B4ySOZ}Q%&cz<-v#r1DOlreeUGYg=%Hp

X*V_!h+ZAv+`w=Z&lz#pdH=k(WvqC$r5W_uLb?BD@8$R5*5&?k5!Hix|J07#G?EKo93e2p zJIga$wb5CpJC=)D&^7M15!J7VHee9iffZ0p(49Z|7x6fgXiw~#$pX9B>A9KvtQ478 zd00#^E_JqYtvP^8Rs3Q)2+rKsNG9%9>mw`fE)Fgplhbqm0cqZfA_%59G?lZPSLn~r z&cp9=P2c&{Enl=bpN=yFJjmBB1;Q{F{zPB>A$jjQ6frYDx1X$PZ#6zY=Ty$U;~ z1^*rY; zmVP{XTlgX1*LaLG)55jk=7Z`b+F6zvfMgqAgn&_btlBYtBUIWVS5~i@?C}rEIz8Hj z1M0`hFR@Mxa8>tKv}kB^k5U+Jxhs#U8aYk8nlJ%|mibncM09JBB&tWY{#p}Yvq#L+ z6b>;&`q<0DU(NwGZ%fb~KJz1rVohXXOYLI(`2{Q}J^Qy?K$S$bv^a1ZnMCibyjvAE z^Q9}O=FwlD^`t(=Ye_UQO7|uCPKW?gM}6I+Z=JV*V;(iL5QAFJqKFdQj~)?m)HPYWtMjn~&lZ+BJy^pKYvR^ID9CI5cofUj zl8-VtPwn%czP(#=ab6B*%8mR+UfPrkt5f=CtM>U-&1n?I6^#*&l?jw*WoY#F244Cz zx-{NYlnDcCWPK?&xS_@}#?vOsUOg8=W+nI?F~C{Dg#gOGh2rwDklfZ*D+^}8{J~J} z=>WN7KbG3T1-i7<1N7)9IO3xQmv=W0F5)mG<1T^}yNF2_`hp_}-oV{6mj z?B&Drt%$mvrvxP6WQGE{N;vtKvTxq~-DS~_FRS4C==;z%TvnpsoJQa7pCofA`9n9{Bz2ZMeVhE;d)M`Xy;4Hri07S`v1A1xJyb+!*N|ZRA@edyAsCk?!7LeDyRB0))UCU~9NHtnI;e#z)jQ+_@ZGY|&r|9xBu$y(YWzAM%B zQVUN5oK9Eyh??4N!XoF9@s~E3CN|V)k4q6xZJtJpoj1{cHv1w05Laj=>X9CwZ`%O!{6wAx&wul-86IGiO*yHu5Az zXY2Zgm_#noe8dl#Q`Laq8-4E)TUKwMDD}=QA6F|Er=yBP|IC4<@+C#W{M&@;Me(Z? z#xdoxOAIock~&;eq%`VkH{^&=?`*Q>5nd~EIqDi_s={v*8-NwBz%K*&zV9g(kGTwT zjIwOm&hL_^+ZRh$oa{Oy=S(Sjz*G%t)1#X9O0aEt)2yzW!i0HSJW~Awjd9#{Wje0f zs8LAsemZLA&9iGY9<`HEjt{2p`uB?ZsA{4?%ZcAr^dvr?>9sZM!ckjxRNQiPuNPY3 zsK0)NWdbgX2=LPRPYtJ}Yrt&>OkNriut}`$4PPp0VT(v9@H_{Kk#-G{9eWhrt3?PK mrcksG^>5_f4*nn9@c%A)o^THNYLD+|0&NQZ+WPO$mK~M)=MBNGs;>NWugSgOzxKn})T?E09_?by0QIeVI`d7W2dtX2 zJMpVwPz2QNFk z*$JjZH(j235t`7`1vWqV{eWI-&!&4o^GSNgB&1;L6aal}b%ML;JeSDQTghMPbL>^l zy6}>xp>Bt56}|>0UN<{<1J742KfDjL&)I%5XUXJ=iI?=YD<;P!fTmoYZpoE*Y}J1o z4RwgO3=V_lH+S{)U2ALH^1@eOb5yA4J(6?ko zP#M+yS7NDTs^#^&q?7zf0x+=W;41mExqr!gQPojImR=cU3Rh5zf;PcI9X^s-UvQ?VXxZ?aT`0v3D$uT}4_}Vq~j{A_-&&B?g?W+Q1gogkK zFJI)D@9Nxq`NaQQUjOLh-nm<9XI^38H)QagrJ^Ik4So4Fm}81a}YaY&2+af(8g~!QCZD(BQ5KuEG80f6jX! zUe!JKetA7J)w8LtHow&a03ZMfz{Upt^$=001y-ZH(dY+0L}l#Advs2 z$q@iR9}xgJIscm`K>`3TGys4?|C^>l0e}Qt0D!^%n~uN%fLgMD0{^xD$iJlq00A}t zh*Ew376X+O_1`dtg1n6Szy99}0Bq!cvH{A=SJQtTii^CS8vwxV|K|cV`l;LhlX0ma zBdIAkI}rpr#`l%WTgXZNb6E_d%Z}Ya~k|9dr>$XmesD`S=QNey3v_&rS1%e>3$CJ6lv~T znjlf2)fn0P9oe*|xORB5-cIc+m$WM1=uww)#5$U#@mGWE5?`^PSg!nAq}sBZ!jDG# z->fLJCC0Gv2|}eb*19o}WUKGR)o(tJ9cpK`MWs3}S^L1Xu%9X843D)grm)fSBfTH# zF*?CoVWS@)2eH7lvTslpdbU9P$Z!*L&5?#aN0k|Ey_haqcO8Zt6d0~Z_*qcVC}(A( zI$$BvU7^(Fj>f{pa7Yo{hMzNhMxVI6kdk=y5uQ#SA6{f&T%8#$_lBqkaZ#jvnb>ep z85G!;Dx?ocTxL;M*%rbb4hR!9=(s}KP58MmH-#}P|BJs3o-Q?c9~}J-4R`8uruR8s-J`Z0}-R($n1CI59=RBgkW~Fp&yBMAr;QblOTO} zK_s8_&^4FbR-9xiF|gjJ12b#(AjVCFuw%qtPoy6rGZ^W9gQ!U!@hA=%cR$omiS}MY zzB#L&6aHu)+$(yzG!qzX2qy~aAEajcuJWAxYW0kegDO|yn66RxPbUBc+4nNvrA$Kp zHwyGmWdY*Q9)0GCAM7Nt@~KWgz^JcVefwIj)hhHQ*^yWEN2KD!*M&8~@7?h0gXaW&srWX2-0O z4xj-POHESi4j$mao7TkIB<>lf2yu^YOqYY(kx^wxe!u37%}GeS1I22DMy`{S?xpd~ z(SNET*a-*nyvO-_d6kP4&m*AM42Oh{dov!D=w!ZgY@%;D(%D|22!{(41T7w`lqhz4 zS%YhWGcghn)-a?Hx4j9oo-=w7Lwd>`@_N41!-YA6~X2q1fpAkx`OgYvXEvcUr zieV*8B|YYxKS4e&$n%bx-HInk3o6kqs?~L5d7y3rvhyKmBUKRVKem!MYY2DSS zo91_jR#)yY>E=UB@@Be4RK9A;cx6L4^vD)5ttE~7>stf(9iax8-nNf=#KO;!%IdTQ zn=w~0i~7opb=++7zP#M}R10Cc*}x^3YYkEDgmjWoUZL$H*3n0D6O6qeE_t=I$W4qx z>4(9NW)f!owvb*rM%%{dX_}tRt}1z5zPO7`do%u)u6+KtWf)-;4e*@DQ=!b;bK)-4 z{q%G28sq4pnIMlUuet=pLEUP>rR9-bUxtFO!Q42v-S^V^@x8CTd5kwJHz^Frm$T<4#@H zU7cNNupO#aPQExbg4mjT8j4y=Sf6mHy8UiRTYw6mU+TRaq3uD~iltC|ta^v}pTw^o znSlevUGVK>?)jy6_<0|Hq2Dzp%%PjD=XD!bdJkn*3g|w)*8jRhI%WC}tS@Mf{a;KJ z@^2pUUnc72>wEP-m?-?@e@s-j|3qifc~u7gJvf*;u&Ln>cw4;;IjZOT$y!*`aZyU- zB877%+f)NHLp1b_kYppcEQIE5o3I`1-ld^BM5AzO29{xguC zvSZm5H>lG*;4pr2yQo8fQ46WR2@=kSvL zCwi0_`u5#*MfsZh4H7R<+1S8SF$h%S8j`U?@*)MP?C=jPD2&>+|IPFc^j zb2=XEv={6U)@T(E`~v73xi1NN@4To3jaEA8tc58<2r)nGxClScY9Hq&uc$R?jmDrr zvysvlEquOd!g8Bwwmq?A&a+Jo(`9^vW&m$dZ>PxHv<14F z{H+HzeTli$>CqYy3g}arI|K=S&P{)g=fPz1SjCH!-FTEcK}}Gzh&w~KLXfQ9Zc8mh zU^#Op?~OEJB#z+R6rxTo4c~hP(yON%1$s(oO@)4gl*crI0npQMZ_VWRcKN=hCU4z& z>`kI;F`aq(n1kftqY+G>eW1ch@wAGAR!4zh7UX$H0KC+gJ@VLgQaX=_Aot|O0n(rG z63X!w(~dYYo%Un_U#3RpH8m%D;EmKA1Xb)R*K zh(UK=9-vh4h09mY+GNr1aH+AKZP568OYadP+I#S3#m{s>JSTi$ygzwOI5H4a>e+XaKBmQH_dO3p|=ow>$pTb)J%|TJ`EGH^u zvNcvbt;@V9*B*a*`2`2ue^wJ0*TIB%QS3LH(+Mj1XE*Mot%6ipg3b`61K*L~XHbkY zyj(!d3gJ-DzTCF`U9YrQr^KS}u@i^AKtj>k&N7~#ySgS~5g{XT+?}C+%8FOTN>8#t zg>+LhH5m_FFBsz(28d^TRNsY;G&Xg|T?Xt|UXk6~OExC2rQiYag?)5vv60E+d()=8 z`>nAV+rgi^uooybf>1?|5+LJ3C?cF5NY=1&64jf74IBriPAk1Dd8WqAb4xG8y0#aW zI{%7k*!Q_hX44BpWj0zu>1v}hYTxhf89A5E=EZg__WDXh^3gf*#$X{R;28nw<)oxu zB_;U(;vD~_)BxvyIY%Av{ipxIIUd#GlY`&Y|;W+v zi?8*QXHHd}ldXHJyAz#$xg?a)OEp4X>3+9H8~+abfTOD`*4Xe`&X*u*t{jv(mu@vB zSDD6aoh$v-FVA1t4@nAQVyZE_KM8S5ovEYmg;{Bo6Mx6zv6E0hR=LB>AY7NKr}?(P z$aYr9CB8M(Ofd5-#j|>-YbF}>jms4dW2nR~vA;%~l(LLIzg|TE(eM0nvM;OW%LjsA zSxzuA8!PCj2Rt5Cda~UdrK$0ol1|{8&b)Wg;pu#<8V>0q3UEw$=z*I1%t6N}6KUtm zR?^d(fGzfINz$-fm=fKg^P@3hA&+C`AlbxdIXQey;Oe+HB5*^#9tDI*y8#JsV1{q$ zMx0Mz_IK|w=5iXd)!$mT5;X`EttA|El`om*=^3r&2s;dBOHuin9f<Kg^wO)_V{@D%gQ_U`>fJyv{kr`Dy@^KM1Uu!=lOfWrf^V; zI#4%*T0HynYS2CB#W9&BnEwlxURi`7Kq%};coH~$+)ZgyRIEyI{(y(TKjB-?AU zdyXmQX0+81F5qI!(PyJo-m+4plIzoh>Qswv=poI-k+;D5+N6KM|2K^9Iu9S{kK&T4 z-?vIy$l#LwNmw*J)ti9kKn2H<8%6|c)@KupJVO8cUW^tMu!ZwO9QleQvPX!6z2aOQ z5#GI9LWKz45%I-DzXvUNw@K;1tiO9A_9U6-N!NiV-oE>>l$F!rUp1G!m;LuCcQlxg z;jTU(w3x&;ePE;sp6Gt3`O_4I(_Xqb2ajRw-@dXVENS^;dME4>e*cpwbK-5kk zHd|3)y;~IH9LcKO6?i`C019!mQ!C4>3f^{XY_`93PreaA>-%`|Q# z9Cn%a%7klr^j=C2B#!Ivo)ZpGA}JWPW~>+axSb=KPv)M(2#kSF%GC`S*!Y?pa*T>} zI>s}%#G+aYR#>fkG-Q3+RWm&Du}W0qQhSgV&u*sZqFn9E)N{EHo}op~`P$#?=427} z8*^0WX?u6#`<^yBLeb4U-}jlax<(O4kB0P zXdAPT+o&|wSCR{1WcUWV$8MM9NS(sF4_Abxr3-)iii3`K)r$`O$HbUC|FUH+qEA#& z#+?x&0^xJCH;xvudsirG-Rp~nwxXF)1#%=WLG(F zmk>E;w$MzQkU7z*AM#98Sl$6CzxOiLb^P^(@CK5ZdmbYKhsUiyL z`PG$=yS1QriZQZNwUmumQ@aG0ZNdgxH{7;N*m01x0GQ&z%Pl@qsMIwUvfqi8b z6AW;RVw>(!9Vf7!Goy4?C{D^TI`k|Bcx!)77p@R)M@8{1o2S-M-(Xn@GG1HOPeFp= zATi<D75*J z&c7e0T!jXh$D7`qAOd3YxV9uNwzdf~nX-tNl6mK<6;B9A4ueL|n?+Jg$V6xY!bVL2 zbPGcJj<2LlSP(OX*yddyJunGB*J`_7d6Ga4h;uL5xy!6>lR!f(;0M0BZdTa6-iS-C zV%*VFG!p{Mr>DJ7nmX>MA)dyKYro4R=*8MBW^?<}#nrgiwo`|@gGhke zCmo%Fp2#aGap)50;)Fj`4z9H}=`1++q2!{rIuxxRivb@%D3gu$XIL8 zr@1j3J2gz=)ruivbqr7)Nc*PZQA_Xx31=3SIz%%(GdeHin`Q-d6INA~W}*?4>&J4Q z?Tp1<;Kl1?#!`kw^T_GgQbQjFv8UTo#;gD9^W!8x@2pP1+<%>^tnX((yCuD_G1xj^ zB;Cs}Tcb96`s#do-Qv5s_2ao{)aIUg#M8S$75j=*jcDj8`=!tzyRwi~@>X;6R=8#E=G93c zPkFrc2ITcY6o-A|O3>-StYU(p(W_w2x<9T$*ANDh=T$;y&r&x2d29!x;j#+&AJgzl z5VCSapa#qkrwyn5W)gu`nMOYz60Hy0Fu0>|I{jwVlIPme*I!{7&S7g(=dgDK?T2>V!Q>?s~AIk(|O$m ziyhh_TKlr5fZjQr&I4ynoh?!iQP?2-kC)=Iqk-(wdAMd+x3j^zO7t5z@nrAImp^~& zY^6aS)?9qXm_Uf3lE2#Y<*zs*x6V=tWxJ>_afx5I2qQ}xW*%SQU}nPVm9?7lHW*>d z^LI&IeEq0E>7x&+U&QhNnsU_GhBI{bH*lvPtX%AO9DJdSZvaGx$2&N`Q;(JP1r_vA zGyd|kDJ9PuRqIB7K+*<);ek0wOh>ZrWiKwio8U)Db^WwcQw#v5sm14qXpO`8lKr<{ zek#hmbx`Y717%@lp*E}$w0gD?TgisHqr?sLLF_4v=6QUjw|fLW@_FU9<52wN8<`0t zO6Qewt9$l#1#Gg`C4X}Vjzt!r;TCm_EKh%D_rkRQlS1Ss>E7tU9@Z zFpClktPd}GS8xYWy$vRuQyg7?mt^buaKi*Z;L+1YaKO7F!>W#zdZkRl+R-j=Emz`ExXNl!yQ2WM}`YXrtCyyeM?)Wjk{ z+TEHG(S02A@TSDZK0iJvOk);FNN-K`SNLm1X}nq7Rx(7{U;7KZLZ{;Xyaun}`zzb( z#1&U>er^$DXm$l+v8tjn0Lb>nsCCtDz0=>1@6b%>k)ix-4hpF(`IbYqVv0bX|B<5LwdnA+XMDMLe7bNx@@-l~0oQZUK-N$M9Q;OZ15w_xKzv&K zp^^DVVfX`ro{%`Q&k}i6Ux|O*7fD%#25`Zx2kY@7$BiLLV%`bR1$;B>|-D8ct zS#Xx&#cCjQT94bZ!mDzG-^CE1%ljcm7&;hOEXhd4v~2*@uNzc}rN&V9dmnzJf! z<1Bs^#MmeiKZ*PY6_uAoT8TJ6k07loSr7$v=FOV7;!U52H|jt$ws+cLUH<0rIL3vL zB)?nR#}9c9Cz*?b+jr=^fgyuLYwz9TWVtlH+wgRb?G^T|}Nssp|B z5LdiOq;#cEBUA{NaLkH~=zf3;yUjMbYs#pRJdbHqat@Yi6hGvU5-%?yvlOEVfS6Zm zoa~|VyY*Ty$e3KB?wTAa8QlI@8MO^12Woueb8Ku+zSYJs>ERv4VyAaj-`DpAAiSu9 zYe4|B?38E~<*bhMb{dYK<~36-=UR>(-rFzAR?!Neyp+}hi(v&-M{8v(__-WBUPp__ z9->74&cnfCFqiB*%%~b42->N! z{X+QuA%Fg~R#Uk|`p*QadBb(P)E9m-%=t6pus-y$O4?^l0JypQQA?aM`aNjg205n0 zL|%PH2=tY3519m{K}HQ3yV_(mMk06Y-GBF*gg4H&HBucM&8G$iLf&DTne!B8U3Fkx zA$Ph{Aa{$v)rod%UJ>Hohw8@6d#(TjG8zpOewalDgZ-DHZSBo=*z zM-%KDSr!*n|5V?j`Nb=MNz5x8L;kR$>%Y&JjUx%oPb*dC_J95r&Hj&-57@tH*ncHR z{ooo0Xwv>gjm*Pu#9o_P%W37QS43egWOK4UHE%mn zM<<<%#Dabs3_|{iC_{~kj4T%pm4p=G(rC(~gVhqx8Ey8})e%s6k^H;nI+DxDMYck(;T zRvAZj|ESx3Th*WtkxlCCr++;@uUX*Y{mnLE(II=%PUG&VHzy*gJ}&ULd~4UTANE|U z|56&kR2BjEw1~Ij8e_lqtIOl-Li>bUOz2mgQf~^@)e)8l`y>Ykkg8$E%rQ@qR>51D z*ELnEOEc7n8)|qVA>zzu$Z-?{lNDRFzL9AB0UcP)Q`YB%v;s_egK9rQwKWeW+i# zh%5`S01K48kX@n%uc1ZEaan*1XkiZlHt=v=yoso(nTm1r=5LLva?YGtH|#(g_UW*O zJmIY2#|mHSu@ZG{FEkrC%`rYE5EPF15IM}O==4TC>Ri)n_^^k z(U?Gzx%g7hHcI-v;zf8nt3pwsW;#D{h`I!OdI5&$*7SoZXx5fLF*M8JddECO^=6D*p&y^X?;Kh(-`tx8-m#~lJ=XSl zWv!3`fs1%QD2P8*zpJ=P&O{B_TqgbQk$MPmIA;7z=Wa4>Tpv* zA#kt8#y^n4EEmRLu>2^CEdQK*=ohnU^>^66ZqMSZN-QDWO`Qi5`T#&Fv-Eav<&wE~ zX&&fWeSNyj&e&Yb&Pw=SdLCYt%@Wn$1JHEY4>Fh0_r8ed4&=V=pqQ9$cK86sQ78)zc~bm~ov4)qz2|#FzJA04K^r zX%Ken)2W&%80dKev`@Y*P~nr4hlU&EY(jon~#jwUlEHifQT3-50jD(vf3vi6hH~}GpE+Mem=M7*jQEy zI{+V*OYq3e?n>JN51o}Q7hIU^P&)v@av5oC8yTd6B(~gzj6l#(+5u(K$uCti#R4hQ zV1V5G8F|09GU(5!bmp6GAW|MQ-o2go$lam^4qPnZ?_XyAdo9XQKVo$VtM4v-X!G z>5=|UPFpqpCJ{heQ#2Pf`iV$$b^loo(5+Uzuvj)!1RdfS)>ps$#s}^&fmNv=W$);A zvbC+n$ForB&b$?BU&gNIn4-|I&0XH^sTLw8Y5}PXA4CjTi zU{Vuys(7GBtpB*c>_=@8E{1tYp}MeAUdp>UBbRp1T3!FR3SwUbx|Hkp8?BV&+i_~6$T9ATqIg&b4!hm)v9)4WoblfKIsfYpX@h49@_0HQMp!DpQi&baXOOt=z!+|D`1bnZRw$jBf zHOTbKSO0@6B{BCT062)*gvIQA+d}7y7x2W4dy_<+Bk}0U31WHd$j!fB$~e3cUMQ`>3$ z1DNEf=2cOgWYUw7`5|_g-=Bp{v-Yb}h@sH-VkWs~m?R?D7RpSQ9kc8J0Qh??!si!f zLG+3c#hfkPD@i+Ce@)eODRWbtb~p60RVc6o%Fz7NWoM#&W?3B`$^(&HGkx{AE>tv`HHb*ZD9Re z$`7n27b%>;g=f_*+}}_xOtzvm#wNbxPxF7Q=9d6!*sX`%yGiIhc+`$1;EpNiCE zUA+?FGW*!cZpcSkJ{|k&@(tBTcR6?hoTUwYh}w(CNuCl2=gYJ;GAmocj5ixV@dj(^ ziP=?M*)y5NWP9zmXRv*xe7oV$RRVo#&lE%vc|D3C!hvSX%%*YIi?7H8<}XT6ZPouW z)WAGc-gn!9Ea3;Y8d`Zw_E&tKn1aDhg)`81(u5jGZd!1D$F%uk-NV7n;0fBjy&R39 zt8rEp3kEtuPwsBd^=>m_+p-%1wUpuB-v#9dZS4@Z1oKu=QA^TCdDJz*_0~D-Z;)F) zNeRjtphnlChzkr|)NC{&=#Tv__@Xyu_Qwu+WF7Q+7Lo0MSx}XvQF-~dsFq+UsjI=B z&)!@_c8`;4mnaW&+NwX#hKQjvitI2quO+uk6YpKfJ|3rV?RppzjCm6QsH*W;_Hab1 zM8&qZKSY#d&o6()U#$_jNle_ z^w6$3HXlq@>&!(|L-7SI@|6DW$mnCvqmk*bNY@f@{=@k_PmuQm_D4EXM<;*#{^vK9 zXS;z-n4V2NW*E5AI_@G(5bRhTngTn`XNi1+g*O2F1;!6?xOpp+QPQAvZ9{vS5B>N% zmfx?bKPQAv9Vk}bn^7k>_G|=|wU*=tsw^CO#fLWjGLq&h8Jh8jn`S(!Hbe@7R6an} zTSH8Z1KboZ-&H*wZ*O6y6Wwu+2A0#MULxb}vpYG1w`4yhkKtS$!J%e)xYgrwKA*uV z*Nti0n)197?rA1s#XT_$=Q##a82S0elzC|)F|^M&9P{n_+n$YllXVqq?3qu<3~MKL z*l`Nqe%00J36561lyhE4V#5YWyZn)&gZB8BZiF_@rT^NaU*>z$_N^yp(6#G**SA~{ z*$Yl>7am)O1$h(xW&dfN*LFy3kBOt7jyVFyE^&|WS|OQvaKN=y^Dg-a#--nHyWroT zr2AeJuhkpbLOZdA*hb0m7rmY*ERq^~QQkgSqu3z9WiLlSJYfv|hSS=@D}>%Tqx$Z4 z0Wr1R8hrV-74U4h(`u~V4!LjryUt@8vF2oyz9DEZsV6}R!1u`2#xuv^?ILMNt04?K z1#S1^Q<=*}%+!9V!1!AcqG_+Ge-vQn^gtOnVIA1oM$z?lPxAPz;R+QwTNQ=!%e9`3&c^nrip znd#x7C);|?Nv}KrQ0m@Q_GPlMwQw~Kz1AHOuZ*3&@{I_h|D(ixKnRq!wd1q)i2Z}L z)i3+W$^CKnURG*O3d$WPZIjSD2Di9%)U|G6Hyq!052&0sp67xp&e5%HSjQYM5~6Rk ztZjz%ov&O)#p(BbRBN4-cdsfYUP~>jE=4Ru^@erR_-E*49}qoYSzZXlzJz=hKID{m zW9VT?yzK&s;vuSLjaf%>iIPGsmxfk^Zg6ePgJmvSPqZxil^4S7?8{SaM#$YuR#|f=>#w5#Vvx*Rd@V9cJ7rD25;+MTY`S&*5#|AejqE%asF-Rge@@BzqD#H6(=HnA-MVr6F{zf ziRPIW!as3m2e9`8ItatVR9INzC%IO>xB+?VL%+BvEOu=OM+5o3V_a`b2{8Hq2`M}_oXQ;4S1ei0O`aeaSfjC~(y-oxQMk3VRf9hX z9>^h=DUO8Ft9KcSNtm6K6pZRBf_jP_uYNsskiW<`PVJ9cOrrwa3IjUUW!7|`!1JNphqJua#3Y!1c9RO|j(c2o}ED|Q(e{8rRcOnFae`BEgVJ0bJA;soze8tofs>&^& z^9AngY3`#4Rw5doN!udHX9)57|JA3M4=*d5Fg`K%?TQ9pe$vZ$kACT)|1XE)_x@(@ z_r{ODVQ!$N6%ODKeA;@Krl=xI?y)uFvt)>ujmRC~-a^xaLaz)0$~p1S6oyqRsSU_J zhqI;BFpn4;kc|_jlLOzBN*a%OKlbywscJ44kXD4u0J#_RLhSg!*0MZ(T{r(+-DWrC z$QQDOL1pT<_8=&KIHvA550AM1RD5%!yUPWs!BZk`4SY+hq;ykWdjNZ*lcK>!YYcWE zNP|89NXj1-VOy4?!8d6m0;aqP&0}CEsVht_GZgQSid>jF=aMrq4@AlGoTvlcPK_1% zfJUMQ5P=T$E5GMjVwqtZekikU)+pjzA;vi)gqRn8(UZ*~4irDd9b^aLB=rD!N}9c!fPA`D!IY4?QK{}Gn>4`LxY{R%>V*ck~A zzj6Nuto$GLpMw6)f`I=*VGLbMt^NlT1_J!QC=5!9p;m@d!Z1C=Ml&H4W& zikC*5M5XA}*?Bk~bQVxu-ZrDiR$X4P{@mq8v8LQ6Qr33+`ttmW^qC(*W zs92{D#fLYo(OZ=|mqlac9Uf!nE|#w(K{l+bUN(%Y&Nll^&x?EBM1Le}iMy!V1YXmA z{X@Ku*UGI_3ZF=HE}N7ob5+)Ycjfv^kYY^JSFqC#PH|v-Coo9H zTeFRx_Vc*9yvLWedi`G0TqxxbWAXRhu%+HnO?a6=%=OQ{`Qeoxe?K2W(|F!@64J<9 zsPLOCJXIxF^Ofq5AJX8hP@;^2f8w;qWtyVGa>9-mbeUvu_5R8lZj5tjTt+&9=rJH3 znCCsiQe2Nc*y4eG3inF^M6Gz1(kK!?p7}B#OA=Oy4{pOsot3x}uFnRJA2sBGq7ojf za2$Z><$>tE6a{13$>k@?CIi!r(WtH1NPPDNZI7e)t9oPWOezCL^3G-tXeJFed9ZkD zkR}zr?uPsGK9rn?;fZy7E zFUg@-tVmGdoQ4~^n#ZC1T6r4FWuK7K$=YX32i&yr?fE6)^{^IVt}h`qfXl;Za_eI5>|Z1F)0jrmu;h3kCL#LLx?Ykjb-;QuaJsQD1dug?X4Z` zKZbf)&sb6OsR*Bd$KS0}7K+IfnHSSny_@5KKV=!h@nOn&TAH0T8LO>elBuNApBOo> zKs%UV;1u)&h4m-I-(2erCpt|Gpcnc%>siRrUtu2E)551Vp04`*?MqPFrmc)de$o5awB@|e@q$VyF zI30-^!qYGo1@&rT);|DSnz!T?S7B4iGj30A4AI#)Q7Cmny#5qoJ)Go#3;*86hXT9F z$fd}1sD$g)ne(cKUrG5Oy=*o#YXXDFuyw_`D}Ji&ZZCA0o;1nK#6RXQ$Umtm4f3z@ zjrG2#c6EN^d=bWJX>*h)#4x~nStm9xoQvjsf(DHbQtC~>-^kE)D~0Nae>+qr4o8bngU z!10%csi)5z9&OJ0uNxP#f68sZ?<~(-B~bFTrJBnlZn29U&iVgK;jxuM(<* z*uFh0djd&{87rIY!3YV_AK$MRybGL+s!M=Y)*>P`zmX@pCrsSi>%#&$^S)O5Va-0h zqzoobzXuW(A=}`HTU<6tACaK7ckj?d4w0R-K2AzaTX^O5Rr6gM^R00dEf@^;2?QduXnCT(~0 z_mB$R*2Y&nwoXu3o5JPBZ@oeZtQgIr3c$H_u{-U_+^;;m zFQV2F6&mEvUowI6GK#mA{?nE5*SU16I`fJe{x)VO&I3xah4;9TFR!)5_ZmjB%p4%4 zCiqgW-TnY+@SG&$?=ubU(4Sv(`|)#S#$QJZIbVwyV3~BPp9ziq^A;5H0y&xIu8hZ? zo*k%Q-MPbJCjAnkF=$MJUA-`xf5mhC^gAMCLa6{SN=#o^%hiO!$m$1nklH+aT;7)=oHT(kJs>zoC!O(#2uvu~hNB~$bv7q24+1l!YB85h#Nxeh!OY2iJmc94 z60%+#ns-(JgEqGU;7B|r$4tJ|Ir)F|+6E=wS1&ckJo{sY1dv}n7nMIEff^Di`8)k| zX}d1P&3=PzCdl>Fvf;t|2{(JcoxIy;aUSnl=E3kV>s@cymhqa8G6=k%c_Sg6lurqP z!GDpFOAX0s=Y+scZC+vbFDrH7)4!%=UnGGl%;?gZ2N>Jv)yZ3r(`gvc_UCaXdswk( z{@?o3G~`~{%RPj-tlq#+<#=rL=4y1F!@*D0qS0C*r%(6^7l=WD4=9I+r+!5X|M=&1 zhuY1>Tze&=U>%^QoNP83-zBGK!k9y_5XDI zZa_tb_{`+8J#3oK%tIeTfg~*C%omM{NmMKqxq28NtoB2J_Sq6y0y)o3mah1A#Dbmp zKTk+_yC4_;8|=3%;$ZSQO26SCL5B3&PL2t?MImcT{u3lDt{$zfq|+Gm3koRcTg|?I z=F-#d`{&Br8)vV)n2VoxgPZgfC4rHu+Z%qW4J`2fO_6z=84*DPb|=4@g;hI^qXM9Y)U$=PioE+QpwmYs1Q1pww`mJnS(b$dHR$iD?94cq zAv`CXIU`$ei;WY~n4J3e*%vFlb|0L`qUYJ?WW1cERWA8*Mh9dz0)HoTWX0Nb1}zmi zA@YTpbI)G<&Im9dv%DMIe)XjipZ@djpOj49gz_*#S3gEJH`c90Y;xL8*fRuPfZog{Cy>kvbpG4a48^m$Y zY972ZL-nTa92FTDw9?M(c>9U4BbTTHx?c zHQ%*ER5Ke&1zz7qJqcKw1&YLviEC5yVeYCL03CmrA2oZ|x6vG!p(k;H2>c^UnML>Vpi$zjaCO9s`ekq?8B=2ms5X5cS~N_}g*f^o)+~I$$Qj z0NvHdL9eJmo?9l=8w{=IfQHMP5<2LYCteCX6C(29UXmR^Z$>1*Qvfm!-?MweWzSgp zTaIPRweyOth?oK~NP;a5X1gcr*;AGG-sC|LI)Bcdvo@s608+ZSUbEIJDEt8@YVx;a z7&k985X;yx)YhR^EkSDTM^AS~F1>H_oFQ`rp^fXHN77%iHlgrl zEU@Z|8?AyUxEk*3zu8v5ht})@qDm|V>X2OJF0~%~E#A%DH+=XO|S8N#P zZ>`utv|9jr(0v*<*!~0?shOMUQZ>&dT4A^+Aka^U+x+k zbgA#5_`zP|-5)V|u9oNX=Yc ztRMb{ZWUNFo~ygZl(m>t^n}Q z?1LE0M7Q&3YjEW_?6~;q;{{*-|`qVwfTuH1G$939w4j;E#%= zyij_+Yr0)GR6uQfB*xT~LT(vV4cHTVVy;%@gFAV?XqOPpu?nW&LtRrtGW{?)h|*YD zuvNP97dk&aQ01TC!m!;bT4EafI%a~i{{4xvv_Y9__htXAHS6X0auoeYbJ^T`c(!b6 zcngl4RA3?`WWBMD0A&ujCnKN5MGjm#i{_=(a}?ImG&@%S`%uJuEUGez)U;?ZFXA;l z85l*_<&Za+ZC)%w_Tr36bp`#j17b~5`rbq1KVEO;Gl!P5`?fhpzNR|6AKa1?Wc(~g z-*0X?Ks)9jn>MwrMI|BaZxJN7ai%u&iwD1DW$Z?%M&BRzX0GAsIx>S^T*nIvKn1E` ze|Ui;HtINbw<6y%jfVu)$BK!(Gh9ulBkb|Vp85AiWr`=u~~y zr*ry-pSfm^2CToeB;&u>0?_UjxG~z)113UeUs;;vEHt<0egu)g{}0k)l1B+%`e>y8 zy<{WQ`(uK76B6!9@KUfh%pqKBsL?}8#o@^a1CK!ikB#Jl6%}e65 zYeD0g1Oa;%!7$&Ck&7P?gFRpQkL`gHaeY4(gh@Nf6-qyK8-rnumer>v7HgNo0>T$d z=r;rjzda*@Y>Ff^ZOqkvOQd`hlK0?C-!&Auw5wSc5%}SbF3zh--5s>-R?tb9uuJ$U zkd-O6o82BhUUBbMg$GNOt8>&h2Qb?*ZVH@KX!vC-lEqYNmo`h5Q%Yz8_q+8znrptl zi=*gsptvA&?yKOMyo)~OLs!N^=&gF<{EhUmSvzIShk|3}-ce|=0U7AUvJ$!47MK69 z!V3kmemJVpxG|{^ zkMSG7B-iofHwRfgr|Ito2Tm>BAr=DWH%D~kS7@FeFBSS_%Z+H+{xQ=%@p+At7Q00bFL9wV@MU$Cqa~J*r^@pu-|ErX9jc3AbgsbGAV;f8cq!O_Rk<~(3_H>6qaq%pl5y0@(VCzE zDREo)xK$$i#XDMufAFgRhJDQ)#|ooOkfV#;}rJ^$DwsYwn14B5%{af%;%b)bz=&(L>4` zMZ>>Wdo~9|0b#9^KjPgPin}VY7AAX*Nk}37EOaJ7rpc-xp9b)HzddFQszU|y_~~D0 z@;k@{R$E%^__?yaP=2+qdEym$v$7?-Y1=KRk?@sisYG*@BrgEpA5^>^l#%|S`_7AP zR6B9K^}z9=X4g@%uVUw{BdYScHN9%-3`7<0bO;WV8g*H^+BNV+H{h3z&gH{`P6G1^ zpDeIt9B$TbOl^lpMn!_tzUH54{Je)bhm8@L!~B$N3m-~XXS<{K=XNnUk2b#S{G0Pr z?T>3|2s{}##!b-Vm-YImJO)J&tHK!z!UoLw)TMyx)K0}j1IME;`s>A7WM>J>;lvD3 zeTr3=>zT%bKlLPl4b7uK#foj1=SewQ9%_z~zAJ3@SVtH9Qiqm)&eXU(SG*!BShhsv z9X#puZt8qUrT-ASq`#eLKOvR*wW?7>Hn>mpg3>5E<}HeJgf0|_YwF%$ez<-0Oyf;R z+pk6Voge$vFl6K1Q{A3JyEDFDJ^v!SV^d3VqR~2Qf5y~*)bXtHjC~H@Y#KpT<<_{LDPQaaw7%xx0)I&NF0*ySZ`HGY^gO>{F=PA`^0JXB zxeD75G}L_g%>srL;$*ctqtuP5K{2L<>pf8BJbH!^Y5wz>Uq4fk*IV}EbUfK}a6*dS z0|M#@1M{9t)z91XSqmA!p7hS^VX6DqN*O8`7mCcmwVCf$Tk!60b*RD8i;46`;-@Zp zaE8i`eF?#B(Q(WiVX_=^Kxk&GxTl4ccrYN`oZTHqic!!1k%4o|-+q51&s~cIcRKyx zIhQ&pubmM1!#}N$2R?f6L^th|GT)k~TmW*^c0cbL%tmeeW=wDbzl&<)x7Yk2 z`?eNv`QAdZ_mvX~HZNa_T|}qde9`U+n{{D`NLE(oJP)Dux|Gp@&;Wv63UzlQ9;X1g}LsO96)g}~JyX?GMC@JF>sUD})3KJqOHjfiW zqX@HYv-jA1m7N=w*6IUf1WQEvdpks?jExV==i-vM$Ee}RkGN01*P?SQcCV;~RFzWq zpdg&o>rtnvan5AUR0pVS=+3O?wsw$TS`V`yO`Z*`<@4K9RDF8P&T4*tajnT=YmZyI zufX(Mq|Qwm%JZ%k92cPdj;XjA9f>h&sT$jWUHqf|>B>JXWxqrxM5o+GVdLgJ|^ z$s|{tXM`ntUMHj&=ZHZ72ygcS;P3p&Qs0M%;-6s`Io58gf*{nbInRGNVGj2&Py`bP z&j`REma@6Nhv&vz-Y(}%B%8DFJ~#-ZP_oOdoylBt)=RE%86@xW0CCifbc;zCm~w+r z;Ow|`-fZUy7e0hDy7zg`&KswD^{GP&ZDv|6YmkcDb%IbtF1XN$*CmBp_)b>%MUn4( zeCzd798yREINWmFYw&;~VBo-kDQAplTLTP=ko1G6#8IgwOU*_adR*4oBqbmk9z49; zz}Ee~c?g6K8BbA5K+40lrNNXh39y{4$IhNIv3gd>+ zy=)wYTe23f0C>Tv_p6Wy1CCdKM~OTH0H&BX!USau3KmxIico}A!UOc$n8uzNAjson z4udIK2V<@V(jj2V!&wz-{GuQT{WT63`B4ySOZ}Q%&cz<-v#r1DOlreeUGYg=%Hp

X*V_!h+ZAv+`w=Z&lz#pdH=k(WvqC$r5W_uLb?BD@8$R5*5&?k5!Hix|J07#G?EKo93e2p zJIga$wb5CpJC=)D&^7M15!J7VHee9iffZ0p(49Z|7x6fgXiw~#$pX9B>A9KvtQ478 zd00#^E_JqYtvP^8Rs3Q)2+rKsNG9%9>mw`fE)Fgplhbqm0cqZfA_%59G?lZPSLn~r z&cp9=P2c&{Enl=bpN=yFJjmBB1;Q{F{zPB>A$jjQ6frYDx1X$PZ#6zY=Ty$U;~ z1^*rY; zmVP{XTlgX1*LaLG)55jk=7Z`b+F6zvfMgqAgn&_btlBYtBUIWVS5~i@?C}rEIz8Hj z1M0`hFR@Mxa8>tKv}kB^k5U+Jxhs#U8aYk8nlJ%|mibncM09JBB&tWY{#p}Yvq#L+ z6b>;&`q<0DU(NwGZ%fb~KJz1rVohXXOYLI(`2{Q}J^Qy?K$S$bv^a1ZnMCibyjvAE z^Q9}O=FwlD^`t(=Ye_UQO7|uCPKW?gM}6I+Z=JV*V;(iL5QAFJqKFdQj~)?m)HPYWtMjn~&lZ+BJy^pKYvR^ID9CI5cofUj zl8-VtPwn%czP(#=ab6B*%8mR+UfPrkt5f=CtM>U-&1n?I6^#*&l?jw*WoY#F244Cz zx-{NYlnDcCWPK?&xS_@}#?vOsUOg8=W+nI?F~C{Dg#gOGh2rwDklfZ*D+^}8{J~J} z=>WN7KbG3T1-i7<1N7)9IO3xQmv=W0F5)mG<1T^}yNF2_`hp_}-oV{6mj z?B&Drt%$mvrvxP6WQGE{N;vtKvTxq~-DS~_FRS4C==;z%TvnpsoJQa7pCofA`9n9{Bz2ZMeVhE;d)M`Xy;4Hri07S`v1A1xJyb+!*N|ZRA@edyAsCk?!7LeDyRB0))UCU~9NHtnI;e#z)jQ+_@ZGY|&r|9xBu$y(YWzAM%B zQVUN5oK9Eyh??4N!XoF9@s~E3CN|V)k4q6xZJtJpoj1{cHv1w05Laj=>X9CwZ`%O!{6wAx&wul-86IGiO*yHu5Az zXY2Zgm_#noe8dl#Q`Laq8-4E)TUKwMDD}=QA6F|Er=yBP|IC4<@+C#W{M&@;Me(Z? z#xdoxOAIock~&;eq%`VkH{^&=?`*Q>5nd~EIqDi_s={v*8-NwBz%K*&zV9g(kGTwT zjIwOm&hL_^+ZRh$oa{Oy=S(Sjz*G%t)1#X9O0aEt)2yzW!i0HSJW~Awjd9#{Wje0f zs8LAsemZLA&9iGY9<`HEjt{2p`uB?ZsA{4?%ZcAr^dvr?>9sZM!ckjxRNQiPuNPY3 zsK0)NWdbgX2=LPRPYtJ}Yrt&>OkNriut}`$4PPp0VT(v9@H_{Kk#-G{9eWhrt3?PK mrcksG^>5_f4*nn9@c%A)o^THNYLD+|0&NQZ+WPOLTo~e zQhRI7;_2^Ccs|#4?(^onKj*scI75RcAQ~*wnpd1(an>U%^J z(5e46hwFu;vQ*N8LJto?W{1J#rPoJgOEVIBKjkBbXenq|`zY9QX6fXU^z`V=1{h;r zz(oTDfTSf^o9zLoBdz;a=vY42slA(p_NJ|A?d%T|R|}h$dnbFl+kyiBUj|COTACJyZWR(@O^V;^D?1^rwyE~BRnw|hR z55-KbqXgs{$A>s5c-okmCuB|I=eSGRa%>Wy3~9>6dmJ1<|&s}jHFy63@_w%q} zkAAA}`cfxuI-f20`HY7)t{M}vb(Q+oaBxflzA4* zKN<>b&}VW6+yde&3J=LDzT1AF+8BP>T@Ho@;D2)U%~CJ6^W-~@**ytxT#z|N!6c(e zgNmKbYLRBTz|7eg8alnCqD}Ni){Vy4oVy20?L$dI#2&+u-p=R3WGb5E7qVNd}HifdMA5 z?~QOK5ML2we&2^XO<_99kRg-%ZtCpFvudw+k6$z!B6i#`2vjqlyjGQ`EiL?2gFjBqKVl!ygH-UL+TGB&i#i21?wvXFfnLEzd#B&JM{88xw6#5+ zY?i7HVdPmWK^8lGwPw9*@lcHk8{B+nxWuM`c;4Y1l#567 z3>Fx*xM<5+$)7Q+xhf?YcblvB(ER9~#`H%Dco2UsOJJ;VPUtgcYhjd&XS9^Q=htU+ z2p0bSpGNEoHCr#YZ_ON7pAY=h2KqX9RUiX>6}NTNMqD3=uO(C<+gnkSqL$H$pR>6a z{O{N#%vgctUB9rSa_mdis`U!eZUcA;-k{0X+d9Y_t2#H(pKzm8ZHhL&x7d~+5X4a@ z76;{!tKb`%fkg^ZV=G1jD@LE7@;y*~{OHZjxm6c8 zI=~5S{WLb+N3|irme*}s6A#|Apu5eo!sXEzGZ}e~AKXp%N98G7OcyZ{EWxPad;tr* zNwvY}Jk<#vcmg8L9fxkYHt#(<(z9ZtVysGKK;#c}wY@|c<-c(^h#m1}k;k7E0+O2! za%KaD>oGCzo1bKA9;=;x-6Qj8<7OvCOyB|1)A${NNn1UndPS?yE(5f2(&m05yzUje z%DEx+#XfkYhw0RUR8i2M@}NZ)L5O(R1R9XUbI>>&369@XG5WZiN71j07w`19h`Ws$ zsEyRGYth<=2md-a+jmwG&16TdY_%AfIYq4P$moYh!Pl}k!!@*b#T4-R>Fzng%QVwU zF6Ev_lzx6ykHEiUAG^Ufqr8T0U)W~l^$u|3=l2ciyoC=A!k2kETL5|gF$PwCh>dbFxCWgQq6?De<1m3ftaM zW%K{U94?XcSFJon92qE1k|*1@6a$OdcASh;WnY-+XTis1oCs6O1okWx4wK^(Yu5+L zOQOELmt&q6QpLxiE3&VhLl)J(MZrf4#-%jYLv`?DK9h zT#^VDO#q}1yjkS z6V5Zo`(dSu*9Ko3Bg{3s8f%wJti5z|qDIfp*!U5z6%ybN=`YW(a}jIGY9I)-$rfqms)1* zRQ__zQ!d8pk#p#lO=W-8)>r5X_ThhzLIm>?j^0s1 z3-GkXhp2S*Iyc*{A*bcAey;4CCQQZbwm``=u)@W%#BsdSMfq6eT&ommQBW?b~dD#~y{? zxxZB1fq);f30w7Zqn4D}&lWWyj4AeK+Y#ndPm1^ZVfj~)Jhq z`;tmtPSbM(FqmNlQBp`bwWoD}^l)Rpv9A~dtS36Hy_xkm1iMV#fQ><{7}Xc%naO;% z+G5P?{<5~v1m3?n{Y|rVKS4VO7%TD+IeK!%0f=Zag2J%7ugO~=598{1>c z<6Hmx!K=OSyeVqUh}Q2*U7x@F?`z8-l^4>1?SfvCYPSMwPKxEFeZ*cL!>B0@WqaJx zhEtf!^^^zM*%3LDdA^t~7e;kopM?FJFD1|N(Fv{LFGG4Wu<@Uj;xy4J@7RG|FY_(f zwfs*V;~AiG!W=)4i_ve%eSdeTgp+MKwJ|JOlO3*>0{_c^HtcyU86KHA_$JNM!kFwakjlw@9j;(SD_h-0%KXtEfWC?fPP>gJMDWn)|G?LpZmE zVB)v!e3o^&M0Q@& zQ&FVj$(L3So~?=8aRH5>5FOvovG}DjF_qc^Ikn7Vt+QayBnSLnuc??l6Lg$9u3CAk zGAiQlX7cPO8I@6tZkf?@epuZZ1z5M!-Q78WX!oq}8klPI>~!FTe;+pqSpU?~qPxTR z$E-n!Bk@g4O@q|WiQsW;asgtWHyEy3(eeEmuYTz)EuC!RglxR4})nuxUp-*MxGVK1~71rd@bgS_k&LWLoQ{MVpMqPH1ULR@VO4oF! z*Bsu5_Z#dcS=&>fD8;(=G~!l60CEaFO&!owJ0KjXP{W$Ibl|1s;$TbG%qxO-;`2to z9ZRGUrGNDacDlIQiY8@O(5#HgSM(+4nGw!-Pu@P>z?H_h#)7KRqJ9SL+;ixS(fc$^ z9wwdf4y?5$Gs7{ToT07nQgj_)=o4srS`Uer!Cbsg*(Bau^?a2lN_y5H!HbP?-`0#g zm+>QI>kKCL3@0wXnB~1xa5b_SBStWN(_yf=Pj5oBl%U25V7IGJe`ZP<#nP1`4f7Wh zsh-UH3?2;AO7_vrXeB;lA``yQd`sV|{sqRfkWs5_s=o8>&51}n$Zu?8Op&*5{VlVbUntFle$?*H3;@i&#X_U^k_qgA2@_fq_da@&LE;QJOX*AHZodsR@`PvECBbbmIdh)8 zsbH41vNNgJ@orXPh**B@T?Y!r`=3rIGrg-0q*5h&SjFlc{rk-3vY`@U*i!$gf&($T z=kJWh4=r4~FWX+ONEWdxNwM;f#>IXwso9A%M`G7w31Wl5 z_1`ua44FCSQ>h;cND*phQ-aEcv1rQbO!-Yo(BXA#6*oG9RRV~VsbwMz*h*=Vidk^+ z>C@yfDf|@MP6_WrGK9=(l)@%~VSm5!M_txsuLh2n3Bgm-yZxGoEV2~Oni9`{?-f*_ zBie>ljPNN`@aqMFR{EVT{TDdatzG*9Rmx}o<8UFl+4G!-`qsrc>VEd8#eupH|1BWR zGF&3nsy*G1Y2HwGr6EW&`mk?&Sm=jrQ(55AJT7q+YONrx5IK)n^ zT56y-Xk%grxyTRUR+8FvtdM~|$Q2g}`6klD{5D18dh+p){6e8X?m2`dE5# zgdL^$IUNnVVfEP2C<3g)FeL%qp6UAgKmmslJQ1PPe((PAnnui)k5J)FdG1730_)m| z|3Lj>tog~S3~cKBk09S2&d4_NOf%<~qej6f7D?jwz_ld1ne0Lwn~sO2 zKN^Mfs+1_zj-n@T3LYk>2X?HXF+UZP#J5}n^g!j+A-`td_6g_1R8FoaZVc-xo6#_yS+4v-g8OVPfGrCgntzXd=!O6`KZv(bCL!`ZCtHl) z^fuSK)FutiFnx=>1Qpt6Fd_7O)T=JA=bn1ukxA0gc5V;<%k3{idZ_i~Y3T1)ox{+B z40wB!?gP(?c-03)%Z$ijjo`UOMZ1T-{eK(uR3ej<4!0M`v)a$Hxlvif9rc=q-r=u{ zgcis0BQoAULLymvz2^l8;BQjCECUvI8CD{fb${6dX)@kvg`De{LEDz_+JgWL&%ceZ z+V3Y}WwCGLfEuM3O$8-x`2X z&tI(8?9(c-i|>385yXRGk7A4o$oS_&B|fB@l#28tn+tjAb?c|LJeSX;QI^)y40@n; z(`qZ1r!8Obb$PV+VPXGSjU2Pjw z);P92MA;m_m2T6SYl?ax{^>~T?Vo7b^U)d6eZrM2QxsC;IU-vGd3kIq)u4geSjTJ=O+dY9GZ67|rT}Qk_xi=mZtyXh%ldP4ik!bdy%=*~O7W zD}c*W1$f7!M49A`bV1MP(CnyG7OA*EHX+!GY7rA;Z-?Zh zfKZOfmNws_QU}5f`TUcF(DQ<*2MS$YY_Ydu690PCx!rg=i_NG~D5U0$>7vd>z)^JN zoY&_Ol}LYw-vn*ZKsE%Qt`<6>jGUocVI&Q@zW;oB74rE-Fo_>f8Vj+vm*=Rdw!A@` zwWb!Es9IxMkSom~sF&78{jDG3zq^)C8DfYnFl9l`M`Fl;O)y^I$FhEkRN-nz0kN9n zMCU<^6U>hc(Z)G?=0vsk1m1IJreChpgwU|LoZ-Nm8c*#9d|=Yu$tZZT?82#X`U-IA zUW(W8qRhg^YXZ3Bx?JrJbu%X&xtZM95@N4O+in=-(`A<*n}m1^L?umua8mINHfnDz zZx13}~|D6k27Kf=((5YAEM7w{7m!oe2UX!S`__ol!}P!fo5K7Pz| z+hOVU3@p-|Soa^TOzj}a$&w4-n}h}b1BeUTlNdpQ^z$e^?v&Ro_F;{+g^!jgm83C6 zsrOsGT9!e!7%Dr9Ih`{Vr$>3Bte#`{+*$SwBJe#o2QGR5!fxiP$K07s;|}&k)L>Bx zeNyf}rlx^?Qr8N{QTWE>|5sZZ)e?b(%Ha`UM7ltGzBDwwb?$()?)8<{U6Bm}Z4D&wo zo$pMg^4}a#_8Tbkj4OZjBofb77VzQ`@^G9<^MUIS=`!s?ner^!oAuz~pK!t(cLb; z*FJ*wpP+3oG3CC3G<~cZ3b3+|hk8((ItGM%p(N4i=ZzcmjrVvbZa49b&>mCvVYRh6 za;lLv=@YE>0zFtkC9j};d>f>F#FTj;l${uud*)Pt(0&ryW)*u~M!-6x_9n%vt^No| zx5%Vj!vZhyJ}H}c?MD+^*iB{%>49V?Gx@E7kxPvXSnP+{H*X~u`Ivb66p;VHIo<&UYY{ZU-3Ei~;78hEFv g_AdN&694@C0^Vv9Ws7L`KmY&$07*qoM6N<$f@Qb-O8@`> literal 0 HcmV?d00001 diff --git a/packages/design-tokens/brand/generated/web/favicon-32.png b/packages/design-tokens/brand/generated/web/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..000e93681bf7d79032f15dfe909f47fe5bdd156a GIT binary patch literal 1173 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 WpquJ@!Nh-SBcLRwCR;9J?EhaTR@Q3( literal 0 HcmV?d00001 diff --git a/packages/design-tokens/brand/generated/web/install-icon-512.png b/packages/design-tokens/brand/generated/web/install-icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..0f459f709c076cf376ac9972014c68170d906b97 GIT binary patch literal 18761 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 JF6*2UngExV1VjJ; literal 0 HcmV?d00001 diff --git a/packages/design-tokens/brand/generated/web/navigation-wordmark-black-204x50.png b/packages/design-tokens/brand/generated/web/navigation-wordmark-black-204x50.png new file mode 100644 index 0000000000000000000000000000000000000000..d2d03b721e5960bd4c0dc7bcdc48330a8f6c3c82 GIT binary patch literal 3946 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 Eg254Mo&W#< literal 0 HcmV?d00001 diff --git a/packages/design-tokens/brand/generated/web/navigation-wordmark-blue-204x50.png b/packages/design-tokens/brand/generated/web/navigation-wordmark-blue-204x50.png new file mode 100644 index 0000000000000000000000000000000000000000..3c2f19b9e72d1cb173cce0d7970cda222b96fb50 GIT binary patch literal 6632 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 asset.width || + asset.contentBox.y + asset.contentBox.height > asset.height + ) { + throw new Error(`Content box exceeds output bounds for ${asset.file}`); + } + + if (asset.containsWordmark && asset.adjacentProductNamePolicy !== 'forbidden') { + throw new Error(`Wordmark ${asset.file} must forbid adjacent duplicate product text`); + } + if (!asset.containsWordmark && asset.adjacentProductNamePolicy !== 'accessible-context-only') { + throw new Error( + `Standalone mark ${asset.file} may add product text only for accessible context`, + ); + } + + if (asset.frames) { + if (!asset.file.endsWith('.ico') || asset.frames.length === 0) { + throw new Error(`Only non-empty ICO frame lists are supported for ${asset.file}`); + } + for (const frame of asset.frames) assertInteger(frame, `${asset.file} frame`, 1); + } else if (!asset.file.endsWith('.png')) { + throw new Error(`PNG output required for ${asset.file}`); + } + } + + return plan; +} + +async function loadInputs() { + const [planBytes, sourceManifestBytes] = await Promise.all([ + readFile(planPath), + readFile(sourceManifestPath), + ]); + const plan = validateDerivativePlan(JSON.parse(planBytes.toString('utf8'))); + const sourceManifest = JSON.parse(sourceManifestBytes.toString('utf8')); + const approvedSources = new Map(sourceManifest.assets.map((asset) => [asset.file, asset])); + const sourceBytes = new Map(); + + for (const source of Object.values(plan.sources)) { + const approved = approvedSources.get(source.file); + if (!approved) throw new Error(`Derivative plan references unapproved source ${source.file}`); + if (!sourceBytes.has(source.file)) { + const bytes = await readFile(join(sourceDirectory, source.file)); + assertApprovedSourceBytes({ approvedSha256: approved.sha256, bytes, file: source.file }); + sourceBytes.set(source.file, bytes); + } + } + + return { plan, sourceBytes, sourceManifestBytes, approvedSources }; +} + +async function renderPng(sourceBytes, source, output, pngOptions) { + let image = sharp(sourceBytes, { failOn: 'error', limitInputPixels: 64_000_000 }); + if (source.crop) image = image.extract(source.crop); + + const resized = await image + .resize({ + width: output.contentBox.width, + height: output.contentBox.height, + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + kernel: sharp.kernel.lanczos3, + }) + .png(pngOptions) + .toBuffer(); + + return sharp({ + create: { + width: output.width, + height: output.height, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .composite([{ input: resized, left: output.contentBox.x, top: output.contentBox.y }]) + .png(pngOptions) + .toBuffer(); +} + +function scaleContentBox(asset, size) { + const width = Math.max(1, Math.round((asset.contentBox.width / asset.width) * size)); + const height = Math.max(1, Math.round((asset.contentBox.height / asset.height) * size)); + return { + x: Math.floor((size - width) / 2), + y: Math.floor((size - height) / 2), + width, + height, + }; +} + +function fittedBoxFor(source, approvedSource, contentBox) { + const sourceWidth = source.crop?.width ?? approvedSource.width; + const sourceHeight = source.crop?.height ?? approvedSource.height; + const sourceAspect = sourceWidth / sourceHeight; + const boxAspect = contentBox.width / contentBox.height; + const width = + sourceAspect >= boxAspect ? contentBox.width : Math.round(contentBox.height * sourceAspect); + const height = + sourceAspect >= boxAspect ? Math.round(contentBox.width / sourceAspect) : contentBox.height; + return { + x: contentBox.x + Math.floor((contentBox.width - width) / 2), + y: contentBox.y + Math.floor((contentBox.height - height) / 2), + width, + height, + }; +} + +function createIco(pngFrames) { + const directoryLength = 6 + pngFrames.length * 16; + const header = Buffer.alloc(directoryLength); + header.writeUInt16LE(0, 0); + header.writeUInt16LE(1, 2); + header.writeUInt16LE(pngFrames.length, 4); + + let offset = directoryLength; + for (let index = 0; index < pngFrames.length; index += 1) { + const { bytes, size } = pngFrames[index]; + const entry = 6 + index * 16; + header.writeUInt8(size === 256 ? 0 : size, entry); + header.writeUInt8(size === 256 ? 0 : size, entry + 1); + header.writeUInt8(0, entry + 2); + header.writeUInt8(0, entry + 3); + header.writeUInt16LE(1, entry + 4); + header.writeUInt16LE(32, entry + 6); + header.writeUInt32LE(bytes.length, entry + 8); + header.writeUInt32LE(offset, entry + 12); + offset += bytes.length; + } + + return Buffer.concat([header, ...pngFrames.map(({ bytes }) => bytes)]); +} + +async function visualHash(pngBytes) { + const pixels = await sharp(pngBytes) + .ensureAlpha() + .resize({ width: 32, height: 32, fit: 'fill', kernel: sharp.kernel.lanczos3 }) + .raw() + .toBuffer(); + return sha256(pixels); +} + +export async function generateBrandDerivatives({ outputDirectory, manifestPath }) { + validateOutputTargets({ manifestPath, outputDirectory }); + + const { plan, sourceBytes, sourceManifestBytes, approvedSources } = await loadInputs(); + const manifestAssets = []; + + for (const asset of plan.assets) { + const source = plan.sources[asset.source]; + const approvedSource = approvedSources.get(source.file); + const bytes = sourceBytes.get(source.file); + let outputBytes; + let visualBytes; + + if (asset.frames) { + const frames = []; + for (const size of asset.frames) { + const frameAsset = { + ...asset, + width: size, + height: size, + contentBox: scaleContentBox(asset, size), + }; + frames.push({ + bytes: await renderPng(bytes, source, frameAsset, plan.pipeline.png), + size, + }); + } + outputBytes = createIco(frames); + visualBytes = frames.at(-1).bytes; + } else { + outputBytes = await renderPng(bytes, source, asset, plan.pipeline.png); + visualBytes = outputBytes; + } + + const outputPath = join(outputDirectory, asset.file); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, outputBytes); + manifestAssets.push({ + adjacentProductNamePolicy: asset.adjacentProductNamePolicy, + containsWordmark: asset.containsWordmark, + contentBox: asset.contentBox, + file: asset.file, + fittedBox: fittedBoxFor(source, approvedSource, asset.contentBox), + ...(asset.frames ? { frames: asset.frames } : {}), + height: asset.height, + mediaType: asset.frames ? 'image/x-icon' : 'image/png', + platform: asset.platform, + purpose: asset.purpose, + safeZone: asset.safeZone, + sha256: sha256(outputBytes), + source: { + ...(source.crop ? { crop: source.crop } : {}), + file: source.file, + sha256: approvedSource.sha256, + }, + transform: 'aspect-preserving-contain', + visualSha256: await visualHash(visualBytes), + width: asset.width, + }); + } + + const manifest = { + schemaVersion: 1, + generator: { + engine: plan.pipeline.engine, + engineVersion: plan.pipeline.engineVersion, + icoContainer: 'png-frame-ico-v1', + png: plan.pipeline.png, + }, + sourceManifestSha256: sha256(sourceManifestBytes), + assets: manifestAssets, + }; + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, stableJson(manifest)); + return manifest; +} + +async function listFilesRecursively(directory, prefix = '') { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + files.push(...(await listFilesRecursively(join(directory, entry.name), relativePath))); + } else if (entry.isFile()) { + files.push(relativePath); + } else { + throw new Error(`Unexpected generated asset entry: ${relativePath}`); + } + } + return files; +} + +async function assertSameFile(expectedPath, actualPath, label) { + const [expected, actual] = await Promise.all([readFile(expectedPath), readFile(actualPath)]); + if (!expected.equals(actual)) throw new Error(`Brand derivative drift detected: ${label}`); +} + +export function validateOutputTargets({ manifestPath, outputDirectory }) { + if ( + isWithin(sourceDirectory, outputDirectory) || + isWithin(outputDirectory, sourceDirectory) || + resolve(outputDirectory) === resolve(brandDirectory) + ) { + throw new Error( + 'Derivative output directory must not contain or overwrite immutable brand sources', + ); + } + if ( + isWithin(sourceDirectory, manifestPath) || + resolve(manifestPath) === resolve(sourceManifestPath) + ) { + throw new Error('Derivative manifest must not contain or overwrite immutable brand sources'); + } +} + +export async function compareBrandDerivatives({ + expectedManifestPath = defaultManifestPath, + expectedOutputDirectory = defaultOutputDirectory, +} = {}) { + const temporaryRoot = await mkdtemp(join(tmpdir(), 'databreeze-brand-check-')); + try { + const temporaryOutput = join(temporaryRoot, 'generated'); + const temporaryManifest = join(temporaryRoot, 'derivatives.json'); + await generateBrandDerivatives({ + outputDirectory: temporaryOutput, + manifestPath: temporaryManifest, + }); + + const [committedFiles, generatedFiles] = await Promise.all([ + listFilesRecursively(expectedOutputDirectory), + listFilesRecursively(temporaryOutput), + ]); + if (JSON.stringify(committedFiles) !== JSON.stringify(generatedFiles)) { + throw new Error('Brand derivative inventory drift detected'); + } + await Promise.all( + generatedFiles.map((file) => + assertSameFile(join(expectedOutputDirectory, file), join(temporaryOutput, file), file), + ), + ); + await assertSameFile(expectedManifestPath, temporaryManifest, 'derivatives.json'); + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } +} + +export async function checkBrandDerivatives() { + await compareBrandDerivatives(); +} + +function parseArguments(argv) { + const options = { + check: false, + outputDirectory: defaultOutputDirectory, + manifestPath: defaultManifestPath, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--check') { + options.check = true; + } else if (argument === '--output' || argument === '--manifest') { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`${argument} requires a path`); + index += 1; + if (argument === '--output') options.outputDirectory = resolve(value); + else options.manifestPath = resolve(value); + } else { + throw new Error(`Unknown argument: ${argument}`); + } + } + return options; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.check) { + await checkBrandDerivatives(); + process.stdout.write('Brand derivatives are reproducible and current.\n'); + } else { + await generateBrandDerivatives(options); + process.stdout.write(`Generated brand derivatives in ${options.outputDirectory}.\n`); + } +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ''; +if (invokedPath === fileURLToPath(import.meta.url)) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/packages/design-tokens/test/brand-derivatives.test.mjs b/packages/design-tokens/test/brand-derivatives.test.mjs new file mode 100644 index 00000000..f1eb97cd --- /dev/null +++ b/packages/design-tokens/test/brand-derivatives.test.mjs @@ -0,0 +1,275 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { test } from 'node:test'; +import { fileURLToPath, URL } from 'node:url'; + +const execFileAsync = promisify(execFile); +const packageDirectory = fileURLToPath(new URL('../', import.meta.url)); +const generatorPath = join(packageDirectory, 'scripts', 'generate-brand-derivatives.mjs'); +const brandDirectory = join(packageDirectory, 'brand'); +const committedOutputDirectory = join(brandDirectory, 'generated'); +const committedManifestPath = join(brandDirectory, 'derivatives.json'); +const goldenPath = join(packageDirectory, 'test', 'fixtures', 'brand-visual-golden.json'); + +const expectedInventory = [ + ['android/adaptive-foreground-432.png', 432, 432], + ['android/launcher-hdpi-72.png', 72, 72], + ['android/launcher-mdpi-48.png', 48, 48], + ['android/launcher-xhdpi-96.png', 96, 96], + ['android/launcher-xxhdpi-144.png', 144, 144], + ['android/launcher-xxxhdpi-192.png', 192, 192], + ['android/notification-hdpi-36.png', 36, 36], + ['android/notification-mdpi-24.png', 24, 24], + ['android/notification-xhdpi-48.png', 48, 48], + ['android/notification-xxhdpi-72.png', 72, 72], + ['android/notification-xxxhdpi-96.png', 96, 96], + ['desktop/application-256.png', 256, 256], + ['desktop/application.ico', 256, 256], + ['desktop/installer.ico', 256, 256], + ['desktop/notification-32.png', 32, 32], + ['desktop/updater.ico', 256, 256], + ['web/apple-touch-icon-180.png', 180, 180], + ['web/favicon-16.png', 16, 16], + ['web/favicon-32.png', 32, 32], + ['web/install-icon-192.png', 192, 192], + ['web/install-icon-512.png', 512, 512], + ['web/navigation-wordmark-black-204x50.png', 204, 50], + ['web/navigation-wordmark-blue-204x50.png', 204, 50], + ['web/social-card-1200x630.png', 1200, 630], +]; + +function runGenerator(args) { + return execFileAsync(process.execPath, [generatorPath, ...args], { + cwd: packageDirectory, + encoding: 'utf8', + }); +} + +function hash(bytes) { + return createHash('sha256').update(bytes).digest('hex').toUpperCase(); +} + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +function parseIco(bytes) { + assert.equal(bytes.readUInt16LE(0), 0); + assert.equal(bytes.readUInt16LE(2), 1); + const count = bytes.readUInt16LE(4); + const frames = []; + for (let index = 0; index < count; index += 1) { + const entry = 6 + index * 16; + const width = bytes.readUInt8(entry) || 256; + const height = bytes.readUInt8(entry + 1) || 256; + const length = bytes.readUInt32LE(entry + 8); + const offset = bytes.readUInt32LE(entry + 12); + frames.push({ bytes: bytes.subarray(offset, offset + length), height, width }); + } + return frames; +} + +test('a clean output directory receives the complete deterministic platform inventory', async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), 'databreeze-brand-derivatives-')); + const outputDirectory = join(temporaryRoot, 'generated'); + const manifestPath = join(temporaryRoot, 'derivatives.json'); + + try { + await runGenerator(['--output', outputDirectory, '--manifest', manifestPath]); + + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + const actual = manifest.assets.map(({ file, height, width }) => [file, width, height]); + assert.deepEqual(actual, expectedInventory); + + const platformDirectories = await readdir(outputDirectory); + assert.deepEqual(platformDirectories.sort(), ['android', 'desktop', 'web']); + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } +}); + +test('committed derivatives reproduce byte-for-byte and a changed output is detected as drift', async () => { + const module = await import('../scripts/generate-brand-derivatives.mjs'); + assert.equal(typeof module.compareBrandDerivatives, 'function'); + + await module.compareBrandDerivatives({ + expectedManifestPath: committedManifestPath, + expectedOutputDirectory: committedOutputDirectory, + }); + + const temporaryRoot = await mkdtemp(join(tmpdir(), 'databreeze-brand-drift-')); + try { + const outputDirectory = join(temporaryRoot, 'generated'); + const manifestPath = join(temporaryRoot, 'derivatives.json'); + await runGenerator(['--output', outputDirectory, '--manifest', manifestPath]); + const faviconPath = join(outputDirectory, 'web', 'favicon-16.png'); + const changed = Buffer.from(await readFile(faviconPath)); + changed[changed.length - 1] ^= 0xff; + await import('node:fs/promises').then(({ writeFile }) => writeFile(faviconPath, changed)); + + await assert.rejects( + module.compareBrandDerivatives({ + expectedManifestPath: manifestPath, + expectedOutputDirectory: outputDirectory, + }), + /Brand derivative drift detected: web\/favicon-16\.png/, + ); + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } +}); + +test('the derivative manifest links every output to an approved source and records safe fitted geometry', async () => { + const [manifest, sourceManifest] = await Promise.all([ + readFile(committedManifestPath, 'utf8').then(JSON.parse), + readFile(join(brandDirectory, 'manifest.json'), 'utf8').then(JSON.parse), + ]); + const approved = new Map(sourceManifest.assets.map((source) => [source.file, source])); + + assert.equal( + manifest.sourceManifestSha256, + hash(await readFile(join(brandDirectory, 'manifest.json'))), + ); + for (const asset of manifest.assets) { + const source = approved.get(asset.source.file); + assert.ok(source, `${asset.file} must use an approved source`); + assert.equal(asset.source.sha256, source.sha256); + assert.equal(asset.transform, 'aspect-preserving-contain'); + assert.ok(asset.safeZone.length > 0); + assert.ok(asset.fittedBox.x >= asset.contentBox.x); + assert.ok(asset.fittedBox.y >= asset.contentBox.y); + assert.ok( + asset.fittedBox.x + asset.fittedBox.width <= asset.contentBox.x + asset.contentBox.width, + ); + assert.ok( + asset.fittedBox.y + asset.fittedBox.height <= asset.contentBox.y + asset.contentBox.height, + ); + + const sourceWidth = asset.source.crop?.width ?? source.width; + const sourceHeight = asset.source.crop?.height ?? source.height; + const sourceAspect = sourceWidth / sourceHeight; + const fittedAspectError = + sourceAspect >= asset.contentBox.width / asset.contentBox.height + ? Math.abs(asset.fittedBox.height - asset.fittedBox.width / sourceAspect) + : Math.abs(asset.fittedBox.width - asset.fittedBox.height * sourceAspect); + assert.ok(fittedAspectError <= 0.5, `${asset.file} must preserve the source aspect ratio`); + } +}); + +test('PNG and ICO outputs have declared dimensions, transparent clear space, and preserved brand colors', async () => { + const [{ default: sharp }, manifest] = await Promise.all([ + import('sharp'), + readFile(committedManifestPath, 'utf8').then(JSON.parse), + ]); + const sourceColors = { + 'databreeze-mark-dark.png': [ + [4, 9, 32], + [52, 78, 248], + ], + 'databreeze-wordmark-black.png': [[0, 0, 0]], + 'databreeze-wordmark-blue.png': [[52, 78, 248]], + }; + + for (const asset of manifest.assets) { + const bytes = await readFile(join(committedOutputDirectory, asset.file)); + assert.equal(hash(bytes), asset.sha256); + const pngs = + asset.mediaType === 'image/x-icon' + ? parseIco(bytes) + : [{ bytes, width: asset.width, height: asset.height }]; + + if (asset.frames) + assert.deepEqual( + pngs.map(({ width }) => width), + asset.frames, + ); + for (const frame of pngs) { + if (asset.frames) assert.equal(frame.width, frame.height); + assert.deepEqual(frame.bytes.subarray(0, 8), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + const { data, info } = await sharp(frame.bytes) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + assert.equal(info.width, frame.width); + assert.equal(info.height, frame.height); + + let transparentPixelFound = false; + const visibleColors = new Set(); + for (let offset = 0; offset < data.length; offset += 4) { + if (data[offset + 3] === 0) transparentPixelFound = true; + if (data[offset + 3] > 0) + visibleColors.add(`${data[offset]},${data[offset + 1]},${data[offset + 2]}`); + } + assert.ok(transparentPixelFound, `${asset.file} must retain transparent clear space`); + assert.ok( + sourceColors[asset.source.file].some((color) => visibleColors.has(color.join(','))), + `${asset.file} must retain an approved source color`, + ); + } + } +}); + +test('visual signatures match the separately approved golden fixture', async () => { + const [manifest, golden] = await Promise.all([ + readFile(committedManifestPath, 'utf8').then(JSON.parse), + readFile(goldenPath, 'utf8').then(JSON.parse), + ]); + assert.deepEqual( + Object.fromEntries(manifest.assets.map((asset) => [asset.file, asset.visualSha256])), + golden.assets, + ); +}); + +test('plan validation blocks unsafe paths, invalid safe zones, and duplicate wordmark text policy', async () => { + const { validateDerivativePlan } = await import('../scripts/generate-brand-derivatives.mjs'); + const plan = JSON.parse(await readFile(join(brandDirectory, 'derivative-plan.json'), 'utf8')); + + const traversal = cloneJson(plan); + traversal.assets[0].file = '../source/changed.png'; + assert.throws(() => validateDerivativePlan(traversal), /Unsafe derivative output path/); + + const unsafeGeometry = cloneJson(plan); + unsafeGeometry.assets[0].contentBox.width = unsafeGeometry.assets[0].width; + assert.throws(() => validateDerivativePlan(unsafeGeometry), /Content box exceeds output bounds/); + + const duplicateText = cloneJson(plan); + const wordmark = duplicateText.assets.find((asset) => asset.containsWordmark); + wordmark.adjacentProductNamePolicy = 'allowed'; + assert.throws( + () => validateDerivativePlan(duplicateText), + /must forbid adjacent duplicate product text/, + ); +}); + +test('output target validation prevents any generated write beneath immutable sources', async () => { + const { validateOutputTargets } = await import('../scripts/generate-brand-derivatives.mjs'); + assert.equal(typeof validateOutputTargets, 'function'); + assert.throws( + () => + validateOutputTargets({ + manifestPath: join(brandDirectory, 'source', 'derivatives.json'), + outputDirectory: join(brandDirectory, 'generated'), + }), + /must not contain or overwrite immutable brand sources/, + ); +}); + +test('the source hash gate rejects bytes that are not the approved canonical asset', async () => { + const { assertApprovedSourceBytes } = await import('../scripts/generate-brand-derivatives.mjs'); + assert.equal(typeof assertApprovedSourceBytes, 'function'); + assert.throws( + () => + assertApprovedSourceBytes({ + approvedSha256: 'A'.repeat(64), + bytes: Buffer.from('changed source'), + file: 'databreeze-wordmark-blue.png', + }), + /Approved source checksum mismatch: databreeze-wordmark-blue\.png/, + ); +}); diff --git a/packages/design-tokens/test/fixtures/brand-visual-golden.json b/packages/design-tokens/test/fixtures/brand-visual-golden.json new file mode 100644 index 00000000..49f67660 --- /dev/null +++ b/packages/design-tokens/test/fixtures/brand-visual-golden.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "assets": { + "android/adaptive-foreground-432.png": "C16B2F8154035627EE4BC1970B6BFC21C557AE8D44F1A33F85DE3C3377A7E645", + "android/launcher-hdpi-72.png": "C850208368B49DD40EB5206145DC8044A0AD97AD5A15222B5AAA14458ACBD626", + "android/launcher-mdpi-48.png": "2249A9C233228CDF3E7CD648AD7E011F1369837C00BA34F4407B71FA2C134216", + "android/launcher-xhdpi-96.png": "D8C304EDE847F7F8B370F3477EBE155E951644F7406FE77FA44596CBB4497EBB", + "android/launcher-xxhdpi-144.png": "56AAE8DD4F4DDA38B333325F56D2CF0064E7866DF22C15C7564D294B8E08377E", + "android/launcher-xxxhdpi-192.png": "B83E179BC03B00A0C6B9ED6CD14B89BDBB792730799FD1890DC1B6769DE1367B", + "android/notification-hdpi-36.png": "52210F51643C72E92152F2DA156CC8B18EA0C215BE49452C4E1F905B05A944B3", + "android/notification-mdpi-24.png": "1FAB0BBDBE6BB698871E7CC7A53F67309C430800AF8A67DA8145C6A0E080A629", + "android/notification-xhdpi-48.png": "C2A801F109B8819723F889AD3D37A64CA9CE88EAE3C1C19E74A79AA0443F4842", + "android/notification-xxhdpi-72.png": "72DBB2A440698170F2FCA82C15C1F21CB6A0A5DD754B16F301B467234540A7ED", + "android/notification-xxxhdpi-96.png": "2B5CB8E94058FE42C45B43D9E21933A41816FBB91A82F123E2AF460BFA405477", + "desktop/application-256.png": "E38BD7B30191F57F9399D39972649B252AD8C30708FCB476A77C77A0A4E4E373", + "desktop/application.ico": "DFE2F8828A981E686B2EAF8BB7E2C960AB7575B88081D59B79469374021B0058", + "desktop/installer.ico": "DFE2F8828A981E686B2EAF8BB7E2C960AB7575B88081D59B79469374021B0058", + "desktop/notification-32.png": "38903201463667946F1232BF5235841B937208520E39568C564F0BC2E3F64ADD", + "desktop/updater.ico": "DFE2F8828A981E686B2EAF8BB7E2C960AB7575B88081D59B79469374021B0058", + "web/apple-touch-icon-180.png": "4049EC9E808406AB767E07EC5F11A37021453D1ED601E04ED2C910612B8248CB", + "web/favicon-16.png": "A7DC5A14885ABC87316C39E803D9C8BEE2FB560189C1096F2CC3E323CE175327", + "web/favicon-32.png": "C01AA26FD1B9DDC0CAE0175EB7AC7A16E0CF802C354AB1DE7A76D922EB17702C", + "web/install-icon-192.png": "B83E179BC03B00A0C6B9ED6CD14B89BDBB792730799FD1890DC1B6769DE1367B", + "web/install-icon-512.png": "F3FC6E0C0F32F40C26961A5570321D025540313693793C44B58B7B8718621792", + "web/navigation-wordmark-black-204x50.png": "AD5EE9CB6E4738BE1EDF118B2DB3BC7ADEA4785373E0D053E5822E733B8D40B5", + "web/navigation-wordmark-blue-204x50.png": "F0A731AF0F0366BE5A9ABED49913EDE32B67A67D333496B04B68BDAB54DC5FEC", + "web/social-card-1200x630.png": "EC86B2F83795CBD4F6267A704B87A47EA672702E615661CCC0DECB16455DCD96" + } +} diff --git a/packages/design-tokens/turbo.json b/packages/design-tokens/turbo.json index 25e76c6b..009e3262 100644 --- a/packages/design-tokens/turbo.json +++ b/packages/design-tokens/turbo.json @@ -2,6 +2,9 @@ "$schema": "https://turbo.build/schema.json", "extends": ["//"], "tasks": { + "build": { + "outputs": [] + }, "test": { "outputs": [] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 704a4b92..a749a58b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,7 +42,11 @@ importers: specifier: 3.0.1 version: 3.0.1(ajv@8.17.1) - packages/design-tokens: {} + packages/design-tokens: + devDependencies: + sharp: + specifier: 0.35.3 + version: 0.35.3 packages/domain: dependencies: @@ -72,6 +76,9 @@ importers: packages: + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -130,6 +137,168 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -285,6 +454,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -534,6 +707,15 @@ packages: engines: {node: '>=10'} hasBin: true + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -560,6 +742,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + turbo-darwin-64@2.5.6: resolution: {integrity: sha512-3C1xEdo4aFwMJAPvtlPqz1Sw/+cddWIOmsalHFMrsqqydcptwBfu26WW2cDm3u93bUzMbBJ8k3zNKFqxJ9ei2A==} cpu: [x64] @@ -628,6 +813,11 @@ packages: snapshots: + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@9.36.0)': dependencies: eslint: 9.36.0 @@ -688,6 +878,112 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -869,6 +1165,8 @@ snapshots: deep-is@0.1.4: {} + detect-libc@2.1.2: {} + escape-string-regexp@4.0.0: {} eslint-scope@8.4.0: @@ -1106,6 +1404,38 @@ snapshots: semver@7.8.5: {} + sharp@0.35.3: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -1126,6 +1456,9 @@ snapshots: dependencies: typescript: 5.9.2 + tslib@2.8.1: + optional: true + turbo-darwin-64@2.5.6: optional: true From 9e4524969a472c534e83da68dc851daaac5cc863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 11:49:41 +0700 Subject: [PATCH 30/51] fix(brand): harden derivative generation boundaries --- .../design-tokens/brand/derivative-plan.json | 2 + packages/design-tokens/brand/derivatives.json | 5 + .../scripts/generate-brand-derivatives.mjs | 557 +++++++++++++++--- .../test/brand-derivative-security.test.mjs | 215 +++++++ .../test/brand-derivatives.test.mjs | 27 +- 5 files changed, 723 insertions(+), 83 deletions(-) create mode 100644 packages/design-tokens/test/brand-derivative-security.test.mjs diff --git a/packages/design-tokens/brand/derivative-plan.json b/packages/design-tokens/brand/derivative-plan.json index 2a5fc716..7ab5a1f3 100644 --- a/packages/design-tokens/brand/derivative-plan.json +++ b/packages/design-tokens/brand/derivative-plan.json @@ -3,6 +3,8 @@ "pipeline": { "engine": "sharp", "engineVersion": "0.35.3", + "libvipsVersion": "8.18.3", + "pngVersion": "1.6.58", "pixelPolicy": "sRGB source colors are preserved; only crop, aspect-preserving contain resize, transparent padding, and PNG/ICO container conversion are allowed", "png": { "adaptiveFiltering": false, diff --git a/packages/design-tokens/brand/derivatives.json b/packages/design-tokens/brand/derivatives.json index fa082c62..c2c54714 100644 --- a/packages/design-tokens/brand/derivatives.json +++ b/packages/design-tokens/brand/derivatives.json @@ -9,6 +9,11 @@ "compressionLevel": 9, "effort": 10, "palette": false + }, + "runtime": { + "libvips": "8.18.3", + "png": "1.6.58", + "sharp": "0.35.3" } }, "sourceManifestSha256": "78F718C9B32303F5C2C746BFF5F7698740CCC1565F5D0242AF0E4B83263BF7A0", diff --git a/packages/design-tokens/scripts/generate-brand-derivatives.mjs b/packages/design-tokens/scripts/generate-brand-derivatives.mjs index 4d7812d7..8c0c6b47 100644 --- a/packages/design-tokens/scripts/generate-brand-derivatives.mjs +++ b/packages/design-tokens/scripts/generate-brand-derivatives.mjs @@ -1,8 +1,19 @@ import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + realpath, + rename, + rm, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path'; +import process from 'node:process'; import { fileURLToPath, URL } from 'node:url'; import sharp from 'sharp'; @@ -14,6 +25,47 @@ const planPath = join(brandDirectory, 'derivative-plan.json'); const defaultOutputDirectory = join(brandDirectory, 'generated'); const defaultManifestPath = join(brandDirectory, 'derivatives.json'); +const PIXEL_POLICY = + 'sRGB source colors are preserved; only crop, aspect-preserving contain resize, transparent padding, and PNG/ICO container conversion are allowed'; +const REQUIRED_SOURCE_DEFINITIONS = { + blackWordmark: { file: 'databreeze-wordmark-black.png' }, + blueMark: { + crop: { height: 1155, left: 0, top: 0, width: 1155 }, + file: 'databreeze-wordmark-blue.png', + }, + blueWordmark: { file: 'databreeze-wordmark-blue.png' }, + darkMark: { file: 'databreeze-mark-dark.png' }, +}; +const REQUIRED_ASSET_FILES = [ + 'android/adaptive-foreground-432.png', + 'android/launcher-hdpi-72.png', + 'android/launcher-mdpi-48.png', + 'android/launcher-xhdpi-96.png', + 'android/launcher-xxhdpi-144.png', + 'android/launcher-xxxhdpi-192.png', + 'android/notification-hdpi-36.png', + 'android/notification-mdpi-24.png', + 'android/notification-xhdpi-48.png', + 'android/notification-xxhdpi-72.png', + 'android/notification-xxxhdpi-96.png', + 'desktop/application-256.png', + 'desktop/application.ico', + 'desktop/installer.ico', + 'desktop/notification-32.png', + 'desktop/updater.ico', + 'web/apple-touch-icon-180.png', + 'web/favicon-16.png', + 'web/favicon-32.png', + 'web/install-icon-192.png', + 'web/install-icon-512.png', + 'web/navigation-wordmark-black-204x50.png', + 'web/navigation-wordmark-blue-204x50.png', + 'web/social-card-1200x630.png', +]; +const PLATFORM_VALUES = new Set(['android', 'desktop', 'web']); +const ADJACENT_NAME_POLICIES = new Set(['accessible-context-only', 'forbidden']); +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + sharp.cache(false); sharp.concurrency(1); sharp.simd(false); @@ -45,70 +97,218 @@ function assertInteger(value, label, minimum = 0) { } } -export function validateDerivativePlan(plan) { - if (plan?.schemaVersion !== 1 || plan?.pipeline?.engine !== 'sharp') { - throw new Error('Unsupported brand derivative plan'); +function assertPlainObject(value, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); } +} - if (plan.pipeline.engineVersion !== sharp.versions.sharp) { - throw new Error( - `Brand pipeline requires sharp ${plan.pipeline.engineVersion}; loaded ${sharp.versions.sharp}`, - ); +function assertExactKeys(value, expectedKeys, label) { + assertPlainObject(value, label); + const expected = [...expectedKeys].sort(); + const actual = Object.keys(value).sort(); + const unknown = actual.filter((key) => !expected.includes(key)); + const missing = expected.filter((key) => !actual.includes(key)); + if (unknown.length > 0) throw new Error(`${label} has unknown key "${unknown[0]}"`); + if (missing.length > 0) throw new Error(`${label} is missing key "${missing[0]}"`); +} + +function assertNonEmptyString(value, label) { + if (typeof value !== 'string' || value.trim() !== value || value.length === 0) { + throw new Error(`${label} must be a non-empty trimmed string`); } +} - const seenFiles = new Set(); - for (const asset of plan.assets ?? []) { - if ( - typeof asset.file !== 'string' || - isAbsolute(asset.file) || - asset.file.split(/[\\/]/u).includes('..') - ) { - throw new Error(`Unsafe derivative output path: ${String(asset.file)}`); - } - if (seenFiles.has(asset.file)) { - throw new Error(`Duplicate derivative output path: ${asset.file}`); - } - seenFiles.add(asset.file); +function assertPortableAssetPath(file) { + if (typeof file !== 'string' || isAbsolute(file) || /^[A-Za-z]:/u.test(file)) { + throw new Error(`Derivative file must be a portable relative path: ${String(file)}`); + } + if (file.includes('\\')) { + throw new Error(`Derivative file must be a portable POSIX path: ${file}`); + } + const segments = file.split('/'); + if ( + segments.length < 2 || + segments.some( + (segment) => + segment.length === 0 || + segment === '.' || + segment === '..' || + !/^[A-Za-z0-9._-]+$/u.test(segment), + ) + ) { + throw new Error(`Derivative file must be a portable normalized path: ${file}`); + } +} - if (!plan.sources?.[asset.source]) { - throw new Error(`Unknown derivative source "${String(asset.source)}" for ${asset.file}`); - } +function approvedSourceMap(sourceManifest) { + assertExactKeys(sourceManifest, ['assets', 'schemaVersion'], 'source manifest'); + if (sourceManifest.schemaVersion !== 1 || !Array.isArray(sourceManifest.assets)) { + throw new Error('Unsupported source manifest'); + } + return new Map(sourceManifest.assets.map((asset) => [asset.file, asset])); +} - assertInteger(asset.width, `${asset.file} width`, 1); - assertInteger(asset.height, `${asset.file} height`, 1); - for (const field of ['x', 'y', 'width', 'height']) { - assertInteger( - asset.contentBox?.[field], - `${asset.file} contentBox.${field}`, - field === 'width' || field === 'height' ? 1 : 0, - ); - } - if ( - asset.contentBox.x + asset.contentBox.width > asset.width || - asset.contentBox.y + asset.contentBox.height > asset.height - ) { - throw new Error(`Content box exceeds output bounds for ${asset.file}`); - } +function validatePipeline(pipeline) { + assertExactKeys( + pipeline, + ['engine', 'engineVersion', 'libvipsVersion', 'pixelPolicy', 'png', 'pngVersion'], + 'pipeline', + ); + if (pipeline.engine !== 'sharp') throw new Error('Unsupported brand derivative plan engine'); + const runtimePins = [ + ['Sharp', pipeline.engineVersion, sharp.versions.sharp], + ['libvips', pipeline.libvipsVersion, sharp.versions.vips], + ['PNG', pipeline.pngVersion, sharp.versions.png], + ]; + for (const [label, planned, loaded] of runtimePins) { + if (planned !== loaded) + throw new Error(`${label} runtime requires ${planned}; loaded ${loaded}`); + } + if (pipeline.pixelPolicy !== PIXEL_POLICY) { + throw new Error('Pipeline pixel policy must use the approved aspect-preserving transform'); + } + assertExactKeys( + pipeline.png, + ['adaptiveFiltering', 'compressionLevel', 'effort', 'palette'], + 'pipeline.png', + ); + if ( + pipeline.png.adaptiveFiltering !== false || + pipeline.png.compressionLevel !== 9 || + pipeline.png.effort !== 10 || + pipeline.png.palette !== false + ) { + throw new Error('Pipeline PNG options must match the deterministic encoder policy'); + } +} - if (asset.containsWordmark && asset.adjacentProductNamePolicy !== 'forbidden') { - throw new Error(`Wordmark ${asset.file} must forbid adjacent duplicate product text`); - } - if (!asset.containsWordmark && asset.adjacentProductNamePolicy !== 'accessible-context-only') { - throw new Error( - `Standalone mark ${asset.file} may add product text only for accessible context`, - ); +function validateSources(sources, approvedSources) { + assertExactKeys(sources, Object.keys(REQUIRED_SOURCE_DEFINITIONS), 'source keys'); + for (const [key, expected] of Object.entries(REQUIRED_SOURCE_DEFINITIONS)) { + const source = sources[key]; + assertExactKeys(source, expected.crop ? ['crop', 'file'] : ['file'], `source ${key}`); + if (source.file !== expected.file) throw new Error(`Source ${key} must use ${expected.file}`); + const approved = approvedSources.get(source.file); + if (!approved) throw new Error(`Source ${key} is not approved: ${source.file}`); + if (expected.crop) { + assertExactKeys(source.crop, ['height', 'left', 'top', 'width'], `source ${key} crop`); + for (const field of ['height', 'left', 'top', 'width']) { + assertInteger( + source.crop[field], + `source ${key} crop.${field}`, + field === 'height' || field === 'width' ? 1 : 0, + ); + } + if ( + source.crop.left + source.crop.width > approved.width || + source.crop.top + source.crop.height > approved.height + ) { + throw new Error(`Source ${key} crop exceeds approved source bounds`); + } + if (JSON.stringify(source.crop) !== JSON.stringify(expected.crop)) { + throw new Error(`Source ${key} crop must match the approved extraction`); + } } + } +} - if (asset.frames) { - if (!asset.file.endsWith('.ico') || asset.frames.length === 0) { - throw new Error(`Only non-empty ICO frame lists are supported for ${asset.file}`); - } - for (const frame of asset.frames) assertInteger(frame, `${asset.file} frame`, 1); - } else if (!asset.file.endsWith('.png')) { - throw new Error(`PNG output required for ${asset.file}`); +function validateAsset(asset, sources, seenFiles) { + const hasFrames = Object.prototype.hasOwnProperty.call(asset, 'frames'); + assertExactKeys( + asset, + [ + 'adjacentProductNamePolicy', + 'containsWordmark', + 'contentBox', + 'file', + ...(hasFrames ? ['frames'] : []), + 'height', + 'platform', + 'purpose', + 'safeZone', + 'source', + 'width', + ], + `asset ${String(asset?.file)}`, + ); + assertPortableAssetPath(asset.file); + const foldedFile = asset.file.toLowerCase(); + if (seenFiles.has(foldedFile)) throw new Error(`Duplicate derivative output path: ${asset.file}`); + seenFiles.add(foldedFile); + + if (!PLATFORM_VALUES.has(asset.platform)) throw new Error(`Invalid platform for ${asset.file}`); + if (!asset.file.startsWith(`${asset.platform}/`)) { + throw new Error(`Asset path must begin with its platform for ${asset.file}`); + } + assertNonEmptyString(asset.purpose, `${asset.file} purpose`); + assertNonEmptyString(asset.safeZone, `${asset.file} safeZone`); + if (!Object.prototype.hasOwnProperty.call(sources, asset.source)) { + throw new Error(`Unknown derivative source "${String(asset.source)}" for ${asset.file}`); + } + if (typeof asset.containsWordmark !== 'boolean') { + throw new Error(`${asset.file} containsWordmark must be boolean`); + } + if (!ADJACENT_NAME_POLICIES.has(asset.adjacentProductNamePolicy)) { + throw new Error(`Invalid adjacent product name policy for ${asset.file}`); + } + if (asset.containsWordmark && asset.adjacentProductNamePolicy !== 'forbidden') { + throw new Error(`Wordmark ${asset.file} must forbid adjacent duplicate product text`); + } + if (!asset.containsWordmark && asset.adjacentProductNamePolicy !== 'accessible-context-only') { + throw new Error( + `Standalone mark ${asset.file} may add product text only for accessible context`, + ); + } + + assertInteger(asset.width, `${asset.file} width`, 1); + assertInteger(asset.height, `${asset.file} height`, 1); + assertExactKeys(asset.contentBox, ['height', 'width', 'x', 'y'], `${asset.file} contentBox`); + for (const field of ['x', 'y', 'width', 'height']) { + assertInteger( + asset.contentBox[field], + `${asset.file} contentBox.${field}`, + field === 'width' || field === 'height' ? 1 : 0, + ); + } + if ( + asset.contentBox.x + asset.contentBox.width > asset.width || + asset.contentBox.y + asset.contentBox.height > asset.height + ) { + throw new Error(`Content box exceeds output bounds for ${asset.file}`); + } + + if (hasFrames) { + if (!asset.file.endsWith('.ico') || !Array.isArray(asset.frames) || asset.frames.length === 0) { + throw new Error(`Only non-empty ICO frame lists are supported for ${asset.file}`); + } + let previous = 0; + for (const frame of asset.frames) { + assertInteger(frame, `${asset.file} frame`, 1); + if (frame > 256) throw new Error(`${asset.file} frame must be at most 256`); + if (frame <= previous) throw new Error(`${asset.file} frames must be unique and ascending`); + previous = frame; } + } else if (!asset.file.endsWith('.png')) { + throw new Error(`PNG output required for ${asset.file}`); } +} +export function validateDerivativePlan(plan, { sourceManifest } = {}) { + assertExactKeys(plan, ['assets', 'pipeline', 'schemaVersion', 'sources'], 'plan'); + if (plan.schemaVersion !== 1) throw new Error('Unsupported brand derivative plan schema'); + validatePipeline(plan.pipeline); + const approvedSources = approvedSourceMap(sourceManifest); + validateSources(plan.sources, approvedSources); + if (!Array.isArray(plan.assets) || plan.assets.length === 0) { + throw new Error('Derivative plan must contain the complete platform inventory'); + } + const seenFiles = new Set(); + for (const asset of plan.assets) validateAsset(asset, plan.sources, seenFiles); + const actualFiles = plan.assets.map((asset) => asset.file).sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify([...REQUIRED_ASSET_FILES].sort())) { + throw new Error('Derivative plan must contain the complete required platform inventory'); + } return plan; } @@ -117,8 +317,8 @@ async function loadInputs() { readFile(planPath), readFile(sourceManifestPath), ]); - const plan = validateDerivativePlan(JSON.parse(planBytes.toString('utf8'))); const sourceManifest = JSON.parse(sourceManifestBytes.toString('utf8')); + const plan = validateDerivativePlan(JSON.parse(planBytes.toString('utf8')), { sourceManifest }); const approvedSources = new Map(sourceManifest.assets.map((asset) => [asset.file, asset])); const sourceBytes = new Map(); @@ -216,6 +416,63 @@ function createIco(pngFrames) { return Buffer.concat([header, ...pngFrames.map(({ bytes }) => bytes)]); } +export function parseAndValidateIco(bytes, expectedSizes) { + if (!Buffer.isBuffer(bytes) || bytes.length < 22) throw new Error('ICO is truncated'); + if (bytes.readUInt16LE(0) !== 0) throw new Error('ICO reserved header must be zero'); + if (bytes.readUInt16LE(2) !== 1) throw new Error('ICO type must be icon'); + const count = bytes.readUInt16LE(4); + if (count === 0) throw new Error('ICO must contain at least one frame'); + const directoryEnd = 6 + count * 16; + if (directoryEnd > bytes.length) throw new Error('ICO directory is truncated'); + + const frames = []; + let expectedOffset = directoryEnd; + let previousSize = 0; + for (let index = 0; index < count; index += 1) { + const entry = 6 + index * 16; + const width = bytes.readUInt8(entry) || 256; + const height = bytes.readUInt8(entry + 1) || 256; + const colorCount = bytes.readUInt8(entry + 2); + const reserved = bytes.readUInt8(entry + 3); + const planes = bytes.readUInt16LE(entry + 4); + const bitDepth = bytes.readUInt16LE(entry + 6); + const length = bytes.readUInt32LE(entry + 8); + const offset = bytes.readUInt32LE(entry + 12); + if (width !== height || width <= previousSize) { + throw new Error('ICO frame sizes must be square, unique, and strictly ascending'); + } + if (colorCount !== 0 || reserved !== 0) + throw new Error('ICO entry reserved fields must be zero'); + if (planes !== 1) throw new Error('ICO frame planes must equal one'); + if (bitDepth !== 32) throw new Error('ICO frames must use 32-bit depth'); + if (length < 24 || offset !== expectedOffset || offset + length > bytes.length) { + throw new Error('ICO frame offsets must be ordered, contiguous, and within bounds'); + } + const frameBytes = bytes.subarray(offset, offset + length); + if (!frameBytes.subarray(0, 8).equals(PNG_SIGNATURE)) { + throw new Error('ICO frame must contain PNG bytes'); + } + if ( + frameBytes.subarray(12, 16).toString('ascii') !== 'IHDR' || + frameBytes.readUInt32BE(16) !== width || + frameBytes.readUInt32BE(20) !== height + ) { + throw new Error('ICO frame PNG dimensions must match its directory entry'); + } + frames.push({ bytes: frameBytes, height, width }); + previousSize = width; + expectedOffset = offset + length; + } + if (expectedOffset !== bytes.length) throw new Error('ICO must not contain trailing bytes'); + if ( + expectedSizes && + JSON.stringify(frames.map((frame) => frame.width)) !== JSON.stringify(expectedSizes) + ) { + throw new Error('ICO frame inventory does not match the derivative plan'); + } + return frames; +} + async function visualHash(pngBytes) { const pixels = await sharp(pngBytes) .ensureAlpha() @@ -225,10 +482,106 @@ async function visualHash(pngBytes) { return sha256(pixels); } -export async function generateBrandDerivatives({ outputDirectory, manifestPath }) { - validateOutputTargets({ manifestPath, outputDirectory }); +async function inspectPathWithoutLinks(targetPath, { label, leafType = 'any' }) { + const absolutePath = resolve(targetPath); + const root = parse(absolutePath).root; + const segments = relative(root, absolutePath).split(sep).filter(Boolean); + let lexicalPath = root; + let canonicalPath = await realpath(root); + + for (let index = 0; index < segments.length; index += 1) { + lexicalPath = join(lexicalPath, segments[index]); + let stats; + try { + stats = await lstat(lexicalPath); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + return { + canonicalPath: resolve(canonicalPath, ...segments.slice(index)), + exists: false, + }; + } + if (stats.isSymbolicLink()) { + throw new Error(`${label} must not use symbolic link, junction, or reparse-point ancestry`); + } + const isLeaf = index === segments.length - 1; + if (!isLeaf && !stats.isDirectory()) throw new Error(`${label} ancestry must be directories`); + if (isLeaf && leafType === 'directory' && !stats.isDirectory()) { + throw new Error(`${label} must be a directory`); + } + if (isLeaf && leafType === 'file' && !stats.isFile()) { + throw new Error(`${label} must be a regular file`); + } + canonicalPath = await realpath(lexicalPath); + } + return { canonicalPath, exists: true }; +} + +function pathsEqual(left, right) { + const normalize = (value) => + process.platform === 'win32' ? resolve(value).toLowerCase() : resolve(value); + return normalize(left) === normalize(right); +} + +let temporaryWriteSequence = 0; + +async function writeFileSafely(targetPath, bytes, targetContext) { + await validateOutputTargets(targetContext); + await inspectPathWithoutLinks(targetPath, { label: 'Derivative write target', leafType: 'file' }); + const parentPath = dirname(targetPath); + await inspectPathWithoutLinks(parentPath, { + label: 'Derivative write parent', + leafType: 'directory', + }); + + temporaryWriteSequence += 1; + const temporaryPath = `${targetPath}.tmp-${process.pid}-${temporaryWriteSequence}`; + let handle; + try { + handle = await open(temporaryPath, 'wx', 0o600); + await handle.writeFile(bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + await validateOutputTargets(targetContext); + await inspectPathWithoutLinks(targetPath, { + label: 'Derivative write target', + leafType: 'file', + }); + await inspectPathWithoutLinks(parentPath, { + label: 'Derivative write parent', + leafType: 'directory', + }); + await rename(temporaryPath, targetPath); + } finally { + if (handle) await handle.close(); + await rm(temporaryPath, { force: true }); + } + await inspectPathWithoutLinks(targetPath, { + label: 'Written derivative', + leafType: 'file', + }); +} +async function ensureSafeDirectory(directoryPath, targetContext) { + await validateOutputTargets(targetContext); + await inspectPathWithoutLinks(directoryPath, { + label: 'Derivative directory', + leafType: 'directory', + }); + await mkdir(directoryPath, { recursive: true }); + await inspectPathWithoutLinks(directoryPath, { + label: 'Derivative directory', + leafType: 'directory', + }); + await validateOutputTargets(targetContext); +} + +export async function generateBrandDerivatives({ outputDirectory, manifestPath }) { const { plan, sourceBytes, sourceManifestBytes, approvedSources } = await loadInputs(); + const assetFiles = plan.assets.map((asset) => asset.file); + const targetContext = { assetFiles, manifestPath, outputDirectory }; + await validateOutputTargets(targetContext); const manifestAssets = []; for (const asset of plan.assets) { @@ -253,6 +606,7 @@ export async function generateBrandDerivatives({ outputDirectory, manifestPath } }); } outputBytes = createIco(frames); + parseAndValidateIco(outputBytes, asset.frames); visualBytes = frames.at(-1).bytes; } else { outputBytes = await renderPng(bytes, source, asset, plan.pipeline.png); @@ -260,8 +614,8 @@ export async function generateBrandDerivatives({ outputDirectory, manifestPath } } const outputPath = join(outputDirectory, asset.file); - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, outputBytes); + await ensureSafeDirectory(dirname(outputPath), targetContext); + await writeFileSafely(outputPath, outputBytes, targetContext); manifestAssets.push({ adjacentProductNamePolicy: asset.adjacentProductNamePolicy, containsWordmark: asset.containsWordmark, @@ -293,29 +647,43 @@ export async function generateBrandDerivatives({ outputDirectory, manifestPath } engineVersion: plan.pipeline.engineVersion, icoContainer: 'png-frame-ico-v1', png: plan.pipeline.png, + runtime: { + libvips: sharp.versions.vips, + png: sharp.versions.png, + sharp: sharp.versions.sharp, + }, }, sourceManifestSha256: sha256(sourceManifestBytes), assets: manifestAssets, }; - await mkdir(dirname(manifestPath), { recursive: true }); - await writeFile(manifestPath, stableJson(manifest)); + await ensureSafeDirectory(dirname(manifestPath), targetContext); + await writeFileSafely(manifestPath, stableJson(manifest), targetContext); return manifest; } -async function listFilesRecursively(directory, prefix = '') { +async function listGeneratedTree(directory, prefix = '') { const entries = await readdir(directory, { withFileTypes: true }); const files = []; + const directories = []; for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; - if (entry.isDirectory()) { - files.push(...(await listFilesRecursively(join(directory, entry.name), relativePath))); - } else if (entry.isFile()) { + const entryPath = join(directory, entry.name); + const stats = await lstat(entryPath); + if (stats.isSymbolicLink()) { + throw new Error(`Unexpected generated asset entry: ${relativePath}`); + } + if (stats.isDirectory()) { + directories.push(relativePath); + const child = await listGeneratedTree(entryPath, relativePath); + files.push(...child.files); + directories.push(...child.directories); + } else if (stats.isFile()) { files.push(relativePath); } else { throw new Error(`Unexpected generated asset entry: ${relativePath}`); } } - return files; + return { directories, files }; } async function assertSameFile(expectedPath, actualPath, label) { @@ -323,7 +691,8 @@ async function assertSameFile(expectedPath, actualPath, label) { if (!expected.equals(actual)) throw new Error(`Brand derivative drift detected: ${label}`); } -export function validateOutputTargets({ manifestPath, outputDirectory }) { +export async function validateOutputTargets({ assetFiles = [], manifestPath, outputDirectory }) { + if (!Array.isArray(assetFiles)) throw new Error('Generated asset inventory must be an array'); if ( isWithin(sourceDirectory, outputDirectory) || isWithin(outputDirectory, sourceDirectory) || @@ -339,6 +708,50 @@ export function validateOutputTargets({ manifestPath, outputDirectory }) { ) { throw new Error('Derivative manifest must not contain or overwrite immutable brand sources'); } + for (const file of assetFiles) { + if (pathsEqual(manifestPath, join(outputDirectory, file))) { + throw new Error(`Derivative manifest must not collide with generated asset ${file}`); + } + } + if (isWithin(outputDirectory, manifestPath)) { + throw new Error('Derivative manifest must not be nested inside the generated output'); + } + + const [sourceResult, sourceManifestResult, outputResult, manifestResult] = await Promise.all([ + inspectPathWithoutLinks(sourceDirectory, { + label: 'Immutable brand source directory', + leafType: 'directory', + }), + inspectPathWithoutLinks(sourceManifestPath, { + label: 'Immutable brand source manifest', + leafType: 'file', + }), + inspectPathWithoutLinks(outputDirectory, { + label: 'Derivative output directory', + leafType: 'directory', + }), + inspectPathWithoutLinks(manifestPath, { + label: 'Derivative manifest', + leafType: 'file', + }), + ]); + if ( + isWithin(sourceResult.canonicalPath, outputResult.canonicalPath) || + isWithin(outputResult.canonicalPath, sourceResult.canonicalPath) + ) { + throw new Error( + 'Derivative output directory must not contain or overwrite immutable brand sources', + ); + } + if ( + isWithin(sourceResult.canonicalPath, manifestResult.canonicalPath) || + pathsEqual(sourceManifestResult.canonicalPath, manifestResult.canonicalPath) + ) { + throw new Error('Derivative manifest must not contain or overwrite immutable brand sources'); + } + if (isWithin(outputResult.canonicalPath, manifestResult.canonicalPath)) { + throw new Error('Derivative manifest must not be nested inside the generated output'); + } } export async function compareBrandDerivatives({ @@ -354,15 +767,15 @@ export async function compareBrandDerivatives({ manifestPath: temporaryManifest, }); - const [committedFiles, generatedFiles] = await Promise.all([ - listFilesRecursively(expectedOutputDirectory), - listFilesRecursively(temporaryOutput), + const [committedTree, generatedTree] = await Promise.all([ + listGeneratedTree(expectedOutputDirectory), + listGeneratedTree(temporaryOutput), ]); - if (JSON.stringify(committedFiles) !== JSON.stringify(generatedFiles)) { + if (JSON.stringify(committedTree) !== JSON.stringify(generatedTree)) { throw new Error('Brand derivative inventory drift detected'); } await Promise.all( - generatedFiles.map((file) => + generatedTree.files.map((file) => assertSameFile(join(expectedOutputDirectory, file), join(temporaryOutput, file), file), ), ); diff --git a/packages/design-tokens/test/brand-derivative-security.test.mjs b/packages/design-tokens/test/brand-derivative-security.test.mjs new file mode 100644 index 00000000..21e45e85 --- /dev/null +++ b/packages/design-tokens/test/brand-derivative-security.test.mjs @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { mkdir, mkdtemp, readFile, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; +import { test } from 'node:test'; +import { fileURLToPath, URL } from 'node:url'; + +const packageDirectory = fileURLToPath(new URL('../', import.meta.url)); +const brandDirectory = join(packageDirectory, 'brand'); +const sourceDirectory = join(brandDirectory, 'source'); +const committedOutputDirectory = join(brandDirectory, 'generated'); +const committedManifestPath = join(brandDirectory, 'derivatives.json'); + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +async function loadValidationInputs() { + const [plan, sourceManifest] = await Promise.all([ + readFile(join(brandDirectory, 'derivative-plan.json'), 'utf8').then(JSON.parse), + readFile(join(brandDirectory, 'manifest.json'), 'utf8').then(JSON.parse), + ]); + return { plan, sourceManifest }; +} + +test('the derivative plan is a closed, typed, complete portable contract', async (context) => { + const { validateDerivativePlan } = await import('../scripts/generate-brand-derivatives.mjs'); + const { plan, sourceManifest } = await loadValidationInputs(); + + const mutations = [ + ['unknown root key', (value) => (value.unexpected = true), /unknown key/i], + ['unknown pipeline key', (value) => (value.pipeline.unexpected = true), /unknown key/i], + ['wrong libvips runtime', (value) => (value.pipeline.libvipsVersion = '0.0.0'), /libvips/i], + ['wrong PNG runtime', (value) => (value.pipeline.pngVersion = '0.0.0'), /PNG/i], + ['empty asset inventory', (value) => (value.assets = []), /complete.*inventory/i], + ['missing required asset', (value) => value.assets.pop(), /complete.*inventory/i], + ['backslash path', (value) => (value.assets[0].file = 'android\\icon.png'), /portable.*path/i], + [ + 'dot path segment', + (value) => (value.assets[0].file = 'android/./icon.png'), + /portable.*path/i, + ], + [ + 'case-folded duplicate path', + (value) => (value.assets[1].file = value.assets[0].file.toUpperCase()), + /duplicate.*path/i, + ], + [ + 'unknown source key', + (value) => (value.sources.extra = value.sources.blueMark), + /source keys/i, + ], + ['unknown source field', (value) => (value.sources.blueMark.extra = true), /unknown key/i], + [ + 'out-of-bounds source crop', + (value) => (value.sources.blueMark.crop.width = 99999), + /crop.*bounds/i, + ], + ['unknown asset field', (value) => (value.assets[0].extra = true), /unknown key/i], + ['invalid platform', (value) => (value.assets[0].platform = 'ios'), /platform/i], + ['empty purpose', (value) => (value.assets[0].purpose = ''), /purpose/i], + ['empty safe zone', (value) => (value.assets[0].safeZone = ''), /safeZone/i], + [ + 'wrong transform policy', + (value) => (value.pipeline.pixelPolicy = 'stretch'), + /pixel policy|transform/i, + ], + [ + 'unordered ICO frames', + (value) => (value.assets.find((asset) => asset.frames).frames = [16, 32, 24]), + /frames.*ascending/i, + ], + [ + 'duplicate ICO frames', + (value) => (value.assets.find((asset) => asset.frames).frames = [16, 16, 32]), + /frames.*ascending/i, + ], + [ + 'oversized ICO frame', + (value) => (value.assets.find((asset) => asset.frames).frames = [16, 257]), + /frame.*256/i, + ], + ]; + + for (const [name, mutate, expected] of mutations) { + await context.test(name, () => { + const changed = cloneJson(plan); + mutate(changed); + assert.throws(() => validateDerivativePlan(changed, { sourceManifest }), expected); + }); + } +}); + +test('manifest provenance pins the loaded Sharp libvips and PNG runtimes', async () => { + const [{ default: sharp }, manifest] = await Promise.all([ + import('sharp'), + readFile(committedManifestPath, 'utf8').then(JSON.parse), + ]); + assert.deepEqual(manifest.generator.runtime, { + libvips: sharp.versions.vips, + png: sharp.versions.png, + sharp: sharp.versions.sharp, + }); +}); + +test('filesystem-aware target validation rejects linked ancestry and output/manifest collisions', async () => { + const { validateOutputTargets } = await import('../scripts/generate-brand-derivatives.mjs'); + const temporaryRoot = await mkdtemp(join(tmpdir(), 'databreeze-brand-targets-')); + try { + const linkPath = join(temporaryRoot, 'linked-source'); + await symlink(sourceDirectory, linkPath, process.platform === 'win32' ? 'junction' : 'dir'); + + await assert.rejects( + validateOutputTargets({ + assetFiles: [], + manifestPath: join(temporaryRoot, 'derivatives.json'), + outputDirectory: join(linkPath, 'generated'), + }), + /symbolic link|junction|reparse/i, + ); + + await assert.rejects( + validateOutputTargets({ + assetFiles: [], + manifestPath: join(temporaryRoot, 'generated', 'derivatives.json'), + outputDirectory: join(temporaryRoot, 'generated'), + }), + /manifest.*output/i, + ); + + await assert.rejects( + validateOutputTargets({ + assetFiles: ['web/favicon-16.png'], + manifestPath: join(temporaryRoot, 'generated', 'web', 'favicon-16.png'), + outputDirectory: join(temporaryRoot, 'generated'), + }), + /manifest.*generated asset/i, + ); + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } +}); + +test('generation refuses a junction output ancestor before writing through it', async () => { + const { generateBrandDerivatives } = await import('../scripts/generate-brand-derivatives.mjs'); + const temporaryRoot = await mkdtemp(join(tmpdir(), 'databreeze-brand-write-link-')); + try { + const externalTarget = join(temporaryRoot, 'external'); + const linkPath = join(temporaryRoot, 'linked'); + await mkdir(externalTarget); + await symlink(externalTarget, linkPath, process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects( + generateBrandDerivatives({ + manifestPath: join(temporaryRoot, 'derivatives.json'), + outputDirectory: join(linkPath, 'generated'), + }), + /symbolic link|junction|reparse/i, + ); + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } +}); + +test('drift comparison rejects extra empty directories', async () => { + const { compareBrandDerivatives, generateBrandDerivatives } = await import( + '../scripts/generate-brand-derivatives.mjs' + ); + const temporaryRoot = await mkdtemp(join(tmpdir(), 'databreeze-brand-empty-dir-')); + try { + const outputDirectory = join(temporaryRoot, 'generated'); + const manifestPath = join(temporaryRoot, 'derivatives.json'); + await generateBrandDerivatives({ manifestPath, outputDirectory }); + await mkdir(join(outputDirectory, 'unexpected-empty')); + await assert.rejects( + compareBrandDerivatives({ + expectedManifestPath: manifestPath, + expectedOutputDirectory: outputDirectory, + }), + /inventory drift/i, + ); + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } +}); + +test('ICO validation rejects malformed headers, directory entries, offsets, and trailing bytes', async () => { + const { parseAndValidateIco } = await import('../scripts/generate-brand-derivatives.mjs'); + assert.equal(typeof parseAndValidateIco, 'function'); + const original = await readFile(join(committedOutputDirectory, 'desktop', 'application.ico')); + const mutate = (callback) => { + const bytes = Buffer.from(original); + callback(bytes); + return bytes; + }; + + const cases = [ + ['reserved header', mutate((bytes) => bytes.writeUInt16LE(1, 0))], + ['icon type', mutate((bytes) => bytes.writeUInt16LE(2, 2))], + ['reserved entry', mutate((bytes) => bytes.writeUInt8(1, 9))], + ['planes', mutate((bytes) => bytes.writeUInt16LE(2, 10))], + ['bit depth', mutate((bytes) => bytes.writeUInt16LE(24, 12))], + ['overlapping offset', mutate((bytes) => bytes.writeUInt32LE(6, 18))], + ['out-of-bounds length', mutate((bytes) => bytes.writeUInt32LE(0xffffffff, 14))], + ['trailing bytes', Buffer.concat([original, Buffer.from([0])])], + ]; + for (const [name, bytes] of cases) { + assert.throws( + () => parseAndValidateIco(bytes, [16, 24, 32, 48, 64, 128, 256]), + undefined, + name, + ); + } +}); diff --git a/packages/design-tokens/test/brand-derivatives.test.mjs b/packages/design-tokens/test/brand-derivatives.test.mjs index f1eb97cd..c5cd7f72 100644 --- a/packages/design-tokens/test/brand-derivatives.test.mjs +++ b/packages/design-tokens/test/brand-derivatives.test.mjs @@ -228,34 +228,39 @@ test('visual signatures match the separately approved golden fixture', async () test('plan validation blocks unsafe paths, invalid safe zones, and duplicate wordmark text policy', async () => { const { validateDerivativePlan } = await import('../scripts/generate-brand-derivatives.mjs'); - const plan = JSON.parse(await readFile(join(brandDirectory, 'derivative-plan.json'), 'utf8')); + const [plan, sourceManifest] = await Promise.all([ + readFile(join(brandDirectory, 'derivative-plan.json'), 'utf8').then(JSON.parse), + readFile(join(brandDirectory, 'manifest.json'), 'utf8').then(JSON.parse), + ]); const traversal = cloneJson(plan); traversal.assets[0].file = '../source/changed.png'; - assert.throws(() => validateDerivativePlan(traversal), /Unsafe derivative output path/); + assert.throws(() => validateDerivativePlan(traversal, { sourceManifest }), /portable.*path/i); const unsafeGeometry = cloneJson(plan); unsafeGeometry.assets[0].contentBox.width = unsafeGeometry.assets[0].width; - assert.throws(() => validateDerivativePlan(unsafeGeometry), /Content box exceeds output bounds/); + assert.throws( + () => validateDerivativePlan(unsafeGeometry, { sourceManifest }), + /Content box exceeds output bounds/, + ); const duplicateText = cloneJson(plan); const wordmark = duplicateText.assets.find((asset) => asset.containsWordmark); wordmark.adjacentProductNamePolicy = 'allowed'; assert.throws( - () => validateDerivativePlan(duplicateText), - /must forbid adjacent duplicate product text/, + () => validateDerivativePlan(duplicateText, { sourceManifest }), + /invalid adjacent product name policy|must forbid adjacent duplicate product text/i, ); }); test('output target validation prevents any generated write beneath immutable sources', async () => { const { validateOutputTargets } = await import('../scripts/generate-brand-derivatives.mjs'); assert.equal(typeof validateOutputTargets, 'function'); - assert.throws( - () => - validateOutputTargets({ - manifestPath: join(brandDirectory, 'source', 'derivatives.json'), - outputDirectory: join(brandDirectory, 'generated'), - }), + await assert.rejects( + validateOutputTargets({ + manifestPath: join(brandDirectory, 'source', 'derivatives.json'), + outputDirectory: join(brandDirectory, 'generated'), + }), /must not contain or overwrite immutable brand sources/, ); }); From 2cedb88a2a033ec13e0223b35adf25b3f558cd33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 11:56:18 +0700 Subject: [PATCH 31/51] fix(brand): complete platform-ready logo outputs --- packages/design-tokens/README.md | 4 +- .../design-tokens/brand/derivative-plan.json | 39 ++- packages/design-tokens/brand/derivatives.json | 256 +++++++++++++++--- .../android/notification-hdpi-36.png | Bin 1184 -> 728 bytes .../android/notification-mdpi-24.png | Bin 782 -> 473 bytes .../android/notification-xhdpi-48.png | Bin 1582 -> 966 bytes .../android/notification-xxhdpi-72.png | Bin 2264 -> 1422 bytes .../android/notification-xxxhdpi-96.png | Bin 3015 -> 1925 bytes .../web/navigation-wordmark-black-204x50.png | Bin 3946 -> 3283 bytes .../web/navigation-wordmark-blue-204x50.png | Bin 6632 -> 5599 bytes .../generated/web/social-card-1200x630.png | Bin 33448 -> 32558 bytes .../scripts/generate-brand-derivatives.mjs | 117 +++++++- .../test/brand-derivative-security.test.mjs | 2 +- .../test/brand-derivatives.test.mjs | 19 +- .../test/brand-platform-assets.test.mjs | 193 +++++++++++++ .../test/fixtures/brand-visual-golden.json | 23 +- 16 files changed, 581 insertions(+), 72 deletions(-) create mode 100644 packages/design-tokens/test/brand-platform-assets.test.mjs diff --git a/packages/design-tokens/README.md b/packages/design-tokens/README.md index bbd4508e..be359c70 100644 --- a/packages/design-tokens/README.md +++ b/packages/design-tokens/README.md @@ -6,10 +6,10 @@ Platform-neutral DataBreeze color, typography, spacing, motion, and icon tokens `brand/source/` contains the three immutable legacy assets. Never edit those files. Their approved dimensions and SHA-256 values live in `brand/manifest.json` and are checked before any derivative is created. -The declarative `brand/derivative-plan.json` records every Web, Windows Desktop, and Android output, including its source, purpose, dimensions, content box, and safe-zone policy. The generator permits only source cropping, aspect-preserving resizing, transparent padding, and PNG/ICO container conversion. It does not redraw, recolor, or distort the logo. +The declarative `brand/derivative-plan.json` records every Web, Windows Desktop, and Android output, including its source, purpose, dimensions, content box, and safe-zone policy. The generator permits only approved source cropping, aspect-preserving resizing, transparent padding, an approved source-color background, Android alpha-mask extraction, and PNG/ICO container conversion. It does not redraw, recolor, or distort a presented logo. Run `pnpm brand:generate` after an approved plan or pipeline change. Run `pnpm brand:check` to regenerate into a temporary clean directory and byte-compare the result with `brand/generated/` and `brand/derivatives.json`. `pnpm build` performs the same drift check. Wordmark derivatives already contain the DataBreeze name and must not be placed beside duplicate visible “DataBreeze” text. Standalone-mark derivatives may be paired with product text only when the surrounding interface or accessible name requires it. -Android notification PNGs are full-color, transparent reference sources. The native Android shell owns the later platform-specific monochrome/tint resource so this foundation pipeline never recolors the approved source. +Android notification PNGs are platform-ready white alpha masks derived from the approved mark’s alpha geometry. Their white RGB bytes are non-presentational mask data; Android controls the runtime tint. The pipeline verifies that mask alpha matches the approved mark geometry exactly. diff --git a/packages/design-tokens/brand/derivative-plan.json b/packages/design-tokens/brand/derivative-plan.json index 7ab5a1f3..4fc25e9d 100644 --- a/packages/design-tokens/brand/derivative-plan.json +++ b/packages/design-tokens/brand/derivative-plan.json @@ -1,11 +1,24 @@ { "schemaVersion": 1, + "approval": { + "status": "plan-approved", + "reviewedOn": "2026-08-01", + "reviewSource": "approved Task 11 plan and DataBreeze brand specification", + "specReference": "docs/product/brand-and-experience.md#1-brand-continuity", + "taskReference": "docs/plans/010-engineering-foundation.md#task-11-reproducible-brand-derivatives", + "cropRationale": "The blue mark is the left 1155x1155 square of the approved blue wordmark; cropping removes only the adjacent DataBreeze letters and does not redraw geometry.", + "sourceHashes": { + "databreeze-mark-dark.png": "5EE10842AD090F2BB980B51DDCF8BB4F8738C87B9659BE10387FE0B2D845B7A4", + "databreeze-wordmark-black.png": "4F37835E9648E7035DE9BCB6ADA05C1203A1C05A1D0DB81DF1D1AEA01D46FC98", + "databreeze-wordmark-blue.png": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + } + }, "pipeline": { "engine": "sharp", "engineVersion": "0.35.3", "libvipsVersion": "8.18.3", "pngVersion": "1.6.58", - "pixelPolicy": "sRGB source colors are preserved; only crop, aspect-preserving contain resize, transparent padding, and PNG/ICO container conversion are allowed", + "pixelPolicy": "sRGB brand colors are preserved; allowed operations are approved cropping, aspect-preserving contain resize, transparent padding, approved-source-color background compositing, Android alpha-mask extraction, and PNG/ICO container conversion", "png": { "adaptiveFiltering": false, "compressionLevel": 9, @@ -98,8 +111,9 @@ { "file": "android/notification-hdpi-36.png", "platform": "android", - "purpose": "Full-color notification reference source (hdpi); platform tint asset is created in the Android shell", + "purpose": "Android runtime-tinted notification alpha mask (hdpi)", "source": "blueMark", + "outputMode": "android-alpha-mask", "width": 36, "height": 36, "contentBox": { "x": 6, "y": 6, "width": 24, "height": 24 }, @@ -110,8 +124,9 @@ { "file": "android/notification-mdpi-24.png", "platform": "android", - "purpose": "Full-color notification reference source (mdpi); platform tint asset is created in the Android shell", + "purpose": "Android runtime-tinted notification alpha mask (mdpi)", "source": "blueMark", + "outputMode": "android-alpha-mask", "width": 24, "height": 24, "contentBox": { "x": 4, "y": 4, "width": 16, "height": 16 }, @@ -122,8 +137,9 @@ { "file": "android/notification-xhdpi-48.png", "platform": "android", - "purpose": "Full-color notification reference source (xhdpi); platform tint asset is created in the Android shell", + "purpose": "Android runtime-tinted notification alpha mask (xhdpi)", "source": "blueMark", + "outputMode": "android-alpha-mask", "width": 48, "height": 48, "contentBox": { "x": 8, "y": 8, "width": 32, "height": 32 }, @@ -134,8 +150,9 @@ { "file": "android/notification-xxhdpi-72.png", "platform": "android", - "purpose": "Full-color notification reference source (xxhdpi); platform tint asset is created in the Android shell", + "purpose": "Android runtime-tinted notification alpha mask (xxhdpi)", "source": "blueMark", + "outputMode": "android-alpha-mask", "width": 72, "height": 72, "contentBox": { "x": 12, "y": 12, "width": 48, "height": 48 }, @@ -146,8 +163,9 @@ { "file": "android/notification-xxxhdpi-96.png", "platform": "android", - "purpose": "Full-color notification reference source (xxxhdpi); platform tint asset is created in the Android shell", + "purpose": "Android runtime-tinted notification alpha mask (xxxhdpi)", "source": "blueMark", + "outputMode": "android-alpha-mask", "width": 96, "height": 96, "contentBox": { "x": 16, "y": 16, "width": 64, "height": 64 }, @@ -285,8 +303,8 @@ "source": "blackWordmark", "width": 204, "height": 50, - "contentBox": { "x": 0, "y": 0, "width": 204, "height": 50 }, - "safeZone": "source-clear-space-only", + "contentBox": { "x": 10, "y": 5, "width": 184, "height": 40 }, + "safeZone": "minimum-5px-vertical-and-20px-fitted-horizontal", "containsWordmark": true, "adjacentProductNamePolicy": "forbidden" }, @@ -297,8 +315,8 @@ "source": "blueWordmark", "width": 204, "height": 50, - "contentBox": { "x": 0, "y": 0, "width": 204, "height": 50 }, - "safeZone": "source-clear-space-only", + "contentBox": { "x": 10, "y": 5, "width": 184, "height": 40 }, + "safeZone": "minimum-5px-vertical-and-20px-fitted-horizontal", "containsWordmark": true, "adjacentProductNamePolicy": "forbidden" }, @@ -307,6 +325,7 @@ "platform": "web", "purpose": "Social metadata image without adjacent duplicate product text", "source": "blueWordmark", + "backgroundColor": { "red": 4, "green": 9, "blue": 32, "alpha": 1 }, "width": 1200, "height": 630, "contentBox": { "x": 120, "y": 126, "width": 960, "height": 378 }, diff --git a/packages/design-tokens/brand/derivatives.json b/packages/design-tokens/brand/derivatives.json index c2c54714..38ef590b 100644 --- a/packages/design-tokens/brand/derivatives.json +++ b/packages/design-tokens/brand/derivatives.json @@ -1,5 +1,18 @@ { "schemaVersion": 1, + "approval": { + "status": "plan-approved", + "reviewedOn": "2026-08-01", + "reviewSource": "approved Task 11 plan and DataBreeze brand specification", + "specReference": "docs/product/brand-and-experience.md#1-brand-continuity", + "taskReference": "docs/plans/010-engineering-foundation.md#task-11-reproducible-brand-derivatives", + "cropRationale": "The blue mark is the left 1155x1155 square of the approved blue wordmark; cropping removes only the adjacent DataBreeze letters and does not redraw geometry.", + "sourceHashes": { + "databreeze-mark-dark.png": "5EE10842AD090F2BB980B51DDCF8BB4F8738C87B9659BE10387FE0B2D845B7A4", + "databreeze-wordmark-black.png": "4F37835E9648E7035DE9BCB6ADA05C1203A1C05A1D0DB81DF1D1AEA01D46FC98", + "databreeze-wordmark-blue.png": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" + } + }, "generator": { "engine": "sharp", "engineVersion": "0.35.3", @@ -34,6 +47,12 @@ "width": 264, "height": 264 }, + "visibleBounds": { + "maxX": 347, + "maxY": 347, + "minX": 84, + "minY": 84 + }, "height": 432, "mediaType": "image/png", "platform": "android", @@ -70,6 +89,12 @@ "width": 58, "height": 58 }, + "visibleBounds": { + "maxX": 64, + "maxY": 64, + "minX": 7, + "minY": 7 + }, "height": 72, "mediaType": "image/png", "platform": "android", @@ -106,6 +131,12 @@ "width": 38, "height": 38 }, + "visibleBounds": { + "maxX": 42, + "maxY": 42, + "minX": 5, + "minY": 5 + }, "height": 48, "mediaType": "image/png", "platform": "android", @@ -142,6 +173,12 @@ "width": 76, "height": 76 }, + "visibleBounds": { + "maxX": 85, + "maxY": 85, + "minX": 10, + "minY": 10 + }, "height": 96, "mediaType": "image/png", "platform": "android", @@ -178,6 +215,12 @@ "width": 116, "height": 116 }, + "visibleBounds": { + "maxX": 129, + "maxY": 129, + "minX": 14, + "minY": 14 + }, "height": 144, "mediaType": "image/png", "platform": "android", @@ -214,6 +257,12 @@ "width": 154, "height": 154 }, + "visibleBounds": { + "maxX": 172, + "maxY": 172, + "minX": 19, + "minY": 19 + }, "height": 192, "mediaType": "image/png", "platform": "android", @@ -250,12 +299,18 @@ "width": 24, "height": 24 }, + "visibleBounds": { + "maxX": 29, + "maxY": 29, + "minX": 6, + "minY": 6 + }, "height": 36, "mediaType": "image/png", "platform": "android", - "purpose": "Full-color notification reference source (hdpi); platform tint asset is created in the Android shell", + "purpose": "Android runtime-tinted notification alpha mask (hdpi)", "safeZone": "one-sixth-per-edge", - "sha256": "703BCC1E42F25D5AA205DB7612AB4E17E9CED5057A8992E29E54F21449C092D0", + "sha256": "451F41912F10904EBF98DE21406A64A1560F3B513A2EB5C71A9605D4447B7B98", "source": { "crop": { "height": 1155, @@ -266,8 +321,9 @@ "file": "databreeze-wordmark-blue.png", "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" }, - "transform": "aspect-preserving-contain", - "visualSha256": "52210F51643C72E92152F2DA156CC8B18EA0C215BE49452C4E1F905B05A944B3", + "transform": "alpha-mask-from-approved-geometry", + "outputMode": "android-alpha-mask", + "visualSha256": "73F2DA8D89A22CEEDF5CFC85303FE80114A5F7158AB5BC585FDCF0BFC5D885C7", "width": 36 }, { @@ -286,12 +342,18 @@ "width": 16, "height": 16 }, + "visibleBounds": { + "maxX": 19, + "maxY": 19, + "minX": 4, + "minY": 4 + }, "height": 24, "mediaType": "image/png", "platform": "android", - "purpose": "Full-color notification reference source (mdpi); platform tint asset is created in the Android shell", + "purpose": "Android runtime-tinted notification alpha mask (mdpi)", "safeZone": "one-sixth-per-edge", - "sha256": "F64E241F0B86D328A10C616F933066CBFADDBB7AF41C29C91C3F8662477A8410", + "sha256": "330D591C013421A0B158C95FEFDC97B8A87028CE8DDEA2D01D705DB7A3B14E13", "source": { "crop": { "height": 1155, @@ -302,8 +364,9 @@ "file": "databreeze-wordmark-blue.png", "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" }, - "transform": "aspect-preserving-contain", - "visualSha256": "1FAB0BBDBE6BB698871E7CC7A53F67309C430800AF8A67DA8145C6A0E080A629", + "transform": "alpha-mask-from-approved-geometry", + "outputMode": "android-alpha-mask", + "visualSha256": "3B6BFDF2EB3696325AD644F268CD7DE9F8014B19C0DA2698BD19BE6E5984F717", "width": 24 }, { @@ -322,12 +385,18 @@ "width": 32, "height": 32 }, + "visibleBounds": { + "maxX": 39, + "maxY": 39, + "minX": 8, + "minY": 8 + }, "height": 48, "mediaType": "image/png", "platform": "android", - "purpose": "Full-color notification reference source (xhdpi); platform tint asset is created in the Android shell", + "purpose": "Android runtime-tinted notification alpha mask (xhdpi)", "safeZone": "one-sixth-per-edge", - "sha256": "7BC721DDBD0F1D18E6039F06F01F2CB5585B1DCC1A24E1FF0D8A11CDC1E5564B", + "sha256": "B02A36C9E1690F54B68E91C1D0E24A8596CCC7797D7DB62A4C56B1178C8A7CA3", "source": { "crop": { "height": 1155, @@ -338,8 +407,9 @@ "file": "databreeze-wordmark-blue.png", "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" }, - "transform": "aspect-preserving-contain", - "visualSha256": "C2A801F109B8819723F889AD3D37A64CA9CE88EAE3C1C19E74A79AA0443F4842", + "transform": "alpha-mask-from-approved-geometry", + "outputMode": "android-alpha-mask", + "visualSha256": "BEEDF672DC8861EB6BAE1C47A5B669205C5B46B27B6870C7F78EAD6715519E56", "width": 48 }, { @@ -358,12 +428,18 @@ "width": 48, "height": 48 }, + "visibleBounds": { + "maxX": 59, + "maxY": 59, + "minX": 12, + "minY": 12 + }, "height": 72, "mediaType": "image/png", "platform": "android", - "purpose": "Full-color notification reference source (xxhdpi); platform tint asset is created in the Android shell", + "purpose": "Android runtime-tinted notification alpha mask (xxhdpi)", "safeZone": "one-sixth-per-edge", - "sha256": "C369CB8CB832B282F8878E650141C46F3C32D3E9FCDDDEB2FDCBF6ED015E1EF6", + "sha256": "51B1EFEC750833CC2BCBB3B5C5319CDA3348B471BA23D7A4301E5CF6FD2866A5", "source": { "crop": { "height": 1155, @@ -374,8 +450,9 @@ "file": "databreeze-wordmark-blue.png", "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" }, - "transform": "aspect-preserving-contain", - "visualSha256": "72DBB2A440698170F2FCA82C15C1F21CB6A0A5DD754B16F301B467234540A7ED", + "transform": "alpha-mask-from-approved-geometry", + "outputMode": "android-alpha-mask", + "visualSha256": "DFE4ED9EB98092732874F42629A5359259D9D8B4EC9307131EC625C991891290", "width": 72 }, { @@ -394,12 +471,18 @@ "width": 64, "height": 64 }, + "visibleBounds": { + "maxX": 79, + "maxY": 79, + "minX": 16, + "minY": 16 + }, "height": 96, "mediaType": "image/png", "platform": "android", - "purpose": "Full-color notification reference source (xxxhdpi); platform tint asset is created in the Android shell", + "purpose": "Android runtime-tinted notification alpha mask (xxxhdpi)", "safeZone": "one-sixth-per-edge", - "sha256": "6145C0AF5B8BEBA206CCDF2948A560402570EE044199C75AA0A979C1071AC227", + "sha256": "9539808522823869BE44BA3AE079A603E94E4AD95AFB2F0206F71CBADC8160F4", "source": { "crop": { "height": 1155, @@ -410,8 +493,9 @@ "file": "databreeze-wordmark-blue.png", "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" }, - "transform": "aspect-preserving-contain", - "visualSha256": "2B5CB8E94058FE42C45B43D9E21933A41816FBB91A82F123E2AF460BFA405477", + "transform": "alpha-mask-from-approved-geometry", + "outputMode": "android-alpha-mask", + "visualSha256": "B555B71BBBC56556AA0B650D1BE63296A2EF7CCFB3528D88FEE23F6EE6D708BF", "width": 96 }, { @@ -430,6 +514,12 @@ "width": 230, "height": 230 }, + "visibleBounds": { + "maxX": 242, + "maxY": 242, + "minX": 13, + "minY": 13 + }, "height": 256, "mediaType": "image/png", "platform": "desktop", @@ -460,6 +550,12 @@ "width": 204, "height": 204 }, + "visibleBounds": { + "maxX": 229, + "maxY": 229, + "minX": 26, + "minY": 26 + }, "frames": [ 16, 24, @@ -505,6 +601,12 @@ "width": 204, "height": 204 }, + "visibleBounds": { + "maxX": 229, + "maxY": 229, + "minX": 26, + "minY": 26 + }, "frames": [ 16, 24, @@ -550,6 +652,12 @@ "width": 22, "height": 22 }, + "visibleBounds": { + "maxX": 26, + "maxY": 26, + "minX": 5, + "minY": 5 + }, "height": 32, "mediaType": "image/png", "platform": "desktop", @@ -586,6 +694,12 @@ "width": 204, "height": 204 }, + "visibleBounds": { + "maxX": 229, + "maxY": 229, + "minX": 26, + "minY": 26 + }, "frames": [ 16, 24, @@ -631,6 +745,12 @@ "width": 144, "height": 144 }, + "visibleBounds": { + "maxX": 161, + "maxY": 161, + "minX": 18, + "minY": 18 + }, "height": 180, "mediaType": "image/png", "platform": "web", @@ -667,6 +787,12 @@ "width": 12, "height": 12 }, + "visibleBounds": { + "maxX": 13, + "maxY": 13, + "minX": 2, + "minY": 2 + }, "height": 16, "mediaType": "image/png", "platform": "web", @@ -703,6 +829,12 @@ "width": 24, "height": 24 }, + "visibleBounds": { + "maxX": 27, + "maxY": 27, + "minX": 4, + "minY": 4 + }, "height": 32, "mediaType": "image/png", "platform": "web", @@ -739,6 +871,12 @@ "width": 154, "height": 154 }, + "visibleBounds": { + "maxX": 172, + "maxY": 172, + "minX": 19, + "minY": 19 + }, "height": 192, "mediaType": "image/png", "platform": "web", @@ -775,6 +913,12 @@ "width": 410, "height": 410 }, + "visibleBounds": { + "maxX": 460, + "maxY": 460, + "minX": 51, + "minY": 51 + }, "height": 512, "mediaType": "image/png", "platform": "web", @@ -799,60 +943,72 @@ "adjacentProductNamePolicy": "forbidden", "containsWordmark": true, "contentBox": { - "x": 0, - "y": 0, - "width": 204, - "height": 50 + "x": 10, + "y": 5, + "width": 184, + "height": 40 }, "file": "web/navigation-wordmark-black-204x50.png", "fittedBox": { - "x": 0, - "y": 0, - "width": 204, - "height": 50 + "x": 20, + "y": 5, + "width": 163, + "height": 40 + }, + "visibleBounds": { + "maxX": 182, + "maxY": 44, + "minX": 20, + "minY": 5 }, "height": 50, "mediaType": "image/png", "platform": "web", "purpose": "Black navigation wordmark for light surfaces", - "safeZone": "source-clear-space-only", - "sha256": "7A77C7D071DFD45D73A9782F7BEC69407D06CAD99D9E08D1B289D48C25E28625", + "safeZone": "minimum-5px-vertical-and-20px-fitted-horizontal", + "sha256": "658DFFF140DC5BE0E18D37B845656A9B9C5C2C6E1150EC8BB79FD93B9987B2F3", "source": { "file": "databreeze-wordmark-black.png", "sha256": "4F37835E9648E7035DE9BCB6ADA05C1203A1C05A1D0DB81DF1D1AEA01D46FC98" }, "transform": "aspect-preserving-contain", - "visualSha256": "AD5EE9CB6E4738BE1EDF118B2DB3BC7ADEA4785373E0D053E5822E733B8D40B5", + "visualSha256": "468507ED94F13BABE083361984247E177C92C4514D03536862A2F0D102FD5688", "width": 204 }, { "adjacentProductNamePolicy": "forbidden", "containsWordmark": true, "contentBox": { - "x": 0, - "y": 0, - "width": 204, - "height": 50 + "x": 10, + "y": 5, + "width": 184, + "height": 40 }, "file": "web/navigation-wordmark-blue-204x50.png", "fittedBox": { - "x": 0, - "y": 0, - "width": 204, - "height": 50 + "x": 20, + "y": 5, + "width": 163, + "height": 40 + }, + "visibleBounds": { + "maxX": 182, + "maxY": 44, + "minX": 20, + "minY": 5 }, "height": 50, "mediaType": "image/png", "platform": "web", "purpose": "Primary blue navigation wordmark for light surfaces", - "safeZone": "source-clear-space-only", - "sha256": "18FBE61BD29BF5D43A93A041A0493908B7E1E22AF1912652B1461D551CDE2A13", + "safeZone": "minimum-5px-vertical-and-20px-fitted-horizontal", + "sha256": "905FA49ADC0810A57067728615658AF00DDD47A6653733FA485BF2246C122DBF", "source": { "file": "databreeze-wordmark-blue.png", "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" }, "transform": "aspect-preserving-contain", - "visualSha256": "F0A731AF0F0366BE5A9ABED49913EDE32B67A67D333496B04B68BDAB54DC5FEC", + "visualSha256": "CD44FB5AD502B125863C26331FF3BB0C6E88804D6B6FC0261DE05E66400AFC62", "width": 204 }, { @@ -871,18 +1027,30 @@ "width": 960, "height": 235 }, + "visibleBounds": { + "maxX": 1079, + "maxY": 431, + "minX": 120, + "minY": 197 + }, + "backgroundColor": { + "red": 4, + "green": 9, + "blue": 32, + "alpha": 1 + }, "height": 630, "mediaType": "image/png", "platform": "web", "purpose": "Social metadata image without adjacent duplicate product text", "safeZone": "10-percent-horizontal-and-20-percent-vertical", - "sha256": "97B2323193D284D3DD4750276B3939B4EA6F5123E31DB7E00E5241D7FD9391CF", + "sha256": "34AE6C90BFD7FF1AC72E82244AEC77AF1105FA3179EE9E35D5D4D63244C4E800", "source": { "file": "databreeze-wordmark-blue.png", "sha256": "B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D" }, "transform": "aspect-preserving-contain", - "visualSha256": "EC86B2F83795CBD4F6267A704B87A47EA672702E615661CCC0DECB16455DCD96", + "visualSha256": "C890808C04E3A41315B7471C2BA25B7F0438D26E37367308525D29F0AD1AF531", "width": 1200 } ] diff --git a/packages/design-tokens/brand/generated/android/notification-hdpi-36.png b/packages/design-tokens/brand/generated/android/notification-hdpi-36.png index 4decb86f6426f85b2edf23449b510033f9ea81bd..b439526957c0ea2aaca1a87ea30003e2d910f3a2 100644 GIT binary patch delta 684 zcmV;d0#p5<3D^aYIDZ0)NklocTLIpuYu@X5U30w%Ih@sl*e_ta;ghx8wTv^ar z;s!P}1zjQfjGCmMFA(>LA~7YnL)?lJvMlk=Qe!w|+o(f&?jhVy5T7kJ{QT3^Lpt`0 zSfqx`VQNM#;up|W_(g~fC&H?WpezbmSS{kQ$HHbHBmqpD`!0gsM~Df;eH`=0h_4Z1 z*k+G7-+$@W!6Mg%H_J^bJC8qi&9YjPmZ6K@sA#U65xvY8H7g&dx!M-y@uHZU6Wunrw_i SyL!X`0000|C1)vF#IDZ6ENkl=M?cl6Y5hIN^ zat*A%lV70PbX z4wUTA!I)lf#Hx~CkZR*n89=G$uK2loKu!?S0frMqI4mtqNy%Ih8WsP9+At>v8lYsq z28qZx;uGdZh<^lM4hBYN(FF}BywH8h6cCXe7Nkf)Y&v0Fn!yb#{Jh3NfEtk+vHg?G zi&BcHY5U8-C_uKKMTk$Y5)iKg!?SRdZfT1z^62!<%?*GbS5`T@wqB6E`#K==$W zx>Cbmh9;KYEDWezey{~wr5}l ziYcS2pntx8l<37QQs5L1n~due@n1)4@(lp^OL55NDS)LaMXm@?ZC~R3s|~3rytK1n z$XA#b-r4)1lsGsZt4WzzI{rRAOCOI_#clwY*VV^kfO-I+=S)1$&ls%TUq`~3+p0hf zJ5P5Y6|=CzEjA0He#8zud5gY}*uf#gdur7q@_)it7Glc|c0#2;WiCHNW!q%4NXy52 z_Q}^8WYs=+H;hP~Vh%sfZ2nnph}`Gk+tI?&87wbsGTi9L@eh!mX{kt>SxTuZ&7}X5 z4$o4tU3kBMw4--ny$Fjec9LKCOF>E&2Xg_5hyKo$yd40+wc9i6}oiic~UW| zo9pTBmD&|QJx3U+lanZ>rWckKXAbz|xWMn7z+`=pdYwO174LMf)Ls-BcIUoi;fF0R z{x;_sG-zI5Ac#8lGA*Q+3r>uIXxm&fkbfX;FkA`8RCAq_Td?;LZr`?-(Zvu=_{5Kg z96neKQ2X&Cp%iAkI-c*fCeqCSy0EgBDWmko*#tRTSh3pjfDyUK~O|&#U`Sb zmX?RU?)C+KF|!FV`;G>MbP^j0W0FO-C_P;*$*jV1+uP} zya38vAdpi;7uYYkuZYlc4Kw^`(;3;BJ&B?Co%Aojgt#m1BGAZo7VLs`kYkF*!75k- znWS}KcQFt4!G8wX14A;d8*G9@jyquADMeXHgI@R)op*DeA}bWWA6FE;FC zb=$>1X@WBQTdsvx%ceQT&to+bN;PMa7TP`N0j;q*2ziR=$-7>%&)wL?-@q7SUEoPh zW^3UTw7UxE$L%KI0$&is VSsbP?VUGX+002ovPDHLkV1gpWZ)bOBcgOGT-kI2Vi;Wf; z81~0|^L_7q@4YFNcGiEl3_Dn1w*S;{AnfWI~@ z8FLY9?u0Ad2^>wloJix;SdvQ$#hcRVKAI+~`^Xf*=A(nje&kBTCa% ztHbq;fq~kGy?=QBLc|nx<^vM$LLOHwpO|PJ?MeWEh>HjWentYaD2uM4<{8tyAYJD| z(f_nfl9-Sa*-RBGa#_e}NFcEZKv5Qk69O zE@EPwAV-RpU}rz?1^ruVel@I2DK z%Po16+x{%taUOu9OvvlV<_5F;Q-3uFohVSei;(2LVFhyloWu7$=0uf_Uv*3$hW!>K zdzksz@&SDp9JX+((oFO=iBgiK(o}V_Q#XP1@yYC;PH2`oyCOABsWhcTH1fF+SW+%VI z5GpQImv+=!gO)`Wf8_NaN7!Y(9|5czPSgg*MivwRvRWvpzl>;r2)qJt%)@tF52wj? zeB#?7*gt0k@I$R)0%sz8HdtFbP>6twG{5Xf23y;IU3OdSSAEWG^fM8Er2&IIMO_lK z^1`6k0oS{VT-WcQ{}ar?g{t>y-}cru$J2w5?k(h4Y`3ag*LeZ(o%K)FPc&)g U+uj3kI{*Lx07*qoM6N<$f&d9(;{X5v diff --git a/packages/design-tokens/brand/generated/android/notification-xhdpi-48.png b/packages/design-tokens/brand/generated/android/notification-xhdpi-48.png index 8afee3571e44fc57f6b23fbaa5866c463d2ad52a..31b9926ca82acc5f2e00d530b33fe546cd504109 100644 GIT binary patch delta 924 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 delta 2232 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 delta 2989 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 diff --git a/packages/design-tokens/brand/generated/web/navigation-wordmark-black-204x50.png b/packages/design-tokens/brand/generated/web/navigation-wordmark-black-204x50.png index d2d03b721e5960bd4c0dc7bcdc48330a8f6c3c82..d3dce7df170294c719ca965b3819f9c4f337da18 100644 GIT binary patch delta 3259 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 delta 3927 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 diff --git a/packages/design-tokens/brand/generated/web/navigation-wordmark-blue-204x50.png b/packages/design-tokens/brand/generated/web/navigation-wordmark-blue-204x50.png index 3c2f19b9e72d1cb173cce0d7970cda222b96fb50..76b0b8875c58f711468aa205c0fb7c1a5c75e847 100644 GIT binary patch delta 5593 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 literal 33448 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 0); assert.ok(asset.fittedBox.x >= asset.contentBox.x); assert.ok(asset.fittedBox.y >= asset.contentBox.y); @@ -206,9 +211,17 @@ test('PNG and ICO outputs have declared dimensions, transparent clear space, and if (data[offset + 3] > 0) visibleColors.add(`${data[offset]},${data[offset + 1]},${data[offset + 2]}`); } - assert.ok(transparentPixelFound, `${asset.file} must retain transparent clear space`); + if (asset.file === 'web/social-card-1200x630.png') { + assert.equal(transparentPixelFound, false, `${asset.file} must be fully opaque`); + } else { + assert.ok(transparentPixelFound, `${asset.file} must retain transparent clear space`); + } + const expectedColors = + asset.outputMode === 'android-alpha-mask' + ? [[255, 255, 255]] + : sourceColors[asset.source.file]; assert.ok( - sourceColors[asset.source.file].some((color) => visibleColors.has(color.join(','))), + expectedColors.some((color) => visibleColors.has(color.join(','))), `${asset.file} must retain an approved source color`, ); } diff --git a/packages/design-tokens/test/brand-platform-assets.test.mjs b/packages/design-tokens/test/brand-platform-assets.test.mjs new file mode 100644 index 00000000..d1f2fc03 --- /dev/null +++ b/packages/design-tokens/test/brand-platform-assets.test.mjs @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath, URL } from 'node:url'; +import sharp from 'sharp'; + +const packageDirectory = fileURLToPath(new URL('../', import.meta.url)); +const brandDirectory = join(packageDirectory, 'brand'); +const outputDirectory = join(brandDirectory, 'generated'); +const planPath = join(brandDirectory, 'derivative-plan.json'); +const manifestPath = join(brandDirectory, 'derivatives.json'); +const sourcePath = join(brandDirectory, 'source', 'databreeze-wordmark-blue.png'); + +async function rgba(file) { + return sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); +} + +function visibleBounds(data, width, height) { + const bounds = { maxX: -1, maxY: -1, minX: width, minY: height }; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (data[(y * width + x) * 4 + 3] === 0) continue; + bounds.minX = Math.min(bounds.minX, x); + bounds.minY = Math.min(bounds.minY, y); + bounds.maxX = Math.max(bounds.maxX, x); + bounds.maxY = Math.max(bounds.maxY, y); + } + } + return bounds; +} + +function foregroundBounds(data, width, height, background) { + const bounds = { maxX: -1, maxY: -1, minX: width, minY: height }; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const offset = (y * width + x) * 4; + if ( + data[offset] === background[0] && + data[offset + 1] === background[1] && + data[offset + 2] === background[2] && + data[offset + 3] === background[3] + ) { + continue; + } + bounds.minX = Math.min(bounds.minX, x); + bounds.minY = Math.min(bounds.minY, y); + bounds.maxX = Math.max(bounds.maxX, x); + bounds.maxY = Math.max(bounds.maxY, y); + } + } + return bounds; +} + +function linearChannel(value) { + const normalized = value / 255; + return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; +} + +function contrast(left, right) { + const luminance = (color) => + 0.2126 * linearChannel(color[0]) + + 0.7152 * linearChannel(color[1]) + + 0.0722 * linearChannel(color[2]); + const values = [luminance(left), luminance(right)].sort((a, b) => b - a); + return (values[0] + 0.05) / (values[1] + 0.05); +} + +test('navigation wordmarks have documented transparent clear space on every edge', async () => { + const [plan, manifest] = await Promise.all([ + readFile(planPath, 'utf8').then(JSON.parse), + readFile(manifestPath, 'utf8').then(JSON.parse), + ]); + for (const name of ['black', 'blue']) { + const file = `web/navigation-wordmark-${name}-204x50.png`; + const assetPlan = plan.assets.find((asset) => asset.file === file); + const assetManifest = manifest.assets.find((asset) => asset.file === file); + const { data, info } = await rgba(join(outputDirectory, file)); + const bounds = visibleBounds(data, info.width, info.height); + assert.deepEqual(assetPlan.contentBox, { x: 10, y: 5, width: 184, height: 40 }); + assert.equal(assetPlan.safeZone, 'minimum-5px-vertical-and-20px-fitted-horizontal'); + assert.ok(bounds.minX >= 20 && bounds.minY >= 5, `${file} leading clear space`); + assert.ok(bounds.maxX <= 183 && bounds.maxY <= 44, `${file} trailing clear space`); + assert.deepEqual(assetManifest.visibleBounds, bounds); + } +}); + +test('Android notification sources are white alpha masks with exact approved-mark geometry', async () => { + const plan = JSON.parse(await readFile(planPath, 'utf8')); + const source = plan.sources.blueMark; + for (const asset of plan.assets.filter((candidate) => + candidate.file.startsWith('android/notification-'), + )) { + assert.equal(asset.outputMode, 'android-alpha-mask'); + let reference = sharp(sourcePath) + .extract(source.crop) + .resize({ + width: asset.contentBox.width, + height: asset.contentBox.height, + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + kernel: sharp.kernel.lanczos3, + }); + const resized = await reference.png().toBuffer(); + const referenceCanvas = await sharp({ + create: { + width: asset.width, + height: asset.height, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .composite([{ input: resized, left: asset.contentBox.x, top: asset.contentBox.y }]) + .ensureAlpha() + .raw() + .toBuffer(); + const { data: mask, info } = await rgba(join(outputDirectory, asset.file)); + assert.equal(mask.length, referenceCanvas.length); + for (let offset = 0; offset < mask.length; offset += 4) { + assert.equal(mask[offset + 3], referenceCanvas[offset + 3], `${asset.file} alpha geometry`); + if (mask[offset + 3] > 0) { + assert.deepEqual([...mask.subarray(offset, offset + 3)], [255, 255, 255]); + } + } + assert.equal(info.width, asset.width); + assert.equal(info.height, asset.height); + } +}); + +test('social metadata uses the approved opaque dark background and unchanged blue wordmark', async () => { + const [plan, manifest, { data, info }] = await Promise.all([ + readFile(planPath, 'utf8').then(JSON.parse), + readFile(manifestPath, 'utf8').then(JSON.parse), + rgba(join(outputDirectory, 'web', 'social-card-1200x630.png')), + ]); + const assetPlan = plan.assets.find((asset) => asset.file === 'web/social-card-1200x630.png'); + const assetManifest = manifest.assets.find( + (asset) => asset.file === 'web/social-card-1200x630.png', + ); + assert.deepEqual(assetPlan.backgroundColor, { red: 4, green: 9, blue: 32, alpha: 1 }); + assert.deepEqual(assetManifest.backgroundColor, assetPlan.backgroundColor); + let blueFound = false; + for (let offset = 0; offset < data.length; offset += 4) { + assert.equal(data[offset + 3], 255, 'social image must be fully opaque'); + if (data[offset] === 52 && data[offset + 1] === 78 && data[offset + 2] === 248) { + blueFound = true; + } + } + assert.deepEqual([...data.subarray(0, 4)], [4, 9, 32, 255]); + assert.ok(blueFound, 'social image must retain exact approved blue pixels'); + assert.ok(contrast([52, 78, 248], [4, 9, 32]) >= 3); + const bounds = foregroundBounds(data, info.width, info.height, [4, 9, 32, 255]); + assert.deepEqual(assetManifest.visibleBounds, bounds); + assert.ok(bounds.minX >= 120 && bounds.maxX < info.width - 120); + assert.ok(bounds.minY >= 126 && bounds.maxY < info.height - 126); +}); + +test('visual approval provenance is explicit and independently anchored to approved source hashes', async () => { + const [plan, manifest, golden] = await Promise.all([ + readFile(planPath, 'utf8').then(JSON.parse), + readFile(manifestPath, 'utf8').then(JSON.parse), + readFile(join(packageDirectory, 'test', 'fixtures', 'brand-visual-golden.json'), 'utf8').then( + JSON.parse, + ), + ]); + const expected = { + status: 'plan-approved', + reviewedOn: '2026-08-01', + reviewSource: 'approved Task 11 plan and DataBreeze brand specification', + specReference: 'docs/product/brand-and-experience.md#1-brand-continuity', + taskReference: + 'docs/plans/010-engineering-foundation.md#task-11-reproducible-brand-derivatives', + cropRationale: + 'The blue mark is the left 1155x1155 square of the approved blue wordmark; cropping removes only the adjacent DataBreeze letters and does not redraw geometry.', + sourceHashes: { + 'databreeze-mark-dark.png': + '5EE10842AD090F2BB980B51DDCF8BB4F8738C87B9659BE10387FE0B2D845B7A4', + 'databreeze-wordmark-black.png': + '4F37835E9648E7035DE9BCB6ADA05C1203A1C05A1D0DB81DF1D1AEA01D46FC98', + 'databreeze-wordmark-blue.png': + 'B2BB9353A2E2C42DAC8F68EC5BC30A9EB366F3C8139A46D4FDEC686264590D3D', + }, + }; + assert.deepEqual(plan.approval, expected); + assert.deepEqual(manifest.approval, expected); + assert.deepEqual(golden.approval, { + status: expected.status, + reviewedOn: expected.reviewedOn, + reviewSource: expected.reviewSource, + specReference: expected.specReference, + taskReference: expected.taskReference, + }); +}); diff --git a/packages/design-tokens/test/fixtures/brand-visual-golden.json b/packages/design-tokens/test/fixtures/brand-visual-golden.json index 49f67660..3f4eb728 100644 --- a/packages/design-tokens/test/fixtures/brand-visual-golden.json +++ b/packages/design-tokens/test/fixtures/brand-visual-golden.json @@ -1,5 +1,12 @@ { "schemaVersion": 1, + "approval": { + "status": "plan-approved", + "reviewedOn": "2026-08-01", + "reviewSource": "approved Task 11 plan and DataBreeze brand specification", + "specReference": "docs/product/brand-and-experience.md#1-brand-continuity", + "taskReference": "docs/plans/010-engineering-foundation.md#task-11-reproducible-brand-derivatives" + }, "assets": { "android/adaptive-foreground-432.png": "C16B2F8154035627EE4BC1970B6BFC21C557AE8D44F1A33F85DE3C3377A7E645", "android/launcher-hdpi-72.png": "C850208368B49DD40EB5206145DC8044A0AD97AD5A15222B5AAA14458ACBD626", @@ -7,11 +14,11 @@ "android/launcher-xhdpi-96.png": "D8C304EDE847F7F8B370F3477EBE155E951644F7406FE77FA44596CBB4497EBB", "android/launcher-xxhdpi-144.png": "56AAE8DD4F4DDA38B333325F56D2CF0064E7866DF22C15C7564D294B8E08377E", "android/launcher-xxxhdpi-192.png": "B83E179BC03B00A0C6B9ED6CD14B89BDBB792730799FD1890DC1B6769DE1367B", - "android/notification-hdpi-36.png": "52210F51643C72E92152F2DA156CC8B18EA0C215BE49452C4E1F905B05A944B3", - "android/notification-mdpi-24.png": "1FAB0BBDBE6BB698871E7CC7A53F67309C430800AF8A67DA8145C6A0E080A629", - "android/notification-xhdpi-48.png": "C2A801F109B8819723F889AD3D37A64CA9CE88EAE3C1C19E74A79AA0443F4842", - "android/notification-xxhdpi-72.png": "72DBB2A440698170F2FCA82C15C1F21CB6A0A5DD754B16F301B467234540A7ED", - "android/notification-xxxhdpi-96.png": "2B5CB8E94058FE42C45B43D9E21933A41816FBB91A82F123E2AF460BFA405477", + "android/notification-hdpi-36.png": "73F2DA8D89A22CEEDF5CFC85303FE80114A5F7158AB5BC585FDCF0BFC5D885C7", + "android/notification-mdpi-24.png": "3B6BFDF2EB3696325AD644F268CD7DE9F8014B19C0DA2698BD19BE6E5984F717", + "android/notification-xhdpi-48.png": "BEEDF672DC8861EB6BAE1C47A5B669205C5B46B27B6870C7F78EAD6715519E56", + "android/notification-xxhdpi-72.png": "DFE4ED9EB98092732874F42629A5359259D9D8B4EC9307131EC625C991891290", + "android/notification-xxxhdpi-96.png": "B555B71BBBC56556AA0B650D1BE63296A2EF7CCFB3528D88FEE23F6EE6D708BF", "desktop/application-256.png": "E38BD7B30191F57F9399D39972649B252AD8C30708FCB476A77C77A0A4E4E373", "desktop/application.ico": "DFE2F8828A981E686B2EAF8BB7E2C960AB7575B88081D59B79469374021B0058", "desktop/installer.ico": "DFE2F8828A981E686B2EAF8BB7E2C960AB7575B88081D59B79469374021B0058", @@ -22,8 +29,8 @@ "web/favicon-32.png": "C01AA26FD1B9DDC0CAE0175EB7AC7A16E0CF802C354AB1DE7A76D922EB17702C", "web/install-icon-192.png": "B83E179BC03B00A0C6B9ED6CD14B89BDBB792730799FD1890DC1B6769DE1367B", "web/install-icon-512.png": "F3FC6E0C0F32F40C26961A5570321D025540313693793C44B58B7B8718621792", - "web/navigation-wordmark-black-204x50.png": "AD5EE9CB6E4738BE1EDF118B2DB3BC7ADEA4785373E0D053E5822E733B8D40B5", - "web/navigation-wordmark-blue-204x50.png": "F0A731AF0F0366BE5A9ABED49913EDE32B67A67D333496B04B68BDAB54DC5FEC", - "web/social-card-1200x630.png": "EC86B2F83795CBD4F6267A704B87A47EA672702E615661CCC0DECB16455DCD96" + "web/navigation-wordmark-black-204x50.png": "468507ED94F13BABE083361984247E177C92C4514D03536862A2F0D102FD5688", + "web/navigation-wordmark-blue-204x50.png": "CD44FB5AD502B125863C26331FF3BB0C6E88804D6B6FC0261DE05E66400AFC62", + "web/social-card-1200x630.png": "C890808C04E3A41315B7471C2BA25B7F0438D26E37367308525D29F0AD1AF531" } } From 4f0e804fdf9b21eed2c571675fba5d3df11e22c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 12:15:38 +0700 Subject: [PATCH 32/51] fix(docs): normalize implementation plan formatting --- docs/plans/000-platform-program.md | 5 ++--- docs/plans/010-engineering-foundation.md | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/plans/000-platform-program.md b/docs/plans/000-platform-program.md index a1c0ae3f..7cb51ef1 100644 --- a/docs/plans/000-platform-program.md +++ b/docs/plans/000-platform-program.md @@ -1,7 +1,7 @@ # DataBreeze Platform Implementation Program -**Status:** Approved -**Implementation branch:** `dev` through short-lived `feat/*` and `fix/*` branches +**Status:** Approved
+**Implementation branch:** `dev` through short-lived `feat/*` and `fix/*` branches
**Primary specifications:** `docs/product/`, `docs/architecture/`, `docs/specs/`, and accepted ADRs ## Goal @@ -59,4 +59,3 @@ Child plans are written and approved before their product slice begins. Each nam - Relevant unit, integration, contract, end-to-end, security, accessibility, recovery, and performance tests pass. - Migrations, observability, operations, rollback, and release evidence are present. - No critical or high security finding remains unresolved for a production release. - diff --git a/docs/plans/010-engineering-foundation.md b/docs/plans/010-engineering-foundation.md index 0716d346..7cfa17bb 100644 --- a/docs/plans/010-engineering-foundation.md +++ b/docs/plans/010-engineering-foundation.md @@ -1,7 +1,7 @@ # Engineering Foundation Implementation Plan -**Status:** Approved -**Parent:** `000-platform-program.md` +**Status:** Approved
+**Parent:** `000-platform-program.md`
**Branch:** `feat/platform-foundation` ## Outcome @@ -127,4 +127,3 @@ Run the complete root verification from a clean worktree, build each deployable, ## Deferred requirements All business workflows and persistent IAM/IAE/DSM/JRA/DSO/NCO/INT/BUA/AUD behavior beyond the explicitly named primitives remain deferred to the subsequent child plans. A passing engineering-foundation build does not mark those requirements implemented. - From 832dd81472d01bebaf253cb56404a773d234f82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 12:58:58 +0700 Subject: [PATCH 33/51] fix(authorization): bound authority provider calls --- packages/domain/README.md | 4 + packages/domain/src/authorization/v1.ts | 99 ++++++++++++++- .../domain/test/authorization-v1.test.mjs | 115 ++++++++++++++++++ .../domain/test/public-api-v1.type-test.ts | 4 +- 4 files changed, 217 insertions(+), 5 deletions(-) diff --git a/packages/domain/README.md b/packages/domain/README.md index 25eaf982..aac7e69c 100644 --- a/packages/domain/README.md +++ b/packages/domain/README.md @@ -50,6 +50,10 @@ evaluates applicable policy from trusted application state. Provider methods are captured when the evaluator is created, so later mutation cannot replace its authority. Provider results are always runtime-validated even when an adapter is typed. +Each provider call has an independently cleaned-up deadline: 1 second by default, configurable +per evaluator from 1 millisecond through 60 seconds with `providerCallTimeoutMs`. A provider +timeout or exception fails closed as `AUTHORITY_UNAVAILABLE`; late provider settlement cannot +resume the authorization flow. Clients may use published permission bundles and applicability as display hints, but authoritative enforcement belongs to a server or trusted worker with its own provider-backed evaluator. diff --git a/packages/domain/src/authorization/v1.ts b/packages/domain/src/authorization/v1.ts index 225ccceb..72fb0ac9 100644 --- a/packages/domain/src/authorization/v1.ts +++ b/packages/domain/src/authorization/v1.ts @@ -112,6 +112,11 @@ export interface ScopedAuthorizationEvaluatorV1 { readonly authorizeV1: (request: unknown) => Promise; } +export interface ScopedAuthorizationEvaluatorOptionsV1 { + /** Maximum time allowed for each call to the provider bound to this evaluator. */ + readonly providerCallTimeoutMs: number; +} + interface ParsedMembershipV1 { readonly roleId: string; readonly membershipScope: TenantScopeV1; @@ -124,6 +129,13 @@ type ParsedValueV1 = const authorizationChannelSet = new Set(AUTHORIZATION_CHANNELS_V1); const resourceTypeSet = new Set(RESOURCE_TYPES_V1); +const DEFAULT_PROVIDER_CALL_TIMEOUT_MS_V1 = 1_000; +const MAX_PROVIDER_CALL_TIMEOUT_MS_V1 = 60_000; + +interface AuthorizationTimerRuntimeV1 { + readonly setTimeout: (callback: () => void, delayMs: number) => unknown; + readonly clearTimeout: (handle: unknown) => void; +} const resourceScopeTypes: Readonly> = Object.freeze({ @@ -150,6 +162,58 @@ function hasExactKeys(input: Record, expectedKeys: readonly str ); } +function providerCallTimeoutMsV1(input: unknown): number { + if (input === undefined) { + return DEFAULT_PROVIDER_CALL_TIMEOUT_MS_V1; + } + if (!isRecord(input) || !hasExactKeys(input, ['providerCallTimeoutMs'])) { + throw new TypeError('Invalid authorization evaluator options'); + } + + const timeoutMs = input['providerCallTimeoutMs']; + if ( + typeof timeoutMs !== 'number' || + !Number.isInteger(timeoutMs) || + timeoutMs < 1 || + timeoutMs > MAX_PROVIDER_CALL_TIMEOUT_MS_V1 + ) { + throw new TypeError('Invalid authorization provider call timeout'); + } + return timeoutMs; +} + +function timerRuntimeV1(): AuthorizationTimerRuntimeV1 { + const runtime = globalThis as unknown as Partial; + if (typeof runtime.setTimeout !== 'function' || typeof runtime.clearTimeout !== 'function') { + throw new TypeError('Authorization evaluator requires timer support'); + } + return Object.freeze({ + setTimeout: runtime.setTimeout.bind(globalThis), + clearTimeout: runtime.clearTimeout.bind(globalThis), + }); +} + +async function withProviderCallTimeoutV1( + operation: () => AwaitableV1, + timeoutMs: number, + timers: AuthorizationTimerRuntimeV1, +): Promise { + let scheduled = false; + let timeoutHandle: unknown; + const timeout = new Promise((_resolve, reject) => { + timeoutHandle = timers.setTimeout(() => reject(new Error('AUTHORITY_TIMEOUT')), timeoutMs); + scheduled = true; + }); + + try { + return await Promise.race([Promise.resolve().then(operation), timeout]); + } finally { + if (scheduled) { + timers.clearTimeout(timeoutHandle); + } + } +} + function isAuthorizationChannelV1(input: unknown): input is AuthorizationChannelV1 { return typeof input === 'string' && authorizationChannelSet.has(input); } @@ -284,7 +348,10 @@ function bindAuthorityMethodV1 resolveAuthenticatedPrincipalV1(), + providerCallTimeoutMs, + timers, + ), + ); if (!principal.accepted) { return deny('AUTHORITY_INVALID'); } @@ -343,7 +416,13 @@ export function createScopedAuthorizationEvaluatorV1( resourceId: resourceSelector.value.resourceId, tenantScope: tenantFilter.value, }); - const resource = parseAuthoritativeResourceV1(await lookupResourceV1(lookupQuery)); + const resource = parseAuthoritativeResourceV1( + await withProviderCallTimeoutV1( + () => lookupResourceV1(lookupQuery), + providerCallTimeoutMs, + timers, + ), + ); if (!resource.accepted) { return deny('AUTHORITY_INVALID'); } @@ -362,7 +441,13 @@ export function createScopedAuthorizationEvaluatorV1( principalId: principal.value, resource: resource.value, }); - const membership = parseMembershipV1(await resolveMembershipV1(membershipQuery)); + const membership = parseMembershipV1( + await withProviderCallTimeoutV1( + () => resolveMembershipV1(membershipQuery), + providerCallTimeoutMs, + timers, + ), + ); if (!membership.accepted) { return deny('AUTHORITY_INVALID'); } @@ -391,7 +476,13 @@ export function createScopedAuthorizationEvaluatorV1( membership: evaluatedMembership, resource: resource.value, }); - const policy = parsePolicyResultV1(await evaluatePolicyV1(policyQuery)); + const policy = parsePolicyResultV1( + await withProviderCallTimeoutV1( + () => evaluatePolicyV1(policyQuery), + providerCallTimeoutMs, + timers, + ), + ); if (!policy.accepted) { return deny('AUTHORITY_INVALID'); } diff --git a/packages/domain/test/authorization-v1.test.mjs b/packages/domain/test/authorization-v1.test.mjs index 434a0c83..8a61ebcf 100644 --- a/packages/domain/test/authorization-v1.test.mjs +++ b/packages/domain/test/authorization-v1.test.mjs @@ -115,6 +115,7 @@ function authorityProvider({ principalResult, membershipResult, policyResult, + hangFrom, throwFrom, } = {}) { const calls = { @@ -128,22 +129,26 @@ function authorityProvider({ const provider = Object.freeze({ async resolveAuthenticatedPrincipalV1() { calls.principal += 1; + if (hangFrom === 'principal') return new Promise(() => {}); if (throwFrom === 'principal') throw new Error('principal unavailable'); return principalResult ?? { principalId }; }, async lookupResourceV1(query) { calls.lookup += 1; calls.lookupQuery = query; + if (hangFrom === 'lookup') return new Promise(() => {}); if (throwFrom === 'lookup') throw new Error('lookup unavailable'); return resource; }, async resolveMembershipV1() { calls.membership += 1; + if (hangFrom === 'membership') return new Promise(() => {}); if (throwFrom === 'membership') throw new Error('membership unavailable'); return membershipResult ?? { roleId, membershipScope, membershipActive }; }, async evaluatePolicyV1() { calls.policy += 1; + if (hangFrom === 'policy') return new Promise(() => {}); if (throwFrom === 'policy') throw new Error('policy unavailable'); return policyResult ?? { satisfied: policyConditionsSatisfied }; }, @@ -152,6 +157,23 @@ function authorityProvider({ return { provider, calls }; } +async function settleWithin(promise, timeoutMs) { + let guard; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + guard = globalThis.setTimeout( + () => reject(new Error('authorization did not settle')), + timeoutMs, + ); + }), + ]); + } finally { + globalThis.clearTimeout(guard); + } +} + test('[IAM-002, IAM-003] evaluator owns authority and exposes no caller minting API', async () => { const billing = resourceFor('billing-account'); const { provider, calls } = authorityProvider({ resource: billing, roleId: 'owner' }); @@ -432,3 +454,96 @@ test('[IAM-002, IAM-003] authority failures and malformed results fail closed', ); } }); + +for (const [hangFrom, expectedCalls] of [ + ['principal', { principal: 1, lookup: 0, membership: 0, policy: 0 }], + ['lookup', { principal: 1, lookup: 1, membership: 0, policy: 0 }], + ['membership', { principal: 1, lookup: 1, membership: 1, policy: 0 }], + ['policy', { principal: 1, lookup: 1, membership: 1, policy: 1 }], +]) { + test(`[IAM-002, IAM-003] ${hangFrom} authority timeout fails closed`, async () => { + const artifact = resourceFor('artifact'); + const { provider, calls } = authorityProvider({ resource: artifact, hangFrom }); + const evaluator = createScopedAuthorizationEvaluatorV1(provider, { + providerCallTimeoutMs: 10, + }); + + assert.deepEqual( + await settleWithin( + evaluator.authorizeV1(requestFor('artifact.record.read', 'web', artifact)), + 250, + ), + { allowed: false, code: 'AUTHORITY_UNAVAILABLE' }, + ); + assert.deepEqual( + { + principal: calls.principal, + lookup: calls.lookup, + membership: calls.membership, + policy: calls.policy, + }, + expectedCalls, + ); + }); +} + +test('[IAM-002, IAM-003] authority deadline timers are cleared after settle', async () => { + const nativeSetTimeout = globalThis.setTimeout; + const nativeClearTimeout = globalThis.clearTimeout; + const activeTimers = new Set(); + let scheduled = 0; + let cleared = 0; + + globalThis.setTimeout = (callback, delay, ...arguments_) => { + scheduled += 1; + const handle = nativeSetTimeout(() => { + activeTimers.delete(handle); + callback(...arguments_); + }, delay); + activeTimers.add(handle); + return handle; + }; + globalThis.clearTimeout = (handle) => { + cleared += 1; + activeTimers.delete(handle); + return nativeClearTimeout(handle); + }; + + try { + const artifact = resourceFor('artifact'); + for (const [throwFrom, expectedDecision] of [ + [undefined, { allowed: true, permission: 'artifact.record.read', tenantScope: projectA }], + ['membership', { allowed: false, code: 'AUTHORITY_UNAVAILABLE' }], + ]) { + const { provider } = authorityProvider({ resource: artifact, throwFrom }); + const evaluator = createScopedAuthorizationEvaluatorV1(provider, { + providerCallTimeoutMs: 1_000, + }); + assert.deepEqual( + await evaluator.authorizeV1(requestFor('artifact.record.read', 'web', artifact)), + expectedDecision, + ); + } + + assert.equal(scheduled, 7); + assert.equal(cleared, 7); + assert.equal(activeTimers.size, 0); + } finally { + globalThis.setTimeout = nativeSetTimeout; + globalThis.clearTimeout = nativeClearTimeout; + for (const handle of activeTimers) nativeClearTimeout(handle); + } +}); + +test('[IAM-002, IAM-003] unsafe authority timeout configuration fails at composition', () => { + const { provider } = authorityProvider(); + for (const options of [ + {}, + { providerCallTimeoutMs: 0 }, + { providerCallTimeoutMs: 1.5 }, + { providerCallTimeoutMs: 60_001 }, + { providerCallTimeoutMs: 1_000, extra: true }, + ]) { + assert.throws(() => createScopedAuthorizationEvaluatorV1(provider, options), TypeError); + } +}); diff --git a/packages/domain/test/public-api-v1.type-test.ts b/packages/domain/test/public-api-v1.type-test.ts index 6e0ef7ea..529da937 100644 --- a/packages/domain/test/public-api-v1.type-test.ts +++ b/packages/domain/test/public-api-v1.type-test.ts @@ -3,6 +3,7 @@ import type { AuthorizationAuthorityProviderV1, AuthorizationRequestV1, ScopedResourceLookupQueryV1, + ScopedAuthorizationEvaluatorOptionsV1, } from '@databreeze/domain/authorization/v1'; import type { StableIdentifierV1, TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; @@ -28,7 +29,8 @@ const provider: AuthorizationAuthorityProviderV1 = { }, }; -const evaluator = createScopedAuthorizationEvaluatorV1(provider); +const evaluatorOptions: ScopedAuthorizationEvaluatorOptionsV1 = { providerCallTimeoutMs: 1_000 }; +const evaluator = createScopedAuthorizationEvaluatorV1(provider, evaluatorOptions); const request: AuthorizationRequestV1 = { permission: 'artifact.record.read', channel: 'web', From 2ccb1eea7363d90ea1f99a559243d7f07a117561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 13:00:43 +0700 Subject: [PATCH 34/51] fix(domain): canonicalize stable UUID identities --- packages/domain/src/tenant-scope/v1.ts | 2 +- packages/domain/test/tenant-scope-v1.test.mjs | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/tenant-scope/v1.ts b/packages/domain/src/tenant-scope/v1.ts index 1def0d92..b8353cea 100644 --- a/packages/domain/src/tenant-scope/v1.ts +++ b/packages/domain/src/tenant-scope/v1.ts @@ -71,7 +71,7 @@ export function parseStableIdentifierV1( return rejected('INVALID_IDENTIFIER'); } - return accepted(parsed.value as StableIdentifierV1); + return accepted(parsed.value.toLowerCase() as StableIdentifierV1); } export function parseStrictUtcTimestampV1( diff --git a/packages/domain/test/tenant-scope-v1.test.mjs b/packages/domain/test/tenant-scope-v1.test.mjs index 24f1527b..216c2894 100644 --- a/packages/domain/test/tenant-scope-v1.test.mjs +++ b/packages/domain/test/tenant-scope-v1.test.mjs @@ -84,6 +84,29 @@ test('[IAM-001] accepts only non-guessable UUIDv4/v7 identifiers and strict UTC }); }); +test('[IAM-001, IAM-019] canonicalizes mixed-case UUID identities to lowercase', async () => { + const api = await loadTenantScope(); + assert.ok(api); + + assert.deepEqual(api.parseStableIdentifierV1(ids.organizationA.toUpperCase()), { + accepted: true, + value: ids.organizationA, + }); + + const mixedCaseProject = { + scopeType: 'project', + organizationId: ids.organizationA.toUpperCase(), + workspaceId: ids.workspaceA.toUpperCase(), + projectId: ids.projectA.toUpperCase(), + }; + const parsed = expectAccepted(api.parseTenantScopeV1(mixedCaseProject)); + assert.deepEqual(parsed, projectA); + assert.equal( + api.tenantScopesEqualV1(parsed, expectAccepted(api.parseTenantScopeV1(projectA))), + true, + ); +}); + test('[IAM-019] parses only complete closed tenant ancestry', async () => { const api = await loadTenantScope(); assert.ok(api); From f2afdda0bd6f04a87a7bd791ab8dd85c583711b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 13:02:11 +0700 Subject: [PATCH 35/51] fix(i18n): accept typed plural relative-time units --- packages/i18n/src/formatting-v1.ts | 8 ++++++++ packages/i18n/test/formatting-v1.test.mjs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/i18n/src/formatting-v1.ts b/packages/i18n/src/formatting-v1.ts index 44104385..e45eaee6 100644 --- a/packages/i18n/src/formatting-v1.ts +++ b/packages/i18n/src/formatting-v1.ts @@ -30,13 +30,21 @@ const CURRENCY_CODES_V1 = new Set(supportedValuesIntrinsicV1('currency')); const MAX_LIST_ITEMS_V1 = 1_000; const RELATIVE_TIME_UNITS_V1 = new Set([ 'day', + 'days', 'hour', + 'hours', 'minute', + 'minutes', 'month', + 'months', 'quarter', + 'quarters', 'second', + 'seconds', 'week', + 'weeks', 'year', + 'years', ]); interface FractionOptionsV1 { diff --git a/packages/i18n/test/formatting-v1.test.mjs b/packages/i18n/test/formatting-v1.test.mjs index 01c45f69..5f812b30 100644 --- a/packages/i18n/test/formatting-v1.test.mjs +++ b/packages/i18n/test/formatting-v1.test.mjs @@ -73,6 +73,24 @@ test('formats lists, relative time, and plural categories for both locales', asy assert.equal(selectPluralCategoryV1(1, { locale: 'vi-VN' }), 'other'); }); +test('accepts every plural relative-time unit admitted by the public TypeScript type', async () => { + const { formatRelativeTimeV1 } = await import('../src/v1.ts'); + const cases = [ + ['years', '2 years ago'], + ['quarters', '2 quarters ago'], + ['months', '2 months ago'], + ['weeks', '2 weeks ago'], + ['days', '2 days ago'], + ['hours', '2 hours ago'], + ['minutes', '2 minutes ago'], + ['seconds', '2 seconds ago'], + ]; + + for (const [unit, expected] of cases) { + assert.equal(formatRelativeTimeV1(-2, unit, { locale: 'en' }), expected); + } +}); + test('rejects invalid locale, time zone, date, currency, numeric values, units, and option keys', async () => { const { formatCurrencyV1, formatDateTimeV1, formatDecimalV1, formatRelativeTimeV1, I18nErrorV1 } = await import('../src/v1.ts'); From cf400519513b521546854e5c9d63ba7cda77c644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 13:03:35 +0700 Subject: [PATCH 36/51] fix(provider-ports): align record delete semantics --- .../test/fixtures/storage-fake-v1.ts | 5 ++++- .../test/interchangeability-v1.test.mjs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/provider-ports/test/fixtures/storage-fake-v1.ts b/packages/provider-ports/test/fixtures/storage-fake-v1.ts index 00b9888e..799776fc 100644 --- a/packages/provider-ports/test/fixtures/storage-fake-v1.ts +++ b/packages/provider-ports/test/fixtures/storage-fake-v1.ts @@ -67,7 +67,10 @@ function createBackingV1(kind: BackingKindV1): BackingV1 { set: (key, value) => { values[key] = value; }, - delete: (key) => delete values[key], + delete: (key) => { + if (!Object.hasOwn(values, key)) return false; + return delete values[key]; + }, }; } diff --git a/packages/provider-ports/test/interchangeability-v1.test.mjs b/packages/provider-ports/test/interchangeability-v1.test.mjs index 0fa6f284..cfd2fb58 100644 --- a/packages/provider-ports/test/interchangeability-v1.test.mjs +++ b/packages/provider-ports/test/interchangeability-v1.test.mjs @@ -299,6 +299,23 @@ for (const [name, port] of [ }); } +test('map and record storage fakes both report an absent delete as false', async () => { + const results = await Promise.all( + [ + ['map', storageFakeV1('map-delete-memory-v1', 'map', sha256)], + ['record', storageFakeV1('record-delete-memory-v1', 'record', sha256)], + ].map(([backing, port]) => + port.deleteVerified({ + context: context('delete-verified', `idem-delete-missing-${backing}`), + objectRef: 'object:missing', + expectedSha256: 'a'.repeat(64), + }), + ), + ); + + assert.deepEqual(results, [{ deleted: false }, { deleted: false }]); +}); + test('declares only the canonical contracts dependency and no provider SDK', () => { const manifest = JSON.parse(readFileSync(path.join(packageDirectory, 'package.json'), 'utf8')); assert.deepEqual(manifest.dependencies, { '@databreeze/contracts': 'workspace:*' }); From fe6a77521017d1b07754f44cde747c67cca2a082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 13:05:38 +0700 Subject: [PATCH 37/51] fix(provider-ports): isolate multipart upload references --- .../test/fixtures/storage-fake-v1.ts | 4 ++- .../test/interchangeability-v1.test.mjs | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/provider-ports/test/fixtures/storage-fake-v1.ts b/packages/provider-ports/test/fixtures/storage-fake-v1.ts index 799776fc..76f5035a 100644 --- a/packages/provider-ports/test/fixtures/storage-fake-v1.ts +++ b/packages/provider-ports/test/fixtures/storage-fake-v1.ts @@ -127,6 +127,7 @@ export function storageFakeV1( const objects = createBackingV1(backingKind); const objectIds = new WeakMap(); let nextObjectId = 1; + let nextUploadRef = 1; const objectId = (value: object): number => { const existing = objectIds.get(value); @@ -166,9 +167,10 @@ export function storageFakeV1( const prior = replayV1(beginReceipts, key, fingerprint, 'begin-multipart-upload'); if (prior !== undefined) return prior; const upload = defineObjectStorageMultipartUploadV1({ - uploadRef: `upload:${request.plan.objectKey}`, + uploadRef: `upload:${nextUploadRef}`, plan: request.plan, }); + nextUploadRef += 1; beginReceipts.set(key, Object.freeze({ fingerprint, result: upload })); await Promise.resolve(); return upload; diff --git a/packages/provider-ports/test/interchangeability-v1.test.mjs b/packages/provider-ports/test/interchangeability-v1.test.mjs index cfd2fb58..ab9a0095 100644 --- a/packages/provider-ports/test/interchangeability-v1.test.mjs +++ b/packages/provider-ports/test/interchangeability-v1.test.mjs @@ -137,6 +137,34 @@ test('begin rejects reuse of an idempotency key for a different multipart plan', ); }); +test('distinct begin requests get unique upload references while replays reuse the receipt', async () => { + const plan = defineObjectStorageMultipartPlanV1({ + objectKey: 'workspace/distinct-upload-references', + expectedSha256: 'a'.repeat(64), + expectedByteLength: 3, + partSizeBytes: 8 * 1024 * 1024, + }); + + for (const [backing, port] of [ + ['map', storageFakeV1('map-upload-ref-memory-v1', 'map', sha256)], + ['record', storageFakeV1('record-upload-ref-memory-v1', 'record', sha256)], + ]) { + const firstRequest = { + context: context('begin-multipart-upload', `idem-upload-ref-${backing}-first`), + plan, + }; + const first = await port.beginMultipartUpload(firstRequest); + const replay = await port.beginMultipartUpload(firstRequest); + const second = await port.beginMultipartUpload({ + context: context('begin-multipart-upload', `idem-upload-ref-${backing}-second`), + plan, + }); + + assert.equal(replay, first); + assert.notEqual(second.uploadRef, first.uploadRef); + } +}); + test('upload rejects reuse of an idempotency key for another upload or part integrity tuple', async () => { const port = storageFakeV1('upload-conflict-memory-v1', 'map', sha256); const plan = defineObjectStorageMultipartPlanV1({ From e3e0fb8bb0219a83f5247c3fbb2194f3f26075ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 12:50:54 +0700 Subject: [PATCH 38/51] fix(tooling): enforce literal dependency boundaries --- .../src/check-dependency-boundaries.mjs | 81 +++++++++++++++---- .../test/dependency-boundaries.test.mjs | 26 ++++++ .../apps/web/src/dynamic.ts | 1 + .../apps/web/src/import-equals.ts | 3 + .../apps/web/src/required.ts | 3 + .../services/api/package.json | 5 ++ .../services/api/src/internal.ts | 1 + .../apps/web/src/blocked.ts | 1 + .../apps/web/src/private.ts | 1 + .../apps/web/src/public.ts | 1 + .../packages/contracts/package.json | 11 +++ .../packages/contracts/src/blocked.ts | 1 + .../contracts/src/features/private/secret.ts | 1 + .../packages/contracts/src/features/public.ts | 1 + .../packages/contracts/src/index.ts | 1 + 15 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/dynamic.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/import-equals.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/required.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/services/api/package.json create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/services/api/src/internal.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/blocked.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/private.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/public.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/package.json create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/blocked.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/features/private/secret.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/features/public.ts create mode 100644 tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/index.ts diff --git a/tools/repo-cli/src/check-dependency-boundaries.mjs b/tools/repo-cli/src/check-dependency-boundaries.mjs index 810aa2b3..fd3d7af5 100644 --- a/tools/repo-cli/src/check-dependency-boundaries.mjs +++ b/tools/repo-cli/src/check-dependency-boundaries.mjs @@ -72,6 +72,14 @@ function importedModules(filePath) { ); const moduleSpecifiers = []; + function addStringLiteral(node) { + if (node !== undefined && ts.isStringLiteralLike(node)) { + moduleSpecifiers.push(node.text); + return true; + } + return false; + } + function visit(node) { if ( (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && @@ -80,6 +88,23 @@ function importedModules(filePath) { ) { moduleSpecifiers.push(node.moduleSpecifier.text); } + + if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) && + addStringLiteral(node.moduleReference.expression) + ) { + return; + } + + if ( + ts.isCallExpression(node) && + node.arguments.length === 1 && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require')) + ) { + addStringLiteral(node.arguments[0]); + } ts.forEachChild(node, visit); } @@ -224,31 +249,57 @@ function escapesForRegularExpression(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +function hasPublicExportTarget(target) { + if (typeof target === 'string') { + return true; + } + if (Array.isArray(target)) { + return target.some(hasPublicExportTarget); + } + if (target === null || typeof target !== 'object') { + return false; + } + return Object.values(target).some(hasPublicExportTarget); +} + +function exportPatternMatches(pattern, subpath) { + return new RegExp(`^${escapesForRegularExpression(pattern).replace('\\*', '.*')}$`).test(subpath); +} + +function compareExportPatterns(left, right) { + const leftPrefixLength = left.indexOf('*') + 1; + const rightPrefixLength = right.indexOf('*') + 1; + + if (leftPrefixLength !== rightPrefixLength) { + return rightPrefixLength - leftPrefixLength; + } + return right.length - left.length; +} + function exportsSubpath(exportsField, subpath) { - if (typeof exportsField === 'string') { - return subpath === '.'; + if (typeof exportsField === 'string' || Array.isArray(exportsField)) { + return subpath === '.' && hasPublicExportTarget(exportsField); } - if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) { + if (exportsField === null || typeof exportsField !== 'object') { return false; } const exportKeys = Object.keys(exportsField); const publicSubpaths = exportKeys.filter((key) => key.startsWith('.')); if (publicSubpaths.length === 0) { - return subpath === '.'; + return subpath === '.' && hasPublicExportTarget(exportsField); } - return publicSubpaths.some((exportedSubpath) => { - if (exportedSubpath === subpath) { - return true; - } - if (!exportedSubpath.includes('*')) { - return false; - } - return new RegExp( - `^${escapesForRegularExpression(exportedSubpath).replace('\\*', '.+')}$`, - ).test(subpath); - }); + if (Object.prototype.hasOwnProperty.call(exportsField, subpath)) { + return hasPublicExportTarget(exportsField[subpath]); + } + + const matchingPattern = publicSubpaths + .filter((exportedSubpath) => exportedSubpath.includes('*')) + .filter((exportedSubpath) => exportPatternMatches(exportedSubpath, subpath)) + .sort(compareExportPatterns)[0]; + + return matchingPattern !== undefined && hasPublicExportTarget(exportsField[matchingPattern]); } function checkPrivateWorkspacePackageImports(repositoryRoot) { diff --git a/tools/repo-cli/test/dependency-boundaries.test.mjs b/tools/repo-cli/test/dependency-boundaries.test.mjs index f0e9d5da..335c9b4d 100644 --- a/tools/repo-cli/test/dependency-boundaries.test.mjs +++ b/tools/repo-cli/test/dependency-boundaries.test.mjs @@ -29,6 +29,19 @@ test('rejects a client import of a service implementation', () => { assert.match(result.stderr, /rule=clients-must-not-import-service-implementations/); }); +test('rejects literal dynamic imports, require calls, and TypeScript import-equals of services', () => { + const result = checkFixture('client-loads-service-literals'); + + assert.equal(result.status, 1); + assert.match(result.stderr, /dynamic\.ts/); + assert.match(result.stderr, /required\.ts/); + assert.match(result.stderr, /import-equals\.ts/); + assert.equal( + result.stderr.match(/rule=clients-must-not-import-service-implementations/g)?.length, + 3, + ); +}); + test('rejects a client import of the API directory itself', () => { const result = checkFixture('client-imports-service-directory'); @@ -90,3 +103,16 @@ test('rejects a client import of a private workspace-package subpath', () => { assert.match(result.stderr, /apps[\\/]web[\\/]src[\\/]client\.ts/); assert.match(result.stderr, /rule=workspace-packages-must-not-import-private-subpaths/); }); + +test('uses exact and most-specific export entries before broader patterns, including null targets', () => { + const result = checkFixture('exports-null-precedence'); + + assert.equal(result.status, 1); + assert.match(result.stderr, /blocked\.ts/); + assert.match(result.stderr, /private\.ts/); + assert.doesNotMatch(result.stderr, /public\.ts/); + assert.equal( + result.stderr.match(/rule=workspace-packages-must-not-import-private-subpaths/g)?.length, + 2, + ); +}); diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/dynamic.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/dynamic.ts new file mode 100644 index 00000000..fa106a96 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/dynamic.ts @@ -0,0 +1 @@ +export const loadInternal = () => import('@fixture/api/internal'); diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/import-equals.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/import-equals.ts new file mode 100644 index 00000000..92d495f3 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/import-equals.ts @@ -0,0 +1,3 @@ +import internal = require('@fixture/api/internal'); + +export { internal }; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/required.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/required.ts new file mode 100644 index 00000000..f4955f90 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/apps/web/src/required.ts @@ -0,0 +1,3 @@ +const internal = require('@fixture/api/internal'); + +export { internal }; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/services/api/package.json b/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/services/api/package.json new file mode 100644 index 00000000..bae8e3fe --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/services/api/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fixture/api", + "private": true, + "type": "module" +} diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/services/api/src/internal.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/services/api/src/internal.ts new file mode 100644 index 00000000..cc7ad554 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/client-loads-service-literals/services/api/src/internal.ts @@ -0,0 +1 @@ +export const internal = true; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/blocked.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/blocked.ts new file mode 100644 index 00000000..44789ad7 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/blocked.ts @@ -0,0 +1 @@ +import '@fixture/contracts/blocked'; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/private.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/private.ts new file mode 100644 index 00000000..0ed63085 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/private.ts @@ -0,0 +1 @@ +import '@fixture/contracts/features/private/secret'; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/public.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/public.ts new file mode 100644 index 00000000..913602fa --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/apps/web/src/public.ts @@ -0,0 +1 @@ +import '@fixture/contracts/features/public'; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/package.json b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/package.json new file mode 100644 index 00000000..9513f976 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/package.json @@ -0,0 +1,11 @@ +{ + "name": "@fixture/contracts", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts", + "./blocked": null, + "./features/*": "./src/features/*.ts", + "./features/private/*": null + } +} diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/blocked.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/blocked.ts new file mode 100644 index 00000000..9a49d578 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/blocked.ts @@ -0,0 +1 @@ +export const blocked = true; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/features/private/secret.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/features/private/secret.ts new file mode 100644 index 00000000..9c06c29b --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/features/private/secret.ts @@ -0,0 +1 @@ +export const secret = true; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/features/public.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/features/public.ts new file mode 100644 index 00000000..9bf2b97a --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/features/public.ts @@ -0,0 +1 @@ +export const visible = true; diff --git a/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/index.ts b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/index.ts new file mode 100644 index 00000000..49800830 --- /dev/null +++ b/tools/repo-cli/test/fixtures/dependency-boundaries/exports-null-precedence/packages/contracts/src/index.ts @@ -0,0 +1 @@ +export const contract = true; From a4f60e7083ce5485a92e24a2c218a554fb20c95d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 12:52:08 +0700 Subject: [PATCH 39/51] fix(tooling): preserve traceability command paths --- .../src/generate-requirement-index.mjs | 6 +++++- .../test/requirement-traceability.test.mjs | 19 +++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/tools/repo-cli/src/generate-requirement-index.mjs b/tools/repo-cli/src/generate-requirement-index.mjs index 665db3b0..910c36ef 100644 --- a/tools/repo-cli/src/generate-requirement-index.mjs +++ b/tools/repo-cli/src/generate-requirement-index.mjs @@ -36,6 +36,10 @@ function parseOptions(argumentsList) { return options; } +function quoteCommandArgument(value) { + return process.platform === 'win32' ? `"${value}"` : `'${value.replaceAll("'", "'\\''")}'`; +} + function listMarkdownFiles(directory) { if (!existsSync(directory)) { return []; @@ -233,7 +237,7 @@ function run(argumentsList) { if (options.check) { if (!existsSync(options.output) || readFileSync(options.output, 'utf8') !== contents) { process.stderr.write( - `requirement index drift: ${options.output} is missing or differs; run node tools/repo-cli/src/generate-requirement-index.mjs --output ${options.output}\n`, + `requirement index drift: ${options.output} is missing or differs; run node tools/repo-cli/src/generate-requirement-index.mjs --root ${quoteCommandArgument(options.root)} --output ${quoteCommandArgument(options.output)}\n`, ); return 1; } diff --git a/tools/repo-cli/test/requirement-traceability.test.mjs b/tools/repo-cli/test/requirement-traceability.test.mjs index a2353925..8525f87a 100644 --- a/tools/repo-cli/test/requirement-traceability.test.mjs +++ b/tools/repo-cli/test/requirement-traceability.test.mjs @@ -10,26 +10,25 @@ const testDirectory = path.dirname(fileURLToPath(import.meta.url)); const indexerPath = path.join(testDirectory, '..', 'src', 'generate-requirement-index.mjs'); const fixturesDirectory = path.join(testDirectory, 'fixtures', 'requirement-traceability'); +function quoteCommandArgument(value) { + return process.platform === 'win32' ? `"${value}"` : `'${value.replaceAll("'", "'\\''")}'`; +} + function runIndexer(fixtureName, extraArguments = [], prepareOutput) { const outputDirectory = mkdtempSync(path.join(os.tmpdir(), 'databreeze-requirements-')); const outputPath = path.join(outputDirectory, 'requirements-index.json'); + const rootPath = path.join(fixturesDirectory, fixtureName); prepareOutput?.(outputPath); const result = spawnSync( process.execPath, - [ - indexerPath, - '--root', - path.join(fixturesDirectory, fixtureName), - '--output', - outputPath, - ...extraArguments, - ], + [indexerPath, '--root', rootPath, '--output', outputPath, ...extraArguments], { encoding: 'utf8' }, ); return { ...result, outputPath, + rootPath, cleanup() { rmSync(outputDirectory, { force: true, recursive: true }); }, @@ -138,7 +137,7 @@ test('reports output drift without rewriting the checked index', () => { assert.equal(result.status, 1); assert.equal( result.stderr, - `requirement index drift: ${result.outputPath} is missing or differs; run node tools/repo-cli/src/generate-requirement-index.mjs --output ${result.outputPath}\n`, + `requirement index drift: ${result.outputPath} is missing or differs; run node tools/repo-cli/src/generate-requirement-index.mjs --root ${quoteCommandArgument(result.rootPath)} --output ${quoteCommandArgument(result.outputPath)}\n`, ); } finally { result.cleanup(); @@ -155,7 +154,7 @@ test('reports stale index drift without rewriting the existing bytes', () => { assert.equal(result.status, 1); assert.equal( result.stderr, - `requirement index drift: ${result.outputPath} is missing or differs; run node tools/repo-cli/src/generate-requirement-index.mjs --output ${result.outputPath}\n`, + `requirement index drift: ${result.outputPath} is missing or differs; run node tools/repo-cli/src/generate-requirement-index.mjs --root ${quoteCommandArgument(result.rootPath)} --output ${quoteCommandArgument(result.outputPath)}\n`, ); assert.equal(readFileSync(result.outputPath, 'utf8'), staleContents); } finally { From cfd1ff430a6bccd190823bb721b69ac199f56b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 12:54:26 +0700 Subject: [PATCH 40/51] fix(brand): harden derivative validation --- .../scripts/generate-brand-derivatives.mjs | 10 +++++--- .../test/brand-derivative-security.test.mjs | 1 + .../test/brand-derivatives.test.mjs | 23 +++++++++++++++++++ .../test/brand-platform-assets.test.mjs | 7 ++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/design-tokens/scripts/generate-brand-derivatives.mjs b/packages/design-tokens/scripts/generate-brand-derivatives.mjs index 69f799bf..fbe721b0 100644 --- a/packages/design-tokens/scripts/generate-brand-derivatives.mjs +++ b/packages/design-tokens/scripts/generate-brand-derivatives.mjs @@ -249,6 +249,7 @@ function validateApproval(approval, approvedSources) { } function validateAsset(asset, sources, seenFiles) { + assertPlainObject(asset, 'asset'); const hasFrames = Object.prototype.hasOwnProperty.call(asset, 'frames'); const hasOutputMode = Object.prototype.hasOwnProperty.call(asset, 'outputMode'); const hasBackground = Object.prototype.hasOwnProperty.call(asset, 'backgroundColor'); @@ -894,8 +895,8 @@ export async function compareBrandDerivatives({ } } -export async function checkBrandDerivatives() { - await compareBrandDerivatives(); +export async function checkBrandDerivatives(options) { + await compareBrandDerivatives(options); } function parseArguments(argv) { @@ -924,7 +925,10 @@ function parseArguments(argv) { async function main() { const options = parseArguments(process.argv.slice(2)); if (options.check) { - await checkBrandDerivatives(); + await checkBrandDerivatives({ + expectedManifestPath: options.manifestPath, + expectedOutputDirectory: options.outputDirectory, + }); process.stdout.write('Brand derivatives are reproducible and current.\n'); } else { await generateBrandDerivatives(options); diff --git a/packages/design-tokens/test/brand-derivative-security.test.mjs b/packages/design-tokens/test/brand-derivative-security.test.mjs index fd4d70ab..701f41d4 100644 --- a/packages/design-tokens/test/brand-derivative-security.test.mjs +++ b/packages/design-tokens/test/brand-derivative-security.test.mjs @@ -59,6 +59,7 @@ test('the derivative plan is a closed, typed, complete portable contract', async /crop.*bounds/i, ], ['unknown asset field', (value) => (value.assets[0].extra = true), /unknown key/i], + ['non-object asset', (value) => (value.assets[0] = null), /asset.*object/i], ['invalid platform', (value) => (value.assets[0].platform = 'ios'), /platform/i], ['empty purpose', (value) => (value.assets[0].purpose = ''), /purpose/i], ['empty safe zone', (value) => (value.assets[0].safeZone = ''), /safeZone/i], diff --git a/packages/design-tokens/test/brand-derivatives.test.mjs b/packages/design-tokens/test/brand-derivatives.test.mjs index 40cbcc9b..c7497931 100644 --- a/packages/design-tokens/test/brand-derivatives.test.mjs +++ b/packages/design-tokens/test/brand-derivatives.test.mjs @@ -125,6 +125,29 @@ test('committed derivatives reproduce byte-for-byte and a changed output is dete } }); +test('--check validates the output and manifest paths supplied by the caller', async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), 'databreeze-brand-custom-check-')); + const outputDirectory = join(temporaryRoot, 'generated'); + const manifestPath = join(temporaryRoot, 'derivatives.json'); + + try { + await runGenerator(['--output', outputDirectory, '--manifest', manifestPath]); + await runGenerator(['--check', '--output', outputDirectory, '--manifest', manifestPath]); + + const faviconPath = join(outputDirectory, 'web', 'favicon-16.png'); + const changed = Buffer.from(await readFile(faviconPath)); + changed[changed.length - 1] ^= 0xff; + await import('node:fs/promises').then(({ writeFile }) => writeFile(faviconPath, changed)); + + await assert.rejects( + runGenerator(['--check', '--output', outputDirectory, '--manifest', manifestPath]), + /Brand derivative drift detected: web\/favicon-16\.png/, + ); + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } +}); + test('the derivative manifest links every output to an approved source and records safe fitted geometry', async () => { const [manifest, sourceManifest] = await Promise.all([ readFile(committedManifestPath, 'utf8').then(JSON.parse), diff --git a/packages/design-tokens/test/brand-platform-assets.test.mjs b/packages/design-tokens/test/brand-platform-assets.test.mjs index d1f2fc03..f5665bc9 100644 --- a/packages/design-tokens/test/brand-platform-assets.test.mjs +++ b/packages/design-tokens/test/brand-platform-assets.test.mjs @@ -27,6 +27,9 @@ function visibleBounds(data, width, height) { bounds.maxY = Math.max(bounds.maxY, y); } } + if (bounds.maxX < 0 || bounds.maxY < 0) { + throw new Error('Image must contain visible pixels'); + } return bounds; } @@ -66,6 +69,10 @@ function contrast(left, right) { return (values[0] + 0.05) / (values[1] + 0.05); } +test('visible bounds reject an image with no visible pixels', () => { + assert.throws(() => visibleBounds(Buffer.alloc(4 * 4 * 4), 4, 4), /visible pixels/i); +}); + test('navigation wordmarks have documented transparent clear space on every edge', async () => { const [plan, manifest] = await Promise.all([ readFile(planPath, 'utf8').then(JSON.parse), From a99d63cf26887e99f15a60e823f72abfc37412b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 12:56:26 +0700 Subject: [PATCH 41/51] fix(tooling): preserve Gradle application arguments --- .../src/gradle-application-arguments.mjs | 12 ++++++ .../src/run-contract-parity.mjs | 9 ++-- .../test/contract-parity.test.mjs | 43 +++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 tools/fixture-validation/src/gradle-application-arguments.mjs diff --git a/tools/fixture-validation/src/gradle-application-arguments.mjs b/tools/fixture-validation/src/gradle-application-arguments.mjs new file mode 100644 index 00000000..88fbb89d --- /dev/null +++ b/tools/fixture-validation/src/gradle-application-arguments.mjs @@ -0,0 +1,12 @@ +export function quoteGradleApplicationArgument(value) { + if (value.length === 0) return '""'; + + return [...value] + .map((character) => { + if (character === '"') return `'"'`; + if (character === "'") return `"'"`; + if (/\s/u.test(character)) return `"${character}"`; + return character; + }) + .join(''); +} diff --git a/tools/fixture-validation/src/run-contract-parity.mjs b/tools/fixture-validation/src/run-contract-parity.mjs index ed9eba71..0d856bc5 100644 --- a/tools/fixture-validation/src/run-contract-parity.mjs +++ b/tools/fixture-validation/src/run-contract-parity.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import { compareContractResults } from './compare-contract-results.mjs'; +import { quoteGradleApplicationArgument } from './gradle-application-arguments.mjs'; const toolRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const repositoryRoot = resolve(toolRoot, '../..'); @@ -47,10 +48,6 @@ function runCommand(command, argumentsList, options = {}) { return run; } -function quoteApplicationArgument(value) { - return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; -} - function runTypeScript(fixtureManifest, output) { const typeScriptCompiler = require.resolve('typescript/bin/tsc'); runCommand(process.execPath, [ @@ -110,9 +107,9 @@ function runKotlin(fixtureManifest, output) { const wrapperJar = resolve(kotlinRoot, 'gradle/wrapper/gradle-wrapper.jar'); const applicationArguments = [ '--fixture-manifest', - quoteApplicationArgument(fixtureManifest), + quoteGradleApplicationArgument(fixtureManifest), '--output', - quoteApplicationArgument(output), + quoteGradleApplicationArgument(output), ].join(' '); runCommand( javaCommand, diff --git a/tools/fixture-validation/test/contract-parity.test.mjs b/tools/fixture-validation/test/contract-parity.test.mjs index d2db53f9..28625d79 100644 --- a/tools/fixture-validation/test/contract-parity.test.mjs +++ b/tools/fixture-validation/test/contract-parity.test.mjs @@ -8,6 +8,8 @@ import { createRequire } from 'node:module'; import { spawnSync } from 'node:child_process'; import test from 'node:test'; +import { quoteGradleApplicationArgument } from '../src/gradle-application-arguments.mjs'; + const toolRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const repositoryRoot = resolve(toolRoot, '../..'); const comparatorPath = resolve(toolRoot, 'src/compare-contract-results.mjs'); @@ -19,6 +21,47 @@ const fixtureManifestPath = resolve( const generatedContractsRoot = resolve(repositoryRoot, 'packages/contracts/generated'); const require = createRequire(import.meta.url); +function splitLikeGradle(value) { + const argumentsList = []; + let current = ''; + let hasArgument = false; + let quote; + for (const character of value) { + if (quote === undefined && /\s/u.test(character)) { + if (hasArgument) { + argumentsList.push(current); + current = ''; + hasArgument = false; + } + } else if (quote === undefined && (character === '"' || character === "'")) { + quote = character; + hasArgument = true; + } else if (character === quote) { + quote = undefined; + } else { + current += character; + hasArgument = true; + } + } + if (hasArgument) argumentsList.push(current); + return argumentsList; +} + +test('Gradle application arguments preserve whitespace, slashes, and embedded quotes', () => { + const expected = [ + '', + 'plain', + 'with spaces', + 'double"quote', + "single'quote", + `both"'quotes`, + 'C:\\folder with spaces\\fixture.json', + ]; + const encoded = expected.map(quoteGradleApplicationArgument).join(' '); + + assert.deepEqual(splitLikeGradle(encoded), expected); +}); + function snapshotDirectory(root, directory = root) { return readdirSync(directory, { withFileTypes: true }) .flatMap((entry) => { From 2cc06aa2247691ec860844a83d748b417c79fdb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 13:02:43 +0700 Subject: [PATCH 42/51] fix(deps): update Jackson Kotlin runtime --- .../fixture-validation/kotlin/build.gradle.kts | 2 +- tools/fixture-validation/kotlin/gradle.lockfile | 12 ++++++------ .../test/contract-parity.test.mjs | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/tools/fixture-validation/kotlin/build.gradle.kts b/tools/fixture-validation/kotlin/build.gradle.kts index 8dd13f41..1c9e9409 100644 --- a/tools/fixture-validation/kotlin/build.gradle.kts +++ b/tools/fixture-validation/kotlin/build.gradle.kts @@ -9,7 +9,7 @@ group = "com.databreeze.fixturevalidation" version = "1.0.0" dependencies { - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.21.0") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.21.5") implementation("com.networknt:json-schema-validator:2.0.4") runtimeOnly("org.slf4j:slf4j-nop:2.0.17") } diff --git a/tools/fixture-validation/kotlin/gradle.lockfile b/tools/fixture-validation/kotlin/gradle.lockfile index df54c56c..e5e256b6 100644 --- a/tools/fixture-validation/kotlin/gradle.lockfile +++ b/tools/fixture-validation/kotlin/gradle.lockfile @@ -3,11 +3,11 @@ # This file is expected to be part of source control. com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath -com.fasterxml.jackson.core:jackson-core:2.21.0=compileClasspath,runtimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.21.0=compileClasspath,runtimeClasspath -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.0=compileClasspath,runtimeClasspath -com.fasterxml.jackson.module:jackson-module-kotlin:2.21.0=compileClasspath,runtimeClasspath -com.fasterxml.jackson:jackson-bom:2.21.0=compileClasspath,runtimeClasspath +com.fasterxml.jackson.core:jackson-core:2.21.5=compileClasspath,runtimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.21.5=compileClasspath,runtimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.5=compileClasspath,runtimeClasspath +com.fasterxml.jackson.module:jackson-module-kotlin:2.21.5=compileClasspath,runtimeClasspath +com.fasterxml.jackson:jackson-bom:2.21.5=compileClasspath,runtimeClasspath com.networknt:json-schema-validator:2.0.4=compileClasspath,runtimeClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.2.20=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-build-tools-impl:2.2.20=kotlinBuildToolsApiClasspath @@ -27,5 +27,5 @@ org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClass org.jetbrains:annotations:13.0=compileClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,runtimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath org.slf4j:slf4j-nop:2.0.17=runtimeClasspath -org.yaml:snakeyaml:2.4=compileClasspath,runtimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath empty=kotlinScriptDefExtensions diff --git a/tools/fixture-validation/test/contract-parity.test.mjs b/tools/fixture-validation/test/contract-parity.test.mjs index 28625d79..0a1aee09 100644 --- a/tools/fixture-validation/test/contract-parity.test.mjs +++ b/tools/fixture-validation/test/contract-parity.test.mjs @@ -62,6 +62,23 @@ test('Gradle application arguments preserve whitespace, slashes, and embedded qu assert.deepEqual(splitLikeGradle(encoded), expected); }); +test('the Kotlin validator pins the patched Jackson 2.21 line consistently', () => { + const kotlinRoot = resolve(toolRoot, 'kotlin'); + const build = readFileSync(resolve(kotlinRoot, 'build.gradle.kts'), 'utf8'); + const lock = readFileSync(resolve(kotlinRoot, 'gradle.lockfile'), 'utf8'); + + assert.match(build, /jackson-module-kotlin:2\.21\.5/u); + for (const artifact of [ + 'jackson-core', + 'jackson-databind', + 'jackson-dataformat-yaml', + 'jackson-module-kotlin', + 'jackson-bom', + ]) { + assert.match(lock, new RegExp(`${artifact}:2\\.21\\.5(?:=|\\n)`, 'u')); + } +}); + function snapshotDirectory(root, directory = root) { return readdirSync(directory, { withFileTypes: true }) .flatMap((entry) => { From 8aad0069728a382a3187bc4fb5d091a798e95f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 13:10:20 +0700 Subject: [PATCH 43/51] test(brand): declare transparent fixture buffer --- packages/design-tokens/test/brand-platform-assets.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/design-tokens/test/brand-platform-assets.test.mjs b/packages/design-tokens/test/brand-platform-assets.test.mjs index f5665bc9..9fb22d25 100644 --- a/packages/design-tokens/test/brand-platform-assets.test.mjs +++ b/packages/design-tokens/test/brand-platform-assets.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { test } from 'node:test'; From 53ca3ee730b0de25301f697d26c501d49e29c792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 12:52:32 +0700 Subject: [PATCH 44/51] fix(toolchain): harden pnpm runtime policy --- .npmrc | 3 --- .tool-versions | 2 +- package.json | 11 +++++++++-- pnpm-workspace.yaml | 3 +++ tools/repo-cli/test/workspace-runtime-policy.test.mjs | 10 +++++++++- 5 files changed, 22 insertions(+), 7 deletions(-) delete mode 100644 .npmrc diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 79390353..00000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -engine-strict=true -manage-package-manager-versions=true -use-node-version=24.17.0 diff --git a/.tool-versions b/.tool-versions index c2506c54..1576427e 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,5 +1,5 @@ nodejs 24.17.0 -pnpm 11.9.0 +pnpm 11.19.0 python 3.13.0 java temurin-21 postgres 17 diff --git a/package.json b/package.json index aa4611c5..86af3753 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,17 @@ "name": "@databreeze/platform", "version": "0.0.0", "private": true, - "packageManager": "pnpm@11.9.0", + "packageManager": "pnpm@11.19.0", "engines": { "node": "24.17.0", - "pnpm": "11.9.0" + "pnpm": "11.19.0" + }, + "devEngines": { + "runtime": { + "name": "node", + "version": "24.17.0", + "onFail": "error" + } }, "scripts": { "build": "turbo run build", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8f0e3467..e97158ff 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,6 @@ packages: - 'packages/*' - 'services/api' - 'tools/*' + +engineStrict: true +pmOnFail: download diff --git a/tools/repo-cli/test/workspace-runtime-policy.test.mjs b/tools/repo-cli/test/workspace-runtime-policy.test.mjs index be9dc857..01e8f1a8 100644 --- a/tools/repo-cli/test/workspace-runtime-policy.test.mjs +++ b/tools/repo-cli/test/workspace-runtime-policy.test.mjs @@ -9,7 +9,7 @@ const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)) const expectedRuntimeVersions = { nodejs: '24.17.0', - pnpm: '11.9.0', + pnpm: '11.19.0', python: '3.13.0', java: 'temurin-21', postgres: '17', @@ -61,6 +61,14 @@ test('discovers the root workspace and enforces the repository runtime policy', ); assert.equal(run('corepack', ['pnpm', '--version']), expectedRuntimeVersions.pnpm); assert.equal(packageManifest.packageManager, `pnpm@${expectedRuntimeVersions.pnpm}`); + assert.deepEqual(packageManifest.devEngines?.runtime, { + name: 'node', + version: expectedRuntimeVersions.nodejs, + onFail: 'error', + }); + assert.equal(run('corepack', ['pnpm', 'config', 'get', 'engineStrict']), 'true'); + assert.equal(run('corepack', ['pnpm', 'config', 'get', 'pmOnFail']), 'download'); + assert.equal(existsSync(path.join(repositoryRoot, '.npmrc')), false); assert.equal( readFileSync(path.join(repositoryRoot, '.node-version'), 'utf8').trim(), expectedRuntimeVersions.nodejs, From f798f34dbe3d1f6c619a67585509bae1cd8c61e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 12:53:38 +0700 Subject: [PATCH 45/51] fix(config): export emitted runtime declarations --- packages/config/package.json | 2 +- packages/config/test/built-public-api-smoke.mjs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/config/package.json b/packages/config/package.json index cc4ec514..b3483533 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -5,7 +5,7 @@ "type": "module", "exports": { "./runtime/v1": { - "types": "./src/runtime-config/v1.ts", + "types": "./dist/runtime-config/v1.d.ts", "import": "./dist/runtime-config/v1.js" } }, diff --git a/packages/config/test/built-public-api-smoke.mjs b/packages/config/test/built-public-api-smoke.mjs index 7f722107..6c101f4a 100644 --- a/packages/config/test/built-public-api-smoke.mjs +++ b/packages/config/test/built-public-api-smoke.mjs @@ -1,4 +1,14 @@ import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const manifest = JSON.parse(readFileSync(path.join(packageDirectory, 'package.json'), 'utf8')); +const typesTarget = manifest.exports['./runtime/v1'].types; + +assert.equal(typesTarget, './dist/runtime-config/v1.d.ts'); +assert.equal(existsSync(path.resolve(packageDirectory, typesTarget)), true); const runtime = await import('../dist/runtime-config/v1.js'); From 5ced52d45fff7bd0c1a59df91d6cb589b5691426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 12:57:12 +0700 Subject: [PATCH 46/51] fix(contracts): make runtime probes portable --- packages/contracts/package.json | 2 +- packages/contracts/test/generation.test.mjs | 7 +++++-- .../test/python-format-runtime-probe.mjs | 7 +++++-- .../contracts/test/test-runtime-tools.mjs | 15 ++++++++++++++ .../test/test-runtime-tools.test.mjs | 20 +++++++++++++++++++ tools/fixture-validation/package.json | 2 +- 6 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 packages/contracts/test/test-runtime-tools.mjs create mode 100644 packages/contracts/test/test-runtime-tools.test.mjs diff --git a/packages/contracts/package.json b/packages/contracts/package.json index dbf1139a..2abb1818 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -28,7 +28,7 @@ "fixtures:check": "node ../../tools/fixture-validation/src/run-contract-parity.mjs", "generate": "node scripts/generate-models.mjs", "generate:check": "node scripts/generate-models.mjs --check", - "test": "node --test test/**/*.test.mjs", + "test": "node --test \"test/**/*.test.mjs\"", "test:python-formats": "node test/python-format-runtime-probe.mjs" }, "dependencies": { diff --git a/packages/contracts/test/generation.test.mjs b/packages/contracts/test/generation.test.mjs index 8271aee6..8d746a8c 100644 --- a/packages/contracts/test/generation.test.mjs +++ b/packages/contracts/test/generation.test.mjs @@ -18,10 +18,13 @@ import test from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; +import { resolvePythonInterpreter } from './test-runtime-tools.mjs'; + const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const generatorPath = resolve(packageRoot, 'scripts/generate-models.mjs'); const fixtureRoot = resolve(packageRoot, 'test/fixtures/generator'); const generatedRoot = resolve(packageRoot, 'generated'); +const pythonInterpreter = resolvePythonInterpreter(); const expectedFiles = [ 'kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt', 'kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt', @@ -74,7 +77,7 @@ function withTemporaryDirectory(run) { function runPythonValidationProgram(program) { const source = resolve(generatedRoot, 'python/databreeze_contracts/v1/_validation.py'); assert.equal(existsSync(source), true, 'generated Python validation helpers are missing'); - return spawnSync('python', ['-c', program, source], { + return spawnSync(pythonInterpreter, ['-c', program, source], { cwd: packageRoot, encoding: 'utf8', }); @@ -264,7 +267,7 @@ test('the checked-in Python package compiles with the available interpreter', () ' source = Path(name).read_text(encoding="utf-8")', ' compile(source, name, "exec")', ].join('\n'); - const result = spawnSync('python', ['-c', program, ...files], { + const result = spawnSync(pythonInterpreter, ['-c', program, ...files], { cwd: packageRoot, encoding: 'utf8', }); diff --git a/packages/contracts/test/python-format-runtime-probe.mjs b/packages/contracts/test/python-format-runtime-probe.mjs index 7d5d08e0..0ee6619d 100644 --- a/packages/contracts/test/python-format-runtime-probe.mjs +++ b/packages/contracts/test/python-format-runtime-probe.mjs @@ -7,8 +7,11 @@ import { spawnSync } from 'node:child_process'; import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; +import { resolvePythonInterpreter } from './test-runtime-tools.mjs'; + const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const generatedPython = resolve(packageRoot, 'generated/python'); +const pythonInterpreter = resolvePythonInterpreter(); const pyproject = readFileSync(resolve(generatedPython, 'pyproject.toml'), 'utf8'); const dependenciesSection = /dependencies\s*=\s*\[([\s\S]*?)\]/u.exec(pyproject); assert.ok(dependenciesSection, 'generated pyproject.toml must declare project dependencies'); @@ -71,7 +74,7 @@ const temporaryRoot = mkdtempSync(resolve(tmpdir(), 'databreeze-python-formats-' try { const dependenciesRoot = resolve(temporaryRoot, 'site-packages'); const install = spawnSync( - 'python', + pythonInterpreter, [ '-m', 'pip', @@ -88,7 +91,7 @@ try { const validationPath = resolve(generatedPython, 'databreeze_contracts/v1/_validation.py'); const probe = spawnSync( - 'python', + pythonInterpreter, ['-c', pythonProgram, dependenciesRoot, validationPath, JSON.stringify(cases)], { cwd: packageRoot, diff --git a/packages/contracts/test/test-runtime-tools.mjs b/packages/contracts/test/test-runtime-tools.mjs new file mode 100644 index 00000000..09a0a31a --- /dev/null +++ b/packages/contracts/test/test-runtime-tools.mjs @@ -0,0 +1,15 @@ +import { spawnSync } from 'node:child_process'; + +const defaultCandidates = + process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python']; + +export function resolvePythonInterpreter(candidates = defaultCandidates) { + for (const candidate of candidates) { + const result = spawnSync(candidate, ['--version'], { encoding: 'utf8' }); + if (result.status === 0 && /^Python 3\./u.test(`${result.stdout}${result.stderr}`)) { + return candidate; + } + } + + throw new Error(`Python 3 interpreter is required; tried: ${candidates.join(', ')}`); +} diff --git a/packages/contracts/test/test-runtime-tools.test.mjs b/packages/contracts/test/test-runtime-tools.test.mjs new file mode 100644 index 00000000..87f45c54 --- /dev/null +++ b/packages/contracts/test/test-runtime-tools.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +import { resolvePythonInterpreter } from './test-runtime-tools.mjs'; + +test('resolves an available Python 3 interpreter for contract probes', () => { + const interpreter = resolvePythonInterpreter(); + const result = spawnSync(interpreter, ['--version'], { encoding: 'utf8' }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(`${result.stdout}${result.stderr}`, /^Python 3\./u); +}); + +test('reports every attempted interpreter when Python 3 is unavailable', () => { + assert.throws( + () => resolvePythonInterpreter(['databreeze-missing-python-a', 'databreeze-missing-python-b']), + /Python 3 interpreter is required; tried: databreeze-missing-python-a, databreeze-missing-python-b/u, + ); +}); diff --git a/tools/fixture-validation/package.json b/tools/fixture-validation/package.json index 61ff3342..00c2335e 100644 --- a/tools/fixture-validation/package.json +++ b/tools/fixture-validation/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "parity": "node src/run-contract-parity.mjs", - "test": "node --test test/**/*.test.mjs" + "test": "node --test \"test/**/*.test.mjs\"" }, "dependencies": { "@databreeze/contracts": "workspace:*" From 9dc0762c6a5892e6bbaae029da1f9ac74949baf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 12:59:22 +0700 Subject: [PATCH 47/51] fix(contracts): reject flag-shaped option values --- .../scripts/contract-compatibility.mjs | 2 +- .../contracts/test/compatibility.test.mjs | 27 +++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/contracts/scripts/contract-compatibility.mjs b/packages/contracts/scripts/contract-compatibility.mjs index 8fe94fa6..f923c9da 100644 --- a/packages/contracts/scripts/contract-compatibility.mjs +++ b/packages/contracts/scripts/contract-compatibility.mjs @@ -411,7 +411,7 @@ function readArguments(argumentsList) { options.approved = true; } else if (argument === '--root' || argument === '--version') { const value = argumentsList[index + 1]; - if (!value) fail(`${argument} requires a value`); + if (!value || value.startsWith('--')) fail(`${argument} requires a value`); if (argument === '--root') options.root = resolve(value); else options.version = Number(value); index += 1; diff --git a/packages/contracts/test/compatibility.test.mjs b/packages/contracts/test/compatibility.test.mjs index 35054b20..a5321db7 100644 --- a/packages/contracts/test/compatibility.test.mjs +++ b/packages/contracts/test/compatibility.test.mjs @@ -18,11 +18,14 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const compatibilityScript = resolve(packageRoot, 'scripts/contract-compatibility.mjs'); function runCompatibility(root, command, extraArguments = []) { - return spawnSync( - process.execPath, - [compatibilityScript, command, '--root', root, ...extraArguments], - { cwd: packageRoot, encoding: 'utf8' }, - ); + return runCompatibilityArguments(command, '--root', root, ...extraArguments); +} + +function runCompatibilityArguments(...argumentsList) { + return spawnSync(process.execPath, [compatibilityScript, ...argumentsList], { + cwd: packageRoot, + encoding: 'utf8', + }); } function withPackageCopy(callback) { @@ -42,6 +45,20 @@ test('the checked-in published v1 compatibility baseline accepts unchanged contr assert.match(run.stdout, /Published contract compatibility baseline is unchanged/u); }); +test('compatibility options reject another flag where a value is required', () => { + for (const { argumentsList, option } of [ + { argumentsList: ['check', '--root', '--version', '2'], option: '--root' }, + { + argumentsList: ['update', '--root', packageRoot, '--version', '--approve-new-version'], + option: '--version', + }, + ]) { + const run = runCompatibilityArguments(...argumentsList); + assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`); + assert.match(run.stderr, new RegExp(`${option} requires a value`, 'u')); + } +}); + test('compatibility check rejects a missing published schema', () => { withPackageCopy((copyRoot) => { rmSync(resolve(copyRoot, 'schemas/v1/identifier.schema.json')); From e54c4e69be2bfce160609dfec1fc453eb1a78822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 12:59:29 +0700 Subject: [PATCH 48/51] docs(plans): clarify security severity gate --- docs/plans/000-platform-program.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/000-platform-program.md b/docs/plans/000-platform-program.md index 7cb51ef1..273d9f69 100644 --- a/docs/plans/000-platform-program.md +++ b/docs/plans/000-platform-program.md @@ -58,4 +58,4 @@ Child plans are written and approved before their product slice begins. Each nam - Vietnamese and English user-facing copy are complete for the delivered slice. - Relevant unit, integration, contract, end-to-end, security, accessibility, recovery, and performance tests pass. - Migrations, observability, operations, rollback, and release evidence are present. -- No critical or high security finding remains unresolved for a production release. +- No critical- or high-severity security finding remains unresolved for a production release. From 78166ac56bd0b6e8475682a9847ab67d8990bd1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 13:03:12 +0700 Subject: [PATCH 49/51] fix(toolchain): provision the pinned node runtime --- package.json | 2 +- pnpm-lock.yaml | 214 ++++++++++++++---- .../test/workspace-runtime-policy.test.mjs | 2 +- 3 files changed, 173 insertions(+), 45 deletions(-) diff --git a/package.json b/package.json index 86af3753..72e5d011 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "runtime": { "name": "node", "version": "24.17.0", - "onFail": "error" + "onFail": "download" } }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a749a58b..2293a949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,10 @@ importers: version: 9.36.0 eslint: specifier: 9.36.0 - version: 9.36.0 + version: 9.36.0(supports-color@7.2.0) + node: + specifier: runtime:24.17.0 + version: runtime:24.17.0 prettier: specifier: 3.6.2 version: 3.6.2 @@ -25,7 +28,7 @@ importers: version: 5.9.2 typescript-eslint: specifier: 8.43.0 - version: 8.43.0(eslint@9.36.0)(typescript@5.9.2) + version: 8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2) packages/config: dependencies: @@ -643,6 +646,127 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node@runtime:24.17.0: + resolution: + type: variations + variants: + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-Men8JJx0o6bf7KR1ginwA2IEWbQsN0n3xCPymZ0Jpyc= + type: binary + url: https://nodejs.org/download/release/v24.17.0/node-v24.17.0-aix-ppc64.tar.gz + targets: + - cpu: ppc64 + os: aix + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-T8MmajcC7rw5zDdmHPTuzureMH4kKrZOTXznlJGX4R8= + type: binary + url: https://nodejs.org/download/release/v24.17.0/node-v24.17.0-darwin-arm64.tar.gz + targets: + - cpu: arm64 + os: darwin + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-gNpVL+A3KQyxMOnepZD17ut6pFBjbwyJq0FBVRHB7Cc= + type: binary + url: https://nodejs.org/download/release/v24.17.0/node-v24.17.0-darwin-x64.tar.gz + targets: + - cpu: x64 + os: darwin + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-+qDVm6f+cEXJUO0JsZBXj7ju5z5DWGhtOPzJnKWMFIA= + type: binary + url: https://nodejs.org/download/release/v24.17.0/node-v24.17.0-linux-arm64.tar.gz + targets: + - cpu: arm64 + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-gE7UoaDvKNWSQIuErCqF6FirkSTa2TPhK0MjYJQRuAk= + type: binary + url: https://nodejs.org/download/release/v24.17.0/node-v24.17.0-linux-ppc64le.tar.gz + targets: + - cpu: ppc64le + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-plnpwm/NZI8zWdv9KS8HhDQWgEDy+xrPPJwbzT/Deys= + type: binary + url: https://nodejs.org/download/release/v24.17.0/node-v24.17.0-linux-s390x.tar.gz + targets: + - cpu: s390x + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-4EckJ6p5GtgL3EJv98xzzdKO0PYW0f+WiaI6f0fxJl8= + type: binary + url: https://nodejs.org/download/release/v24.17.0/node-v24.17.0-linux-x64.tar.gz + targets: + - cpu: x64 + os: linux + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-SVdxL2f85Vd5zHlNm0354OgCoYyEGtWk5C8XvkkOY00= + prefix: node-v24.17.0-win-arm64 + type: binary + url: https://nodejs.org/download/release/v24.17.0/node-v24.17.0-win-arm64.zip + targets: + - cpu: arm64 + os: win32 + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-8qozs1t1rKXz97hWdab2QjIBBT6TgZEeZJYfO9olKKs= + prefix: node-v24.17.0-win-x64 + type: binary + url: https://nodejs.org/download/release/v24.17.0/node-v24.17.0-win-x64.zip + targets: + - cpu: x64 + os: win32 + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-zFT3OhpBCNnjJesgHZCyXuq2DtySsqEyQLKHenTFlto= + type: binary + url: https://unofficial-builds.nodejs.org/download/release/v24.17.0/node-v24.17.0-linux-arm64-musl.tar.gz + targets: + - cpu: arm64 + os: linux + libc: musl + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-+ITJWMBlL3zQ6kP9w53d0amEVuqbGt7KhZZIR+Ljn7A= + type: binary + url: https://unofficial-builds.nodejs.org/download/release/v24.17.0/node-v24.17.0-linux-x64-musl.tar.gz + targets: + - cpu: x64 + os: linux + libc: musl + version: 24.17.0 + hasBin: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -818,17 +942,17 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@9.36.0)': + '@eslint-community/eslint-utils@4.10.1(eslint@9.36.0(supports-color@7.2.0))': dependencies: - eslint: 9.36.0 + eslint: 9.36.0(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.2(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -839,10 +963,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.6': + '@eslint/eslintrc@3.3.6(supports-color@7.2.0)': dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -1000,15 +1124,15 @@ snapshots: '@types/json-schema@7.0.15': {} - '@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0)(typescript@5.9.2)': + '@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2))(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.43.0(eslint@9.36.0)(typescript@5.9.2) + '@typescript-eslint/parser': 8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2) '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/type-utils': 8.43.0(eslint@9.36.0)(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.36.0)(typescript@5.9.2) + '@typescript-eslint/type-utils': 8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2) + '@typescript-eslint/utils': 8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.43.0 - eslint: 9.36.0 + eslint: 9.36.0(supports-color@7.2.0) graphemer: 1.4.0 ignore: 7.0.6 natural-compare: 1.4.0 @@ -1017,23 +1141,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.43.0(eslint@9.36.0)(typescript@5.9.2)': + '@typescript-eslint/parser@8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2)': dependencies: '@typescript-eslint/scope-manager': 8.43.0 '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) + '@typescript-eslint/typescript-estree': 8.43.0(supports-color@7.2.0)(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.43.0 - debug: 4.4.3 - eslint: 9.36.0 + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.36.0(supports-color@7.2.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.43.0(typescript@5.9.2)': + '@typescript-eslint/project-service@8.43.0(supports-color@7.2.0)(typescript@5.9.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2) '@typescript-eslint/types': 8.43.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -1047,13 +1171,13 @@ snapshots: dependencies: typescript: 5.9.2 - '@typescript-eslint/type-utils@8.43.0(eslint@9.36.0)(typescript@5.9.2)': + '@typescript-eslint/type-utils@8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2)': dependencies: '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.36.0)(typescript@5.9.2) - debug: 4.4.3 - eslint: 9.36.0 + '@typescript-eslint/typescript-estree': 8.43.0(supports-color@7.2.0)(typescript@5.9.2) + '@typescript-eslint/utils': 8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.36.0(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: @@ -1061,13 +1185,13 @@ snapshots: '@typescript-eslint/types@8.43.0': {} - '@typescript-eslint/typescript-estree@8.43.0(typescript@5.9.2)': + '@typescript-eslint/typescript-estree@8.43.0(supports-color@7.2.0)(typescript@5.9.2)': dependencies: - '@typescript-eslint/project-service': 8.43.0(typescript@5.9.2) + '@typescript-eslint/project-service': 8.43.0(supports-color@7.2.0)(typescript@5.9.2) '@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2) '@typescript-eslint/types': 8.43.0 '@typescript-eslint/visitor-keys': 8.43.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.9 @@ -1077,13 +1201,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.43.0(eslint@9.36.0)(typescript@5.9.2)': + '@typescript-eslint/utils@8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.36.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.36.0(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.43.0 '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - eslint: 9.36.0 + '@typescript-eslint/typescript-estree': 8.43.0(supports-color@7.2.0)(typescript@5.9.2) + eslint: 9.36.0(supports-color@7.2.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -1159,9 +1283,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 deep-is@0.1.4: {} @@ -1178,14 +1304,14 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.36.0: + eslint@9.36.0(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.36.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.36.0(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.2(supports-color@7.2.0) '@eslint/config-helpers': 0.3.1 '@eslint/core': 0.15.2 - '@eslint/eslintrc': 3.3.6 + '@eslint/eslintrc': 3.3.6(supports-color@7.2.0) '@eslint/js': 9.36.0 '@eslint/plugin-kit': 0.3.5 '@humanfs/node': 0.16.8 @@ -1196,7 +1322,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -1357,6 +1483,8 @@ snapshots: natural-compare@1.4.0: {} + node@runtime:24.17.0: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1490,13 +1618,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.43.0(eslint@9.36.0)(typescript@5.9.2): + typescript-eslint@8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0)(typescript@5.9.2) - '@typescript-eslint/parser': 8.43.0(eslint@9.36.0)(typescript@5.9.2) - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.36.0)(typescript@5.9.2) - eslint: 9.36.0 + '@typescript-eslint/eslint-plugin': 8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2))(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2) + '@typescript-eslint/parser': 8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2) + '@typescript-eslint/typescript-estree': 8.43.0(supports-color@7.2.0)(typescript@5.9.2) + '@typescript-eslint/utils': 8.43.0(eslint@9.36.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2) + eslint: 9.36.0(supports-color@7.2.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color diff --git a/tools/repo-cli/test/workspace-runtime-policy.test.mjs b/tools/repo-cli/test/workspace-runtime-policy.test.mjs index 01e8f1a8..b1e6998a 100644 --- a/tools/repo-cli/test/workspace-runtime-policy.test.mjs +++ b/tools/repo-cli/test/workspace-runtime-policy.test.mjs @@ -64,7 +64,7 @@ test('discovers the root workspace and enforces the repository runtime policy', assert.deepEqual(packageManifest.devEngines?.runtime, { name: 'node', version: expectedRuntimeVersions.nodejs, - onFail: 'error', + onFail: 'download', }); assert.equal(run('corepack', ['pnpm', 'config', 'get', 'engineStrict']), 'true'); assert.equal(run('corepack', ['pnpm', 'config', 'get', 'pmOnFail']), 'download'); From c44ee3a2ad53824f200683d887446a4a45e68f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 13:06:23 +0700 Subject: [PATCH 50/51] fix(toolchain): track migrated workspace config --- turbo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index 7e4c1b0a..7d370f57 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,6 @@ { "$schema": "https://turbo.build/schema.json", - "globalDependencies": [".node-version", ".npmrc", ".tool-versions"], + "globalDependencies": [".node-version", ".tool-versions", "pnpm-workspace.yaml"], "globalPassThroughEnv": ["DATABREEZE_JAVA", "DATABREEZE_UV", "JAVA_HOME"], "tasks": { "build": { From 504c1e4265feea3a73561be795c30bcb490fe5f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sat, 1 Aug 2026 13:17:05 +0700 Subject: [PATCH 51/51] fix(toolchain): pin stable pnpm release --- .tool-versions | 2 +- package.json | 4 ++-- tools/repo-cli/test/workspace-runtime-policy.test.mjs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.tool-versions b/.tool-versions index 1576427e..bd70f7ae 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,5 +1,5 @@ nodejs 24.17.0 -pnpm 11.19.0 +pnpm 11.18.0 python 3.13.0 java temurin-21 postgres 17 diff --git a/package.json b/package.json index 72e5d011..9353d58f 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,10 @@ "name": "@databreeze/platform", "version": "0.0.0", "private": true, - "packageManager": "pnpm@11.19.0", + "packageManager": "pnpm@11.18.0", "engines": { "node": "24.17.0", - "pnpm": "11.19.0" + "pnpm": "11.18.0" }, "devEngines": { "runtime": { diff --git a/tools/repo-cli/test/workspace-runtime-policy.test.mjs b/tools/repo-cli/test/workspace-runtime-policy.test.mjs index b1e6998a..cd965d3e 100644 --- a/tools/repo-cli/test/workspace-runtime-policy.test.mjs +++ b/tools/repo-cli/test/workspace-runtime-policy.test.mjs @@ -9,7 +9,7 @@ const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)) const expectedRuntimeVersions = { nodejs: '24.17.0', - pnpm: '11.19.0', + pnpm: '11.18.0', python: '3.13.0', java: 'temurin-21', postgres: '17',