Files
suaveplan/openspec/archive/2026-05-04-enterprise-drive-sharing/specs/drive-sharing/spec.md
2026-05-27 23:29:53 +01:00

368 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Capability Spec Delta: drive-sharing (enterprise-drive-sharing)
**Package**: `@suaveplan/drive-sharing`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
---
## ADDED 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 twelve `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 using `globalThis.crypto.subtle`. `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 `CryptoProvider`. `envelopeDecrypt(key, bundle)` SHALL decrypt. Both SHALL throw `DriveShareError(SHARE_ENCRYPTION_FAILED)` on any crypto failure.
#### 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. `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.
#### 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–1000 ms), clock advances by T+1
- **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, 100-key key-ring add/get, and envelope encrypt+decrypt on 10 KiB.
#### Scenario: Benchmarks run without error
- **WHEN** `bun run bench` is executed
- **THEN** all four 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"
---
## MODIFIED Requirements
> None — this is a new package migration with no prior folded spec.
## REMOVED Requirements
> None — this is a new package migration with no prior folded spec.