Files
2026-05-27 23:29:53 +01:00

19 KiB
Raw Permalink Blame History

Capability Spec: drive-sharing

Package: @suaveplan/drive-sharing Status: SHIPPED (genesis)

Folded from enterprise-drive-sharing archive on 2026-05-04.


Requirements

Requirement: Structured DriveShareError hierarchy

The package SHALL export a DriveShareError class extending SuaveplanError from @suaveplan/error. Every throw site SHALL use a named factory helper. DriveShareError SHALL carry a DriveShareErrorCode discriminant. error-codes.ts SHALL register all eleven SHARE.* codes idempotently at module load.

Scenario: Error code coverage

  • WHEN any DriveShareErrorCode is defined in error-codes.ts
  • THEN at least one production code path throws that exact code

Scenario: Factory helpers are used

  • WHEN a consumer inspects a caught DriveShareError
  • THEN error.code is a stable string from DriveShareErrorCode and error instanceof DriveShareError is true

Verified by: src/error-codes.ts (code registration) AND src/partitions/partition-manager/partition-manager.spec-conformance.test.ts::"throws DriveShareError with SHARE_PARTITION_NOT_FOUND for missing partition" AND src/transfer/transfer/transfer.spec-conformance.test.ts::"throws DriveShareError with SHARE_TRANSFER_FAILED on abort"


Requirement: Zod schemas for all public option types

Every public options interface (PartitionManagerConfig, CreatePartitionOptions, AddMemberOptions, CreateGrantOptions, CreateTokenOptions, TokenManagerConfig, TransferOptions, PartitionPolicyConfig, KeyRingConfig) SHALL have a corresponding Zod schema exported from src/types/schemas.ts. Schemas SHALL be wired at every declared boundary.

Scenario: Invalid CreatePartitionOptions rejected

  • WHEN partitionManager.create is called with name missing
  • THEN it throws a validation error before any store mutation

Scenario: PartitionPolicyConfig rejects unknown action

  • WHEN PartitionPolicyConfigSchema is parsed with allowedActions: ["read", "invalid"]
  • THEN ZodError is thrown citing the invalid action

Verified by: src/types/schemas.spec-conformance.test.ts::"CreatePartitionOptionsSchema rejects missing name" AND src/types/schemas.spec-conformance.test.ts::"PartitionPolicyConfigSchema rejects unknown action in allowedActions"


Requirement: Injectable clock and RNG

No Date.now() or Math.random() SHALL appear at module scope in src/. TokenManager and PartitionManager SHALL accept optional clock?: Clock. All timestamp generation SHALL use the injected clock. generateId SHALL accept an optional rng?: () => string defaulting to crypto.randomUUID.

Scenario: Deterministic token expiry

  • WHEN tokenManager.create is called with a stub clock returning 0 and defaultTTL of 1000
  • THEN token.expiresAt equals 1000

Scenario: Injectable RNG produces deterministic IDs

  • WHEN generateId(() => "fixed-id") is called
  • THEN it returns "fixed-id"

Verified by: src/sharing/tokens/tokens.spec-conformance.test.ts::"create uses injected clock for expiresAt" AND src/types/common.spec-conformance.test.ts::"generateId with stub rng returns fixed string"


Requirement: OpenTelemetry instrumentation on all externally-visible operations

Every externally-visible async operation SHALL emit an OTel span and/or counter/histogram as specified in proposal.md. Span names SHALL follow the convention share.<subsystem>.<operation>.

Scenario: partitionManager.create emits span

  • WHEN partitionManager.create(options) succeeds
  • THEN a span named share.partition.create is started and ended

Scenario: tokenManager.validate increments counter

  • WHEN tokenManager.validate(tokenId) is called
  • THEN counter share.token.validate_total is incremented by 1

Verified by: src/partitions/partition-manager/partition-manager.spec-conformance.test.ts::"create emits span share.partition.create" AND src/sharing/tokens/tokens.spec-conformance.test.ts::"validate increments share.token.validate_total counter"


Requirement: AbortSignal on cross-partition transfer

TransferOptions SHALL accept an optional signal?: AbortSignal. When the signal aborts mid-transfer, TransferManager.transfer SHALL reject with DriveShareError(SHARE_TRANSFER_FAILED).

Scenario: Mid-transfer abort rejects

  • WHEN transferManager.transfer(options) is in progress and options.signal aborts
  • THEN the promise rejects with DriveShareError code SHARE_TRANSFER_FAILED

Scenario: Pre-aborted signal rejects immediately

  • WHEN transferManager.transfer is called with an already-aborted signal
  • THEN it rejects immediately with DriveShareError code SHARE_TRANSFER_FAILED

