companion-tier-resolver.mjs
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb
reconcile: import GitHub-direct RBAC/OAuth/companion and ho…
Human
minor
⚠ breaking
6 hours ago
| 1 | /** |
| 2 | * Phase 6 — per-tier, per-artifact storage routing (D6.1). |
| 3 | * |
| 4 | * Maps (artifact_type × privacy_tier) to exactly one of three terminal states: |
| 5 | * 'host_readable' — convenience tier only (server-held-key is this class, not ZK) |
| 6 | * 'client_encrypted' — privacy_max: requires ClientEncryptor with user-held key |
| 7 | * 'local_only' — either tier, when above options are unavailable |
| 8 | * |
| 9 | * ROUTING RULES (D6.1, binding): |
| 10 | * - Server-held-key AES-256-GCM is classified as 'host_readable' for policy purposes |
| 11 | * (the operator can decrypt). It DOES NOT satisfy privacy_max (D6.1.2). |
| 12 | * - Unknown/unresolvable tier → treat as most-restrictive (privacy_max) → fail closed. |
| 13 | * - Until the two-tier vault registry exists, ONLY 'convenience' is resolvable; |
| 14 | * any 'privacy_max' write fails closed (D6.1.1). |
| 15 | * - No caller picks a storage location directly — routing is centralized here (D6.1.3). |
| 16 | * |
| 17 | * DESIGN INVARIANTS: |
| 18 | * - Pure function — no I/O, no network. |
| 19 | * - Fail-closed on every ambiguous or unknown input. |
| 20 | */ |
| 21 | |
| 22 | import { ARTIFACT_TYPES, PRIVACY_TIERS } from './companion-provenance-validator.mjs'; |
| 23 | |
| 24 | /** |
| 25 | * The three legal terminal storage states (D6.1.2). |
| 26 | * There is no fourth state. |
| 27 | * @readonly |
| 28 | */ |
| 29 | export const TERMINAL_STATES = Object.freeze({ |
| 30 | /** Convenience only. Artifact stored plaintext or with a server-held key. Host can read it. */ |
| 31 | HOST_READABLE: 'host_readable', |
| 32 | /** privacy_max. Artifact stored only as ciphertext under a user-held key. Host cannot read it. */ |
| 33 | CLIENT_ENCRYPTED: 'client_encrypted', |
| 34 | /** |
| 35 | * Either tier. Artifact kept only on the local device. |
| 36 | * Used when client encryption is unavailable for privacy_max (fail-closed path). |
| 37 | */ |
| 38 | LOCAL_ONLY: 'local_only', |
| 39 | }); |
| 40 | |
| 41 | /** |
| 42 | * Fixed reason codes for tier resolution failures. |
| 43 | * @readonly |
| 44 | */ |
| 45 | export const TIER_RESOLVE_REASONS = Object.freeze({ |
| 46 | /** Artifact type is not in the known set. */ |
| 47 | INVALID_ARTIFACT_TYPE: 'tier_resolve_invalid_artifact_type', |
| 48 | /** Privacy tier value is unknown — treated as most-restrictive, then fails closed. */ |
| 49 | UNKNOWN_TIER: 'tier_resolve_unknown_tier', |
| 50 | /** |
| 51 | * privacy_max requested but the two-tier vault registry does not exist yet (D6.1.1). |
| 52 | * Phase 6 fails closed rather than inventing a tier source. |
| 53 | */ |
| 54 | PRIVACY_MAX_NO_REGISTRY: 'tier_resolve_privacy_max_no_registry', |
| 55 | }); |
| 56 | |
| 57 | /** |
| 58 | * Resolve the terminal storage state for a derived artifact. |
| 59 | * |
| 60 | * D6.1.1 — tier is resolved from the OWNER's vault tier (passed as `privacyTier`). |
| 61 | * Until `vaultRegistryAvailable` is true, only 'convenience' resolves; any |
| 62 | * 'privacy_max' artifact fails closed. |
| 63 | * |
| 64 | * D6.1.2 — exactly three terminal states. Server-held-key = host_readable (same policy class). |
| 65 | * |
| 66 | * D6.1.3 — routing is centralized; callers supply artifact + tier, not a location. |
| 67 | * |
| 68 | * @param {string} artifactType - One of ARTIFACT_TYPES. |
| 69 | * @param {string} privacyTier - 'convenience' | 'privacy_max' (the OWNER's vault tier). |
| 70 | * @param {{ vaultRegistryAvailable?: boolean }} [opts] |
| 71 | * @returns {{ ok: true; terminalState: string } | { ok: false; reason: string }} |
| 72 | */ |
| 73 | export function resolveTier(artifactType, privacyTier, opts = {}) { |
| 74 | // Validate artifact type — unknown type is a hard reject |
| 75 | if (!ARTIFACT_TYPES.includes(/** @type {any} */ (artifactType))) { |
| 76 | return { ok: false, reason: TIER_RESOLVE_REASONS.INVALID_ARTIFACT_TYPE }; |
| 77 | } |
| 78 | |
| 79 | // Unknown/unresolvable tier → most-restrictive (privacy_max) → fail closed (D6.1 §fail-closed) |
| 80 | if (!PRIVACY_TIERS.includes(/** @type {any} */ (privacyTier))) { |
| 81 | return { ok: false, reason: TIER_RESOLVE_REASONS.UNKNOWN_TIER }; |
| 82 | } |
| 83 | |
| 84 | if (privacyTier === 'privacy_max') { |
| 85 | // Until the two-tier vault registry lands, privacy_max always fails closed (D6.1.1) |
| 86 | if (!opts.vaultRegistryAvailable) { |
| 87 | return { ok: false, reason: TIER_RESOLVE_REASONS.PRIVACY_MAX_NO_REGISTRY }; |
| 88 | } |
| 89 | // Registry present: privacy_max → client_encrypted (requires ClientEncryptor at write time) |
| 90 | return { ok: true, terminalState: TERMINAL_STATES.CLIENT_ENCRYPTED }; |
| 91 | } |
| 92 | |
| 93 | // convenience → host_readable (including server-held-key at-rest path — same policy class) |
| 94 | return { ok: true, terminalState: TERMINAL_STATES.HOST_READABLE }; |
| 95 | } |
File History
1 commit
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb
reconcile: import GitHub-direct RBAC/OAuth/companion and ho…
Human
minor
⚠
6 hours ago