358 lines
19 KiB
Markdown
358 lines
19 KiB
Markdown
# 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"
|