Verified by: src/transfer/transfer/transfer.test.ts::"mid-transfer abort rejects with SHARE_TRANSFER_FAILED" AND src/transfer/transfer/transfer.test.ts::"pre-aborted signal rejects immediately"


Requirement: CryptoProvider interface and WebCryptoProvider

The package SHALL export a CryptoProvider interface and a WebCryptoProvider implementation backed by @suaveplan/crypto-cipher AEAD primitives. WebCryptoProvider SHALL implement encrypt, decrypt, wrapKey, unwrapKey, generateKey, and computeIntegrity.

Scenario: WebCryptoProvider encrypt-decrypt round-trip

  • WHEN webCryptoProvider.encrypt(key, data) produces a bundle and decrypt(key, bundle) is called
  • THEN the decrypted bytes equal the original data

Scenario: WebCryptoProvider wrap-unwrap round-trip

  • WHEN webCryptoProvider.wrapKey(wrappingKey, keyToWrap) produces a wrapped key and unwrapKey(wrappingKey, wrappedKey) is called
  • THEN the unwrapped bytes equal the original keyToWrap

Verified by: src/encryption/envelope/envelope.test.ts::"WebCryptoProvider encrypt-decrypt round-trip" AND src/encryption/envelope/envelope.test.ts::"WebCryptoProvider wrapKey-unwrapKey round-trip"


Requirement: Envelope encryption and decryption

envelopeEncrypt(key, data, options?) SHALL encrypt data with key using the default AEAD. envelopeDecrypt(key, bundle) SHALL decrypt. Both SHALL throw DriveShareError(SHARE_ENCRYPTION_FAILED) on any crypto failure. Empty AAD SHALL be rejected.

Scenario: Envelope encrypt-decrypt round-trip

  • WHEN envelopeEncrypt(key, data) produces a bundle and envelopeDecrypt(key, bundle) is called
  • THEN the decrypted bytes equal the original data

Scenario: Wrong key decryption fails

  • WHEN envelopeDecrypt(wrongKey, bundle) is called
  • THEN it throws DriveShareError(SHARE_ENCRYPTION_FAILED)

Verified by: src/encryption/envelope/envelope.test.ts::"envelopeEncrypt then envelopeDecrypt returns original data" AND src/encryption/envelope/envelope.test.ts::"envelopeDecrypt with wrong key throws SHARE_ENCRYPTION_FAILED"


Requirement: Key wrap, unwrap, and rewrap

wrapKey(wrappingKey, keyToWrap, options?) SHALL produce a WrappedKey. unwrapKey(wrappingKey, wrappedKey) SHALL return the original key bytes. rewrapKey(oldWrappingKey, newWrappingKey, wrappedKey) SHALL unwrap with the old key and rewrap with the new key without exposing plaintext to callers.

Scenario: Rewrap round-trip

  • WHEN rewrapKey(oldKey, newKey, wrappedKey) produces a rewrapped key and unwrapKey(newKey, rewrappedKey) is called
  • THEN the unwrapped bytes equal the original keyToWrap

Scenario: Integrity verification detects corruption

  • WHEN computeIntegrity(data) produces a digest and one byte of data is flipped before verifyIntegrity
  • THEN verifyIntegrity returns false

Verified by: src/encryption/envelope/envelope.test.ts::"rewrapKey round-trip unwraps with new wrapping key" AND src/encryption/envelope/envelope.test.ts::"verifyIntegrity detects single-bit corruption"


Requirement: Key ring management

KeyRing SHALL store per-user keys wrapped with a master key. addKey(userId, key, options?) SHALL wrap and store the key, emitting key:added. getKey(userId) SHALL unwrap and return the key. removeKey(userId) SHALL delete the entry, emitting key:removed. rotateKey(userId, newKey) SHALL rewrap the key, emitting key:rotated. export() and import(keys) SHALL serialize/deserialize the full ring.

Scenario: Add-then-get round-trip

  • WHEN keyRing.addKey(userId, key) then keyRing.getKey(userId) are called
  • THEN the returned bytes equal the original key

Scenario: Export-import round-trip

  • WHEN keyRing.export() produces an array and keyRing.import(array) is called in a fresh instance
  • THEN getKey(userId) returns the original key for every user

Verified by: src/encryption/key-ring/key-ring.test.ts::"addKey then getKey returns original bytes" AND src/encryption/key-ring/key-ring.test.ts::"export-import round-trip preserves all keys"


Requirement: Partition policy creation and validation

