diff --git a/.github/workflows/catalog-drift.yml b/.github/workflows/catalog-drift.yml new file mode 100644 index 00000000..a402669b --- /dev/null +++ b/.github/workflows/catalog-drift.yml @@ -0,0 +1,61 @@ +name: Catalog Drift Check + +on: + pull_request: + branches: [main] + paths: + - 'docs/feature-catalog.yaml' + - 'scripts/website-catalog/**' + - '.github/workflows/catalog-drift.yml' + push: + branches: [main] + paths: + - 'docs/feature-catalog.yaml' + - 'scripts/website-catalog/**' + # Drift can also be introduced from the website side, which changes nothing + # in this repository and so triggers none of the filters above. Check daily + # so an edited or reverted snapshot cannot sit undetected. + schedule: + - cron: '17 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + # Mint a read-only installation token scoped to the website repository + # only. The default GITHUB_TOKEN cannot read Studio-Saelix/sencho-website. + - name: Generate GitHub App installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: sencho-website + permission-contents: read + + # The root checkout must come first. actions/checkout cleans its + # destination, so running it after the nested one deletes website-checkout. + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/sencho-website + token: ${{ steps.app-token.outputs.token }} + path: website-checkout + - name: Install root dependencies + run: npm ci + - name: Canonical validation + run: node scripts/website-catalog/canonical-validate.mjs + # Compare the canonical catalog against the snapshot the website has + # actually committed. Do not run sync-feature-catalog here: it rewrites + # that snapshot from the canonical file, so the comparison would only + # ever read back what it just wrote and could never report drift. + - name: Compare against the committed website snapshot + run: node scripts/website-catalog/check-website-drift.mjs --website-dir website-checkout + - name: Verify no internal identifiers in canonical file + run: node scripts/website-catalog/test-catalog-no-leak.mjs diff --git a/docs/feature-catalog.yaml b/docs/feature-catalog.yaml new file mode 100644 index 00000000..068b63d5 --- /dev/null +++ b/docs/feature-catalog.yaml @@ -0,0 +1,374 @@ +# Canonical feature catalog — Sencho tier reconciliation +# Source of truth. No internal Linear IDs stored here. +# Internal planning provenance lives in Linear, not in committed files. +# Schema: id, name, tier (community|admiral|internal), availability (shipped|planned|internal), +# category, publicName, publicRoadmapKey, summary, description, limitation, +# featured (bool), homepageOrder (int when featured=true) +# Cross-field invariant: tier: internal iff availability: internal. + +version: '1' +schema: 'canonical-v1' + +entries: + # ===== Core Compose & Deployments (Community) ===== + - id: compose-editor + name: Compose editor and stack management + tier: community + availability: shipped + category: compose-deploy + publicName: Compose editor, templates, Git sources + publicRoadmapKey: compose-editor + summary: Manage, edit, and deploy Compose stacks from YAML or templates. + description: Monaco YAML editor with syntax validation, multi-file Compose from Git sources, drift detection for documentation, and ordered multi-file Compose flows. + limitation: '' + featured: true + homepageOrder: 1 + + - id: git-pull-preview + name: Git pull preview and apply flow + tier: community + availability: shipped + category: compose-deploy + publicName: Git pull preview and apply + publicRoadmapKey: git-pull-preview + summary: Preview and apply changes from Git source before deploying. + featured: false + + - id: atomic-deploy-rollback + name: Atomic deployments and rollback + tier: community + availability: shipped + category: compose-deploy + publicName: Atomic deploys + rollback + publicRoadmapKey: atomic-deploy-rollback + summary: Atomic updates with health-gated rollback support. + description: Standard and atomic deploy/update workflows, rollback support, health-gated updates, stalled update detection and recovery. + limitation: '' + featured: true + homepageOrder: 2 + + # ===== Fleet & Orchestration (Community) ===== + - id: multi-node-fleet + name: Multi-node fleet visibility + tier: community + availability: shipped + category: fleet-orchestration + publicName: Multi-node fleet visibility + publicRoadmapKey: multi-node-fleet + summary: Visibility into nodes, stacks, and containers across a fleet. + description: Multi-node support, proxy-connected nodes, pilot agent-connected nodes, node enrollment, node compatibility checks. + limitation: '' + featured: true + homepageOrder: 3 + + - id: fleet-sync-baseline + name: Fleet Sync (policy replication) + tier: community + availability: shipped + category: fleet-orchestration + publicName: Fleet Sync (policy replication) + publicRoadmapKey: fleet-sync-baseline + summary: Replicate security policies, suppressions, and acknowledgements across nodes. + limitation: 'Baseline Fleet Sync is Community; additional governance enforcement is Admiral-planned.' + featured: true + homepageOrder: 4 + + - id: blueprint-reconcile + name: Blueprints and drift reconciliation + tier: community + availability: shipped + category: fleet-orchestration + publicName: Blueprints + drift detection + reconciliation + publicRoadmapKey: blueprint-reconcile + summary: Declarative fleet state with drift detection, manual reconciliation, and label-based node targeting. + description: Blueprints, label-based targeting, drift detection, drift notification and correction, manual reconciliation with Apply Now, stateful deployment review. + limitation: '' + featured: false + + # ===== Security Foundations (Community) ===== + - id: rbac-five-role + name: Full built-in RBAC + tier: community + availability: shipped + category: security-foundation + publicName: Full built-in RBAC (Admin, Viewer, Deployer, Node Admin, Auditor) + publicRoadmapKey: rbac-five-role + summary: Five built-in roles with per-resource scoped assignments. + description: Admin, Viewer, Deployer, Node Admin, Auditor roles; per-resource scoped assignments; unlimited users. + limitation: '' + featured: true + homepageOrder: 5 + + - id: fleet-secrets + name: Fleet Secrets (encrypted env bundles) + tier: community + availability: shipped + category: security-foundation + publicName: Fleet Secrets (encrypted env bundles) + publicRoadmapKey: fleet-secrets + summary: Encrypted versioned environment bundles for fleet-wide secrets. + limitation: '' + featured: true + homepageOrder: 6 + + - id: deploy-enforcement + name: Deploy enforcement policies + tier: community + availability: shipped + category: security-foundation + publicName: Deploy enforcement policies (CVE gate) + publicRoadmapKey: deploy-enforcement + summary: Block deploys based on vulnerability scanning results. + featured: false + + # ===== Identity & Access (Community / Admiral split) ===== + - id: custom-oidc-ssenders + name: Custom OIDC and SSO presets + tier: community + availability: shipped + category: identity-access + publicName: Custom OIDC + Google/GitHub/Okta SSO + publicRoadmapKey: custom-oidc-sso + summary: Self-hosted identity with any OIDC IdP and one-click presets. + description: Custom OIDC SSO, Google, GitHub, Okta presets; 2FA/MFA; recovery codes. + limitation: '' + featured: true + homepageOrder: 7 + + - id: ldap-ad + name: LDAP / Active Directory + tier: admiral + availability: shipped + category: identity-access + publicName: LDAP / Active Directory + publicRoadmapKey: ldap-ad + summary: Enterprise identity integration for organizational assurance. + description: LDAP / Active Directory identity provider integration. + limitation: 'Requires Admiral (paid) license; handled by requireTierForSsoProvider in backend middleware.' + featured: true + homepageOrder: 8 + + # ===== Audit & Evidence (Community partial / Admiral full) ===== + - id: audit-log-14day + name: Recent activity log (14-day window) + tier: community + availability: shipped + category: identity-access + publicName: Recent audit window (14 days) + publicRoadmapKey: audit-log-14day + summary: Basic audit visibility for recent actions. + description: Basic audit log with 14-day retention; visible to Community users. + limitation: 'Full audit log with export, anomaly detection, and extended retention requires Admiral.' + featured: false + + - id: audit-log-full + name: Full audit log (export, anomaly detection, retention) + tier: admiral + availability: shipped + category: identity-access + publicName: Full audit log (export + anomaly detection) + publicRoadmapKey: audit-log-full + summary: Durable audit evidence with export, anomaly detection, and retention. + description: Full audit log with export, anomaly detection, retention policies; managed audit evidence. + limitation: 'Paid-only; requires Admiral license. Enforced by requirePaid in auditLog routes.' + featured: true + homepageOrder: 9 + + # ===== Security Scanning & Policy (Community core / Admiral planned) ===== + - id: scan-on-demand + name: On-demand vulnerability scanning + tier: community + availability: shipped + category: security-foundation + publicName: On-demand vulnerability scanning + publicRoadmapKey: scan-on-demand + summary: Scan stacks and nodes for CVEs with suppression and acknowledgment. + description: On-demand scanning, node-wide scanning, CVE suppressions, misconfiguration acknowledgements, deploy enforcement policies, SARIF export, scan policy packs. + limitation: '' + featured: true + homepageOrder: 10 + + - id: change-review-planned + name: Compose Change Review + tier: admiral + availability: planned + category: security-foundation + publicName: Change Review (planned) + publicRoadmapKey: change-review-planned + summary: Required approval workflow for Compose changes before deployment. + description: Governance layer requiring approvals before deploy. + limitation: 'Approved roadmap; not yet shipped. Explicit Planned label in all public surfaces.' + featured: true + homepageOrder: 11 + + # ===== Fleet Operations (Community core / Admiral planned) ===== + - id: fleet-actions-bulk + name: Fleet Actions and bulk operations + tier: community + availability: shipped + category: fleet-orchestration + publicName: Fleet Actions + bulk operations + publicRoadmapKey: fleet-actions-bulk + summary: Bulk stack lifecycle actions across nodes. + description: Bulk actions on stacks, labels, fleet-wide schedules. + limitation: '' + featured: true + homepageOrder: 12 + + - id: protected-stacks-planned + name: Protected Stacks / Nodes + tier: admiral + availability: planned + category: fleet-orchestration + publicName: Protected stacks / nodes (planned) + publicRoadmapKey: protected-stacks-planned + summary: Protected stacks and nodes with maintenance windows and break-glass. + description: Protected workflows, maintenance windows, freezes, break-glass. + limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.' + featured: true + homepageOrder: 13 + + - id: fleet-readiness-planned + name: Fleet Readiness Score + tier: admiral + availability: planned + category: fleet-orchestration + publicName: Fleet Readiness Score (planned) + publicRoadmapKey: fleet-readiness-planned + summary: Organizational readiness score for fleet health and compliance. + limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.' + featured: false + + # ===== Governance & Policy (Community core / Admiral planned) ===== + - id: api-tokens + name: API tokens + tier: community + availability: shipped + category: identity-access + publicName: API tokens for CI/CD + publicRoadmapKey: api-tokens + summary: Long-lived machine credentials for automation. + limitation: '' + featured: true + homepageOrder: 14 + + - id: policy-pack-planned + name: Policy Pack Assignment + tier: admiral + availability: planned + category: governance + publicName: Policy pack assignment (planned) + publicRoadmapKey: policy-pack-planned + summary: Organization-wide policy pack enforcement with governed exceptions. + limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.' + featured: false + + # ===== Recovery (Community basic / Admiral managed) ===== + - id: manual-snapshots + name: Manual fleet snapshots + tier: community + availability: shipped + category: recovery + publicName: Manual fleet snapshots + publicRoadmapKey: manual-snapshots + summary: User-initiated fleet-wide backups. + limitation: '' + featured: false + + - id: recovery-vault + name: Recovery Vault (managed off-site snapshots) + tier: admiral + availability: shipped + category: recovery + publicName: Recovery Vault (managed off-site) + publicRoadmapKey: recovery-vault + summary: Managed recovery service with durable off-site storage. + description: Recovery vault with managed storage, verification, monitoring, retention, and restore-readiness evidence. + limitation: 'Requires Admiral; managed continuity pillar.' + featured: true + homepageOrder: 15 + + # ===== Business Assurance (Admiral only) ===== + - id: hardened-build + name: Hardened Build image channel + tier: admiral + availability: shipped + category: assurance + publicName: Hardened Build image channel + publicRoadmapKey: hardened-build + summary: Supported release channel with defined supply chain and support commitment. + limitation: 'Admiral entitlement; never a headline reason to purchase.' + featured: true + homepageOrder: 16 + + - id: priority-support + name: Priority Studio Saelix support + tier: admiral + availability: shipped + category: assurance + publicName: Priority email support + publicRoadmapKey: priority-support + summary: Accountable support commitment for Admiral customers. + limitation: 'Requires Admiral.' + featured: false + + - id: incident-timeline-planned + name: Incident Timeline + tier: admiral + availability: planned + category: assurance + publicName: Incident Timeline (planned) + publicRoadmapKey: incident-timeline-planned + summary: Durable incident timeline and service context. + limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.' + featured: false + + # ===== Registration & Registry (Community core / Admiral ECR temporary) ===== + - id: docker-hub-ghcr-custom + name: Local registry credentials (Docker Hub, GHCR, custom) + tier: community + availability: shipped + category: security-foundation + publicName: Local registry credentials + publicRoadmapKey: docker-hub-ghcr-custom + summary: Store and manage registry authentication. + limitation: '' + featured: false + + - id: ecr-admiral-temporary + name: AWS ECR registry credentials + tier: admiral + availability: shipped + category: security-foundation + publicName: AWS ECR registry credentials (temporary availability) + publicRoadmapKey: ecr-admiral-temporary + summary: AWS Elastic Container Registry authentication. + description: Temporary Admiral access to AWS ECR registry credentials; not positioned as core value. + limitation: 'Temporary availability; must not be positioned as core Admiral value in public copy.' + featured: false + + # ===== Internal / Experimental (not public) ===== + - id: mesh-routing + name: Mesh / Routing + tier: internal + availability: internal + category: internal + publicName: Mesh / Routing (internal) + publicRoadmapKey: mesh-routing + summary: '' + description: Internal experimental feature; not public tier; separate graduation decision required. + limitation: 'Internal; excluded from all public matrices. Must remain hidden until graduation decision lands.' + featured: false + homepageOrder: 99 + - id: experimental-flag + name: SENCHO_EXPERIMENTAL + tier: internal + availability: internal + category: internal + publicName: 'SENCHO_EXPERIMENTAL (internal discovery gate)' + publicRoadmapKey: experimental-flag + summary: Internal feature discovery gate; not a tier. + description: Feature flag controlling internal feature visibility; must not be used as user-facing marketing. + limitation: 'Not a public tier; never advertised in public comparison or docs.' + featured: false + homepageOrder: 100 diff --git a/package.json b/package.json index dd417894..6c0d7ec8 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "test": "cd backend && npm test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "catalog:validate": "node scripts/website-catalog/canonical-validate.mjs", + "catalog:sync": "node scripts/website-catalog/sync-feature-catalog.mjs", + "catalog:drift": "node scripts/website-catalog/check-website-drift.mjs", "prepare": "husky" }, "repository": { @@ -29,6 +32,7 @@ "@commitlint/config-conventional": "^21.0.2", "@playwright/test": "^1.62.1", "husky": "^9.1.7", + "js-yaml": "^4.3.1", "otplib": "^13.4.1" } } diff --git a/scripts/website-catalog/canonical-validate.mjs b/scripts/website-catalog/canonical-validate.mjs new file mode 100644 index 00000000..e2d0e968 --- /dev/null +++ b/scripts/website-catalog/canonical-validate.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Sencho tier-reconciliation: canonical catalog validator + * Verifies docs/feature-catalog.yaml against the canonical schema and + * the required cross-field invariant (tier: internal iff availability: internal). + * Exits 0 on valid, exits 1 with diagnostic lines on invalid. + */ +import yaml from 'js-yaml'; +import fs from 'fs'; +import path from 'path'; + +import { fileURLToPath } from 'url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CATALOG_FILE = path.resolve(__dirname, '../../docs/feature-catalog.yaml'); + +const VALID_TIERS = new Set(['community', 'admiral', 'internal']); +const VALID_AVAILABILITY = new Set(['shipped', 'planned', 'internal']); +const VALID_CATEGORIES = new Set([ + 'compose-deploy', 'fleet-orchestration', 'security-foundation', + 'automation-operations', 'recovery', 'identity-access', + 'governance', 'assurance', 'internal', +]); + +const errors = []; + +function error(msg) { errors.push('ERROR: ' + msg); } + +function readCatalog() { + const text = fs.readFileSync(CATALOG_FILE, 'utf8'); + const doc = yaml.load(text, { schema: yaml.CORE_SCHEMA }); + if (!doc || typeof doc !== 'object') { + throw new Error('catalog is not a YAML mapping'); + } + return doc; +} + +function main() { + const doc = readCatalog(); + if (!Array.isArray(doc.entries)) { + console.error('FAIL: catalog has no entries array.'); + process.exit(1); + } + const entries = doc.entries; + const ids = new Set(); + + for (const [i, entry] of entries.entries()) { + const prefix = `entry[${i}].id=${entry?.id ?? '(missing)'}`; + + if (!entry || typeof entry !== 'object') { + error(`${prefix}: entry is not an object`); + continue; + } + + if (!entry.id) error(`${prefix}: missing id`); + else if (ids.has(entry.id)) error(`${prefix}: duplicate id "${entry.id}"`); + else ids.add(entry.id); + + if (!entry.name) error(`${prefix}: missing name`); + if (!VALID_TIERS.has(entry.tier)) + error(`${prefix}: invalid tier "${entry.tier}"; must be one of community/admiral/internal`); + if (!VALID_AVAILABILITY.has(entry.availability)) + error(`${prefix}: invalid availability "${entry.availability}"; must be one of shipped/planned/internal`); + + // Cross-field invariant: tier: internal iff availability: internal + if (entry.tier === 'internal' && entry.availability !== 'internal') + error(`${prefix}: tier: internal requires availability: internal`); + if (entry.tier !== 'internal' && entry.availability === 'internal') + error(`${prefix}: non-internal tier requires non-internal availability`); + + if (entry.availability === 'planned') { + if (!entry.publicRoadmapKey) error(`${prefix}: planned entry must have publicRoadmapKey`); + } + + if (!VALID_CATEGORIES.has(entry.category)) + error(`${prefix}: unknown category "${entry.category}"`); + + // Internal-only fields must not leak into committed catalog. + // The canonical file IS public, so we enforce: no linear, no evidence with internal identifiers, + // no internalNote. Public-name fields only. + if (entry.linear) { + // Reject any internal Linear identifier in committed file. + if (/SEN-[0-9]/.test(String(entry.linear))) + error(`${prefix}: committed catalog contains internal Linear identifier in linear field ("${entry.linear}"); use publicRoadmapKey instead`); + } + // No evidence field allowed in canonical committed file (evidence stays internal). + if (entry.evidence) + error(`${prefix}: evidence field must not appear in committed canonical catalog (use Linear/non-public record for evidence); got: ${entry.evidence}`); + if (entry.internalNote) + error(`${prefix}: internalNote field must not appear in committed canonical catalog`); + } + + if (errors.length === 0) { + console.log(`VALID: catalog has ${entries.length} entries; all invariants pass.`); + process.exit(0); + } else { + for (const msg of errors) console.error(msg); + console.error(`FAIL: ${errors.length} error(s) found.`); + process.exit(1); + } +} + +try { + main(); +} catch (e) { + console.error('FAIL: ' + (e.message || e)); + process.exit(1); +} diff --git a/scripts/website-catalog/check-website-drift.mjs b/scripts/website-catalog/check-website-drift.mjs new file mode 100644 index 00000000..0adb84b6 --- /dev/null +++ b/scripts/website-catalog/check-website-drift.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +/** + * Sencho tier-reconciliation: website drift detector + * Compares the current canonical catalog to the website's committed + * catalog-snapshot. Drift identity is the normalized content checksum. + * Commit SHA is NOT part of drift identity. + * + * Usage: + * node scripts/website-catalog/check-website-drift.mjs --website-dir + * node scripts/website-catalog/check-website-drift.mjs --website-ref + */ +import yaml from 'js-yaml'; +import fs from 'fs'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import os from 'os'; +import { createHash } from 'crypto'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CANONICAL_FILE = path.resolve(__dirname, '../../docs/feature-catalog.yaml'); +const PUBLIC_FIELDS = new Set([ + 'id', 'publicName', 'summary', 'description', 'category', + 'tier', 'availability', 'featured', 'homepageOrder', 'publicRoadmapKey', +]); + +function normalizeYaml(obj) { + return yaml.dump(obj, { sortKeys: true, lineWidth: -1 }); +} + +function computeChecksum(content) { + return createHash('sha256').update(content).digest('hex'); +} + +function loadSnapshot(websiteDir) { + const snapshotPath = path.join(websiteDir, 'src/data/catalog-snapshot.yaml'); + if (!fs.existsSync(snapshotPath)) { + return null; + } + const text = fs.readFileSync(snapshotPath, 'utf8'); + const doc = yaml.load(text, { schema: yaml.CORE_SCHEMA }); + if (!doc || !Array.isArray(doc.entries)) return null; + return { doc, text, snapshotPath }; +} + +function buildProjection(canonical) { + return canonical.entries + .filter((e) => e.tier !== 'internal' && e.availability !== 'internal') + .map((e) => { + const pub = {}; + for (const key of PUBLIC_FIELDS) { + if (key in e) pub[key] = e[key]; + } + return pub; + }); +} + +function main() { + const args = process.argv.slice(2); + let websiteDir = null; + let websiteRef = null; + let cleanup = null; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--website-dir' && args[i + 1]) { + websiteDir = args[i + 1]; i++; + } else if (args[i] === '--website-ref' && args[i + 1]) { + websiteRef = args[i + 1]; i++; + } + } + + if (websiteRef && !websiteDir) { + // Clone to temp dir + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-website-')); + execFileSync('git', ['clone', '--depth', '1', '--branch', websiteRef, + 'https://github.com/Studio-Saelix/sencho-website.git', tmp], + { stdio: 'pipe' }); + websiteDir = tmp; + cleanup = tmp; + } + + if (!websiteDir) { + console.error('ERROR: --website-dir or --website-ref is required'); + process.exit(1); + } + + // Read canonical catalog. + const canonicalText = fs.readFileSync(CANONICAL_FILE, 'utf8'); + const canonical = yaml.load(canonicalText, { schema: yaml.CORE_SCHEMA }); + const projection = buildProjection(canonical); + const projectionText = normalizeYaml({ entries: projection }); + const currentChecksum = computeChecksum(projectionText); + + // Read committed snapshot. + const snap = loadSnapshot(websiteDir); + if (!snap) { + console.error(`FAIL: no catalog-snapshot.yaml at ${path.join(websiteDir, 'src/data/')}`); + if (cleanup) fs.rmSync(cleanup, { recursive: true, force: true }); + process.exit(1); + } + + const metaPath = path.join(websiteDir, 'src/data/catalog-snapshot.meta.json'); + let committedChecksum = null; + if (fs.existsSync(metaPath)) { + try { + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + committedChecksum = meta.checksum; + } catch { /* ignore */ } + } + + // Compute checksum of committed snapshot text (the actual file content). + const snapChecksum = computeChecksum(snap.text); + + if (cleanup) fs.rmSync(cleanup, { recursive: true, force: true }); + + // The snapshot file must match the canonical projection, and the metadata + // must describe that same file. Trusting the metadata alone would let a + // hand-edited or stale snapshot pass beside a freshly written meta.json. + if (currentChecksum === committedChecksum && snapChecksum === committedChecksum) { + console.log(`OK: no drift. checksum=${currentChecksum.slice(0, 12)}...`); + process.exit(0); + } else { + console.error('DRIFT DETECTED:'); + console.error(` current canonical checksum: ${currentChecksum}`); + console.error(` committed snapshot checksum: ${committedChecksum ?? '(none)'}`); + console.error(` committed file checksum: ${snapChecksum}`); + console.error(''); + console.error('Regenerate the website snapshot and commit src/data/ in the website repo:'); + console.error(' npm run catalog:sync -- --website-dir '); + process.exit(1); + } +} + +main(); diff --git a/scripts/website-catalog/sync-feature-catalog.mjs b/scripts/website-catalog/sync-feature-catalog.mjs new file mode 100644 index 00000000..4f036b07 --- /dev/null +++ b/scripts/website-catalog/sync-feature-catalog.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * Sencho tier-reconciliation: sync feature catalog to website + * Reads docs/feature-catalog.yaml (canonical, public-safe), builds a sanitized + * public projection, and writes it to /src/data/. + * + * Usage: node scripts/website-catalog/sync-feature-catalog.mjs --website-dir + * + * The canonical file contains only public-safe identifiers (no SEN-NNN Linear IDs). + * The public projection contains only shipped/planned community|admiral entries + * with public presentation fields. Internal entries are excluded. + */ +import yaml from 'js-yaml'; +import fs from 'fs'; +import path from 'path'; +import { createHash } from 'crypto'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CANONICAL_FILE = path.resolve(__dirname, '../../docs/feature-catalog.yaml'); +const SCHEMA_VERSION = '1'; +const OUTPUT_SNAPSHOT = 'catalog-snapshot.yaml'; +const OUTPUT_META = 'catalog-snapshot.meta.json'; + +// Fields allowed in the public projection. +const PUBLIC_FIELDS = new Set([ + 'id', 'publicName', 'summary', 'description', 'category', + 'tier', 'availability', 'featured', 'homepageOrder', 'publicRoadmapKey', +]); + +function normalizeYaml(obj) { + return yaml.dump(obj, { sortKeys: true, lineWidth: -1, commentString: '' }); +} + +function computeChecksum(content) { + return createHash('sha256').update(content).digest('hex'); +} + +function main() { + const args = process.argv.slice(2); + let websiteDir = null; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--website-dir' && args[i + 1]) { + websiteDir = args[i + 1]; + i++; + } else if (args[i] === '--help') { + console.log('Usage: node sync-feature-catalog.mjs --website-dir '); + process.exit(0); + } + } + + if (!websiteDir) { + console.error('ERROR: --website-dir is required'); + process.exit(1); + } + + // Read and parse canonical catalog. + const catText = fs.readFileSync(CANONICAL_FILE, 'utf8'); + const doc = yaml.load(catText, { schema: yaml.CORE_SCHEMA }); + if (!doc || !Array.isArray(doc.entries)) { + console.error('ERROR: canonical catalog is missing entries array'); + process.exit(1); + } + + // Build sanitized public projection. + const projection = doc.entries + .filter((e) => e.tier !== 'internal' && e.availability !== 'internal') + .map((e) => { + const pub = {}; + for (const key of PUBLIC_FIELDS) { + if (key in e) pub[key] = e[key]; + } + return pub; + }); + + const projectionText = normalizeYaml({ entries: projection }); + const checksum = computeChecksum(projectionText); + + // Write snapshot. + const outDir = path.join(websiteDir, 'src', 'data'); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + + fs.writeFileSync(path.join(outDir, OUTPUT_SNAPSHOT), projectionText, 'utf8'); + + // Write metadata (checksum-based provenance, no commit SHA). + const meta = { + schemaVersion: SCHEMA_VERSION, + checksum, + }; + fs.writeFileSync( + path.join(outDir, OUTPUT_META), + JSON.stringify(meta, null, 2) + '\n', + 'utf8' + ); + + console.log(`Synced ${projection.length} public entries to ${outDir}/`); + console.log(`Checksum: ${checksum}`); + process.exit(0); +} + +main(); diff --git a/scripts/website-catalog/test-catalog-no-leak.mjs b/scripts/website-catalog/test-catalog-no-leak.mjs new file mode 100644 index 00000000..1283cc82 --- /dev/null +++ b/scripts/website-catalog/test-catalog-no-leak.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import fs from 'fs'; +import yaml from 'js-yaml'; +import path from 'path'; +import { fileURLToPath } from 'url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CATALOG_FILE = path.resolve(__dirname, '../../docs/feature-catalog.yaml'); +const PROHIBITED_KEYS = ['linear', 'evidence', 'internalNote', 'route', 'service']; + +const text = fs.readFileSync(CATALOG_FILE, 'utf8'); +const doc = yaml.load(text, { schema: yaml.CORE_SCHEMA }); + +let failed = false; +function fail(msg) { console.error('NO-LEAK FAIL: ' + msg); failed = true; } + +if (!doc || !Array.isArray(doc.entries)) fail('catalog has no entries array'); +else { + for (const entry of doc.entries) { + const id = entry?.id || '(unknown)'; + for (const key of PROHIBITED_KEYS) { + if (entry[key]) fail(`entry[${id}] contains prohibited ${key}`); + } + for (const [k, v] of Object.entries(entry)) { + if (typeof v === 'string' && /SEN-[0-9]+/.test(v)) + fail(`entry[${id}] key "${k}" has SEN-NNN: "${v}"`); + } + if (entry.tier === 'internal' && entry.availability !== 'internal') + fail(`entry[${id}] tier internal requires availability internal`); + } +} + +if (failed) process.exit(1); +console.log('NO-LEAK PASS'); +process.exit(0);