createPolicy(config) SHALL produce a valid PartitionPolicyConfig. getDefaultPolicy() SHALL return the default. mergePolicies(base, override) SHALL shallow-merge non-conflicting fields and union arrays with deduplication. validateAction(policy, ctx) SHALL return { allowed: false, reason?: string } for denied actions and { allowed: true } for permitted actions. requiresEncryption and requiresReauth SHALL be pure predicates. DEFAULT_PRESETS SHALL contain public, private, shared, and encrypted presets.

Scenario: Preset validation

  • WHEN validateAction(createPolicy(DEFAULT_PRESETS.encrypted), { action: 'read', userId, partition }) is called
  • THEN allowed is true and requiresEncryption(policy) is true

Scenario: Merge preserves base when override is empty

  • WHEN mergePolicies(base, {}) is called
  • THEN the result equals base

Scenario: MaxMembers violation returns denied

  • WHEN validateAction is called on a partition whose member count exceeds policy.maxMembers
  • THEN allowed is false and reason mentions maxMembers

Verified by: src/partitions/partition-policy/partition-policy.test.ts::"encrypted preset allows read and requiresEncryption is true" AND src/partitions/partition-policy/partition-policy.test.ts::"mergePolicies with empty override returns base unchanged" AND src/partitions/partition-policy/partition-policy.test.ts::"validateAction denies when maxMembers exceeded"


Requirement: Partition store interface

PartitionStore SHALL declare get, set, delete, and list. InMemoryPartitionStore SHALL implement the interface with a Map<PartitionId, Partition> backing.

Scenario: In-memory store read-after-write

  • WHEN store.set(partition) then store.get(partition.id) are called
  • THEN the returned partition equals the stored partition

Scenario: In-memory store list returns all partitions

  • WHEN two partitions are stored and store.list() is called
  • THEN the result contains both partitions

Verified by: src/partitions/partition-store/partition-store.test.ts::"set then get returns identical partition" AND src/partitions/partition-store/partition-store.test.ts::"list returns all stored partitions"


Requirement: Partition manager CRUD and membership

PartitionManager SHALL create, get, list, delete, update, add-member, and remove-member. create SHALL generate a PartitionId, apply the default or provided policy, store via config.store, and emit partition:created. addMember SHALL enforce maxMembers policy and emit member:added. removeMember SHALL emit member:removed. get on a missing id SHALL throw DriveShareError(SHARE_PARTITION_NOT_FOUND).

Scenario: Create emits event

  • WHEN partitionManager.create(options) succeeds
  • THEN event bus emits partition:created with the new partition

Scenario: AddMember enforces maxMembers

  • WHEN addMember is called on a partition already at maxMembers
  • THEN it throws DriveShareError(SHARE_POLICY_VIOLATION)

Scenario: RemoveMember restores count

  • WHEN addMember then removeMember are called for the same user
  • THEN partition.members.length equals its value before both calls

Verified by: src/partitions/partition-manager/partition-manager.test.ts::"create emits partition:created event" AND src/partitions/partition-manager/partition-manager.test.ts::"addMember throws SHARE_POLICY_VIOLATION when maxMembers reached" AND src/partitions/partition-manager/partition-manager.test.ts::"addMember then removeMember restores original member count"


Requirement: Grant manager create, check, and revoke

GrantManager SHALL maintain grants in memory. create(options) SHALL generate a GrantId, validate that expiresAt is in the future (via injectable clock), and store the grant. check(grantId, userId, action) SHALL verify existence, non-revocation, non-expiry, permission inclusion, and grantee identity. revoke(grantId) SHALL mark the grant revoked. list(query?) SHALL filter by granteeId, partitionId, and active status.

Scenario: Grant check succeeds for valid grantee and action

  • WHEN a grant with permissions: ['read'] is created for user B and check(grantId, userB, 'read') is called
  • THEN allowed is true

Scenario: Expired grant is denied

  • WHEN a grant with expiresAt: 100 is created at clock 0 and check is called at clock 101
  • THEN allowed is false and reason mentions expiry

Scenario: Revoked grant is denied

  • WHEN grantManager.revoke(grantId) is called and then check(grantId, userId, 'read') is called
  • THEN allowed is false and reason mentions revocation

Verified by: src/sharing/grants/grants.test.ts::"check returns allowed true for valid grantee and permitted action" AND src/sharing/grants/grants.test.ts::"check returns allowed false for expired grant" AND src/sharing/grants/grants.test.ts::"check returns allowed false for revoked grant"


Requirement: Token manager create, validate, and revoke with TTL

TokenManager SHALL create, validate, revoke, and list share tokens. create(options) SHALL compute expiresAt = clock.now() + (options.expiresIn ?? defaultTTL). validate(tokenId) SHALL check existence, non-revocation, and expiresAt > clock.now(). revoke(tokenId) SHALL mark the token revoked permanently. verifyIntegrity(token) SHALL constant-time compare the token's HMAC integrity tag against a recomputed expected tag.

Scenario: Valid token passes validation

  • WHEN a token is created with expiresIn: 3600 at clock 0 and validate is called at clock 100
  • THEN valid is true and the token is returned

Scenario: Expired token fails validation

  • WHEN a token is created with expiresIn: 100 at clock 0 and validate is called at clock 101
  • THEN valid is false and reason mentions expiry

Scenario: Revoked token is permanently invalid

  • WHEN tokenManager.revoke(tokenId) is called and validate(tokenId) is called repeatedly
  • THEN every call returns valid: false

Verified by: src/sharing/tokens/tokens.test.ts::"validate returns valid true before expiry" AND src/sharing/tokens/tokens.test.ts::"validate returns valid false after expiry" AND src/sharing/tokens/tokens.test.ts::"revoked token validate always returns valid false"


Requirement: Cross-partition file transfer

TransferManager SHALL resolve source and target KEKs, retrieve the file DEK, validate source permissions, resolve target policy, rewrap the DEK, and record a TransferRecord. The result SHALL include success, record, and rewrappedKey. Permission or policy violations SHALL throw DriveShareError(SHARE_TRANSFER_FAILED) or SHARE_POLICY_VIOLATION.

Scenario: Successful export transfer

  • WHEN transferManager.transfer({ direction: 'export', ... }) is called with valid keys and policies
  • THEN success is true and record.completedAt is a positive timestamp

Scenario: Policy violation throws

  • WHEN transferManager.transfer is called and the target partition policy denies write
  • THEN it throws DriveShareError(SHARE_POLICY_VIOLATION)

Scenario: Missing file key throws

  • WHEN transferManager.transfer is called for a fileId not present in fileKeyStore
  • THEN it throws DriveShareError(SHARE_KEY_NOT_FOUND)

Verified by: src/transfer/transfer/transfer.test.ts::"successful export returns success true and completed record" AND src/transfer/transfer/transfer.test.ts::"export throws SHARE_POLICY_VIOLATION when target denies write" AND src/transfer/transfer/transfer.test.ts::"transfer throws SHARE_KEY_NOT_FOUND when file key missing"


Requirement: Branded ID utilities

The package SHALL export branded types UserId, PartitionId, GrantId, TokenId, FileId, KeyId using the Brand<T, B> pattern. Factory functions (userId, partitionId, grantId, tokenId, fileId, keyId) SHALL construct branded values. generateId SHALL return a string. ok and err SHALL construct Result<T, E> values.

Scenario: Branded IDs are type-distinct

  • WHEN userId("u1") and partitionId("p1") are created
  • THEN TypeScript reports an error if a UserId is assigned to a PartitionId parameter

Scenario: Result discriminant

  • WHEN ok(42) is created
  • THEN result.ok is true and result.value is 42

Verified by: src/types/common.spec-conformance.test.ts::"userId and partitionId are not assignable to each other" AND src/types/common.spec-conformance.test.ts::"ok result has ok true and correct value"


Requirement: Property-based test invariants

The package SHALL include fast-check property tests covering: grant expiry after TTL advance, token revocation finality, policy merge commutativity for non-conflicting fields, and partition member count invariant.

Scenario: Grant expiry after clock advance

  • WHEN fast-check generates a grant with TTL T (1–100,000 ms), clock advances by T+offset
  • THEN grantManager.check returns allowed: false

Scenario: Token revocation is final

  • WHEN fast-check generates a token, revokes it, then validates any number of times
  • THEN every validation returns valid: false

Scenario: Policy merge on disjoint fields

  • WHEN fast-check generates two policies with disjoint allowedActions
  • THEN mergePolicies(a, b).allowedActions contains the union of both sets

Verified by: tests/property.test.ts::"grant check returns allowed false after advancing clock beyond TTL" AND tests/property.test.ts::"revoked token validate always returns valid false" AND tests/property.test.ts::"mergePolicies on disjoint allowedActions yields union"


Requirement: Benchmark suite coverage

The package SHALL include a mitata benchmark suite in src/__benchmarks__/drive-sharing.bench.ts covering: 10 000 grant checks, 10 000 token validations, 1 MiB envelope encrypt + decrypt, and 1 000 key-ring rotations.

Scenario: Benchmarks run without error

  • WHEN bun run src/__benchmarks__/drive-sharing.bench.ts is executed
  • THEN all five benchmarks complete and print mean ops/sec

Verified by: src/__benchmarks__/drive-sharing.bench.ts (mitata output) AND src/__benchmarks__/drive-sharing.spec-conformance.test.ts::"all benchmark functions complete without throwing"