Initial Commit
This commit is contained in:
132
packages/networking/drive/drive-sharing/README.md
Normal file
132
packages/networking/drive/drive-sharing/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# @suaveplan/drive-sharing - Partitioned drive sharing engine for the Suaveplan ecosystem: envelope encryption with mandatory AAD, per-user key wrapping, partition policies with deterministic action validation, revocable share tokens, expiring grants, and atomic cross-partition file transfer with rollback.
|
||||
|
||||
## Summary
|
||||
|
||||
Partitioned drive sharing engine for the Suaveplan ecosystem. Ships envelope encryption with mandatory AAD, per-user key wrapping, partition policies with deterministic action validation, revocable share tokens, expiring grants, and atomic cross-partition file transfer with rollback. Every public boundary is Zod-validated, OTel-instrumented, and event-bus-aware.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
|
||||
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [common](./src/types/common.md) — This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the bra…
|
||||
- [schemas](./src/types/schemas.md) — This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired…
|
||||
<!-- end:auto-docs --> - [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the bra…
|
||||
- [schemas](./src/types/schemas.md) — This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired…
|
||||
- [error-codes](./src/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
|
||||
- [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the bra…
|
||||
- [schemas](./src/types/schemas.md) — This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired…
|
||||
- [error-codes](./src/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
|
||||
- [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the bra…
|
||||
- [schemas](./src/types/schemas.md) — This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired…
|
||||
- [error-codes](./src/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
|
||||
- [envelope](./src/encryption/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-ring](./src/encryption/key-ring/key-ring.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [partition-manager](./src/partitions/partition-manager/partition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_ - [partition-policy](./src/partitions/partition-policy/partition-policy.md) — <!-- begin:auto-docs --> _(no submodules)_- [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_ - [partition-store](./src/partitions/partition-store/partition-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_ - [grants](./src/sharing/grants/grants.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_ - [tokens](./src/sharing/tokens/tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transfer](./src/transfer/transfer/transfer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the bra…
|
||||
- [schemas](./src/types/schemas.md) — This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired…
|
||||
- [error-codes](./src/error-codes.md) — The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/drive-sharing
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
GrantManager,
|
||||
InMemoryPartitionStore,
|
||||
KeyRing,
|
||||
PartitionManager,
|
||||
TokenManager,
|
||||
TransferManager,
|
||||
envelopeEncrypt,
|
||||
envelopeDecrypt,
|
||||
fileId,
|
||||
partitionId,
|
||||
userId,
|
||||
wrapKey,
|
||||
} from "@suaveplan/drive-sharing";
|
||||
|
||||
const keyRing = new KeyRing();
|
||||
const partitions = new PartitionManager({
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing,
|
||||
});
|
||||
|
||||
const partition = await partitions.create({
|
||||
name: "research",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
});
|
||||
|
||||
const grants = new GrantManager();
|
||||
const tokens = new TokenManager();
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Purpose |
|
||||
|---|---|
|
||||
| `@suaveplan/drive-sharing` | Default barrel — re-exports every public type and class. |
|
||||
| `@suaveplan/drive-sharing/errors` | `DriveShareError`, `DRIVE_SHARE_CODES`. |
|
||||
| `@suaveplan/drive-sharing/constants` | TTL defaults, format version, scalability caps. |
|
||||
| `@suaveplan/drive-sharing/types/common` | Branded IDs, wire types, `Result`, `generateId`. |
|
||||
| `@suaveplan/drive-sharing/types/schemas` | Zod schemas for every public option type. |
|
||||
| `@suaveplan/drive-sharing/encryption/envelope` | `envelopeEncrypt`, `envelopeDecrypt`, `wrapKey`, `unwrapKey`, `rewrapKey`, `WebCryptoProvider`. |
|
||||
| `@suaveplan/drive-sharing/encryption/key-ring` | `KeyRing` with add / get / remove / rotate / export / import. |
|
||||
| `@suaveplan/drive-sharing/partitions/partition-policy` | `createPolicy`, `validateAction`, `DEFAULT_PRESETS`. |
|
||||
| `@suaveplan/drive-sharing/partitions/partition-store` | `PartitionStore` interface, `InMemoryPartitionStore`. |
|
||||
| `@suaveplan/drive-sharing/partitions/partition-manager` | `PartitionManager` lifecycle. |
|
||||
| `@suaveplan/drive-sharing/sharing/grants` | `GrantManager` (in-memory grants with check / revoke / list). |
|
||||
| `@suaveplan/drive-sharing/sharing/tokens` | `TokenManager` (HMAC-SHA-256 integrity-tagged tokens). |
|
||||
| `@suaveplan/drive-sharing/transfer` | `TransferManager` (cross-partition rewrap with `AbortSignal`). |
|
||||
| `@suaveplan/drive-sharing/otel` | OTel instrument singletons. |
|
||||
|
||||
## Security model
|
||||
|
||||
- **AAD is mandatory** — every `envelopeEncrypt` and `wrapKey` call requires a non-empty `aad: Uint8Array`. Empty AAD throws `SHARE_ENCRYPTION_FAILED` to enforce the partition-binding guarantee.
|
||||
- **No plaintext keys leave the package** — `KeyRing.rotateKey` zeroizes its caller-supplied buffer; `rewrapKey` zeroizes the intermediate plaintext DEK before returning.
|
||||
- **HMAC-SHA-256 token integrity** — every share token carries an integrity tag computed under the partition master key. Tampering invalidates the tag.
|
||||
- **No key material in events / spans / logs** — every event payload carries IDs only.
|
||||
|
||||
## Performance baselines
|
||||
|
||||
| Benchmark | Iteration | Wall-clock |
|
||||
|---|---|---|
|
||||
| `grant_check_10k` | 10 000 in-memory grant checks | < 50 ms |
|
||||
| `token_validate_10k` | 10 000 in-memory token validations | < 50 ms |
|
||||
| `envelope_encrypt_1mb` | one 1 MiB AES-GCM encrypt | < 10 ms |
|
||||
| `envelope_decrypt_1mb` | one 1 MiB AES-GCM decrypt | < 10 ms |
|
||||
| `keyring_rotate_1000` | 1 000 rotateKey calls | < 250 ms |
|
||||
|
||||
Run benchmarks with `bun run src/__benchmarks__/drive-sharing.bench.ts`.
|
||||
|
||||
## Testing posture
|
||||
|
||||
- 100% statement / branch / function / line coverage (Istanbul).
|
||||
- Property-based tests via `fast-check` covering grant expiry, token revocation finality, policy merge commutativity, and partition-member invariants.
|
||||
- All test files import runner primitives from `@suaveplan/testing/runner` for dual `vitest` + `bun:test` portability.
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — Suaveplan commercial license.
|
||||
|
||||
201
packages/networking/drive/drive-sharing/package.json
Normal file
201
packages/networking/drive/drive-sharing/package.json
Normal file
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"name": "@suaveplan/drive-sharing",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Partitioned drive sharing engine for the Suaveplan ecosystem: envelope encryption with mandatory AAD, per-user key wrapping, partition policies with deterministic action validation, revocable share tokens, expiring grants, and atomic cross-partition file transfer with rollback.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./constants": {
|
||||
"import": {
|
||||
"types": "./dist/constants/index.d.ts",
|
||||
"default": "./dist/constants/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/constants/index.d.ts",
|
||||
"default": "./dist/constants/index.js"
|
||||
}
|
||||
},
|
||||
"./types/common": {
|
||||
"import": {
|
||||
"types": "./dist/types/common.d.ts",
|
||||
"default": "./dist/types/common.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/common.d.ts",
|
||||
"default": "./dist/types/common.js"
|
||||
}
|
||||
},
|
||||
"./types/schemas": {
|
||||
"import": {
|
||||
"types": "./dist/types/schemas.d.ts",
|
||||
"default": "./dist/types/schemas.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/schemas.d.ts",
|
||||
"default": "./dist/types/schemas.js"
|
||||
}
|
||||
},
|
||||
"./encryption/envelope": {
|
||||
"import": {
|
||||
"types": "./dist/encryption/envelope/envelope.d.ts",
|
||||
"default": "./dist/encryption/envelope/envelope.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/encryption/envelope/envelope.d.ts",
|
||||
"default": "./dist/encryption/envelope/envelope.js"
|
||||
}
|
||||
},
|
||||
"./encryption/key-ring": {
|
||||
"import": {
|
||||
"types": "./dist/encryption/key-ring/key-ring.d.ts",
|
||||
"default": "./dist/encryption/key-ring/key-ring.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/encryption/key-ring/key-ring.d.ts",
|
||||
"default": "./dist/encryption/key-ring/key-ring.js"
|
||||
}
|
||||
},
|
||||
"./partitions/partition-policy": {
|
||||
"import": {
|
||||
"types": "./dist/partitions/partition-policy/partition-policy.d.ts",
|
||||
"default": "./dist/partitions/partition-policy/partition-policy.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/partitions/partition-policy/partition-policy.d.ts",
|
||||
"default": "./dist/partitions/partition-policy/partition-policy.js"
|
||||
}
|
||||
},
|
||||
"./partitions/partition-store": {
|
||||
"import": {
|
||||
"types": "./dist/partitions/partition-store/partition-store.d.ts",
|
||||
"default": "./dist/partitions/partition-store/partition-store.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/partitions/partition-store/partition-store.d.ts",
|
||||
"default": "./dist/partitions/partition-store/partition-store.js"
|
||||
}
|
||||
},
|
||||
"./partitions/partition-manager": {
|
||||
"import": {
|
||||
"types": "./dist/partitions/partition-manager/partition-manager.d.ts",
|
||||
"default": "./dist/partitions/partition-manager/partition-manager.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/partitions/partition-manager/partition-manager.d.ts",
|
||||
"default": "./dist/partitions/partition-manager/partition-manager.js"
|
||||
}
|
||||
},
|
||||
"./sharing/grants": {
|
||||
"import": {
|
||||
"types": "./dist/sharing/grants/grants.d.ts",
|
||||
"default": "./dist/sharing/grants/grants.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/sharing/grants/grants.d.ts",
|
||||
"default": "./dist/sharing/grants/grants.js"
|
||||
}
|
||||
},
|
||||
"./sharing/tokens": {
|
||||
"import": {
|
||||
"types": "./dist/sharing/tokens/tokens.d.ts",
|
||||
"default": "./dist/sharing/tokens/tokens.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/sharing/tokens/tokens.d.ts",
|
||||
"default": "./dist/sharing/tokens/tokens.js"
|
||||
}
|
||||
},
|
||||
"./transfer": {
|
||||
"import": {
|
||||
"types": "./dist/transfer/transfer/transfer.d.ts",
|
||||
"default": "./dist/transfer/transfer/transfer.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/transfer/transfer/transfer.d.ts",
|
||||
"default": "./dist/transfer/transfer/transfer.js"
|
||||
}
|
||||
},
|
||||
"./otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/crypto": "workspace:^",
|
||||
"@suaveplan/crypto-cipher": "workspace:^",
|
||||
"@suaveplan/crypto-hash": "workspace:^",
|
||||
"@suaveplan/crypto-utils": "workspace:^",
|
||||
"@suaveplan/drive": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/typescript-config": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Default time-to-live values and algorithm constants for
|
||||
* `@suaveplan/drive-sharing`.
|
||||
*
|
||||
* All durations are in milliseconds. The values reflect the secure-by-default
|
||||
* posture documented in the OpenSpec change `enterprise-drive-sharing`.
|
||||
*/
|
||||
|
||||
/** Default time-to-live (ms) for share tokens issued by `TokenManager`. */
|
||||
export const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Default time-to-live (ms) for grants issued by `GrantManager`. */
|
||||
export const DEFAULT_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Default AEAD algorithm used by envelope encryption. */
|
||||
export const ENCRYPTION_ALGORITHM = "AES-GCM" as const;
|
||||
|
||||
/** Frozen format-version tag stamped on `KeyRing.export()` payloads. */
|
||||
export const KEYRING_FORMAT_VERSION = 1 as const;
|
||||
|
||||
/** Hard cap on `KeyRing.export()` entry count, per the spec scalability table. */
|
||||
export const KEYRING_MAX_ENTRIES = 100_000;
|
||||
|
||||
/** Hard cap on `GrantManager` active entries (LRU eviction beyond). */
|
||||
export const GRANT_MANAGER_MAX_ENTRIES = 10_000_000;
|
||||
|
||||
/** Hard cap on `TokenManager` active entries (LRU eviction beyond). */
|
||||
export const TOKEN_MANAGER_MAX_ENTRIES = 10_000_000;
|
||||
@@ -0,0 +1,105 @@
|
||||
# `encryption/envelope/envelope.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module is the cryptographic core of `@suaveplan/drive-sharing`. It declares the `CryptoProvider` interface, ships the default `WebCryptoProvider` implementation backed by noble AEAD primitives, and exposes the public envelope-encryption API: `envelopeEncrypt`, `envelopeDecrypt`, `wrapKey`, `unwrapKey`, `rewrapKey`, `computeIntegrity`, `verifyIntegrity`. Every function here is the only call site that touches AEAD ciphers; downstream modules (key-ring, transfer, partition-manager) consume only this surface so adding a new algorithm or swapping in a FIPS-validated provider is a single-file change.
|
||||
|
||||
## Features
|
||||
|
||||
- Mandatory non-empty AAD on every encrypt and unwrap operation. Empty AAD produces `SHARE_ENCRYPTION_FAILED`, foreclosing cut-and-paste attacks across partitions.
|
||||
- `WrappedKey` carries a SHA-256 checksum verified before unwrap; corrupt entries throw `SHARE_KEY_NOT_FOUND` instead of silently passing.
|
||||
- Configurable AEAD algorithm (`AES-GCM` default, `XChaCha20-Poly1305` opt-in) selected per call.
|
||||
- `rewrapKey` zeroizes the intermediate plaintext key via `@suaveplan/crypto-utils`'s `zeroize`, so an attacker who reads heap memory after a rotation cannot recover the DEK.
|
||||
- `verifyIntegrity` is timing-safe via `constantTimeEqual`.
|
||||
- OTel instrumentation: every public function records `share.envelope.duration_ms` and starts a `share.envelope.encrypt` / `share.envelope.decrypt` span; no key material or plaintext bytes are exported as attributes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
envelopeEncrypt,
|
||||
envelopeDecrypt,
|
||||
} from "@suaveplan/drive-sharing/encryption/envelope";
|
||||
|
||||
const key = new Uint8Array(32).fill(0x42);
|
||||
const aad = new TextEncoder().encode("partition-1::file-7");
|
||||
const data = new TextEncoder().encode("secret payload");
|
||||
|
||||
const bundle = await envelopeEncrypt(key, data, { aad });
|
||||
const plaintext = await envelopeDecrypt(key, bundle);
|
||||
console.log(new TextDecoder().decode(plaintext)); // "secret payload"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
envelopeEncrypt,
|
||||
rewrapKey,
|
||||
wrapKey,
|
||||
unwrapKey,
|
||||
} from "@suaveplan/drive-sharing/encryption/envelope";
|
||||
|
||||
// Cross-partition key migration without exposing plaintext.
|
||||
const oldKEK = new Uint8Array(32).fill(0xaa);
|
||||
const newKEK = new Uint8Array(32).fill(0xbb);
|
||||
const dek = new Uint8Array(32).fill(0xcc);
|
||||
const aad = new TextEncoder().encode("partition-1::user-alice");
|
||||
|
||||
const wrapped = await wrapKey(oldKEK, dek, { aad });
|
||||
const rewrapped = await rewrapKey(oldKEK, newKEK, wrapped, { aad });
|
||||
const recovered = await unwrapKey(newKEK, rewrapped, { aad });
|
||||
// recovered === dek (by value)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface CryptoProvider`
|
||||
|
||||
Pluggable provider interface. Every method is async; AAD is mandatory on `encrypt`, `wrapKey`, and `unwrapKey`. The default implementation is `WebCryptoProvider`.
|
||||
|
||||
### `class WebCryptoProvider implements CryptoProvider`
|
||||
|
||||
Default provider. Uses `aes256gcm` / `xchacha20poly1305` from `@suaveplan/crypto-cipher`, draws nonces from `randomBytes` in `@suaveplan/crypto-utils`, computes integrity via `sha256` in `@suaveplan/crypto-hash`. AES-256-GCM is the default algorithm; `XChaCha20-Poly1305` is opt-in via the `algorithm` option.
|
||||
|
||||
### `async function envelopeEncrypt(key, data, options): Promise<EncryptedBundle>`
|
||||
|
||||
- `key: Uint8Array` — 32-byte AEAD key.
|
||||
- `data: Uint8Array` — plaintext.
|
||||
- `options.aad: Uint8Array` — required, non-empty.
|
||||
- `options.algorithm?: EncryptionAlgorithm` — defaults to `AES-GCM`.
|
||||
- **Throws** `DriveShareError(SHARE_ENCRYPTION_FAILED)` on any failure.
|
||||
|
||||
### `async function envelopeDecrypt(key, bundle): Promise<Uint8Array>`
|
||||
|
||||
Decrypts. Mismatched AAD or tampered ciphertext throws `SHARE_ENCRYPTION_FAILED`.
|
||||
|
||||
### `async function wrapKey(wrappingKey, keyToWrap, options): Promise<WrappedKey>`
|
||||
|
||||
Wraps `keyToWrap` with `wrappingKey`. The result carries `algorithm`, `data`, and a SHA-256 hex `checksum`.
|
||||
|
||||
### `async function unwrapKey(wrappingKey, wrappedKey, options): Promise<Uint8Array>`
|
||||
|
||||
Validates checksum then decrypts. Throws `SHARE_KEY_NOT_FOUND` on checksum mismatch and `SHARE_ENCRYPTION_FAILED` on AEAD failure.
|
||||
|
||||
### `async function rewrapKey(oldWrappingKey, newWrappingKey, wrappedKey, options): Promise<WrappedKey>`
|
||||
|
||||
Atomic rewrap. Zeroizes the intermediate plaintext key before returning.
|
||||
|
||||
### `async function computeIntegrity(data): Promise<string>`
|
||||
|
||||
SHA-256 hex digest.
|
||||
|
||||
### `async function verifyIntegrity(data, expected): Promise<boolean>`
|
||||
|
||||
Constant-time comparison.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bundle stores AAD in-band (`bundle.aad`) so `envelopeDecrypt` is callable with only `(key, bundle)` per the spec scenario. The AAD is also fed to the AEAD primitive at decrypt time, so any tampering of the in-band AAD invalidates the tag.
|
||||
- AES-KW is accepted as an `EncryptionAlgorithm` tag for backward compatibility but internally uses AES-256-GCM with the AAD bound to the partition || user context — this is functionally equivalent and avoids depending on an extra primitive.
|
||||
- The SHA-256 `checksum` field on `WrappedKey` is **not** a substitute for the AEAD tag — it is a fast sanity check so the unwrap function can reject obviously-corrupt data without paying the cost of an AEAD attempt.
|
||||
- Nonces are drawn fresh on every encrypt call via `randomBytes`. Because this routes through `crypto.getRandomValues`, the collision probability for a 96-bit GCM nonce is ~2⁻⁴⁸ per ~2³² keys — well below the IETF's 2⁻³² safety bound.
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
envelopeDecrypt,
|
||||
envelopeEncrypt,
|
||||
rewrapKey,
|
||||
unwrapKey,
|
||||
wrapKey,
|
||||
} from "./envelope.js";
|
||||
|
||||
const KEY = new Uint8Array(32).fill(0x42);
|
||||
const AAD = new TextEncoder().encode("p::f");
|
||||
const DATA = new TextEncoder().encode("payload");
|
||||
|
||||
describe("envelope — spec conformance", () => {
|
||||
it("envelope encrypt/decrypt invertibility", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const bundle = await envelopeEncrypt(KEY, DATA, { aad: AAD });
|
||||
expect(await envelopeDecrypt(KEY, bundle)).toEqual(DATA);
|
||||
});
|
||||
|
||||
it("rewrap then unwrap returns original DEK", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const oldKEK = new Uint8Array(32).fill(0xaa);
|
||||
const newKEK = new Uint8Array(32).fill(0xbb);
|
||||
const dek = new Uint8Array(32).fill(0xcc);
|
||||
const wrapped = await wrapKey(oldKEK, dek, { aad: AAD });
|
||||
const rewrapped = await rewrapKey(oldKEK, newKEK, wrapped, {
|
||||
aad: AAD,
|
||||
});
|
||||
const recovered = await unwrapKey(newKEK, rewrapped, { aad: AAD });
|
||||
expect(recovered).toEqual(dek);
|
||||
});
|
||||
|
||||
it("decrypt with empty AAD throws DriveShareError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bundle = await envelopeEncrypt(KEY, DATA, { aad: AAD });
|
||||
await expect(
|
||||
envelopeDecrypt(KEY, { ...bundle, aad: new Uint8Array(0) })
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
computeIntegrity,
|
||||
envelopeDecrypt,
|
||||
envelopeEncrypt,
|
||||
rewrapKey,
|
||||
unwrapKey,
|
||||
verifyIntegrity,
|
||||
WebCryptoProvider,
|
||||
wrapKey,
|
||||
} from "./envelope.js";
|
||||
|
||||
const KEY = new Uint8Array(32).fill(0x42);
|
||||
const KEY2 = new Uint8Array(32).fill(0x43);
|
||||
const AAD = new TextEncoder().encode("partition-1::file-7");
|
||||
const DATA = new TextEncoder().encode("secret payload");
|
||||
|
||||
describe("envelope — round-trip", () => {
|
||||
it("envelopeEncrypt then envelopeDecrypt returns original data", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const bundle = await envelopeEncrypt(KEY, DATA, { aad: AAD });
|
||||
const out = await envelopeDecrypt(KEY, bundle);
|
||||
expect(out).toEqual(DATA);
|
||||
});
|
||||
|
||||
it("XChaCha20-Poly1305 round-trip", { timeout: 30000 }, async () => {
|
||||
const bundle = await envelopeEncrypt(KEY, DATA, {
|
||||
aad: AAD,
|
||||
algorithm: "XChaCha20-Poly1305",
|
||||
});
|
||||
expect(bundle.algorithm).toBe("XChaCha20-Poly1305");
|
||||
const out = await envelopeDecrypt(KEY, bundle);
|
||||
expect(out).toEqual(DATA);
|
||||
});
|
||||
|
||||
it("envelopeDecrypt with wrong key throws SHARE_ENCRYPTION_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const bundle = await envelopeEncrypt(KEY, DATA, { aad: AAD });
|
||||
await expect(envelopeDecrypt(KEY2, bundle)).rejects.toBeInstanceOf(
|
||||
DriveShareError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty AAD on encrypt", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
envelopeEncrypt(KEY, DATA, { aad: new Uint8Array(0) })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty AAD on decrypt", { timeout: 5000 }, async () => {
|
||||
const bundle = await envelopeEncrypt(KEY, DATA, { aad: AAD });
|
||||
const tampered = { ...bundle, aad: new Uint8Array(0) };
|
||||
await expect(envelopeDecrypt(KEY, tampered)).rejects.toBeInstanceOf(
|
||||
DriveShareError
|
||||
);
|
||||
});
|
||||
|
||||
it("envelopeDecrypt with tampered AAD throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const bundle = await envelopeEncrypt(KEY, DATA, { aad: AAD });
|
||||
const tampered = {
|
||||
...bundle,
|
||||
aad: new TextEncoder().encode("different-aad"),
|
||||
};
|
||||
await expect(envelopeDecrypt(KEY, tampered)).rejects.toBeInstanceOf(
|
||||
DriveShareError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("envelope — wrap / unwrap / rewrap", () => {
|
||||
it("wrapKey then unwrapKey returns original key bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wrapping = new Uint8Array(32).fill(0x10);
|
||||
const dek = new Uint8Array(32).fill(0x20);
|
||||
const wrapped = await wrapKey(wrapping, dek, { aad: AAD });
|
||||
const out = await unwrapKey(wrapping, wrapped, { aad: AAD });
|
||||
expect(out).toEqual(dek);
|
||||
});
|
||||
|
||||
it("rewrapKey round-trip unwraps with new wrapping key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const oldKEK = new Uint8Array(32).fill(0xaa);
|
||||
const newKEK = new Uint8Array(32).fill(0xbb);
|
||||
const dek = new Uint8Array(32).fill(0xcc);
|
||||
const wrapped = await wrapKey(oldKEK, dek, { aad: AAD });
|
||||
const rewrapped = await rewrapKey(oldKEK, newKEK, wrapped, {
|
||||
aad: AAD,
|
||||
});
|
||||
const out = await unwrapKey(newKEK, rewrapped, { aad: AAD });
|
||||
expect(out).toEqual(dek);
|
||||
});
|
||||
|
||||
it("unwrapKey rejects corrupted checksum with SHARE_KEY_NOT_FOUND", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wrapping = new Uint8Array(32).fill(0x10);
|
||||
const dek = new Uint8Array(32).fill(0x20);
|
||||
const wrapped = await wrapKey(wrapping, dek, { aad: AAD });
|
||||
const tampered = { ...wrapped, checksum: "0".repeat(64) };
|
||||
await expect(
|
||||
unwrapKey(wrapping, tampered, { aad: AAD })
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("unwrapKey with wrong AAD throws SHARE_ENCRYPTION_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wrapping = new Uint8Array(32).fill(0x10);
|
||||
const dek = new Uint8Array(32).fill(0x20);
|
||||
const wrapped = await wrapKey(wrapping, dek, { aad: AAD });
|
||||
const otherAad = new TextEncoder().encode("other");
|
||||
await expect(
|
||||
unwrapKey(wrapping, wrapped, { aad: otherAad })
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("envelope — integrity", () => {
|
||||
it("computeIntegrity returns 64-char hex", { timeout: 5000 }, async () => {
|
||||
const out = await computeIntegrity(DATA);
|
||||
expect(out.length).toBe(64);
|
||||
expect(/^[0-9a-f]+$/.test(out)).toBe(true);
|
||||
});
|
||||
|
||||
it("verifyIntegrity matches", { timeout: 5000 }, async () => {
|
||||
const expected = await computeIntegrity(DATA);
|
||||
expect(await verifyIntegrity(DATA, expected)).toBe(true);
|
||||
});
|
||||
|
||||
it("verifyIntegrity detects single-bit corruption", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const expected = await computeIntegrity(DATA);
|
||||
const tampered = new Uint8Array(DATA);
|
||||
tampered[0] = (tampered[0] ?? 0) ^ 0x01;
|
||||
expect(await verifyIntegrity(tampered, expected)).toBe(false);
|
||||
});
|
||||
|
||||
it("verifyIntegrity rejects mismatched length", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
expect(await verifyIntegrity(DATA, "abc")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("envelope — algorithm tags", () => {
|
||||
it("AES-KW tag round-trips (treated as AES-GCM internally)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wrapping = new Uint8Array(32).fill(0xee);
|
||||
const dek = new Uint8Array(32).fill(0xff);
|
||||
const wrapped = await wrapKey(wrapping, dek, {
|
||||
aad: AAD,
|
||||
algorithm: "AES-KW",
|
||||
});
|
||||
expect(wrapped.algorithm).toBe("AES-KW");
|
||||
const out = await unwrapKey(wrapping, wrapped, { aad: AAD });
|
||||
expect(out).toEqual(dek);
|
||||
});
|
||||
});
|
||||
|
||||
describe("envelope — failure rethrow", () => {
|
||||
it("envelopeEncrypt with wrong-size key throws SHARE_ENCRYPTION_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
envelopeEncrypt(new Uint8Array(16), DATA, { aad: AAD })
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("WebCryptoProvider.encrypt with wrong key throws DriveShareError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = new WebCryptoProvider();
|
||||
await expect(
|
||||
p.encrypt(new Uint8Array(16), DATA, { aad: AAD })
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("WebCryptoProvider.decrypt of corrupted ciphertext throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = new WebCryptoProvider();
|
||||
const bundle = await p.encrypt(KEY, DATA, { aad: AAD });
|
||||
const tampered = {
|
||||
...bundle,
|
||||
ciphertext: new Uint8Array(bundle.ciphertext.length).fill(0),
|
||||
};
|
||||
await expect(p.decrypt(KEY, tampered)).rejects.toBeInstanceOf(
|
||||
DriveShareError
|
||||
);
|
||||
});
|
||||
|
||||
it("WebCryptoProvider.wrapKey with wrong-size key throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = new WebCryptoProvider();
|
||||
await expect(
|
||||
p.wrapKey(new Uint8Array(16), KEY, { aad: AAD })
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("envelope — WebCryptoProvider", () => {
|
||||
it("WebCryptoProvider encrypt-decrypt round-trip", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const p = new WebCryptoProvider();
|
||||
const bundle = await p.encrypt(KEY, DATA, { aad: AAD });
|
||||
const out = await p.decrypt(KEY, bundle);
|
||||
expect(out).toEqual(DATA);
|
||||
});
|
||||
|
||||
it("WebCryptoProvider wrapKey-unwrapKey round-trip", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const p = new WebCryptoProvider();
|
||||
const wrapping = new Uint8Array(32).fill(0x55);
|
||||
const dek = new Uint8Array(32).fill(0x66);
|
||||
const wrapped = await p.wrapKey(wrapping, dek, { aad: AAD });
|
||||
const out = await p.unwrapKey(wrapping, wrapped, { aad: AAD });
|
||||
expect(out).toEqual(dek);
|
||||
});
|
||||
|
||||
it("generateKey returns 32-byte buffer by default", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = new WebCryptoProvider();
|
||||
const k = await p.generateKey();
|
||||
expect(k.length).toBe(32);
|
||||
});
|
||||
|
||||
it("generateKey accepts explicit length", { timeout: 5000 }, async () => {
|
||||
const p = new WebCryptoProvider();
|
||||
const k = await p.generateKey(16);
|
||||
expect(k.length).toBe(16);
|
||||
});
|
||||
|
||||
it("computeIntegrity matches free function", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = new WebCryptoProvider();
|
||||
expect(await p.computeIntegrity(DATA)).toEqual(
|
||||
await computeIntegrity(DATA)
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypt rejects bundle with empty AAD", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = new WebCryptoProvider();
|
||||
const bundle = await p.encrypt(KEY, DATA, { aad: AAD });
|
||||
await expect(
|
||||
p.decrypt(KEY, { ...bundle, aad: new Uint8Array(0) })
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("encrypt rejects empty AAD", { timeout: 5000 }, async () => {
|
||||
const p = new WebCryptoProvider();
|
||||
await expect(
|
||||
p.encrypt(KEY, DATA, { aad: new Uint8Array(0) })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { SpanStatusCode } from "@opentelemetry/api";
|
||||
import { aes256gcm, xchacha20poly1305 } from "@suaveplan/crypto-cipher";
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
import {
|
||||
constantTimeEqual,
|
||||
randomBytes,
|
||||
toHex,
|
||||
zeroize,
|
||||
} from "@suaveplan/crypto-utils";
|
||||
import { ENCRYPTION_ALGORITHM } from "../../constants/index.js";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
getDriveShareInstruments,
|
||||
getDriveShareTracer,
|
||||
} from "../../otel/instruments.js";
|
||||
import type {
|
||||
EncryptedBundle,
|
||||
EncryptionAlgorithm,
|
||||
WrappedKey,
|
||||
} from "../../types/common.js";
|
||||
import { AadSchema } from "../../types/schemas.js";
|
||||
|
||||
/**
|
||||
* Caller-injectable crypto provider. Production paths use the default
|
||||
* `WebCryptoProvider`; tests and FIPS / HSM consumers may inject their own.
|
||||
*/
|
||||
export interface CryptoProvider {
|
||||
encrypt(
|
||||
key: Uint8Array,
|
||||
data: Uint8Array,
|
||||
options: { aad: Uint8Array; algorithm?: EncryptionAlgorithm }
|
||||
): Promise<EncryptedBundle>;
|
||||
decrypt(key: Uint8Array, bundle: EncryptedBundle): Promise<Uint8Array>;
|
||||
wrapKey(
|
||||
wrappingKey: Uint8Array,
|
||||
keyToWrap: Uint8Array,
|
||||
options: { aad: Uint8Array; algorithm?: EncryptionAlgorithm }
|
||||
): Promise<WrappedKey>;
|
||||
unwrapKey(
|
||||
wrappingKey: Uint8Array,
|
||||
wrappedKey: WrappedKey,
|
||||
options: { aad: Uint8Array }
|
||||
): Promise<Uint8Array>;
|
||||
generateKey(length?: number): Promise<Uint8Array>;
|
||||
computeIntegrity(data: Uint8Array): Promise<string>;
|
||||
}
|
||||
|
||||
/** Inputs for `envelopeEncrypt`. AAD is mandatory and must be non-empty. */
|
||||
export interface EnvelopeEncryptOptions {
|
||||
readonly aad: Uint8Array;
|
||||
readonly algorithm?: EncryptionAlgorithm;
|
||||
}
|
||||
|
||||
/** Inputs for `wrapKey`. */
|
||||
export interface KeyWrapOptions {
|
||||
readonly aad: Uint8Array;
|
||||
readonly algorithm?: EncryptionAlgorithm;
|
||||
}
|
||||
|
||||
function nonceLength(algorithm: EncryptionAlgorithm): number {
|
||||
if (algorithm === "AES-GCM") return aes256gcm.NONCE_LENGTH;
|
||||
if (algorithm === "XChaCha20-Poly1305")
|
||||
return xchacha20poly1305.NONCE_LENGTH;
|
||||
// AES-KW path uses AES-GCM under the hood (we never expose AES-KW direct).
|
||||
return aes256gcm.NONCE_LENGTH;
|
||||
}
|
||||
|
||||
async function checksumOf(data: Uint8Array): Promise<string> {
|
||||
return toHex(sha256(data));
|
||||
}
|
||||
|
||||
function aeadEncrypt(
|
||||
algorithm: EncryptionAlgorithm,
|
||||
key: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
plaintext: Uint8Array,
|
||||
aad: Uint8Array
|
||||
): Uint8Array {
|
||||
if (algorithm === "AES-GCM" || algorithm === "AES-KW") {
|
||||
return aes256gcm.encrypt(key, nonce, plaintext, aad);
|
||||
}
|
||||
return xchacha20poly1305.encrypt(key, nonce, plaintext, aad);
|
||||
}
|
||||
|
||||
function aeadDecrypt(
|
||||
algorithm: EncryptionAlgorithm,
|
||||
key: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
ciphertext: Uint8Array,
|
||||
aad: Uint8Array
|
||||
): Uint8Array {
|
||||
if (algorithm === "AES-GCM" || algorithm === "AES-KW") {
|
||||
return aes256gcm.decrypt(key, nonce, ciphertext, aad);
|
||||
}
|
||||
return xchacha20poly1305.decrypt(key, nonce, ciphertext, aad);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default `CryptoProvider` backed by the noble AEAD primitives in
|
||||
* `@suaveplan/crypto-cipher`. Nonces are drawn from `crypto.getRandomValues`
|
||||
* (via `@suaveplan/crypto-utils`'s `randomBytes`). All operations are
|
||||
* constant-time at the primitive layer.
|
||||
*/
|
||||
export class WebCryptoProvider implements CryptoProvider {
|
||||
public async encrypt(
|
||||
key: Uint8Array,
|
||||
data: Uint8Array,
|
||||
options: { aad: Uint8Array; algorithm?: EncryptionAlgorithm }
|
||||
): Promise<EncryptedBundle> {
|
||||
const algorithm = options.algorithm ?? ENCRYPTION_ALGORITHM;
|
||||
AadSchema.parse(options.aad);
|
||||
const nonce = randomBytes(nonceLength(algorithm));
|
||||
try {
|
||||
const ciphertext = aeadEncrypt(
|
||||
algorithm,
|
||||
key,
|
||||
nonce,
|
||||
data,
|
||||
options.aad
|
||||
);
|
||||
return { algorithm, nonce, ciphertext, aad: options.aad };
|
||||
} catch (cause) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_ENCRYPTION_FAILED",
|
||||
"error.drive-sharing.envelope.encrypt-failed",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async decrypt(
|
||||
key: Uint8Array,
|
||||
bundle: EncryptedBundle
|
||||
): Promise<Uint8Array> {
|
||||
if (bundle.aad.length === 0) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_ENCRYPTION_FAILED",
|
||||
"error.drive-sharing.envelope.aad-missing"
|
||||
);
|
||||
}
|
||||
try {
|
||||
return aeadDecrypt(
|
||||
bundle.algorithm,
|
||||
key,
|
||||
bundle.nonce,
|
||||
bundle.ciphertext,
|
||||
bundle.aad
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_ENCRYPTION_FAILED",
|
||||
"error.drive-sharing.envelope.decrypt-failed",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async wrapKey(
|
||||
wrappingKey: Uint8Array,
|
||||
keyToWrap: Uint8Array,
|
||||
options: { aad: Uint8Array; algorithm?: EncryptionAlgorithm }
|
||||
): Promise<WrappedKey> {
|
||||
const algorithm = options.algorithm ?? ENCRYPTION_ALGORITHM;
|
||||
AadSchema.parse(options.aad);
|
||||
const nonce = randomBytes(nonceLength(algorithm));
|
||||
try {
|
||||
const ct = aeadEncrypt(
|
||||
algorithm,
|
||||
wrappingKey,
|
||||
nonce,
|
||||
keyToWrap,
|
||||
options.aad
|
||||
);
|
||||
const data = new Uint8Array(nonce.length + ct.length);
|
||||
data.set(nonce, 0);
|
||||
data.set(ct, nonce.length);
|
||||
return { algorithm, data, checksum: await checksumOf(data) };
|
||||
} catch (cause) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_ENCRYPTION_FAILED",
|
||||
"error.drive-sharing.envelope.wrap-failed",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async unwrapKey(
|
||||
wrappingKey: Uint8Array,
|
||||
wrapped: WrappedKey,
|
||||
options: { aad: Uint8Array }
|
||||
): Promise<Uint8Array> {
|
||||
AadSchema.parse(options.aad);
|
||||
const recomputed = await checksumOf(wrapped.data);
|
||||
if (recomputed !== wrapped.checksum) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_KEY_NOT_FOUND",
|
||||
"error.drive-sharing.envelope.checksum-mismatch"
|
||||
);
|
||||
}
|
||||
const nLen = nonceLength(wrapped.algorithm);
|
||||
const nonce = wrapped.data.subarray(0, nLen);
|
||||
const ct = wrapped.data.subarray(nLen);
|
||||
try {
|
||||
return aeadDecrypt(
|
||||
wrapped.algorithm,
|
||||
wrappingKey,
|
||||
nonce,
|
||||
ct,
|
||||
options.aad
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_ENCRYPTION_FAILED",
|
||||
"error.drive-sharing.envelope.unwrap-failed",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async generateKey(length = 32): Promise<Uint8Array> {
|
||||
return randomBytes(length);
|
||||
}
|
||||
|
||||
public async computeIntegrity(data: Uint8Array): Promise<string> {
|
||||
return checksumOf(data);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProvider: CryptoProvider = new WebCryptoProvider();
|
||||
|
||||
/**
|
||||
* Encrypts `data` with `key` using AEAD. The AAD MUST be non-empty — calls
|
||||
* with an empty AAD throw `SHARE_ENCRYPTION_FAILED` to enforce the partition-
|
||||
* binding security guarantee. Records `share.envelope.duration_ms` and
|
||||
* starts a `share.envelope.encrypt` span.
|
||||
*/
|
||||
export async function envelopeEncrypt(
|
||||
key: Uint8Array,
|
||||
data: Uint8Array,
|
||||
options: EnvelopeEncryptOptions
|
||||
): Promise<EncryptedBundle> {
|
||||
AadSchema.parse(options.aad);
|
||||
const algorithm = options.algorithm ?? ENCRYPTION_ALGORITHM;
|
||||
const span = getDriveShareTracer().startSpan("share.envelope.encrypt");
|
||||
span.setAttribute("encryption.algorithm", algorithm);
|
||||
span.setAttribute("aad.length", options.aad.length);
|
||||
const start = performance.now();
|
||||
try {
|
||||
const nonce = randomBytes(nonceLength(algorithm));
|
||||
const ciphertext = aeadEncrypt(
|
||||
algorithm,
|
||||
key,
|
||||
nonce,
|
||||
data,
|
||||
options.aad
|
||||
);
|
||||
return { algorithm, nonce, ciphertext, aad: options.aad };
|
||||
} catch (cause) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_ENCRYPTION_FAILED",
|
||||
"error.drive-sharing.envelope.encrypt-failed",
|
||||
{ cause }
|
||||
);
|
||||
} finally {
|
||||
const elapsed = performance.now() - start;
|
||||
getDriveShareInstruments().envelopeDurationMs.record(elapsed, {
|
||||
op: "encrypt",
|
||||
});
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an `EncryptedBundle` produced by `envelopeEncrypt`. The bundle
|
||||
* carries the AAD as a sealed field; mismatched AAD or a tampered ciphertext
|
||||
* produces `SHARE_ENCRYPTION_FAILED`. Records `share.envelope.duration_ms`
|
||||
* and starts a `share.envelope.decrypt` span.
|
||||
*/
|
||||
export async function envelopeDecrypt(
|
||||
key: Uint8Array,
|
||||
bundle: EncryptedBundle
|
||||
): Promise<Uint8Array> {
|
||||
if (bundle.aad.length === 0) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_ENCRYPTION_FAILED",
|
||||
"error.drive-sharing.envelope.aad-missing"
|
||||
);
|
||||
}
|
||||
const span = getDriveShareTracer().startSpan("share.envelope.decrypt");
|
||||
span.setAttribute("encryption.algorithm", bundle.algorithm);
|
||||
span.setAttribute("aad.length", bundle.aad.length);
|
||||
const start = performance.now();
|
||||
try {
|
||||
return aeadDecrypt(
|
||||
bundle.algorithm,
|
||||
key,
|
||||
bundle.nonce,
|
||||
bundle.ciphertext,
|
||||
bundle.aad
|
||||
);
|
||||
} catch (cause) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_ENCRYPTION_FAILED",
|
||||
"error.drive-sharing.envelope.decrypt-failed",
|
||||
{ cause }
|
||||
);
|
||||
} finally {
|
||||
const elapsed = performance.now() - start;
|
||||
getDriveShareInstruments().envelopeDurationMs.record(elapsed, {
|
||||
op: "decrypt",
|
||||
});
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps `keyToWrap` with `wrappingKey`. Produces a `WrappedKey` with the
|
||||
* AEAD nonce concatenated to the ciphertext, plus a SHA-256 checksum of the
|
||||
* concatenation for tamper detection by `unwrapKey`.
|
||||
*/
|
||||
export async function wrapKey(
|
||||
wrappingKey: Uint8Array,
|
||||
keyToWrap: Uint8Array,
|
||||
options: KeyWrapOptions
|
||||
): Promise<WrappedKey> {
|
||||
return defaultProvider.wrapKey(wrappingKey, keyToWrap, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps `wrappedKey` with `wrappingKey`. Validates the SHA-256 checksum
|
||||
* before invoking the AEAD primitive — a checksum mismatch throws
|
||||
* `SHARE_KEY_NOT_FOUND` (the `WrappedKey` is treated as corrupt and absent
|
||||
* from the keyring). Returns the original key bytes on success.
|
||||
*/
|
||||
export async function unwrapKey(
|
||||
wrappingKey: Uint8Array,
|
||||
wrappedKey: WrappedKey,
|
||||
options: { aad: Uint8Array }
|
||||
): Promise<Uint8Array> {
|
||||
return defaultProvider.unwrapKey(wrappingKey, wrappedKey, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically rewraps `wrappedKey` from `oldWrappingKey` to `newWrappingKey`
|
||||
* without exposing plaintext to the caller. Performs the unwrap and rewrap
|
||||
* in a single function so the intermediate plaintext key is zeroized
|
||||
* eagerly even on success.
|
||||
*
|
||||
* `oldAad` is the AAD originally bound when wrapping; `newAad` is the AAD
|
||||
* to bind the rewrapped output. When both are equal the option may be
|
||||
* supplied as `aad` for convenience.
|
||||
*/
|
||||
export async function rewrapKey(
|
||||
oldWrappingKey: Uint8Array,
|
||||
newWrappingKey: Uint8Array,
|
||||
wrappedKey: WrappedKey,
|
||||
options:
|
||||
| { aad: Uint8Array; algorithm?: EncryptionAlgorithm }
|
||||
| {
|
||||
oldAad: Uint8Array;
|
||||
newAad: Uint8Array;
|
||||
algorithm?: EncryptionAlgorithm;
|
||||
}
|
||||
): Promise<WrappedKey> {
|
||||
const oldAad = "aad" in options ? options.aad : options.oldAad;
|
||||
const newAad = "aad" in options ? options.aad : options.newAad;
|
||||
const dek = await unwrapKey(oldWrappingKey, wrappedKey, { aad: oldAad });
|
||||
try {
|
||||
const wrapOpts: KeyWrapOptions =
|
||||
options.algorithm !== undefined
|
||||
? { aad: newAad, algorithm: options.algorithm }
|
||||
: { aad: newAad };
|
||||
return await wrapKey(newWrappingKey, dek, wrapOpts);
|
||||
} finally {
|
||||
zeroize(dek);
|
||||
}
|
||||
}
|
||||
|
||||
/** SHA-256 hex digest of `data`. */
|
||||
export async function computeIntegrity(data: Uint8Array): Promise<string> {
|
||||
return checksumOf(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time integrity verification. Returns `true` iff
|
||||
* `computeIntegrity(data) === expected`. Uses `constantTimeEqual` so an
|
||||
* attacker cannot probe via timing.
|
||||
*/
|
||||
export async function verifyIntegrity(
|
||||
data: Uint8Array,
|
||||
expected: string
|
||||
): Promise<boolean> {
|
||||
const actual = await computeIntegrity(data);
|
||||
if (actual.length !== expected.length) return false;
|
||||
const a = new TextEncoder().encode(actual);
|
||||
const b = new TextEncoder().encode(expected);
|
||||
return constantTimeEqual(a, b);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# `encryption/key-ring/key-ring.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`KeyRing` is the multi-user wrapped-key store at the heart of `@suaveplan/drive-sharing`. It owns the partition master key, holds per-user data keys (DEKs) wrapped under that master key, and exposes the lifecycle operations needed to add, retrieve, rotate, remove, export, and import wrapped entries. Every operation emits a lifecycle event so callers can persist to durable storage and audit changes; events deliberately carry only `userId` (no key material).
|
||||
|
||||
## Features
|
||||
|
||||
- AAD-bound wrap / unwrap via `wrapKey` / `unwrapKey` from `encryption/envelope`. The AAD is per-user (`drive-sharing/v1/keyring/<userId>`) so a wrapped key cannot be moved between users without producing an AEAD failure.
|
||||
- `rotateKey` rewraps a single user's entry; `rotateMasterKey` rewraps every entry atomically. Both zeroize old buffers via `@suaveplan/crypto-utils`'s `zeroize`.
|
||||
- Versioned JSON-like `export` / `import`. The export carries `keyring_format_version: 1`; unknown versions throw `SHARE_KEYRING_FORMAT_UNSUPPORTED`.
|
||||
- Hard-cap on entries (`KEYRING_MAX_ENTRIES = 100_000`) prevents pathological bulk-add denial-of-service.
|
||||
- Event bus (`key:added`, `key:removed`, `key:rotated`) with `on(event, listener) => unsubscribe`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
|
||||
import { userId } from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
const ring = new KeyRing();
|
||||
const dek = new Uint8Array(32).fill(0xab);
|
||||
await ring.addKey(userId("alice"), dek);
|
||||
const recovered = await ring.getKey(userId("alice"));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
|
||||
import { userId } from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
const masterKey = new Uint8Array(32).fill(0x01);
|
||||
const ring = new KeyRing({ masterKey });
|
||||
|
||||
const off = ring.on("key:rotated", ({ userId }) => {
|
||||
console.log("rotated:", userId);
|
||||
});
|
||||
|
||||
await ring.addKey(userId("alice"), new Uint8Array(32).fill(0x10));
|
||||
await ring.rotateKey(userId("alice"), new Uint8Array(32).fill(0x20));
|
||||
|
||||
// Backup / restore round-trip.
|
||||
const blob = ring.export();
|
||||
const restored = new KeyRing({ masterKey });
|
||||
await restored.import(blob);
|
||||
|
||||
off();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class KeyRing`
|
||||
|
||||
- `constructor(config?: KeyRingConfig)` — `masterKey` defaults to a fresh 32-byte CSPRNG buffer.
|
||||
- `hasKey(userId): boolean`
|
||||
- `listKeys(): UserId[]`
|
||||
- `addKey(userId, key): Promise<void>` — wraps and stores. Emits `key:added`.
|
||||
- `getKey(userId): Promise<Uint8Array | undefined>`
|
||||
- `removeKey(userId): Promise<void>` — emits `key:removed` only when an entry is actually removed.
|
||||
- `rotateKey(userId, newKey): Promise<UserWrappedKey>` — replaces the entry, emits `key:rotated`, increments `share.key.rotate_total`.
|
||||
- `rotateMasterKey(newMasterKey): Promise<void>` — rewraps every entry atomically.
|
||||
- `export(): { keyring_format_version: 1; entries: UserWrappedKey[] }`
|
||||
- `import(payload): Promise<void>` — Zod-validated.
|
||||
- `on(event, listener): () => void`
|
||||
|
||||
### `interface UserWrappedKey`
|
||||
|
||||
`{ userId, wrappedKey }`. The export shape stores arrays of these.
|
||||
|
||||
### `interface KeyRingConfig`
|
||||
|
||||
`{ masterKey?: Uint8Array }`. Master key length must be exactly 32 bytes.
|
||||
|
||||
### Events
|
||||
|
||||
- `key:added` — `{ userId }`
|
||||
- `key:removed` — `{ userId }`
|
||||
- `key:rotated` — `{ userId }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The constructor copies the supplied `masterKey` so the caller's buffer can be zeroized independently. `rotateMasterKey` writes the new key into the same buffer slot via `Uint8Array.set` so external references to the slot are invalidated rather than left dangling.
|
||||
- `export` deliberately produces a plain object (not a JSON string) so the caller can pipe it through their preferred serializer (e.g. `serialize` for tagged JSON). Validation lives in `KeyRingExportSchema`.
|
||||
- The hard cap `KEYRING_MAX_ENTRIES = 100_000` matches the OpenSpec scalability table. Above this, the spec calls for sharded ring storage which is out of scope for this package.
|
||||
- `rotateKey` zeroizes the caller-supplied `newKey` on success — this matches the spec's "no plaintext key in caller's heap after success" guarantee. If the caller wants to retain the bytes they should pass a copy.
|
||||
- The `aadFor` derivation uses ASCII text (`drive-sharing/v1/keyring/<userId>`) rather than raw bytes so the AAD is human-readable in audit logs that capture it.
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { userId } from "../../types/common.js";
|
||||
import { KeyRing } from "./key-ring.js";
|
||||
|
||||
describe("KeyRing — spec conformance", () => {
|
||||
it("export carries keyring_format_version 1", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const ring = new KeyRing();
|
||||
await ring.addKey(userId("a"), new Uint8Array(32).fill(0x10));
|
||||
const blob = ring.export();
|
||||
expect(blob.keyring_format_version).toBe(1);
|
||||
});
|
||||
|
||||
it("addKey emits key:added with no key material", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const ring = new KeyRing();
|
||||
const events: unknown[] = [];
|
||||
ring.on("key:added", (p) => events.push(p));
|
||||
await ring.addKey(userId("a"), new Uint8Array(32).fill(0x10));
|
||||
expect(events).toEqual([{ userId: "a" }]);
|
||||
// No key material in the payload.
|
||||
const payload = events[0] as { userId: string };
|
||||
expect(Object.keys(payload).sort()).toEqual(["userId"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import { userId } from "../../types/common.js";
|
||||
import { KeyRing } from "./key-ring.js";
|
||||
|
||||
const ALICE = userId("alice");
|
||||
const BOB = userId("bob");
|
||||
|
||||
describe("KeyRing — basic", () => {
|
||||
it("addKey then getKey returns original bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const ring = new KeyRing();
|
||||
const dek = new Uint8Array(32).fill(0xaa);
|
||||
await ring.addKey(ALICE, dek);
|
||||
const recovered = await ring.getKey(ALICE);
|
||||
expect(recovered).toEqual(dek);
|
||||
});
|
||||
|
||||
it("getKey returns undefined for missing user", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ring = new KeyRing();
|
||||
expect(await ring.getKey(ALICE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hasKey reflects add/remove", { timeout: 30000 }, async () => {
|
||||
const ring = new KeyRing();
|
||||
await ring.addKey(ALICE, new Uint8Array(32).fill(0x11));
|
||||
expect(ring.hasKey(ALICE)).toBe(true);
|
||||
await ring.removeKey(ALICE);
|
||||
expect(ring.hasKey(ALICE)).toBe(false);
|
||||
});
|
||||
|
||||
it("listKeys returns added users", { timeout: 30000 }, async () => {
|
||||
const ring = new KeyRing();
|
||||
await ring.addKey(ALICE, new Uint8Array(32).fill(0x11));
|
||||
await ring.addKey(BOB, new Uint8Array(32).fill(0x22));
|
||||
expect(ring.listKeys().sort()).toEqual([ALICE, BOB].sort());
|
||||
});
|
||||
|
||||
it("removeKey on missing user is a no-op", { timeout: 5000 }, async () => {
|
||||
const ring = new KeyRing();
|
||||
await expect(ring.removeKey(ALICE)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects 31-byte master key", { timeout: 5000 }, () => {
|
||||
expect(() => new KeyRing({ masterKey: new Uint8Array(31) })).toThrow(
|
||||
DriveShareError
|
||||
);
|
||||
});
|
||||
|
||||
it("addKey throws SHARE_POLICY_VIOLATION beyond maxEntries", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const ring = new KeyRing({ maxEntries: 1 });
|
||||
await ring.addKey(ALICE, new Uint8Array(32).fill(0x10));
|
||||
await expect(
|
||||
ring.addKey(BOB, new Uint8Array(32).fill(0x20))
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("export caps at maxEntries", { timeout: 30000 }, async () => {
|
||||
const ring = new KeyRing({ maxEntries: 2 });
|
||||
await ring.addKey(ALICE, new Uint8Array(32).fill(0x10));
|
||||
await ring.addKey(BOB, new Uint8Array(32).fill(0x20));
|
||||
// Cannot exceed maxEntries via addKey, so the cap branch fires only
|
||||
// when the export iterator would otherwise emit a third entry; test
|
||||
// boundary is the count itself.
|
||||
const blob = ring.export();
|
||||
expect(blob.entries.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyRing — events", () => {
|
||||
it("emits key:added on addKey", { timeout: 30000 }, async () => {
|
||||
const ring = new KeyRing();
|
||||
const events: unknown[] = [];
|
||||
const off = ring.on("key:added", (p) => events.push(p));
|
||||
await ring.addKey(ALICE, new Uint8Array(32).fill(0x55));
|
||||
expect(events).toEqual([{ userId: ALICE }]);
|
||||
off();
|
||||
});
|
||||
|
||||
it("emits key:removed on removeKey when present", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const ring = new KeyRing();
|
||||
await ring.addKey(ALICE, new Uint8Array(32).fill(0x55));
|
||||
const events: unknown[] = [];
|
||||
ring.on("key:removed", (p) => events.push(p));
|
||||
await ring.removeKey(ALICE);
|
||||
expect(events).toEqual([{ userId: ALICE }]);
|
||||
});
|
||||
|
||||
it("emits key:rotated on rotateKey", { timeout: 30000 }, async () => {
|
||||
const ring = new KeyRing();
|
||||
await ring.addKey(ALICE, new Uint8Array(32).fill(0x55));
|
||||
const events: unknown[] = [];
|
||||
ring.on("key:rotated", (p) => events.push(p));
|
||||
const next = new Uint8Array(32).fill(0x66);
|
||||
await ring.rotateKey(ALICE, next);
|
||||
expect(events).toEqual([{ userId: ALICE }]);
|
||||
});
|
||||
|
||||
it("unsubscribe stops further events", { timeout: 30000 }, async () => {
|
||||
const ring = new KeyRing();
|
||||
const events: unknown[] = [];
|
||||
const off = ring.on("key:added", (p) => events.push(p));
|
||||
off();
|
||||
await ring.addKey(ALICE, new Uint8Array(32).fill(0x55));
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyRing — rotation", () => {
|
||||
it("rotateKey replaces the entry and produces a new wrapped key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const ring = new KeyRing();
|
||||
await ring.addKey(ALICE, new Uint8Array(32).fill(0x55));
|
||||
const next = new Uint8Array(32).fill(0x66);
|
||||
const result = await ring.rotateKey(ALICE, next.slice());
|
||||
expect(result.userId).toBe(ALICE);
|
||||
const reread = await ring.getKey(ALICE);
|
||||
expect(reread).toEqual(next);
|
||||
});
|
||||
|
||||
it("rotateKey throws SHARE_KEY_NOT_FOUND when entry missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ring = new KeyRing();
|
||||
await expect(
|
||||
ring.rotateKey(ALICE, new Uint8Array(32).fill(0xaa))
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("rotateMasterKey rewraps every entry", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const ring = new KeyRing({ masterKey: new Uint8Array(32).fill(0x01) });
|
||||
const aliceKey = new Uint8Array(32).fill(0x10);
|
||||
const bobKey = new Uint8Array(32).fill(0x20);
|
||||
await ring.addKey(ALICE, aliceKey.slice());
|
||||
await ring.addKey(BOB, bobKey.slice());
|
||||
await ring.rotateMasterKey(new Uint8Array(32).fill(0x02));
|
||||
expect(await ring.getKey(ALICE)).toEqual(aliceKey);
|
||||
expect(await ring.getKey(BOB)).toEqual(bobKey);
|
||||
});
|
||||
|
||||
it("rotateMasterKey rejects 16-byte key", { timeout: 5000 }, async () => {
|
||||
const ring = new KeyRing();
|
||||
await expect(
|
||||
ring.rotateMasterKey(new Uint8Array(16))
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyRing — export / import", () => {
|
||||
it("export-import round-trip preserves all keys", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const masterKey = new Uint8Array(32).fill(0x77);
|
||||
const a = new KeyRing({ masterKey });
|
||||
const aliceKey = new Uint8Array(32).fill(0x10);
|
||||
const bobKey = new Uint8Array(32).fill(0x20);
|
||||
await a.addKey(ALICE, aliceKey.slice());
|
||||
await a.addKey(BOB, bobKey.slice());
|
||||
const blob = a.export();
|
||||
expect(blob.keyring_format_version).toBe(1);
|
||||
expect(blob.entries.length).toBe(2);
|
||||
|
||||
const b = new KeyRing({ masterKey });
|
||||
await b.import(blob);
|
||||
expect(await b.getKey(ALICE)).toEqual(aliceKey);
|
||||
expect(await b.getKey(BOB)).toEqual(bobKey);
|
||||
});
|
||||
|
||||
it("import rejects unknown version with SHARE_KEYRING_FORMAT_UNSUPPORTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ring = new KeyRing();
|
||||
await expect(
|
||||
ring.import({ keyring_format_version: 2, entries: [] })
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("import rejects malformed entry", { timeout: 5000 }, async () => {
|
||||
const ring = new KeyRing();
|
||||
await expect(
|
||||
ring.import({
|
||||
keyring_format_version: 1,
|
||||
entries: [{ userId: "u", wrappedKey: { algorithm: "X" } }],
|
||||
})
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { zeroize } from "@suaveplan/crypto-utils";
|
||||
import {
|
||||
KEYRING_FORMAT_VERSION,
|
||||
KEYRING_MAX_ENTRIES,
|
||||
} from "../../constants/index.js";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import { getDriveShareInstruments } from "../../otel/instruments.js";
|
||||
import type { UserId, WrappedKey } from "../../types/common.js";
|
||||
import { KeyRingExportSchema } from "../../types/schemas.js";
|
||||
import { rewrapKey, unwrapKey, wrapKey } from "../envelope/envelope.js";
|
||||
|
||||
/** Wrapped-key entry persisted in `KeyRing`. */
|
||||
export interface UserWrappedKey {
|
||||
readonly userId: UserId;
|
||||
readonly wrappedKey: WrappedKey;
|
||||
}
|
||||
|
||||
/** Constructor options for `KeyRing`. */
|
||||
export interface KeyRingConfig {
|
||||
/** Master key used as the wrapping key. Generated if omitted. */
|
||||
readonly masterKey?: Uint8Array;
|
||||
/** Override the entry cap. Defaults to `KEYRING_MAX_ENTRIES`. */
|
||||
readonly maxEntries?: number;
|
||||
}
|
||||
|
||||
/** Lifecycle event payloads emitted by `KeyRing`. */
|
||||
export interface KeyRingEventPayloads {
|
||||
"key:added": { userId: UserId };
|
||||
"key:removed": { userId: UserId };
|
||||
"key:rotated": { userId: UserId };
|
||||
}
|
||||
|
||||
type Listener<K extends keyof KeyRingEventPayloads> = (
|
||||
payload: KeyRingEventPayloads[K]
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Multi-user wrapped-key store. Each entry stores a per-user data key
|
||||
* (DEK) wrapped under the master key. Operations are eventfully observable
|
||||
* (`key:added` / `key:removed` / `key:rotated`) so callers can persist
|
||||
* state changes to durable storage.
|
||||
*/
|
||||
export class KeyRing {
|
||||
private readonly entries: Map<UserId, WrappedKey> = new Map();
|
||||
private readonly masterKey: Uint8Array;
|
||||
private readonly maxEntries: number;
|
||||
private readonly listeners: {
|
||||
[K in keyof KeyRingEventPayloads]: Set<Listener<K>>;
|
||||
} = {
|
||||
"key:added": new Set<Listener<"key:added">>(),
|
||||
"key:removed": new Set<Listener<"key:removed">>(),
|
||||
"key:rotated": new Set<Listener<"key:rotated">>(),
|
||||
};
|
||||
|
||||
public constructor(config: KeyRingConfig = {}) {
|
||||
this.maxEntries = config.maxEntries ?? KEYRING_MAX_ENTRIES;
|
||||
if (config.masterKey !== undefined) {
|
||||
if (config.masterKey.length !== 32) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_KEY_NOT_FOUND",
|
||||
"error.drive-sharing.keyring.master-key-length"
|
||||
);
|
||||
}
|
||||
this.masterKey = config.masterKey.slice();
|
||||
} else {
|
||||
const buf = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(buf);
|
||||
this.masterKey = buf;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true when an entry for `userId` exists. */
|
||||
public hasKey(userId: UserId): boolean {
|
||||
return this.entries.has(userId);
|
||||
}
|
||||
|
||||
/** Lists all user IDs currently present in the ring. */
|
||||
public listKeys(): UserId[] {
|
||||
return Array.from(this.entries.keys());
|
||||
}
|
||||
|
||||
/** Wraps and stores `key` for `userId`. Replaces any existing entry. */
|
||||
public async addKey(userId: UserId, key: Uint8Array): Promise<void> {
|
||||
if (this.entries.size >= this.maxEntries) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_POLICY_VIOLATION",
|
||||
"error.drive-sharing.keyring.max-entries"
|
||||
);
|
||||
}
|
||||
const aad = this.aadFor(userId);
|
||||
const wrapped = await wrapKey(this.masterKey, key, { aad });
|
||||
this.entries.set(userId, wrapped);
|
||||
this.emit("key:added", { userId });
|
||||
}
|
||||
|
||||
/** Unwraps and returns the key for `userId`, or `undefined` if missing. */
|
||||
public async getKey(userId: UserId): Promise<Uint8Array | undefined> {
|
||||
const wrapped = this.entries.get(userId);
|
||||
if (wrapped === undefined) return undefined;
|
||||
const aad = this.aadFor(userId);
|
||||
return unwrapKey(this.masterKey, wrapped, { aad });
|
||||
}
|
||||
|
||||
/** Removes the entry for `userId`. */
|
||||
public async removeKey(userId: UserId): Promise<void> {
|
||||
if (!this.entries.delete(userId)) return;
|
||||
this.emit("key:removed", { userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically rewraps the entry for `userId` to a new plaintext key.
|
||||
* Emits `key:rotated`. Returns the new wrapped key.
|
||||
*/
|
||||
public async rotateKey(
|
||||
userId: UserId,
|
||||
newKey: Uint8Array
|
||||
): Promise<UserWrappedKey> {
|
||||
const existing = this.entries.get(userId);
|
||||
if (existing === undefined) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_KEY_NOT_FOUND",
|
||||
"error.drive-sharing.keyring.rotate-missing"
|
||||
);
|
||||
}
|
||||
const aad = this.aadFor(userId);
|
||||
// Wrap the new key fresh (we do not need to unwrap the old one).
|
||||
const wrapped = await wrapKey(this.masterKey, newKey, { aad });
|
||||
this.entries.set(userId, wrapped);
|
||||
// Discourage data-key retention in the caller's heap.
|
||||
zeroize(newKey);
|
||||
this.emit("key:rotated", { userId });
|
||||
getDriveShareInstruments().keyRotateTotal.add(1);
|
||||
return { userId, wrappedKey: wrapped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-wraps every entry to a new master key. Old wrapped keys are
|
||||
* discarded only after every rewrap succeeds — failure mid-operation
|
||||
* leaves the ring untouched.
|
||||
*/
|
||||
public async rotateMasterKey(newMasterKey: Uint8Array): Promise<void> {
|
||||
if (newMasterKey.length !== 32) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_KEY_NOT_FOUND",
|
||||
"error.drive-sharing.keyring.master-key-length"
|
||||
);
|
||||
}
|
||||
const next = new Map<UserId, WrappedKey>();
|
||||
for (const [userId, wrapped] of this.entries) {
|
||||
const aad = this.aadFor(userId);
|
||||
const rewrapped = await rewrapKey(
|
||||
this.masterKey,
|
||||
newMasterKey,
|
||||
wrapped,
|
||||
{ aad }
|
||||
);
|
||||
next.set(userId, rewrapped);
|
||||
}
|
||||
zeroize(this.masterKey);
|
||||
// Replace the master key bytes in place so consumers cannot retain
|
||||
// a reference to the old buffer.
|
||||
this.masterKey.set(newMasterKey);
|
||||
this.entries.clear();
|
||||
for (const [userId, wrapped] of next) {
|
||||
this.entries.set(userId, wrapped);
|
||||
getDriveShareInstruments().keyRotateTotal.add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the entire ring as a versioned, schema-validatable JSON-like
|
||||
* structure. Caps at `KEYRING_MAX_ENTRIES` to bound memory.
|
||||
*/
|
||||
public export(): {
|
||||
keyring_format_version: 1;
|
||||
entries: UserWrappedKey[];
|
||||
} {
|
||||
const entries: UserWrappedKey[] = [];
|
||||
for (const [userId, wrappedKey] of this.entries) {
|
||||
entries.push({ userId, wrappedKey });
|
||||
if (entries.length >= this.maxEntries) break;
|
||||
}
|
||||
return {
|
||||
keyring_format_version: KEYRING_FORMAT_VERSION,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a previously-exported payload. Rejects unknown format versions
|
||||
* with `SHARE_KEYRING_FORMAT_UNSUPPORTED`.
|
||||
*/
|
||||
public async import(payload: unknown): Promise<void> {
|
||||
const parsed = KeyRingExportSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_KEYRING_FORMAT_UNSUPPORTED",
|
||||
"error.drive-sharing.keyring.format-unsupported",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
for (const entry of parsed.data.entries) {
|
||||
this.entries.set(
|
||||
entry.userId as UserId,
|
||||
entry.wrappedKey as WrappedKey
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a lifecycle event. Returns an unsubscribe function that
|
||||
* detaches the listener.
|
||||
*/
|
||||
public on<K extends keyof KeyRingEventPayloads>(
|
||||
event: K,
|
||||
listener: Listener<K>
|
||||
): () => void {
|
||||
this.listeners[event].add(listener as Listener<K>);
|
||||
return () => {
|
||||
this.listeners[event].delete(listener as Listener<K>);
|
||||
};
|
||||
}
|
||||
|
||||
private emit<K extends keyof KeyRingEventPayloads>(
|
||||
event: K,
|
||||
payload: KeyRingEventPayloads[K]
|
||||
): void {
|
||||
for (const listener of this.listeners[event]) {
|
||||
(listener as Listener<K>)(payload);
|
||||
}
|
||||
}
|
||||
|
||||
private aadFor(userId: UserId): Uint8Array {
|
||||
return new TextEncoder().encode(`drive-sharing/v1/keyring/${userId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `error-codes` module is the single source of truth for every failure category emitted by `@suaveplan/drive-sharing`. It registers a frozen, namespaced code list with the global `defineCodes` registry from `@suaveplan/error` and exports the concrete `DriveShareError` subclass that every public code path throws. Stable, machine-readable codes let consumers write exhaustive `switch` statements on caught errors without having to parse human-readable messages. The brand symbol on every `DriveShareError` instance protects callers from imposters (e.g. user-land error classes that copy the field shape but were thrown elsewhere).
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen literal-typed `DRIVE_SHARE_CODES` registry covering eleven distinct failure reasons across partitions, grants, tokens, keys, encryption, transfer, and key-ring import.
|
||||
- `DriveShareError` extends `SuaveplanError<DriveShareErrorCode>` so `error.code` narrows to the literal union.
|
||||
- Brand-symbol stamping plus a static `DriveShareError.is()` guard for cross-realm safety.
|
||||
- Idempotent eager-registration helper (`registerDriveShareErrorCodes`) so consumers can pre-warm the registry from a startup hook.
|
||||
- Side-effect call at module bottom guarantees the codes are registered the first time the module is imported.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
DriveShareError,
|
||||
DRIVE_SHARE_CODES,
|
||||
} from "@suaveplan/drive-sharing/errors";
|
||||
|
||||
try {
|
||||
// ...some drive-sharing call...
|
||||
} catch (error) {
|
||||
if (DriveShareError.is(error)) {
|
||||
if (error.code === DRIVE_SHARE_CODES.SHARE_PARTITION_NOT_FOUND) {
|
||||
console.warn("partition vanished — falling back to read-only");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
DriveShareError,
|
||||
type DriveShareErrorCode,
|
||||
} from "@suaveplan/drive-sharing/errors";
|
||||
|
||||
// Exhaustive switch — TypeScript will complain if you forget a case.
|
||||
function classify(error: DriveShareError): "transient" | "permanent" {
|
||||
const code: DriveShareErrorCode = error.code;
|
||||
switch (code) {
|
||||
case "DRIVE_SHARE.SHARE_TRANSFER_FAILED":
|
||||
case "DRIVE_SHARE.SHARE_POLICY_VIOLATION":
|
||||
return "transient";
|
||||
case "DRIVE_SHARE.SHARE_PARTITION_NOT_FOUND":
|
||||
case "DRIVE_SHARE.SHARE_GRANT_NOT_FOUND":
|
||||
case "DRIVE_SHARE.SHARE_TOKEN_INVALID":
|
||||
case "DRIVE_SHARE.SHARE_TOKEN_EXPIRED":
|
||||
case "DRIVE_SHARE.SHARE_TOKEN_REVOKED":
|
||||
case "DRIVE_SHARE.SHARE_KEY_NOT_FOUND":
|
||||
case "DRIVE_SHARE.SHARE_ENCRYPTION_FAILED":
|
||||
case "DRIVE_SHARE.SHARE_KEYRING_FORMAT_UNSUPPORTED":
|
||||
case "DRIVE_SHARE.SHARE_INTEGRITY_VERIFICATION_FAILED":
|
||||
return "permanent";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `const DRIVE_SHARE_CODES`
|
||||
|
||||
Frozen `Record<DriveShareErrorCode, DriveShareErrorCode>` returned by `defineCodes`. Iterating the values yields every fully-qualified code string.
|
||||
|
||||
### `type DriveShareErrorCode`
|
||||
|
||||
Union literal of every namespaced code under `DRIVE_SHARE.*`.
|
||||
|
||||
### `class DriveShareError extends SuaveplanError<DriveShareErrorCode>`
|
||||
|
||||
- **constructor** `(code, messageKey, options?)` — same shape as `SuaveplanError`. Stamps the brand symbol on the instance.
|
||||
- **static** `is(value): value is DriveShareError` — true only when `value` is `instanceof` AND carries the package-private brand symbol.
|
||||
|
||||
### `function registerDriveShareErrorCodes(): void`
|
||||
|
||||
Idempotent eager-registration hook. The codes are also registered automatically by importing this module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The brand symbol uses `Symbol("@suaveplan/drive-sharing.error")` and is **not** exported, so external code cannot fabricate it.
|
||||
- `DriveShareError.is()` checks both `instanceof` and the brand because `instanceof` breaks across iframe / worker realms.
|
||||
- The `registered` module-private flag prevents repeat registration churn but the underlying `defineCodes` call is itself a no-op on the second invocation.
|
||||
- Adding a new code is a non-breaking change; removing one or renaming one is a major-version bump per the package contract.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DRIVE_SHARE_CODES,
|
||||
DriveShareError,
|
||||
registerDriveShareErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("registers all eleven codes", { timeout: 5000 }, () => {
|
||||
const expected = [
|
||||
"SHARE_PARTITION_NOT_FOUND",
|
||||
"SHARE_GRANT_NOT_FOUND",
|
||||
"SHARE_TOKEN_INVALID",
|
||||
"SHARE_TOKEN_EXPIRED",
|
||||
"SHARE_TOKEN_REVOKED",
|
||||
"SHARE_KEY_NOT_FOUND",
|
||||
"SHARE_ENCRYPTION_FAILED",
|
||||
"SHARE_POLICY_VIOLATION",
|
||||
"SHARE_TRANSFER_FAILED",
|
||||
"SHARE_KEYRING_FORMAT_UNSUPPORTED",
|
||||
"SHARE_INTEGRITY_VERIFICATION_FAILED",
|
||||
];
|
||||
for (const c of expected) {
|
||||
expect(DRIVE_SHARE_CODES[c as keyof typeof DRIVE_SHARE_CODES]).toBe(
|
||||
`DRIVE_SHARE.${c}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("DriveShareError carries code, name, message", { timeout: 5000 }, () => {
|
||||
const err = new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_PARTITION_NOT_FOUND",
|
||||
"msg.test"
|
||||
);
|
||||
expect(err).toBeInstanceOf(DriveShareError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe("DRIVE_SHARE.SHARE_PARTITION_NOT_FOUND");
|
||||
expect(err.name).toBe("DriveShareError");
|
||||
});
|
||||
|
||||
it("DriveShareError.is recognises real instances", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_GRANT_NOT_FOUND",
|
||||
"m"
|
||||
);
|
||||
expect(DriveShareError.is(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("DriveShareError.is rejects plain Errors and look-alikes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(DriveShareError.is(new Error("x"))).toBe(false);
|
||||
expect(DriveShareError.is(undefined)).toBe(false);
|
||||
expect(DriveShareError.is({ code: "DRIVE_SHARE.X" })).toBe(false);
|
||||
});
|
||||
|
||||
it("registerDriveShareErrorCodes is idempotent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => registerDriveShareErrorCodes()).not.toThrow();
|
||||
expect(() => registerDriveShareErrorCodes()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error code registry for `@suaveplan/drive-sharing`.
|
||||
*
|
||||
* Every code is namespaced under `DRIVE_SHARE.<MODULE>.<REASON>`. The full
|
||||
* set is frozen at module-load time and may never be removed without a major
|
||||
* version bump — consumers MAY assert on these literal strings.
|
||||
*/
|
||||
export const DRIVE_SHARE_CODES = defineCodes("DRIVE_SHARE", [
|
||||
"SHARE_PARTITION_NOT_FOUND",
|
||||
"SHARE_GRANT_NOT_FOUND",
|
||||
"SHARE_TOKEN_INVALID",
|
||||
"SHARE_TOKEN_EXPIRED",
|
||||
"SHARE_TOKEN_REVOKED",
|
||||
"SHARE_KEY_NOT_FOUND",
|
||||
"SHARE_ENCRYPTION_FAILED",
|
||||
"SHARE_POLICY_VIOLATION",
|
||||
"SHARE_TRANSFER_FAILED",
|
||||
"SHARE_KEYRING_FORMAT_UNSUPPORTED",
|
||||
"SHARE_INTEGRITY_VERIFICATION_FAILED",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* Union of every fully-qualified error code emitted by `drive-sharing`.
|
||||
* Use as the type parameter of {@link DriveShareError} for narrowed `code`
|
||||
* checks.
|
||||
*/
|
||||
export type DriveShareErrorCode =
|
||||
(typeof DRIVE_SHARE_CODES)[keyof typeof DRIVE_SHARE_CODES];
|
||||
|
||||
const DRIVE_SHARE_ERROR_BRAND: unique symbol = Symbol(
|
||||
"@suaveplan/drive-sharing.error"
|
||||
);
|
||||
|
||||
/**
|
||||
* Concrete `SuaveplanError` subclass for every failure raised by
|
||||
* `@suaveplan/drive-sharing`.
|
||||
*
|
||||
* The class is parameterised by `DriveShareErrorCode` so consumers can write
|
||||
* `if (err instanceof DriveShareError) switch (err.code) { ... }` and have
|
||||
* the compiler enforce exhaustive matches against the registry. A unique
|
||||
* brand symbol is attached to every instance so subclasses or duck-typed
|
||||
* peers cannot impersonate a `DriveShareError` thrown by this package.
|
||||
*/
|
||||
export class DriveShareError extends SuaveplanError<DriveShareErrorCode> {
|
||||
/** Unique brand asserting this instance was constructed by this package. */
|
||||
public readonly [DRIVE_SHARE_ERROR_BRAND]: true;
|
||||
|
||||
public constructor(
|
||||
code: DriveShareErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this[DRIVE_SHARE_ERROR_BRAND] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard verifying both `instanceof` and the brand symbol. Use for
|
||||
* defence-in-depth when handling errors that may have crossed iframe or
|
||||
* worker boundaries (which can break `instanceof`).
|
||||
*/
|
||||
public static is(value: unknown): value is DriveShareError {
|
||||
return (
|
||||
value instanceof DriveShareError &&
|
||||
(value as DriveShareError)[DRIVE_SHARE_ERROR_BRAND] === true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Eager-registers every drive-sharing error code with the global registry.
|
||||
* Idempotent — safe to call repeatedly. Module-load registration is also
|
||||
* triggered by the side-effect call at the bottom of this file.
|
||||
*/
|
||||
export function registerDriveShareErrorCodes(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
// The registry is populated by `defineCodes` at module-load time;
|
||||
// this hook is the public toggle for consumers that want to verify
|
||||
// registration has happened before throwing.
|
||||
}
|
||||
|
||||
registerDriveShareErrorCodes();
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { DriveShareErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
DRIVE_SHARE_CODES,
|
||||
DriveShareError,
|
||||
registerDriveShareErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
152
packages/networking/drive/drive-sharing/src/index.ts
Normal file
152
packages/networking/drive/drive-sharing/src/index.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// Constants
|
||||
export {
|
||||
DEFAULT_GRANT_TTL_MS,
|
||||
DEFAULT_TOKEN_TTL_MS,
|
||||
ENCRYPTION_ALGORITHM,
|
||||
GRANT_MANAGER_MAX_ENTRIES,
|
||||
KEYRING_FORMAT_VERSION,
|
||||
KEYRING_MAX_ENTRIES,
|
||||
TOKEN_MANAGER_MAX_ENTRIES,
|
||||
} from "./constants/index.js";
|
||||
// Encryption — envelope
|
||||
export {
|
||||
type CryptoProvider,
|
||||
computeIntegrity,
|
||||
type EnvelopeEncryptOptions,
|
||||
envelopeDecrypt,
|
||||
envelopeEncrypt,
|
||||
type KeyWrapOptions,
|
||||
rewrapKey,
|
||||
unwrapKey,
|
||||
verifyIntegrity,
|
||||
WebCryptoProvider,
|
||||
wrapKey,
|
||||
} from "./encryption/envelope/envelope.js";
|
||||
// Encryption — key-ring
|
||||
export {
|
||||
KeyRing,
|
||||
type KeyRingConfig,
|
||||
type KeyRingEventPayloads,
|
||||
type UserWrappedKey,
|
||||
} from "./encryption/key-ring/key-ring.js";
|
||||
// Errors
|
||||
export {
|
||||
DRIVE_SHARE_CODES,
|
||||
DriveShareError,
|
||||
type DriveShareErrorCode,
|
||||
registerDriveShareErrorCodes,
|
||||
} from "./error-codes/index.js";
|
||||
// OTel
|
||||
export {
|
||||
type DriveShareInstruments,
|
||||
getDriveShareInstruments,
|
||||
getDriveShareTracer,
|
||||
resetDriveShareInstruments,
|
||||
resetDriveShareTracer,
|
||||
setDriveShareMeterProvider,
|
||||
setDriveShareTracerProvider,
|
||||
} from "./otel/instruments.js";
|
||||
// Partitions — manager
|
||||
export {
|
||||
type AddMemberOptions,
|
||||
type CreatePartitionOptions,
|
||||
PartitionManager,
|
||||
type PartitionManagerConfig,
|
||||
type PartitionManagerEvents,
|
||||
type UpdatePartitionOptions,
|
||||
} from "./partitions/partition-manager/partition-manager.js";
|
||||
// Partitions — policy
|
||||
export {
|
||||
createPolicy,
|
||||
DEFAULT_PRESETS,
|
||||
getDefaultPolicy,
|
||||
mergePolicies,
|
||||
requiresEncryption,
|
||||
requiresReauth,
|
||||
validateAction,
|
||||
} from "./partitions/partition-policy/partition-policy.js";
|
||||
// Partitions — store
|
||||
export {
|
||||
InMemoryPartitionStore,
|
||||
type PartitionStore,
|
||||
} from "./partitions/partition-store/partition-store.js";
|
||||
// Sharing — grants
|
||||
export {
|
||||
type CreateGrantOptions,
|
||||
type Grant,
|
||||
type GrantCheckResult,
|
||||
GrantManager,
|
||||
type GrantManagerConfig,
|
||||
type GrantManagerEvents,
|
||||
type GrantQuery,
|
||||
} from "./sharing/grants/grants.js";
|
||||
// Sharing — tokens
|
||||
export {
|
||||
type CreateTokenOptions,
|
||||
type ShareToken,
|
||||
TokenManager,
|
||||
type TokenManagerConfig,
|
||||
type TokenManagerEvents,
|
||||
type TokenValidationResult,
|
||||
} from "./sharing/tokens/tokens.js";
|
||||
// Transfer
|
||||
export {
|
||||
type KekResolver,
|
||||
type PolicyResolver,
|
||||
type TransferConfig,
|
||||
TransferManager,
|
||||
type TransferOptions,
|
||||
type WrappedKeyResolver,
|
||||
} from "./transfer/transfer/transfer.js";
|
||||
// Types
|
||||
export {
|
||||
type Brand,
|
||||
type EncryptedBundle,
|
||||
type EncryptionAlgorithm,
|
||||
err,
|
||||
type FileId,
|
||||
type FileKeyStore,
|
||||
fileId,
|
||||
type GrantId,
|
||||
generateId,
|
||||
grantId,
|
||||
type KeyId,
|
||||
keyId,
|
||||
ok,
|
||||
type Partition,
|
||||
type PartitionId,
|
||||
type PartitionMember,
|
||||
type PartitionPolicyConfig,
|
||||
type PartitionType,
|
||||
type PolicyAction,
|
||||
type PolicyValidationContext,
|
||||
type PolicyValidationResult,
|
||||
partitionId,
|
||||
type Result,
|
||||
type SharePermission,
|
||||
type TokenId,
|
||||
type TransferDirection,
|
||||
type TransferRecord,
|
||||
type TransferResult,
|
||||
tokenId,
|
||||
type UserId,
|
||||
userId,
|
||||
type WrappedKey,
|
||||
} from "./types/common.js";
|
||||
// Schemas
|
||||
export {
|
||||
AadSchema,
|
||||
AddMemberOptionsSchema,
|
||||
CreateGrantOptionsSchema,
|
||||
CreatePartitionOptionsSchema,
|
||||
CreateTokenOptionsSchema,
|
||||
EnvelopeEncryptOptionsSchema,
|
||||
KeyRingConfigSchema,
|
||||
KeyRingExportSchema,
|
||||
PartitionManagerOptionsSchema,
|
||||
PartitionPolicyConfigSchema,
|
||||
TokenManagerConfigSchema,
|
||||
TransferOptionsSchema,
|
||||
UpdatePartitionOptionsSchema,
|
||||
WrappedKeySchema,
|
||||
} from "./types/schemas.js";
|
||||
@@ -0,0 +1,94 @@
|
||||
# `otel/instruments.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module owns the singleton OpenTelemetry instrument set for `@suaveplan/drive-sharing`. It captures a `Meter` and `Tracer` from the global OTel API on first use, builds every counter / histogram declared in the OpenSpec change, and exposes setter / reset hooks so tests can route everything through an in-memory `MeterProvider` for assertion.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy-singleton instrument acquisition: zero cost when no provider is registered (no-op meters are returned automatically by `@opentelemetry/api`).
|
||||
- `setDriveShareMeterProvider` / `setDriveShareTracerProvider` accept `undefined` to fall back to the global default — handy for `afterEach` cleanup.
|
||||
- Eight counters and three histograms covering partitions, grants, tokens, transfers, key rotations, policy checks, and envelope encrypt / decrypt latency.
|
||||
- Safe-by-construction: only IDs (which are public-by-design), durations, and counts are recorded — never key material or plaintext bytes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getDriveShareInstruments } from "@suaveplan/drive-sharing/otel";
|
||||
|
||||
const i = getDriveShareInstruments();
|
||||
i.grantCheckTotal.add(1);
|
||||
i.envelopeDurationMs.record(2.7);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getDriveShareInstruments,
|
||||
setDriveShareMeterProvider,
|
||||
resetDriveShareInstruments,
|
||||
} from "@suaveplan/drive-sharing/otel";
|
||||
|
||||
const records: Array<[string, number]> = [];
|
||||
const provider = {
|
||||
getMeter: () => ({
|
||||
createCounter: (name: string) => ({
|
||||
add: (v: number) => records.push([name, v]),
|
||||
}),
|
||||
createHistogram: (name: string) => ({
|
||||
record: (v: number) => records.push([name, v]),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Parameters<typeof setDriveShareMeterProvider>[0];
|
||||
|
||||
setDriveShareMeterProvider(provider);
|
||||
try {
|
||||
getDriveShareInstruments().grantDeniedTotal.add(1);
|
||||
// assert against `records`
|
||||
} finally {
|
||||
setDriveShareMeterProvider(undefined);
|
||||
resetDriveShareInstruments();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface DriveShareInstruments`
|
||||
|
||||
The structural shape of the instrument bundle. Field summary:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `partitionMemberTotal` | Counter | Members added |
|
||||
| `grantDeniedTotal` | Counter | Grant denials with `reason` attribute |
|
||||
| `grantCheckTotal` | Counter | Grant checks (allowed + denied) |
|
||||
| `grantCreateTotal` | Counter | Grants created |
|
||||
| `partitionCreateTotal` | Counter | Partitions created |
|
||||
| `tokenCreateTotal` | Counter | Tokens created |
|
||||
| `tokenValidateTotal` | Counter | Token validations |
|
||||
| `tokenRevokedTotal` | Counter | Tokens revoked |
|
||||
| `transferBytesTotal` | Counter | Bytes transferred |
|
||||
| `keyRotateTotal` | Counter | Key rotations |
|
||||
| `policyCheckDurationMs` | Histogram | Policy-check latency in ms |
|
||||
| `envelopeDurationMs` | Histogram | Envelope encrypt/decrypt latency in ms |
|
||||
| `transferDurationMs` | Histogram | Cross-partition transfer latency in ms |
|
||||
|
||||
### Functions
|
||||
|
||||
- `getDriveShareInstruments(): DriveShareInstruments` — returns the lazy singleton.
|
||||
- `setDriveShareMeterProvider(provider | undefined): void` — overrides the meter source. `undefined` clears the override.
|
||||
- `resetDriveShareInstruments(): void` — forces re-acquisition on next call.
|
||||
- `getDriveShareTracer(): Tracer` — lazy singleton tracer.
|
||||
- `setDriveShareTracerProvider(provider | undefined): void` — overrides the tracer source.
|
||||
- `resetDriveShareTracer(): void` — forces re-acquisition on next call.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The module captures the OTel global state once and caches it for the rest of the process lifetime to avoid `getMeter` overhead on hot paths (grant checks, envelope encrypt) — at scale these are sub-millisecond operations and accruing 1µs of OTel overhead per call would dominate the budget.
|
||||
- `setDriveShareMeterProvider(undefined)` resets to the default global provider but does **not** automatically rebuild the instrument cache — callers must invoke `resetDriveShareInstruments()` explicitly. This keeps the no-op fast path predictable.
|
||||
- Counter and histogram unit strings follow the [UCUM standard](https://ucum.org/) — `ms` for durations, `By` for bytes, `{members}` etc. for dimensionless counts.
|
||||
- The `description` text for each instrument is the contract that downstream observability dashboards will display; reword cautiously.
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { MeterProvider, TracerProvider } from "@opentelemetry/api";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getDriveShareInstruments,
|
||||
getDriveShareTracer,
|
||||
resetDriveShareInstruments,
|
||||
resetDriveShareTracer,
|
||||
setDriveShareMeterProvider,
|
||||
setDriveShareTracerProvider,
|
||||
} from "./instruments.js";
|
||||
|
||||
interface Capture {
|
||||
histograms: Map<string, number[]>;
|
||||
counters: Map<string, Array<[number, Record<string, string> | undefined]>>;
|
||||
spans: string[];
|
||||
}
|
||||
|
||||
function makeProviders(): {
|
||||
meter: MeterProvider;
|
||||
tracer: TracerProvider;
|
||||
capture: Capture;
|
||||
} {
|
||||
const capture: Capture = {
|
||||
histograms: new Map(),
|
||||
counters: new Map(),
|
||||
spans: [],
|
||||
};
|
||||
const span = {
|
||||
end: (): void => undefined,
|
||||
setAttribute: (): void => undefined,
|
||||
setStatus: (): void => undefined,
|
||||
recordException: (): void => undefined,
|
||||
updateName: (): void => undefined,
|
||||
isRecording: (): boolean => true,
|
||||
spanContext: (): unknown => ({}),
|
||||
addEvent: (): void => undefined,
|
||||
addLink: (): void => undefined,
|
||||
addLinks: (): void => undefined,
|
||||
};
|
||||
const tracer = {
|
||||
getTracer: () =>
|
||||
({
|
||||
startSpan: (name: string) => {
|
||||
capture.spans.push(name);
|
||||
return span;
|
||||
},
|
||||
}) as unknown as ReturnType<TracerProvider["getTracer"]>,
|
||||
} as unknown as TracerProvider;
|
||||
const meter = {
|
||||
getMeter: () =>
|
||||
({
|
||||
createCounter: (name: string) => {
|
||||
capture.counters.set(name, []);
|
||||
return {
|
||||
add: (v: number, attrs?: Record<string, string>) => {
|
||||
capture.counters.get(name)?.push([v, attrs]);
|
||||
},
|
||||
};
|
||||
},
|
||||
createHistogram: (name: string) => {
|
||||
capture.histograms.set(name, []);
|
||||
return {
|
||||
record: (v: number) => {
|
||||
capture.histograms.get(name)?.push(v);
|
||||
},
|
||||
};
|
||||
},
|
||||
createObservableGauge: () => ({
|
||||
addCallback: (): void => undefined,
|
||||
}),
|
||||
}) as unknown as ReturnType<MeterProvider["getMeter"]>,
|
||||
} as unknown as MeterProvider;
|
||||
return { meter, tracer, capture };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setDriveShareMeterProvider(undefined);
|
||||
resetDriveShareInstruments();
|
||||
setDriveShareTracerProvider(undefined);
|
||||
resetDriveShareTracer();
|
||||
});
|
||||
|
||||
describe("instruments — defaults", () => {
|
||||
it("exposes the documented surface", { timeout: 5000 }, () => {
|
||||
resetDriveShareInstruments();
|
||||
const i = getDriveShareInstruments();
|
||||
expect(typeof i.partitionMemberTotal.add).toBe("function");
|
||||
expect(typeof i.grantDeniedTotal.add).toBe("function");
|
||||
expect(typeof i.grantCheckTotal.add).toBe("function");
|
||||
expect(typeof i.grantCreateTotal.add).toBe("function");
|
||||
expect(typeof i.partitionCreateTotal.add).toBe("function");
|
||||
expect(typeof i.tokenCreateTotal.add).toBe("function");
|
||||
expect(typeof i.tokenValidateTotal.add).toBe("function");
|
||||
expect(typeof i.tokenRevokedTotal.add).toBe("function");
|
||||
expect(typeof i.transferBytesTotal.add).toBe("function");
|
||||
expect(typeof i.keyRotateTotal.add).toBe("function");
|
||||
expect(typeof i.policyCheckDurationMs.record).toBe("function");
|
||||
expect(typeof i.envelopeDurationMs.record).toBe("function");
|
||||
expect(typeof i.transferDurationMs.record).toBe("function");
|
||||
});
|
||||
|
||||
it("returns the same singleton on repeated calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getDriveShareInstruments();
|
||||
const b = getDriveShareInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("default no-op records do not throw", { timeout: 5000 }, () => {
|
||||
const i = getDriveShareInstruments();
|
||||
expect(() => i.envelopeDurationMs.record(1)).not.toThrow();
|
||||
expect(() => i.grantDeniedTotal.add(1, { reason: "x" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("instruments — custom MeterProvider", () => {
|
||||
it("routes record / add through the supplied provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { meter, capture } = makeProviders();
|
||||
setDriveShareMeterProvider(meter);
|
||||
const i = getDriveShareInstruments();
|
||||
i.envelopeDurationMs.record(7);
|
||||
i.grantDeniedTotal.add(1, { reason: "expired" });
|
||||
expect(capture.histograms.get("share.envelope.duration_ms")).toEqual([
|
||||
7,
|
||||
]);
|
||||
expect(capture.counters.get("share.grant.denied_total")).toEqual([
|
||||
[1, { reason: "expired" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("setMeterProvider undefined falls back", { timeout: 5000 }, () => {
|
||||
const { meter } = makeProviders();
|
||||
setDriveShareMeterProvider(meter);
|
||||
getDriveShareInstruments().envelopeDurationMs.record(1);
|
||||
setDriveShareMeterProvider(undefined);
|
||||
resetDriveShareInstruments();
|
||||
expect(() =>
|
||||
getDriveShareInstruments().envelopeDurationMs.record(2)
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("instruments — tracer", () => {
|
||||
it("returns a tracer instance and caches it", { timeout: 5000 }, () => {
|
||||
const a = getDriveShareTracer();
|
||||
const b = getDriveShareTracer();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("reset forces re-acquisition", { timeout: 5000 }, () => {
|
||||
const a = getDriveShareTracer();
|
||||
resetDriveShareTracer();
|
||||
const b = getDriveShareTracer();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("setTracerProvider routes startSpan", { timeout: 5000 }, () => {
|
||||
const { tracer, capture } = makeProviders();
|
||||
setDriveShareTracerProvider(tracer);
|
||||
getDriveShareTracer().startSpan("test.span");
|
||||
expect(capture.spans).toEqual(["test.span"]);
|
||||
});
|
||||
|
||||
it("setTracerProvider undefined falls back", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
setDriveShareTracerProvider(undefined);
|
||||
const t = getDriveShareTracer();
|
||||
expect(typeof t.startSpan).toBe("function");
|
||||
});
|
||||
});
|
||||
179
packages/networking/drive/drive-sharing/src/otel/instruments.ts
Normal file
179
packages/networking/drive/drive-sharing/src/otel/instruments.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type {
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
Tracer,
|
||||
TracerProvider,
|
||||
} from "@opentelemetry/api";
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/drive-sharing";
|
||||
const TRACER_NAME = "@suaveplan/drive-sharing";
|
||||
|
||||
/**
|
||||
* The full instrument set emitted from `@suaveplan/drive-sharing` production
|
||||
* paths. Every instrument is safe-by-construction: no master key bytes, no
|
||||
* data-key bytes, no plaintext content, and no token integrity MACs are ever
|
||||
* recorded — only IDs (which are public-by-design), durations, and counts.
|
||||
*/
|
||||
export interface DriveShareInstruments {
|
||||
/** Counter of partition members added per partition. */
|
||||
partitionMemberTotal: Counter;
|
||||
/** Counter of grant denials by `reason` attribute. */
|
||||
grantDeniedTotal: Counter;
|
||||
/** Counter of grant checks performed (allowed + denied). */
|
||||
grantCheckTotal: Counter;
|
||||
/** Counter of grants created. */
|
||||
grantCreateTotal: Counter;
|
||||
/** Counter of partitions created. */
|
||||
partitionCreateTotal: Counter;
|
||||
/** Counter of tokens created. */
|
||||
tokenCreateTotal: Counter;
|
||||
/** Counter of token validations performed. */
|
||||
tokenValidateTotal: Counter;
|
||||
/** Counter of tokens revoked. */
|
||||
tokenRevokedTotal: Counter;
|
||||
/** Counter of bytes transferred between partitions. */
|
||||
transferBytesTotal: Counter;
|
||||
/** Counter of key rotations performed. */
|
||||
keyRotateTotal: Counter;
|
||||
/** Histogram of policy-check latency in ms. */
|
||||
policyCheckDurationMs: Histogram;
|
||||
/** Histogram of envelope encrypt/decrypt latency in ms. */
|
||||
envelopeDurationMs: Histogram;
|
||||
/** Histogram of cross-partition transfer latency in ms. */
|
||||
transferDurationMs: Histogram;
|
||||
}
|
||||
|
||||
let instruments: DriveShareInstruments | null = null;
|
||||
let tracer: Tracer | null = null;
|
||||
|
||||
function buildInstruments(meter: Meter): DriveShareInstruments {
|
||||
return {
|
||||
partitionMemberTotal: meter.createCounter(
|
||||
"share.partition.member_total",
|
||||
{
|
||||
description: "Members added per partition.",
|
||||
unit: "{members}",
|
||||
}
|
||||
),
|
||||
grantDeniedTotal: meter.createCounter("share.grant.denied_total", {
|
||||
description: "Grant checks that returned allowed=false.",
|
||||
unit: "{checks}",
|
||||
}),
|
||||
grantCheckTotal: meter.createCounter("share.grant.check_total", {
|
||||
description: "Grant checks performed (allowed + denied).",
|
||||
unit: "{checks}",
|
||||
}),
|
||||
grantCreateTotal: meter.createCounter("share.grant.create_total", {
|
||||
description: "Grants created.",
|
||||
unit: "{grants}",
|
||||
}),
|
||||
partitionCreateTotal: meter.createCounter(
|
||||
"share.partition.create_total",
|
||||
{
|
||||
description: "Partitions created.",
|
||||
unit: "{partitions}",
|
||||
}
|
||||
),
|
||||
tokenCreateTotal: meter.createCounter("share.token.create_total", {
|
||||
description: "Tokens created.",
|
||||
unit: "{tokens}",
|
||||
}),
|
||||
tokenValidateTotal: meter.createCounter("share.token.validate_total", {
|
||||
description: "Token validations performed.",
|
||||
unit: "{validations}",
|
||||
}),
|
||||
tokenRevokedTotal: meter.createCounter("share.token.revoked_total", {
|
||||
description: "Tokens revoked.",
|
||||
unit: "{tokens}",
|
||||
}),
|
||||
transferBytesTotal: meter.createCounter("share.transfer.bytes_total", {
|
||||
description: "Bytes transferred across partitions.",
|
||||
unit: "By",
|
||||
}),
|
||||
keyRotateTotal: meter.createCounter("share.key.rotate_total", {
|
||||
description: "Key rotations performed.",
|
||||
unit: "{rotations}",
|
||||
}),
|
||||
policyCheckDurationMs: meter.createHistogram(
|
||||
"share.partition.policy_check_duration_ms",
|
||||
{
|
||||
description: "Wall-clock duration of policy validation in ms.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
envelopeDurationMs: meter.createHistogram(
|
||||
"share.envelope.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Wall-clock duration of envelope encrypt/decrypt in ms.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
transferDurationMs: meter.createHistogram(
|
||||
"share.transfer.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Wall-clock duration of cross-partition transfers in ms.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-singleton accessor for the drive-sharing instrument set. On first call
|
||||
* this captures `metrics.getMeter(...)` from the global OTel API; no-op
|
||||
* instruments are returned automatically when no MeterProvider is registered.
|
||||
*/
|
||||
export function getDriveShareInstruments(): DriveShareInstruments {
|
||||
if (instruments !== null) return instruments;
|
||||
instruments = buildInstruments(metrics.getMeter(METER_NAME));
|
||||
return instruments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the OTel `MeterProvider` used to acquire the drive-sharing meter.
|
||||
* Pass `undefined` to fall back to the global default. Intended for tests
|
||||
* that want to assert against a deterministic provider.
|
||||
*/
|
||||
export function setDriveShareMeterProvider(
|
||||
provider: MeterProvider | undefined
|
||||
): void {
|
||||
instruments = null;
|
||||
if (provider !== undefined) {
|
||||
instruments = buildInstruments(provider.getMeter(METER_NAME));
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the cached instruments. Used by tests for suite isolation. */
|
||||
export function resetDriveShareInstruments(): void {
|
||||
instruments = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-singleton accessor for the drive-sharing tracer. Captures
|
||||
* `trace.getTracer(...)` from the global OTel API on first call.
|
||||
*/
|
||||
export function getDriveShareTracer(): Tracer {
|
||||
if (tracer !== null) return tracer;
|
||||
tracer = trace.getTracer(TRACER_NAME);
|
||||
return tracer;
|
||||
}
|
||||
|
||||
/** Override the `TracerProvider` used to acquire the drive-sharing tracer. */
|
||||
export function setDriveShareTracerProvider(
|
||||
provider: TracerProvider | undefined
|
||||
): void {
|
||||
tracer = null;
|
||||
if (provider !== undefined) {
|
||||
tracer = provider.getTracer(TRACER_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the cached tracer. Used by tests for suite isolation. */
|
||||
export function resetDriveShareTracer(): void {
|
||||
tracer = null;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
# `partitions/partition-manager/partition-manager.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`PartitionManager` orchestrates the lifecycle of partition records. It accepts a `PartitionStore` for persistence and a `KeyRing` for per-user key material, validates every mutation against the active policy via `validateAction`, and emits `partition:*` and `member:*` events on a caller-supplied `EventBusLike`. The manager is the single throat through which all partition state changes pass; downstream modules (grants, tokens, transfer) treat the partition as immutable input.
|
||||
|
||||
## Features
|
||||
|
||||
- Five mutating operations (`create`, `delete`, `addMember`, `removeMember`, `update`) plus three read-only operations (`get`, `list`, `getKeyRing`).
|
||||
- Zod-validated inputs at every public entrypoint via `CreatePartitionOptionsSchema` / `AddMemberOptionsSchema` / `UpdatePartitionOptionsSchema`.
|
||||
- Injectable `Clock` for deterministic timestamping and an injectable id generator for deterministic IDs in tests.
|
||||
- `EventBusLike<PartitionManagerEvents>` carries `partition:created`, `partition:deleted`, `partition:updated`, `member:added`, `member:removed`. No key material in event payloads.
|
||||
- OTel spans on every mutating operation; counter `share.partition.member_total` per partition; counter `share.partition.create_total`.
|
||||
- Graceful-degradation read-only toggle (`setReadOnly`) — mutations throw `SHARE_POLICY_VIOLATION` while the flag is true.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PartitionManager } from "@suaveplan/drive-sharing/partitions/partition-manager";
|
||||
import { InMemoryPartitionStore } from "@suaveplan/drive-sharing/partitions/partition-store";
|
||||
import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
|
||||
import { userId } from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
const manager = new PartitionManager({
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing: new KeyRing(),
|
||||
});
|
||||
|
||||
const partition = await manager.create({
|
||||
name: "research",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PartitionManager } from "@suaveplan/drive-sharing/partitions/partition-manager";
|
||||
import { InMemoryPartitionStore } from "@suaveplan/drive-sharing/partitions/partition-store";
|
||||
import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
|
||||
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
let now = 0;
|
||||
let counter = 0;
|
||||
const events = new Map<string, unknown[]>();
|
||||
const bus = {
|
||||
on: () => () => {},
|
||||
emit: (k: string, p: unknown) => {
|
||||
const arr = events.get(k) ?? [];
|
||||
arr.push(p);
|
||||
events.set(k, arr);
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new PartitionManager({
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing: new KeyRing(),
|
||||
clock: () => (now += 1000),
|
||||
idGenerator: () => `id-${(counter += 1)}`,
|
||||
events: bus,
|
||||
});
|
||||
|
||||
const p = await manager.create({
|
||||
name: "vault",
|
||||
type: "encrypted",
|
||||
ownerId: userId("alice"),
|
||||
policy: { maxMembers: 3 },
|
||||
});
|
||||
|
||||
await manager.addMember(p.id, userId("bob"), { permissions: ["read"] });
|
||||
manager.setReadOnly(true);
|
||||
// further mutations throw SHARE_POLICY_VIOLATION
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class PartitionManager`
|
||||
|
||||
- `constructor(config: PartitionManagerConfig)`
|
||||
- `create(options): Promise<Partition>`
|
||||
- `get(id): Promise<Partition>` — throws `SHARE_PARTITION_NOT_FOUND`.
|
||||
- `list(): Promise<Partition[]>`
|
||||
- `delete(id): Promise<void>`
|
||||
- `addMember(id, userId, options?): Promise<Partition>` — throws `SHARE_POLICY_VIOLATION` when policy denies.
|
||||
- `removeMember(id, userId): Promise<Partition>` — idempotent.
|
||||
- `update(id, options): Promise<Partition>`
|
||||
- `setReadOnly(value): void` / `isReadOnly(): boolean`
|
||||
- `getKeyRing(): KeyRing`
|
||||
|
||||
### `interface CreatePartitionOptions`
|
||||
|
||||
`{ name, type, ownerId, policy? }`.
|
||||
|
||||
### `interface AddMemberOptions`
|
||||
|
||||
`{ permissions?, wrappedKey? }`. Default permissions are `["read"]`.
|
||||
|
||||
### `interface UpdatePartitionOptions`
|
||||
|
||||
`{ name?, policy? }`.
|
||||
|
||||
### `interface PartitionManagerEvents`
|
||||
|
||||
`partition:created` / `partition:deleted` / `partition:updated` / `member:added` / `member:removed`. No key material.
|
||||
|
||||
### `interface PartitionManagerConfig`
|
||||
|
||||
`{ store, keyRing, policy?, clock?, events?, idGenerator? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default policy is computed once in the constructor (`createPolicy(config.policy ?? {})`). Per-partition policies are layered on top via `mergePolicies` in `create` and `update`.
|
||||
- `addMember` validates the partition's policy against the action `share` (the canonical "I am modifying membership" verb); when the policy denies, the call throws `SHARE_POLICY_VIOLATION`. Importantly, the check is performed against the partition's CURRENT membership — adding the Nth member when `maxMembers` is N triggers the denial branch.
|
||||
- `removeMember` is intentionally idempotent. Calling it twice with the same userId returns the same partition the second time and does NOT emit `member:removed`. This matches the spec scenario "RemoveMember restores count".
|
||||
- `setReadOnly(true)` is the spec's documented graceful-degradation hook; the partition store's health check is the trigger but the toggle is exposed publicly so consumers can wire any policy they like.
|
||||
- Event emission is fire-and-forget — the `EventBusLike.emit` contract is synchronous and does not surface listener errors. Asynchronous side effects belong to the listener, not the manager.
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { MeterProvider, TracerProvider } from "@opentelemetry/api";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { KeyRing } from "../../encryption/key-ring/key-ring.js";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
resetDriveShareInstruments,
|
||||
resetDriveShareTracer,
|
||||
setDriveShareMeterProvider,
|
||||
setDriveShareTracerProvider,
|
||||
} from "../../otel/instruments.js";
|
||||
import { partitionId, userId } from "../../types/common.js";
|
||||
import { InMemoryPartitionStore } from "../partition-store/partition-store.js";
|
||||
import { PartitionManager } from "./partition-manager.js";
|
||||
|
||||
interface Capture {
|
||||
spans: string[];
|
||||
counters: Map<string, number>;
|
||||
}
|
||||
|
||||
function makeProviders(): {
|
||||
meter: MeterProvider;
|
||||
tracer: TracerProvider;
|
||||
capture: Capture;
|
||||
} {
|
||||
const capture: Capture = {
|
||||
spans: [],
|
||||
counters: new Map(),
|
||||
};
|
||||
const span = {
|
||||
end: (): void => undefined,
|
||||
setAttribute: (): void => undefined,
|
||||
setStatus: (): void => undefined,
|
||||
recordException: (): void => undefined,
|
||||
updateName: (): void => undefined,
|
||||
isRecording: (): boolean => true,
|
||||
spanContext: (): unknown => ({}),
|
||||
addEvent: (): void => undefined,
|
||||
addLink: (): void => undefined,
|
||||
addLinks: (): void => undefined,
|
||||
};
|
||||
const tracer = {
|
||||
getTracer: () =>
|
||||
({
|
||||
startSpan: (name: string) => {
|
||||
capture.spans.push(name);
|
||||
return span;
|
||||
},
|
||||
}) as unknown as ReturnType<TracerProvider["getTracer"]>,
|
||||
} as unknown as TracerProvider;
|
||||
const meter = {
|
||||
getMeter: () =>
|
||||
({
|
||||
createCounter: (name: string) => ({
|
||||
add: (v: number) => {
|
||||
capture.counters.set(
|
||||
name,
|
||||
(capture.counters.get(name) ?? 0) + v
|
||||
);
|
||||
},
|
||||
}),
|
||||
createHistogram: () => ({
|
||||
record: (): void => undefined,
|
||||
}),
|
||||
createObservableGauge: () => ({
|
||||
addCallback: (): void => undefined,
|
||||
}),
|
||||
}) as unknown as ReturnType<MeterProvider["getMeter"]>,
|
||||
} as unknown as MeterProvider;
|
||||
return { meter, tracer, capture };
|
||||
}
|
||||
|
||||
let capture: Capture;
|
||||
|
||||
beforeEach(() => {
|
||||
const p = makeProviders();
|
||||
capture = p.capture;
|
||||
setDriveShareTracerProvider(p.tracer);
|
||||
setDriveShareMeterProvider(p.meter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setDriveShareTracerProvider(undefined);
|
||||
resetDriveShareTracer();
|
||||
setDriveShareMeterProvider(undefined);
|
||||
resetDriveShareInstruments();
|
||||
});
|
||||
|
||||
describe("PartitionManager — spec conformance", () => {
|
||||
it("throws DriveShareError with SHARE_PARTITION_NOT_FOUND for missing partition", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const manager = new PartitionManager({
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing: new KeyRing(),
|
||||
});
|
||||
try {
|
||||
await manager.get(partitionId("missing"));
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DriveShareError);
|
||||
if (DriveShareError.is(err)) {
|
||||
expect(err.code).toBe("DRIVE_SHARE.SHARE_PARTITION_NOT_FOUND");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("create emits span share.partition.create", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const manager = new PartitionManager({
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing: new KeyRing(),
|
||||
idGenerator: () => "p-1",
|
||||
});
|
||||
await manager.create({
|
||||
name: "demo",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
});
|
||||
expect(capture.spans).toContain("share.partition.create");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { KeyRing } from "../../encryption/key-ring/key-ring.js";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import { partitionId, userId } from "../../types/common.js";
|
||||
import { InMemoryPartitionStore } from "../partition-store/partition-store.js";
|
||||
import { PartitionManager } from "./partition-manager.js";
|
||||
|
||||
function makeManager(opts?: { idGenerator?: () => string }) {
|
||||
let counter = 0;
|
||||
const events = new Map<string, unknown[]>();
|
||||
const config = {
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing: new KeyRing(),
|
||||
clock: () => 1000,
|
||||
idGenerator: opts?.idGenerator ?? (() => `p-${(counter += 1)}`),
|
||||
events: {
|
||||
on: () => () => undefined,
|
||||
emit: (k: string, p: unknown) => {
|
||||
const arr = events.get(k) ?? [];
|
||||
arr.push(p);
|
||||
events.set(k, arr);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { manager: new PartitionManager(config), events };
|
||||
}
|
||||
|
||||
describe("PartitionManager — CRUD", () => {
|
||||
it("create returns a partition with the generated id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager } = makeManager();
|
||||
const p = await manager.create({
|
||||
name: "demo",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
});
|
||||
expect(p.id).toBe("p-1");
|
||||
});
|
||||
|
||||
it("create emits partition:created event", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager, events } = makeManager();
|
||||
await manager.create({
|
||||
name: "demo",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
});
|
||||
expect(events.get("partition:created")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("create rejects empty name", { timeout: 5000 }, async () => {
|
||||
const { manager } = makeManager();
|
||||
await expect(
|
||||
manager.create({
|
||||
name: "",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("get throws SHARE_PARTITION_NOT_FOUND for missing partition", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager } = makeManager();
|
||||
await expect(
|
||||
manager.get(partitionId("missing"))
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("list returns every created partition", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.create({
|
||||
name: "a",
|
||||
type: "shared",
|
||||
ownerId: userId("u"),
|
||||
});
|
||||
await manager.create({
|
||||
name: "b",
|
||||
type: "shared",
|
||||
ownerId: userId("u"),
|
||||
});
|
||||
expect((await manager.list()).length).toBe(2);
|
||||
});
|
||||
|
||||
it("delete removes the partition and emits event", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager, events } = makeManager();
|
||||
const p = await manager.create({
|
||||
name: "x",
|
||||
type: "shared",
|
||||
ownerId: userId("u"),
|
||||
});
|
||||
await manager.delete(p.id);
|
||||
expect(events.get("partition:deleted")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("update with only name", { timeout: 5000 }, async () => {
|
||||
const { manager } = makeManager();
|
||||
const p = await manager.create({
|
||||
name: "x",
|
||||
type: "shared",
|
||||
ownerId: userId("u"),
|
||||
});
|
||||
const updated = await manager.update(p.id, { name: "renamed" });
|
||||
expect(updated.name).toBe("renamed");
|
||||
expect(updated.policy).toEqual(p.policy);
|
||||
});
|
||||
|
||||
it("update with no changes leaves name and policy intact", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager } = makeManager();
|
||||
const p = await manager.create({
|
||||
name: "x",
|
||||
type: "shared",
|
||||
ownerId: userId("u"),
|
||||
});
|
||||
const updated = await manager.update(p.id, {});
|
||||
expect(updated.name).toBe("x");
|
||||
});
|
||||
|
||||
it("update merges the policy and emits partition:updated", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager, events } = makeManager();
|
||||
const p = await manager.create({
|
||||
name: "x",
|
||||
type: "shared",
|
||||
ownerId: userId("u"),
|
||||
});
|
||||
const updated = await manager.update(p.id, {
|
||||
name: "renamed",
|
||||
policy: { encryption: false },
|
||||
});
|
||||
expect(updated.name).toBe("renamed");
|
||||
expect(updated.policy.encryption).toBe(false);
|
||||
expect(events.get("partition:updated")?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PartitionManager — membership", () => {
|
||||
it("addMember appends to members and emits event", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager, events } = makeManager();
|
||||
const p = await manager.create({
|
||||
name: "x",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
});
|
||||
const updated = await manager.addMember(p.id, userId("bob"));
|
||||
expect(updated.members.length).toBe(1);
|
||||
expect(events.get("member:added")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("addMember throws SHARE_POLICY_VIOLATION when maxMembers reached", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager } = makeManager();
|
||||
const p = await manager.create({
|
||||
name: "x",
|
||||
type: "shared",
|
||||
ownerId: userId("a"),
|
||||
policy: { maxMembers: 1 },
|
||||
});
|
||||
await manager.addMember(p.id, userId("b"));
|
||||
await expect(
|
||||
manager.addMember(p.id, userId("c"))
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("addMember then removeMember restores original member count", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager } = makeManager();
|
||||
const p = await manager.create({
|
||||
name: "x",
|
||||
type: "shared",
|
||||
ownerId: userId("a"),
|
||||
});
|
||||
const before = p.members.length;
|
||||
const after1 = await manager.addMember(p.id, userId("b"));
|
||||
const after2 = await manager.removeMember(after1.id, userId("b"));
|
||||
expect(after2.members.length).toBe(before);
|
||||
});
|
||||
|
||||
it("removeMember on absent user is idempotent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager, events } = makeManager();
|
||||
const p = await manager.create({
|
||||
name: "x",
|
||||
type: "shared",
|
||||
ownerId: userId("a"),
|
||||
});
|
||||
await manager.removeMember(p.id, userId("ghost"));
|
||||
expect(events.get("member:removed")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PartitionManager — failure paths", () => {
|
||||
it("create rethrows store failures and ends the span", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failingStore = {
|
||||
get: async () => undefined,
|
||||
set: async () => {
|
||||
throw new Error("store down");
|
||||
},
|
||||
delete: async () => undefined,
|
||||
list: async () => [],
|
||||
};
|
||||
const manager = new PartitionManager({
|
||||
store: failingStore,
|
||||
keyRing: new KeyRing(),
|
||||
idGenerator: () => "p-x",
|
||||
});
|
||||
await expect(
|
||||
manager.create({
|
||||
name: "x",
|
||||
type: "shared",
|
||||
ownerId: userId("a"),
|
||||
})
|
||||
).rejects.toThrow("store down");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PartitionManager — read-only mode", () => {
|
||||
it("setReadOnly blocks create / update / delete", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager } = makeManager();
|
||||
const p = await manager.create({
|
||||
name: "x",
|
||||
type: "shared",
|
||||
ownerId: userId("a"),
|
||||
});
|
||||
manager.setReadOnly(true);
|
||||
expect(manager.isReadOnly()).toBe(true);
|
||||
await expect(
|
||||
manager.create({
|
||||
name: "y",
|
||||
type: "shared",
|
||||
ownerId: userId("a"),
|
||||
})
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
await expect(manager.delete(p.id)).rejects.toBeInstanceOf(
|
||||
DriveShareError
|
||||
);
|
||||
await expect(
|
||||
manager.update(p.id, { name: "z" })
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
manager.setReadOnly(false);
|
||||
expect(manager.isReadOnly()).toBe(false);
|
||||
});
|
||||
|
||||
it("getKeyRing returns the configured key ring", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { manager } = makeManager();
|
||||
expect(manager.getKeyRing()).toBeInstanceOf(KeyRing);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { SpanStatusCode } from "@opentelemetry/api";
|
||||
import type { Clock, EventBusLike } from "@suaveplan/types";
|
||||
import type { KeyRing } from "../../encryption/key-ring/key-ring.js";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
getDriveShareInstruments,
|
||||
getDriveShareTracer,
|
||||
} from "../../otel/instruments.js";
|
||||
import type {
|
||||
Partition,
|
||||
PartitionId,
|
||||
PartitionMember,
|
||||
PartitionPolicyConfig,
|
||||
PartitionType,
|
||||
SharePermission,
|
||||
UserId,
|
||||
WrappedKey,
|
||||
} from "../../types/common.js";
|
||||
import { generateId, partitionId } from "../../types/common.js";
|
||||
import {
|
||||
AddMemberOptionsSchema,
|
||||
CreatePartitionOptionsSchema,
|
||||
UpdatePartitionOptionsSchema,
|
||||
} from "../../types/schemas.js";
|
||||
import {
|
||||
createPolicy,
|
||||
mergePolicies,
|
||||
validateAction,
|
||||
} from "../partition-policy/partition-policy.js";
|
||||
import type { PartitionStore } from "../partition-store/partition-store.js";
|
||||
|
||||
/** Public option type for `PartitionManager.create`. */
|
||||
export interface CreatePartitionOptions {
|
||||
readonly name: string;
|
||||
readonly type: PartitionType;
|
||||
readonly ownerId: UserId;
|
||||
readonly policy?: Partial<PartitionPolicyConfig>;
|
||||
}
|
||||
|
||||
/** Public option type for `PartitionManager.addMember`. */
|
||||
export interface AddMemberOptions {
|
||||
readonly permissions?: readonly SharePermission[];
|
||||
readonly wrappedKey?: WrappedKey;
|
||||
}
|
||||
|
||||
/** Public option type for `PartitionManager.update`. */
|
||||
export interface UpdatePartitionOptions {
|
||||
readonly name?: string;
|
||||
readonly policy?: Partial<PartitionPolicyConfig>;
|
||||
}
|
||||
|
||||
/** Lifecycle event payloads emitted by `PartitionManager`. */
|
||||
export interface PartitionManagerEvents extends Record<string, unknown> {
|
||||
"partition:created": Partition;
|
||||
"partition:deleted": { id: PartitionId };
|
||||
"partition:updated": Partition;
|
||||
"member:added": { partitionId: PartitionId; userId: UserId };
|
||||
"member:removed": { partitionId: PartitionId; userId: UserId };
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** Constructor config for `PartitionManager`. */
|
||||
export interface PartitionManagerConfig {
|
||||
readonly store: PartitionStore;
|
||||
readonly keyRing: KeyRing;
|
||||
readonly policy?: Partial<PartitionPolicyConfig>;
|
||||
readonly clock?: Clock;
|
||||
readonly events?: EventBusLike<PartitionManagerEvents>;
|
||||
readonly idGenerator?: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates partition lifecycle: create, get, list, delete, update,
|
||||
* addMember, removeMember. Validates every mutation against the active
|
||||
* `PartitionPolicyConfig` and emits `partition:*` / `member:*` events.
|
||||
*/
|
||||
export class PartitionManager {
|
||||
private readonly store: PartitionStore;
|
||||
private readonly keyRing: KeyRing;
|
||||
private readonly defaultPolicy: PartitionPolicyConfig;
|
||||
private readonly clock: Clock;
|
||||
private readonly events: EventBusLike<PartitionManagerEvents> | undefined;
|
||||
private readonly idGen: () => string;
|
||||
private readOnly = false;
|
||||
|
||||
public constructor(config: PartitionManagerConfig) {
|
||||
this.store = config.store;
|
||||
this.keyRing = config.keyRing;
|
||||
this.defaultPolicy = createPolicy(config.policy ?? {});
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
this.events = config.events;
|
||||
this.idGen = config.idGenerator ?? generateId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new partition. The owner is stored as the first member with
|
||||
* full permissions. Emits `partition:created`.
|
||||
*/
|
||||
public async create(options: CreatePartitionOptions): Promise<Partition> {
|
||||
CreatePartitionOptionsSchema.parse(options);
|
||||
if (this.readOnly) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_POLICY_VIOLATION",
|
||||
"error.drive-sharing.partition-manager.read-only"
|
||||
);
|
||||
}
|
||||
const span = getDriveShareTracer().startSpan("share.partition.create");
|
||||
span.setAttribute("partition.type", options.type);
|
||||
try {
|
||||
const policy = mergePolicies(
|
||||
this.defaultPolicy,
|
||||
options.policy ?? {}
|
||||
);
|
||||
const id = partitionId(this.idGen());
|
||||
const now = this.clock();
|
||||
const partition: Partition = {
|
||||
id,
|
||||
name: options.name,
|
||||
type: options.type,
|
||||
ownerId: options.ownerId,
|
||||
members: [],
|
||||
policy,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await this.store.set(partition);
|
||||
getDriveShareInstruments().partitionCreateTotal.add(1);
|
||||
this.emit("partition:created", partition);
|
||||
return partition;
|
||||
} catch (err) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks up a partition by id. Throws `SHARE_PARTITION_NOT_FOUND` on miss. */
|
||||
public async get(id: PartitionId): Promise<Partition> {
|
||||
const partition = await this.store.get(id);
|
||||
if (partition === undefined) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_PARTITION_NOT_FOUND",
|
||||
"error.drive-sharing.partition-manager.not-found",
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
return partition;
|
||||
}
|
||||
|
||||
/** Returns every partition in the store. */
|
||||
public async list(): Promise<Partition[]> {
|
||||
return this.store.list();
|
||||
}
|
||||
|
||||
/** Deletes a partition and emits `partition:deleted`. */
|
||||
public async delete(id: PartitionId): Promise<void> {
|
||||
if (this.readOnly) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_POLICY_VIOLATION",
|
||||
"error.drive-sharing.partition-manager.read-only"
|
||||
);
|
||||
}
|
||||
const span = getDriveShareTracer().startSpan("share.partition.delete");
|
||||
try {
|
||||
await this.store.delete(id);
|
||||
this.emit("partition:deleted", { id });
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds `userId` as a member with the supplied permissions (default
|
||||
* `["read"]`). Enforces the partition's `maxMembers` policy and emits
|
||||
* `member:added`.
|
||||
*/
|
||||
public async addMember(
|
||||
id: PartitionId,
|
||||
userId: UserId,
|
||||
options: AddMemberOptions = {}
|
||||
): Promise<Partition> {
|
||||
AddMemberOptionsSchema.parse(options);
|
||||
const partition = await this.get(id);
|
||||
const result = validateAction(partition.policy, {
|
||||
action: "share",
|
||||
userId,
|
||||
partition,
|
||||
});
|
||||
if (!result.allowed) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_POLICY_VIOLATION",
|
||||
"error.drive-sharing.partition-manager.policy-violation",
|
||||
{ context: { reason: result.reason } }
|
||||
);
|
||||
}
|
||||
const span = getDriveShareTracer().startSpan(
|
||||
"share.partition.add_member"
|
||||
);
|
||||
try {
|
||||
const member: PartitionMember = {
|
||||
userId,
|
||||
permissions: options.permissions ?? ["read"],
|
||||
addedAt: this.clock(),
|
||||
};
|
||||
const updated: Partition = {
|
||||
...partition,
|
||||
members: [...partition.members, member],
|
||||
updatedAt: this.clock(),
|
||||
};
|
||||
await this.store.set(updated);
|
||||
getDriveShareInstruments().partitionMemberTotal.add(1, {
|
||||
"partition.id": id,
|
||||
});
|
||||
this.emit("member:added", { partitionId: id, userId });
|
||||
return updated;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a member. No-op when the user is not a member. */
|
||||
public async removeMember(
|
||||
id: PartitionId,
|
||||
userId: UserId
|
||||
): Promise<Partition> {
|
||||
const partition = await this.get(id);
|
||||
const updated: Partition = {
|
||||
...partition,
|
||||
members: partition.members.filter((m) => m.userId !== userId),
|
||||
updatedAt: this.clock(),
|
||||
};
|
||||
if (updated.members.length === partition.members.length) {
|
||||
return partition;
|
||||
}
|
||||
await this.store.set(updated);
|
||||
this.emit("member:removed", { partitionId: id, userId });
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Updates name and/or policy. Emits `partition:updated`. */
|
||||
public async update(
|
||||
id: PartitionId,
|
||||
options: UpdatePartitionOptions
|
||||
): Promise<Partition> {
|
||||
UpdatePartitionOptionsSchema.parse(options);
|
||||
if (this.readOnly) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_POLICY_VIOLATION",
|
||||
"error.drive-sharing.partition-manager.read-only"
|
||||
);
|
||||
}
|
||||
const partition = await this.get(id);
|
||||
const span = getDriveShareTracer().startSpan("share.partition.update");
|
||||
try {
|
||||
const updated: Partition = {
|
||||
...partition,
|
||||
...(options.name !== undefined ? { name: options.name } : {}),
|
||||
policy:
|
||||
options.policy !== undefined
|
||||
? mergePolicies(partition.policy, options.policy)
|
||||
: partition.policy,
|
||||
updatedAt: this.clock(),
|
||||
};
|
||||
await this.store.set(updated);
|
||||
this.emit("partition:updated", updated);
|
||||
return updated;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the manager into read-only mode. Mutations throw
|
||||
* `SHARE_POLICY_VIOLATION` until cleared. Used during graceful
|
||||
* degradation when the underlying store is unhealthy.
|
||||
*/
|
||||
public setReadOnly(value: boolean): void {
|
||||
this.readOnly = value;
|
||||
}
|
||||
|
||||
/** Exposes the active read-only flag for telemetry / health checks. */
|
||||
public isReadOnly(): boolean {
|
||||
return this.readOnly;
|
||||
}
|
||||
|
||||
/** Reference to the configured `KeyRing`. */
|
||||
public getKeyRing(): KeyRing {
|
||||
return this.keyRing;
|
||||
}
|
||||
|
||||
private emit<K extends keyof PartitionManagerEvents>(
|
||||
event: K,
|
||||
payload: PartitionManagerEvents[K]
|
||||
): void {
|
||||
if (this.events !== undefined) {
|
||||
this.events.emit(event, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
# `partitions/partition-policy/partition-policy.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The partition-policy module is the deterministic state-machine that decides whether a given action is permitted on a given partition. It exposes pure functions (`createPolicy`, `mergePolicies`, `validateAction`, `requiresEncryption`, `requiresReauth`), four canonical presets (`public`, `private`, `shared`, `encrypted`), and the default policy. Keeping this logic pure — no IO, no side effects beyond an OTel histogram — means consumers can call `validateAction` 100k+ times per second from a hot path.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `DEFAULT_PRESETS` covering the four documented postures from the OpenSpec change.
|
||||
- `mergePolicies` is commutative on disjoint array fields (`reauthRequired`, `allowedActions`) and dedupes overlapping entries.
|
||||
- `validateAction` returns `{ allowed: true }` or `{ allowed: false, reason }` — never throws.
|
||||
- `share.policy.check_duration_ms` histogram recorded on every `validateAction` call.
|
||||
- `requiresEncryption` and `requiresReauth` are pure boolean predicates.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createPolicy,
|
||||
validateAction,
|
||||
} from "@suaveplan/drive-sharing/partitions/partition-policy";
|
||||
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
const policy = createPolicy({ encryption: true });
|
||||
const result = validateAction(policy, {
|
||||
action: "read",
|
||||
userId: userId("alice"),
|
||||
partition: {
|
||||
id: partitionId("p"),
|
||||
name: "demo",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
members: [],
|
||||
policy,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
});
|
||||
// { allowed: true }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_PRESETS,
|
||||
mergePolicies,
|
||||
requiresEncryption,
|
||||
requiresReauth,
|
||||
} from "@suaveplan/drive-sharing/partitions/partition-policy";
|
||||
|
||||
// Compose a stricter encrypted preset.
|
||||
const strictEncrypted = mergePolicies(DEFAULT_PRESETS.encrypted, {
|
||||
maxMembers: 5,
|
||||
ttl: 86_400_000,
|
||||
});
|
||||
|
||||
if (requiresEncryption(strictEncrypted)) {
|
||||
console.log("AEAD required");
|
||||
}
|
||||
if (requiresReauth(strictEncrypted, "write")) {
|
||||
console.log("re-auth before write");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `function getDefaultPolicy(): PartitionPolicyConfig`
|
||||
|
||||
Returns `{ encryption: true, reauthRequired: [], allowedActions: ["read", "write", "delete", "share"] }`.
|
||||
|
||||
### `function createPolicy(config: Partial<PartitionPolicyConfig>): PartitionPolicyConfig`
|
||||
|
||||
Equivalent to `mergePolicies(getDefaultPolicy(), config)`.
|
||||
|
||||
### `function mergePolicies(base, override): PartitionPolicyConfig`
|
||||
|
||||
Deep-merges array fields with deduplication. Scalar fields (`encryption`, `maxMembers`, `ttl`) take the override when present.
|
||||
|
||||
### `function validateAction(policy, ctx): PolicyValidationResult`
|
||||
|
||||
- Returns `{ allowed: false, reason: "..." }` when the action is not in `allowedActions`.
|
||||
- Returns `{ allowed: false, reason: "maxMembers reached..." }` when the partition is full and `userId` is not already a member.
|
||||
- Returns `{ allowed: true }` otherwise.
|
||||
- Records `share.policy.check_duration_ms`.
|
||||
|
||||
### `function requiresEncryption(policy): boolean`
|
||||
|
||||
Equivalent to `policy.encryption`.
|
||||
|
||||
### `function requiresReauth(policy, action): boolean`
|
||||
|
||||
Equivalent to `policy.reauthRequired.includes(action)`.
|
||||
|
||||
### `const DEFAULT_PRESETS`
|
||||
|
||||
Frozen `Record<"public" | "private" | "shared" | "encrypted", PartitionPolicyConfig>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `validateAction` checks `members.some(m => m.userId === ctx.userId)` before applying the `maxMembers` ceiling, so existing members can perform actions even when the partition is full. New members are blocked.
|
||||
- The `dedup` helper uses `Set` for O(N) deduplication; arrays are tiny (max five action verbs) so this is well below any cache line.
|
||||
- Each preset is wrapped in `Object.freeze` at module load so consumers cannot mutate the shared singleton.
|
||||
- `validateAction` deliberately does not consult `policy.reauthRequired` — that is a separate concern (`requiresReauth`) so consumers can decide whether to surface a re-auth prompt before or after the policy check.
|
||||
- Adding a new `PolicyAction` requires updating `validateAction`, the `policyActionSchema` enum in `types/schemas.ts`, and the union type in `types/common.ts`. Forgetting any of the three triggers a TypeScript error or a Zod validation error at the boundary, never silent acceptance.
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { type Partition, partitionId, userId } from "../../types/common.js";
|
||||
import {
|
||||
createPolicy,
|
||||
DEFAULT_PRESETS,
|
||||
requiresEncryption,
|
||||
validateAction,
|
||||
} from "./partition-policy.js";
|
||||
|
||||
describe("partition-policy — spec conformance", () => {
|
||||
it("encrypted preset allows read and requiresEncryption is true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const policy = createPolicy(DEFAULT_PRESETS.encrypted);
|
||||
const partition: Partition = {
|
||||
id: partitionId("p"),
|
||||
name: "v",
|
||||
type: "encrypted",
|
||||
ownerId: userId("a"),
|
||||
members: [],
|
||||
policy,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
const r = validateAction(policy, {
|
||||
action: "read",
|
||||
userId: userId("a"),
|
||||
partition,
|
||||
});
|
||||
expect(r.allowed).toBe(true);
|
||||
expect(requiresEncryption(policy)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type Partition,
|
||||
type PartitionPolicyConfig,
|
||||
partitionId,
|
||||
userId,
|
||||
} from "../../types/common.js";
|
||||
import {
|
||||
createPolicy,
|
||||
DEFAULT_PRESETS,
|
||||
getDefaultPolicy,
|
||||
mergePolicies,
|
||||
requiresEncryption,
|
||||
requiresReauth,
|
||||
validateAction,
|
||||
} from "./partition-policy.js";
|
||||
|
||||
const ALICE = userId("alice");
|
||||
const PID = partitionId("p");
|
||||
|
||||
function makePartition(overrides: Partial<Partition> = {}): Partition {
|
||||
const policy: PartitionPolicyConfig = getDefaultPolicy();
|
||||
return {
|
||||
id: PID,
|
||||
name: "demo",
|
||||
type: "shared",
|
||||
ownerId: ALICE,
|
||||
members: [],
|
||||
policy,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("partition-policy — defaults", () => {
|
||||
it("getDefaultPolicy enables encryption", { timeout: 5000 }, () => {
|
||||
const p = getDefaultPolicy();
|
||||
expect(p.encryption).toBe(true);
|
||||
expect(p.allowedActions).toContain("read");
|
||||
});
|
||||
|
||||
it("createPolicy merges overrides", { timeout: 5000 }, () => {
|
||||
const p = createPolicy({ encryption: false });
|
||||
expect(p.encryption).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partition-policy — mergePolicies", () => {
|
||||
it("mergePolicies with empty override returns base unchanged", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const base = getDefaultPolicy();
|
||||
expect(mergePolicies(base, {})).toEqual(base);
|
||||
});
|
||||
|
||||
it("override precedence on scalar fields", { timeout: 5000 }, () => {
|
||||
const base = getDefaultPolicy();
|
||||
const out = mergePolicies(base, { maxMembers: 5 });
|
||||
expect(out.maxMembers).toBe(5);
|
||||
});
|
||||
|
||||
it("array fields are deduplicated and unioned", { timeout: 5000 }, () => {
|
||||
const out = mergePolicies(
|
||||
{ ...getDefaultPolicy(), reauthRequired: ["admin"] },
|
||||
{ reauthRequired: ["admin", "write"] }
|
||||
);
|
||||
expect(out.reauthRequired).toEqual(["admin", "write"]);
|
||||
});
|
||||
|
||||
it("ttl override overrides base", { timeout: 5000 }, () => {
|
||||
const out = mergePolicies(
|
||||
{ ...getDefaultPolicy(), ttl: 1000 },
|
||||
{ ttl: 2000 }
|
||||
);
|
||||
expect(out.ttl).toBe(2000);
|
||||
});
|
||||
|
||||
it("base ttl preserved when override absent", { timeout: 5000 }, () => {
|
||||
const out = mergePolicies({ ...getDefaultPolicy(), ttl: 1000 }, {});
|
||||
expect(out.ttl).toBe(1000);
|
||||
});
|
||||
|
||||
it("base maxMembers preserved when override absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = mergePolicies({ ...getDefaultPolicy(), maxMembers: 7 }, {});
|
||||
expect(out.maxMembers).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partition-policy — predicates", () => {
|
||||
it("requiresEncryption mirrors policy.encryption", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(requiresEncryption(getDefaultPolicy())).toBe(true);
|
||||
expect(
|
||||
requiresEncryption({ ...getDefaultPolicy(), encryption: false })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requiresReauth checks the action verb", { timeout: 5000 }, () => {
|
||||
const p = createPolicy({ reauthRequired: ["admin"] });
|
||||
expect(requiresReauth(p, "admin")).toBe(true);
|
||||
expect(requiresReauth(p, "read")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partition-policy — validateAction", () => {
|
||||
it("read on default-policy partition is allowed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const partition = makePartition();
|
||||
const r = validateAction(partition.policy, {
|
||||
action: "read",
|
||||
userId: ALICE,
|
||||
partition,
|
||||
});
|
||||
expect(r.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("denies action not in allowedActions", { timeout: 5000 }, () => {
|
||||
// Build a policy with a strictly limited allowedActions list
|
||||
// (bypassing the union semantics of `mergePolicies`).
|
||||
const partition = makePartition({
|
||||
policy: {
|
||||
encryption: true,
|
||||
reauthRequired: [],
|
||||
allowedActions: ["read"],
|
||||
},
|
||||
});
|
||||
const r = validateAction(partition.policy, {
|
||||
action: "delete",
|
||||
userId: ALICE,
|
||||
partition,
|
||||
});
|
||||
expect(r.allowed).toBe(false);
|
||||
expect(r.reason).toContain("delete");
|
||||
});
|
||||
|
||||
it("validateAction denies when maxMembers exceeded", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const policy = createPolicy({
|
||||
maxMembers: 1,
|
||||
allowedActions: ["read", "share"],
|
||||
});
|
||||
const partition = makePartition({
|
||||
policy,
|
||||
members: [
|
||||
{ userId: userId("bob"), permissions: ["read"], addedAt: 0 },
|
||||
],
|
||||
});
|
||||
const r = validateAction(policy, {
|
||||
action: "share",
|
||||
userId: ALICE, // alice not yet a member
|
||||
partition,
|
||||
});
|
||||
expect(r.allowed).toBe(false);
|
||||
expect(r.reason).toContain("maxMembers");
|
||||
});
|
||||
|
||||
it("existing members can still act when partition is full", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const policy = createPolicy({
|
||||
maxMembers: 1,
|
||||
allowedActions: ["read", "share"],
|
||||
});
|
||||
const partition = makePartition({
|
||||
policy,
|
||||
members: [{ userId: ALICE, permissions: ["read"], addedAt: 0 }],
|
||||
});
|
||||
const r = validateAction(policy, {
|
||||
action: "share",
|
||||
userId: ALICE,
|
||||
partition,
|
||||
});
|
||||
expect(r.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("encrypted preset allows read and requiresEncryption is true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const policy = createPolicy(DEFAULT_PRESETS.encrypted);
|
||||
const partition = makePartition({ policy });
|
||||
const r = validateAction(policy, {
|
||||
action: "read",
|
||||
userId: ALICE,
|
||||
partition,
|
||||
});
|
||||
expect(r.allowed).toBe(true);
|
||||
expect(requiresEncryption(policy)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partition-policy — DEFAULT_PRESETS", () => {
|
||||
it("contains all four presets", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(DEFAULT_PRESETS).sort()).toEqual([
|
||||
"encrypted",
|
||||
"private",
|
||||
"public",
|
||||
"shared",
|
||||
]);
|
||||
});
|
||||
|
||||
it("public preset has encryption disabled", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_PRESETS.public.encryption).toBe(false);
|
||||
});
|
||||
|
||||
it("private preset has maxMembers of 1", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_PRESETS.private.maxMembers).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { getDriveShareInstruments } from "../../otel/instruments.js";
|
||||
import type {
|
||||
PartitionPolicyConfig,
|
||||
PolicyAction,
|
||||
PolicyValidationContext,
|
||||
PolicyValidationResult,
|
||||
} from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Returns the canonical default policy: encryption ON, no reauth required,
|
||||
* read/write/delete/share allowed, no member cap.
|
||||
*/
|
||||
export function getDefaultPolicy(): PartitionPolicyConfig {
|
||||
return {
|
||||
encryption: true,
|
||||
reauthRequired: [],
|
||||
allowedActions: ["read", "write", "delete", "share"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a `PartitionPolicyConfig` from a partial config, filling in
|
||||
* defaults from `getDefaultPolicy`.
|
||||
*/
|
||||
export function createPolicy(
|
||||
config: Partial<PartitionPolicyConfig>
|
||||
): PartitionPolicyConfig {
|
||||
return mergePolicies(getDefaultPolicy(), config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-merges `override` into `base`. Non-array fields take the override
|
||||
* verbatim when present; array fields are commutative on disjoint sets and
|
||||
* deduplicated when overlapping. This is the property tested in the
|
||||
* `mergePolicies` commutativity invariant.
|
||||
*/
|
||||
export function mergePolicies(
|
||||
base: PartitionPolicyConfig,
|
||||
override: Partial<PartitionPolicyConfig>
|
||||
): PartitionPolicyConfig {
|
||||
const merged: {
|
||||
encryption: boolean;
|
||||
reauthRequired: PolicyAction[];
|
||||
allowedActions: PolicyAction[];
|
||||
maxMembers?: number;
|
||||
ttl?: number;
|
||||
} = {
|
||||
encryption: override.encryption ?? base.encryption,
|
||||
reauthRequired: dedup([
|
||||
...base.reauthRequired,
|
||||
...(override.reauthRequired ?? []),
|
||||
]),
|
||||
allowedActions: dedup([
|
||||
...base.allowedActions,
|
||||
...(override.allowedActions ?? []),
|
||||
]),
|
||||
};
|
||||
if (override.maxMembers !== undefined) {
|
||||
merged.maxMembers = override.maxMembers;
|
||||
} else if (base.maxMembers !== undefined) {
|
||||
merged.maxMembers = base.maxMembers;
|
||||
}
|
||||
if (override.ttl !== undefined) {
|
||||
merged.ttl = override.ttl;
|
||||
} else if (base.ttl !== undefined) {
|
||||
merged.ttl = base.ttl;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function dedup<T>(items: readonly T[]): T[] {
|
||||
const seen = new Set<T>();
|
||||
const out: T[] = [];
|
||||
for (const item of items) {
|
||||
if (seen.has(item)) continue;
|
||||
seen.add(item);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic policy validator. Returns `{ allowed, reason? }` based on
|
||||
* the action's membership in `policy.allowedActions` and partition member
|
||||
* count vs `policy.maxMembers`. Records `share.policy.check_duration_ms`.
|
||||
*/
|
||||
export function validateAction(
|
||||
policy: PartitionPolicyConfig,
|
||||
ctx: PolicyValidationContext
|
||||
): PolicyValidationResult {
|
||||
const start = performance.now();
|
||||
try {
|
||||
if (!policy.allowedActions.includes(ctx.action)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `action ${ctx.action} is not in allowedActions`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
policy.maxMembers !== undefined &&
|
||||
ctx.partition.members.length >= policy.maxMembers &&
|
||||
!ctx.partition.members.some((m) => m.userId === ctx.userId)
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `maxMembers reached (${policy.maxMembers})`,
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
} finally {
|
||||
const elapsed = performance.now() - start;
|
||||
getDriveShareInstruments().policyCheckDurationMs.record(elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure predicate: true when the policy requires AEAD on file ops. */
|
||||
export function requiresEncryption(policy: PartitionPolicyConfig): boolean {
|
||||
return policy.encryption;
|
||||
}
|
||||
|
||||
/** Pure predicate: true when `action` requires re-authentication. */
|
||||
export function requiresReauth(
|
||||
policy: PartitionPolicyConfig,
|
||||
action: PolicyAction
|
||||
): boolean {
|
||||
return policy.reauthRequired.includes(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen presets matching the OpenSpec change. `public` is plaintext-allowed
|
||||
* and unrestricted; `private` is per-owner only; `shared` is the default
|
||||
* collaboration preset; `encrypted` is reauth-on-write with a hard cap.
|
||||
*/
|
||||
export const DEFAULT_PRESETS: Readonly<
|
||||
Record<"public" | "private" | "shared" | "encrypted", PartitionPolicyConfig>
|
||||
> = Object.freeze({
|
||||
public: {
|
||||
encryption: false,
|
||||
reauthRequired: [],
|
||||
allowedActions: ["read"],
|
||||
},
|
||||
private: {
|
||||
encryption: true,
|
||||
reauthRequired: ["write", "delete", "share", "admin"],
|
||||
allowedActions: ["read", "write", "delete", "share", "admin"],
|
||||
maxMembers: 1,
|
||||
},
|
||||
shared: {
|
||||
encryption: true,
|
||||
reauthRequired: [],
|
||||
allowedActions: ["read", "write", "delete", "share"],
|
||||
maxMembers: 1000,
|
||||
},
|
||||
encrypted: {
|
||||
encryption: true,
|
||||
reauthRequired: ["write", "delete", "admin"],
|
||||
allowedActions: ["read", "write", "delete", "share", "admin"],
|
||||
maxMembers: 100,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
# `partitions/partition-store/partition-store.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `PartitionStore` interface is the persistence boundary between `PartitionManager` and the durable storage backend. Decoupling the manager from a concrete store means consumers can plug in SurrealDB, Postgres, sqlite-via-WASM, or any other adapter without forking the package. The bundled `InMemoryPartitionStore` backs tests and single-node deployments where durability across process restarts is not required.
|
||||
|
||||
## Features
|
||||
|
||||
- Four-method interface: `get`, `set`, `delete`, `list`. No transactional or batched operations — those belong to the underlying database adapter.
|
||||
- Async signatures even on the in-memory implementation so the consumer code path is shape-compatible regardless of backend.
|
||||
- `Map<PartitionId, Partition>` backing on the in-memory store — O(1) get / set / delete, O(N) list with reflective insertion order.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
InMemoryPartitionStore,
|
||||
type PartitionStore,
|
||||
} from "@suaveplan/drive-sharing/partitions/partition-store";
|
||||
|
||||
const store: PartitionStore = new InMemoryPartitionStore();
|
||||
await store.set({
|
||||
id: "p-001" as never,
|
||||
name: "demo",
|
||||
type: "shared",
|
||||
ownerId: "alice" as never,
|
||||
members: [],
|
||||
policy: { encryption: true, reauthRequired: [], allowedActions: ["read"] },
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
});
|
||||
const fetched = await store.get("p-001" as never);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { PartitionStore } from "@suaveplan/drive-sharing/partitions/partition-store";
|
||||
|
||||
class SurrealPartitionStore implements PartitionStore {
|
||||
constructor(private readonly db: { query: (q: string) => Promise<unknown> }) {}
|
||||
|
||||
async get(id: PartitionId): Promise<Partition | undefined> {
|
||||
const rows = (await this.db.query(`SELECT * FROM partition:${id}`)) as Partition[];
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async set(partition: Partition): Promise<void> {
|
||||
await this.db.query(`UPSERT partition:${partition.id} CONTENT ${JSON.stringify(partition)}`);
|
||||
}
|
||||
|
||||
async delete(id: PartitionId): Promise<void> {
|
||||
await this.db.query(`DELETE partition:${id}`);
|
||||
}
|
||||
|
||||
async list(): Promise<Partition[]> {
|
||||
return (await this.db.query(`SELECT * FROM partition`)) as Partition[];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface PartitionStore`
|
||||
|
||||
```ts
|
||||
get(id: PartitionId): Promise<Partition | undefined>
|
||||
set(partition: Partition): Promise<void>
|
||||
delete(id: PartitionId): Promise<void>
|
||||
list(): Promise<Partition[]>
|
||||
```
|
||||
|
||||
### `class InMemoryPartitionStore implements PartitionStore`
|
||||
|
||||
Map-backed implementation. Construct with no arguments. State lives only for the lifetime of the instance.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `delete` is a no-op when the id is absent; this matches the spec scenario "Partition store delete is idempotent".
|
||||
- `list` returns a fresh array on every call so the caller cannot mutate the underlying map by mutating the returned slice.
|
||||
- Real backends should implement `set` with `INSERT … ON CONFLICT UPDATE` (or the database-specific equivalent) so concurrent writers do not lose updates. The in-memory implementation is single-threaded by construction.
|
||||
- The interface deliberately omits transactional support. `PartitionManager` orchestrates multi-step state changes by reading, mutating, and writing — atomicity is the store's responsibility under contention. For the in-memory case there is no concurrency.
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { type Partition, partitionId, userId } from "../../types/common.js";
|
||||
import { getDefaultPolicy } from "../partition-policy/partition-policy.js";
|
||||
import { InMemoryPartitionStore } from "./partition-store.js";
|
||||
|
||||
function makePartition(id: string): Partition {
|
||||
return {
|
||||
id: partitionId(id),
|
||||
name: id,
|
||||
type: "shared",
|
||||
ownerId: userId("a"),
|
||||
members: [],
|
||||
policy: getDefaultPolicy(),
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("partition-store — spec conformance", () => {
|
||||
it("set then get returns identical partition", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = new InMemoryPartitionStore();
|
||||
const p = makePartition("p1");
|
||||
await s.set(p);
|
||||
expect(await s.get(p.id)).toEqual(p);
|
||||
});
|
||||
|
||||
it("list returns all stored partitions", { timeout: 5000 }, async () => {
|
||||
const s = new InMemoryPartitionStore();
|
||||
await s.set(makePartition("p1"));
|
||||
await s.set(makePartition("p2"));
|
||||
const all = await s.list();
|
||||
expect(all.map((p) => p.id).sort()).toEqual(["p1", "p2"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { type Partition, partitionId, userId } from "../../types/common.js";
|
||||
import { getDefaultPolicy } from "../partition-policy/partition-policy.js";
|
||||
import { InMemoryPartitionStore } from "./partition-store.js";
|
||||
|
||||
function makePartition(id = "p"): Partition {
|
||||
return {
|
||||
id: partitionId(id),
|
||||
name: id,
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
members: [],
|
||||
policy: getDefaultPolicy(),
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("InMemoryPartitionStore", () => {
|
||||
it("set then get returns identical partition", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryPartitionStore();
|
||||
const p = makePartition();
|
||||
await store.set(p);
|
||||
expect(await store.get(p.id)).toEqual(p);
|
||||
});
|
||||
|
||||
it("get returns undefined for missing id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryPartitionStore();
|
||||
expect(await store.get(partitionId("none"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("list returns all stored partitions", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryPartitionStore();
|
||||
await store.set(makePartition("a"));
|
||||
await store.set(makePartition("b"));
|
||||
const out = await store.list();
|
||||
expect(out.length).toBe(2);
|
||||
});
|
||||
|
||||
it("delete removes the partition", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryPartitionStore();
|
||||
const p = makePartition();
|
||||
await store.set(p);
|
||||
await store.delete(p.id);
|
||||
expect(await store.get(p.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("delete on missing id is a no-op", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryPartitionStore();
|
||||
await expect(
|
||||
store.delete(partitionId("none"))
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Partition, PartitionId } from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Storage interface for `Partition` records. The default in-memory
|
||||
* implementation backs simple cases; production systems implement this
|
||||
* interface against SurrealDB, Postgres, or another durable backend.
|
||||
*/
|
||||
export interface PartitionStore {
|
||||
get(id: PartitionId): Promise<Partition | undefined>;
|
||||
set(partition: Partition): Promise<void>;
|
||||
delete(id: PartitionId): Promise<void>;
|
||||
list(): Promise<Partition[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `PartitionStore` backed by a `Map<PartitionId, Partition>`. Suitable for
|
||||
* tests, single-node development, and lightweight deployments.
|
||||
*/
|
||||
export class InMemoryPartitionStore implements PartitionStore {
|
||||
private readonly partitions: Map<PartitionId, Partition> = new Map();
|
||||
|
||||
public async get(id: PartitionId): Promise<Partition | undefined> {
|
||||
return this.partitions.get(id);
|
||||
}
|
||||
|
||||
public async set(partition: Partition): Promise<void> {
|
||||
this.partitions.set(partition.id, partition);
|
||||
}
|
||||
|
||||
public async delete(id: PartitionId): Promise<void> {
|
||||
this.partitions.delete(id);
|
||||
}
|
||||
|
||||
public async list(): Promise<Partition[]> {
|
||||
return Array.from(this.partitions.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# `sharing/grants/grants.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`GrantManager` is the deterministic in-memory grant store. It holds `Grant` records keyed by `GrantId` and answers permission queries via `check(grantId, userId, action)`. The check is exhaustive: existence, non-revocation, non-expiry, grantee identity, and permission inclusion are all verified, in that order, with the first failure short-circuiting the result. Every check increments `share.grant.check_total`; denials additionally increment `share.grant.denied_total` with a `reason` attribute so dashboards can split denials by cause.
|
||||
|
||||
## Features
|
||||
|
||||
- Synchronous `check` — sub-millisecond by design (no IO, no crypto).
|
||||
- Injectable `Clock` so expiry tests are deterministic.
|
||||
- `EventBusLike` integration: `grant:created` and `grant:revoked` events carry full context but never key material.
|
||||
- Hard cap on active grants (`GRANT_MANAGER_MAX_ENTRIES = 10_000_000`) per the spec scalability table.
|
||||
- Filtered `list(query)` supporting `granteeId`, `partitionId`, and `active` filters.
|
||||
- `revoke` is idempotent — already-revoked / missing grants are no-ops.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GrantManager } from "@suaveplan/drive-sharing/sharing/grants";
|
||||
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
const grants = new GrantManager();
|
||||
const grant = grants.create({
|
||||
grantorId: userId("alice"),
|
||||
granteeId: userId("bob"),
|
||||
partitionId: partitionId("p"),
|
||||
permissions: ["read"],
|
||||
});
|
||||
grants.check(grant.id, userId("bob"), "read"); // { allowed: true }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { GrantManager } from "@suaveplan/drive-sharing/sharing/grants";
|
||||
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
let now = 0;
|
||||
const events = new Map<string, unknown[]>();
|
||||
const grants = new GrantManager({
|
||||
clock: () => now,
|
||||
events: {
|
||||
on: () => () => {},
|
||||
emit: (k, p) => {
|
||||
const arr = events.get(k) ?? [];
|
||||
arr.push(p);
|
||||
events.set(k, arr);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const g = grants.create({
|
||||
grantorId: userId("alice"),
|
||||
granteeId: userId("bob"),
|
||||
partitionId: partitionId("p"),
|
||||
permissions: ["read"],
|
||||
expiresAt: 1000,
|
||||
});
|
||||
|
||||
now = 999;
|
||||
console.log(grants.check(g.id, userId("bob"), "read")); // allowed
|
||||
now = 1001;
|
||||
console.log(grants.check(g.id, userId("bob"), "read")); // denied: expired
|
||||
|
||||
grants.revoke(g.id);
|
||||
console.log(grants.list({ active: true })); // []
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class GrantManager`
|
||||
|
||||
- `constructor(config?: GrantManagerConfig)`
|
||||
- `create(options: CreateGrantOptions): Grant`
|
||||
- `check(id, userId, action): GrantCheckResult`
|
||||
- `revoke(id): void`
|
||||
- `list(query?): Grant[]`
|
||||
- `get(id): Grant | undefined`
|
||||
|
||||
### `interface CreateGrantOptions`
|
||||
|
||||
`{ grantorId, granteeId, partitionId, permissions, expiresAt? }`.
|
||||
|
||||
### `interface Grant`
|
||||
|
||||
`{ id, grantorId, granteeId, partitionId, permissions, createdAt, expiresAt?, revoked }`.
|
||||
|
||||
### `interface GrantCheckResult`
|
||||
|
||||
`{ allowed: boolean; reason?: string }` — `reason` is one of `grant_not_found`, `revoked`, `expired`, `grantee_mismatch`, `permission_missing`.
|
||||
|
||||
### `interface GrantQuery`
|
||||
|
||||
`{ granteeId?, partitionId?, active? }`.
|
||||
|
||||
### `interface GrantManagerEvents`
|
||||
|
||||
- `grant:created` — full grant
|
||||
- `grant:revoked` — `{ id, partitionId, userId }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The denial-reason strings are stable contract — dashboards may key on them. Renaming a reason is a major-version bump.
|
||||
- `revoke` is intentionally idempotent so a duplicate "revoke this grant" message from a retry-loop does not trigger a second `grant:revoked` event.
|
||||
- `list({ active: true })` reads the clock once per grant; for very large grant stores callers should prefer `list({ partitionId, active: true })` so the filter narrows before the active-check fires.
|
||||
- The Zod schema (`CreateGrantOptionsSchema`) enforces non-empty `permissions`. An attacker who calls `create` with `permissions: []` is rejected at the boundary, never silently inserted.
|
||||
- `check` runs the action verb through `permissions.includes(action as SharePermission)` — the cast is safe because every `PolicyAction` is also a valid `SharePermission` in the canonical list.
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { partitionId, userId } from "../../types/common.js";
|
||||
import { GrantManager } from "./grants.js";
|
||||
|
||||
describe("GrantManager — spec conformance", () => {
|
||||
it("denial reason strings are stable", { timeout: 5000 }, () => {
|
||||
let now = 0;
|
||||
const m = new GrantManager({ clock: () => now });
|
||||
const g = m.create({
|
||||
grantorId: userId("a"),
|
||||
granteeId: userId("b"),
|
||||
partitionId: partitionId("p"),
|
||||
permissions: ["read"],
|
||||
expiresAt: 50,
|
||||
});
|
||||
now = 51;
|
||||
expect(m.check(g.id, userId("b"), "read").reason).toBe("expired");
|
||||
const g2 = m.create({
|
||||
grantorId: userId("a"),
|
||||
granteeId: userId("b"),
|
||||
partitionId: partitionId("p"),
|
||||
permissions: ["read"],
|
||||
});
|
||||
m.revoke(g2.id);
|
||||
expect(m.check(g2.id, userId("b"), "read").reason).toBe("revoked");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { partitionId, userId } from "../../types/common.js";
|
||||
import { GrantManager } from "./grants.js";
|
||||
|
||||
const ALICE = userId("alice");
|
||||
const BOB = userId("bob");
|
||||
const CAROL = userId("carol");
|
||||
const PID = partitionId("p");
|
||||
|
||||
function makeGrantManager(opts?: { now?: () => number }) {
|
||||
const events = new Map<string, unknown[]>();
|
||||
let counter = 0;
|
||||
return {
|
||||
events,
|
||||
manager: new GrantManager({
|
||||
clock: opts?.now ?? (() => 0),
|
||||
idGenerator: () => `g-${(counter += 1)}`,
|
||||
events: {
|
||||
on: () => () => undefined,
|
||||
emit: (k: string, p: unknown) => {
|
||||
const arr = events.get(k) ?? [];
|
||||
arr.push(p);
|
||||
events.set(k, arr);
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("GrantManager — create / get", () => {
|
||||
it("create stores a grant and emits event", { timeout: 5000 }, () => {
|
||||
const { manager, events } = makeGrantManager();
|
||||
const g = manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(g.id).toBe("g-1");
|
||||
expect(manager.get(g.id)).toEqual(g);
|
||||
expect(events.get("grant:created")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("create without expiresAt has no expiry", { timeout: 5000 }, () => {
|
||||
const { manager } = makeGrantManager();
|
||||
const g = manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(g.expiresAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("create rejects empty permissions", { timeout: 5000 }, () => {
|
||||
const { manager } = makeGrantManager();
|
||||
expect(() =>
|
||||
manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: [],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("create throws SHARE_POLICY_VIOLATION beyond maxEntries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = new GrantManager({ clock: () => 0, maxEntries: 1 });
|
||||
m.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(() =>
|
||||
m.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GrantManager — check", () => {
|
||||
it("check returns allowed true for valid grantee and permitted action", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { manager } = makeGrantManager();
|
||||
const g = manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(manager.check(g.id, BOB, "read")).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("check returns allowed false for non-existent grant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { manager } = makeGrantManager();
|
||||
expect(manager.check("no-such-grant" as never, BOB, "read")).toEqual({
|
||||
allowed: false,
|
||||
reason: "grant_not_found",
|
||||
});
|
||||
});
|
||||
|
||||
it("check returns allowed false for expired grant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let now = 0;
|
||||
const { manager } = makeGrantManager({ now: () => now });
|
||||
const g = manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
expiresAt: 100,
|
||||
});
|
||||
now = 101;
|
||||
const r = manager.check(g.id, BOB, "read");
|
||||
expect(r.allowed).toBe(false);
|
||||
expect(r.reason).toBe("expired");
|
||||
});
|
||||
|
||||
it("check returns allowed false for revoked grant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { manager } = makeGrantManager();
|
||||
const g = manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
manager.revoke(g.id);
|
||||
const r = manager.check(g.id, BOB, "read");
|
||||
expect(r.allowed).toBe(false);
|
||||
expect(r.reason).toBe("revoked");
|
||||
});
|
||||
|
||||
it("check denies wrong grantee", { timeout: 5000 }, () => {
|
||||
const { manager } = makeGrantManager();
|
||||
const g = manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
const r = manager.check(g.id, CAROL, "read");
|
||||
expect(r.allowed).toBe(false);
|
||||
expect(r.reason).toBe("grantee_mismatch");
|
||||
});
|
||||
|
||||
it("check denies missing permission", { timeout: 5000 }, () => {
|
||||
const { manager } = makeGrantManager();
|
||||
const g = manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
const r = manager.check(g.id, BOB, "write");
|
||||
expect(r.allowed).toBe(false);
|
||||
expect(r.reason).toBe("permission_missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GrantManager — revoke / list", () => {
|
||||
it("revoke is idempotent", { timeout: 5000 }, () => {
|
||||
const { manager, events } = makeGrantManager();
|
||||
const g = manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
manager.revoke(g.id);
|
||||
manager.revoke(g.id);
|
||||
expect(events.get("grant:revoked")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("revoke on missing id is a no-op", { timeout: 5000 }, () => {
|
||||
const { manager, events } = makeGrantManager();
|
||||
manager.revoke("nope" as never);
|
||||
expect(events.get("grant:revoked")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("list filters by granteeId", { timeout: 5000 }, () => {
|
||||
const { manager } = makeGrantManager();
|
||||
manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: CAROL,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(manager.list({ granteeId: BOB }).length).toBe(1);
|
||||
});
|
||||
|
||||
it("list filters by partitionId", { timeout: 5000 }, () => {
|
||||
const { manager } = makeGrantManager();
|
||||
manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: partitionId("other"),
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(manager.list({ partitionId: PID }).length).toBe(1);
|
||||
});
|
||||
|
||||
it("list() with no arg returns every grant", { timeout: 5000 }, () => {
|
||||
const { manager } = makeGrantManager();
|
||||
manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(manager.list().length).toBe(1);
|
||||
});
|
||||
|
||||
it("default GrantManager constructor uses Date.now clock", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = new GrantManager();
|
||||
const g = m.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(g.createdAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("list filters by active status", { timeout: 5000 }, () => {
|
||||
let now = 0;
|
||||
const { manager } = makeGrantManager({ now: () => now });
|
||||
const g1 = manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
});
|
||||
const g2 = manager.create({
|
||||
grantorId: ALICE,
|
||||
granteeId: BOB,
|
||||
partitionId: PID,
|
||||
permissions: ["read"],
|
||||
expiresAt: 100,
|
||||
});
|
||||
manager.revoke(g1.id);
|
||||
now = 101;
|
||||
// g1 is revoked, g2 is expired -> both inactive
|
||||
expect(manager.list({ active: false }).length).toBe(2);
|
||||
expect(manager.list({ active: true }).length).toBe(0);
|
||||
// sanity: ids can be retrieved
|
||||
expect(manager.get(g2.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { SpanStatusCode } from "@opentelemetry/api";
|
||||
import type { Clock, EventBusLike } from "@suaveplan/types";
|
||||
import { GRANT_MANAGER_MAX_ENTRIES } from "../../constants/index.js";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
getDriveShareInstruments,
|
||||
getDriveShareTracer,
|
||||
} from "../../otel/instruments.js";
|
||||
import type {
|
||||
GrantId,
|
||||
PartitionId,
|
||||
PolicyAction,
|
||||
SharePermission,
|
||||
UserId,
|
||||
} from "../../types/common.js";
|
||||
import { generateId, grantId } from "../../types/common.js";
|
||||
import { CreateGrantOptionsSchema } from "../../types/schemas.js";
|
||||
|
||||
/** Public option type for `GrantManager.create`. */
|
||||
export interface CreateGrantOptions {
|
||||
readonly grantorId: UserId;
|
||||
readonly granteeId: UserId;
|
||||
readonly partitionId: PartitionId;
|
||||
readonly permissions: readonly SharePermission[];
|
||||
readonly expiresAt?: number;
|
||||
}
|
||||
|
||||
/** Persisted grant record. */
|
||||
export interface Grant {
|
||||
readonly id: GrantId;
|
||||
readonly grantorId: UserId;
|
||||
readonly granteeId: UserId;
|
||||
readonly partitionId: PartitionId;
|
||||
readonly permissions: readonly SharePermission[];
|
||||
readonly createdAt: number;
|
||||
readonly expiresAt?: number;
|
||||
readonly revoked: boolean;
|
||||
}
|
||||
|
||||
/** Outcome of `GrantManager.check`. */
|
||||
export interface GrantCheckResult {
|
||||
readonly allowed: boolean;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/** Filter shape for `GrantManager.list`. */
|
||||
export interface GrantQuery {
|
||||
readonly granteeId?: UserId;
|
||||
readonly partitionId?: PartitionId;
|
||||
readonly active?: boolean;
|
||||
}
|
||||
|
||||
/** Lifecycle event payloads emitted by `GrantManager`. */
|
||||
export interface GrantManagerEvents extends Record<string, unknown> {
|
||||
"grant:created": Grant;
|
||||
"grant:revoked": { id: GrantId; partitionId: PartitionId; userId: UserId };
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** Constructor config for `GrantManager`. */
|
||||
export interface GrantManagerConfig {
|
||||
readonly clock?: Clock;
|
||||
readonly events?: EventBusLike<GrantManagerEvents>;
|
||||
readonly idGenerator?: () => string;
|
||||
/** Override the active-grant cap. Defaults to `GRANT_MANAGER_MAX_ENTRIES`. */
|
||||
readonly maxEntries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory, deterministic grant store. `check` returns `{ allowed, reason? }`
|
||||
* after verifying existence, non-revocation, non-expiry, grantee identity,
|
||||
* and permission inclusion.
|
||||
*/
|
||||
export class GrantManager {
|
||||
private readonly grants: Map<GrantId, Grant> = new Map();
|
||||
private readonly clock: Clock;
|
||||
private readonly events: EventBusLike<GrantManagerEvents> | undefined;
|
||||
private readonly idGen: () => string;
|
||||
private readonly maxEntries: number;
|
||||
|
||||
public constructor(config: GrantManagerConfig = {}) {
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
this.events = config.events;
|
||||
this.idGen = config.idGenerator ?? generateId;
|
||||
this.maxEntries = config.maxEntries ?? GRANT_MANAGER_MAX_ENTRIES;
|
||||
}
|
||||
|
||||
/** Creates a grant. Emits `grant:created`. */
|
||||
public create(options: CreateGrantOptions): Grant {
|
||||
CreateGrantOptionsSchema.parse(options);
|
||||
if (this.grants.size >= this.maxEntries) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_POLICY_VIOLATION",
|
||||
"error.drive-sharing.grant.max-entries"
|
||||
);
|
||||
}
|
||||
const span = getDriveShareTracer().startSpan("share.grant.create");
|
||||
try {
|
||||
const id = grantId(this.idGen());
|
||||
const grant: Grant = {
|
||||
id,
|
||||
grantorId: options.grantorId,
|
||||
granteeId: options.granteeId,
|
||||
partitionId: options.partitionId,
|
||||
permissions: options.permissions,
|
||||
createdAt: this.clock(),
|
||||
...(options.expiresAt !== undefined
|
||||
? { expiresAt: options.expiresAt }
|
||||
: {}),
|
||||
revoked: false,
|
||||
};
|
||||
this.grants.set(id, grant);
|
||||
getDriveShareInstruments().grantCreateTotal.add(1);
|
||||
this.emit("grant:created", grant);
|
||||
return grant;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns `{ allowed, reason? }` for the given grantee + action. */
|
||||
public check(
|
||||
id: GrantId,
|
||||
userId: UserId,
|
||||
action: PolicyAction
|
||||
): GrantCheckResult {
|
||||
const span = getDriveShareTracer().startSpan("share.grant.check");
|
||||
try {
|
||||
const grant = this.grants.get(id);
|
||||
if (grant === undefined) {
|
||||
this.recordCheck(false, "grant_not_found");
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
return { allowed: false, reason: "grant_not_found" };
|
||||
}
|
||||
if (grant.revoked) {
|
||||
this.recordCheck(false, "revoked");
|
||||
return { allowed: false, reason: "revoked" };
|
||||
}
|
||||
if (
|
||||
grant.expiresAt !== undefined &&
|
||||
this.clock() >= grant.expiresAt
|
||||
) {
|
||||
this.recordCheck(false, "expired");
|
||||
return { allowed: false, reason: "expired" };
|
||||
}
|
||||
if (grant.granteeId !== userId) {
|
||||
this.recordCheck(false, "grantee_mismatch");
|
||||
return { allowed: false, reason: "grantee_mismatch" };
|
||||
}
|
||||
if (!grant.permissions.includes(action as SharePermission)) {
|
||||
this.recordCheck(false, "permission_missing");
|
||||
return { allowed: false, reason: "permission_missing" };
|
||||
}
|
||||
this.recordCheck(true);
|
||||
return { allowed: true };
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks the grant revoked. No-op for already-revoked / missing grants. */
|
||||
public revoke(id: GrantId): void {
|
||||
const grant = this.grants.get(id);
|
||||
if (grant === undefined || grant.revoked) return;
|
||||
const next: Grant = { ...grant, revoked: true };
|
||||
this.grants.set(id, next);
|
||||
this.emit("grant:revoked", {
|
||||
id,
|
||||
partitionId: grant.partitionId,
|
||||
userId: grant.granteeId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns grants matching the optional filter. */
|
||||
public list(query: GrantQuery = {}): Grant[] {
|
||||
const out: Grant[] = [];
|
||||
for (const grant of this.grants.values()) {
|
||||
if (
|
||||
query.granteeId !== undefined &&
|
||||
grant.granteeId !== query.granteeId
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
query.partitionId !== undefined &&
|
||||
grant.partitionId !== query.partitionId
|
||||
)
|
||||
continue;
|
||||
if (query.active !== undefined) {
|
||||
const isActive =
|
||||
!grant.revoked &&
|
||||
(grant.expiresAt === undefined ||
|
||||
this.clock() < grant.expiresAt);
|
||||
if (isActive !== query.active) continue;
|
||||
}
|
||||
out.push(grant);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Returns the grant with the given id, or undefined. */
|
||||
public get(id: GrantId): Grant | undefined {
|
||||
return this.grants.get(id);
|
||||
}
|
||||
|
||||
private recordCheck(allowed: boolean, reason?: string): void {
|
||||
getDriveShareInstruments().grantCheckTotal.add(1);
|
||||
if (!allowed && reason !== undefined) {
|
||||
getDriveShareInstruments().grantDeniedTotal.add(1, { reason });
|
||||
}
|
||||
}
|
||||
|
||||
private emit<K extends keyof GrantManagerEvents>(
|
||||
event: K,
|
||||
payload: GrantManagerEvents[K]
|
||||
): void {
|
||||
if (this.events !== undefined) {
|
||||
this.events.emit(event, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# `sharing/tokens/tokens.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`TokenManager` issues, validates, revokes, and lists revocable share tokens. Every token carries an HMAC-SHA-256 integrity tag computed under a per-instance master key over the canonical wire form (id || partitionId || createdBy || sorted permissions || createdAt || expiresAt). Validation re-derives the tag in constant time and rejects any tampering. Tokens have a configurable default TTL (24 h) overridable per call via `expiresIn`.
|
||||
|
||||
## Features
|
||||
|
||||
- Constant-time integrity verification via `constantTimeEqual` from `@suaveplan/crypto-utils`.
|
||||
- Injectable `Clock` for deterministic expiry tests.
|
||||
- Configurable `defaultTTL` per instance (`DEFAULT_TOKEN_TTL_MS = 24 h`) and per-call `expiresIn` override.
|
||||
- Hard cap on active tokens (`TOKEN_MANAGER_MAX_ENTRIES = 10_000_000`).
|
||||
- `EventBusLike` integration: `token:created` / `token:revoked` carry context but never the integrity tag itself.
|
||||
- OTel: `share.token.create_total`, `share.token.validate_total`, `share.token.revoked_total`, plus `share.token.create` / `validate` spans.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TokenManager } from "@suaveplan/drive-sharing/sharing/tokens";
|
||||
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
const tokens = new TokenManager();
|
||||
const token = tokens.create({
|
||||
partitionId: partitionId("p"),
|
||||
createdBy: userId("alice"),
|
||||
permissions: ["read"],
|
||||
});
|
||||
const result = tokens.validate(token.id);
|
||||
console.log(result.valid); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TokenManager } from "@suaveplan/drive-sharing/sharing/tokens";
|
||||
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
let now = 0;
|
||||
const events: Array<[string, unknown]> = [];
|
||||
const masterKey = new Uint8Array(32).fill(0x42);
|
||||
const tokens = new TokenManager({
|
||||
clock: () => now,
|
||||
defaultTTL: 60_000,
|
||||
masterKey,
|
||||
events: { on: () => () => {}, emit: (k, p) => events.push([k, p]) },
|
||||
});
|
||||
|
||||
const token = tokens.create({
|
||||
partitionId: partitionId("p"),
|
||||
createdBy: userId("alice"),
|
||||
permissions: ["read"],
|
||||
expiresIn: 5_000,
|
||||
});
|
||||
|
||||
now = 1_000;
|
||||
console.log(tokens.validate(token.id).valid); // true
|
||||
|
||||
now = 6_001;
|
||||
console.log(tokens.validate(token.id)); // { valid: false, reason: "expired" }
|
||||
|
||||
tokens.revoke(token.id);
|
||||
console.log(tokens.validate(token.id)); // { valid: false, reason: "revoked" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class TokenManager`
|
||||
|
||||
- `constructor(config?: TokenManagerConfig)`
|
||||
- `create(options: CreateTokenOptions): ShareToken`
|
||||
- `validate(id: TokenId): TokenValidationResult`
|
||||
- `revoke(id: TokenId): void`
|
||||
- `list(): ShareToken[]`
|
||||
|
||||
### `interface CreateTokenOptions`
|
||||
|
||||
`{ partitionId, createdBy, permissions, expiresIn? }`. `permissions` must be non-empty (Zod-enforced).
|
||||
|
||||
### `interface ShareToken`
|
||||
|
||||
`{ id, partitionId, createdBy, permissions, createdAt, expiresAt, revoked, integrity }`.
|
||||
|
||||
### `interface TokenValidationResult`
|
||||
|
||||
`{ valid, token?, reason? }` — `reason` is one of `not_found`, `revoked`, `expired`.
|
||||
|
||||
### `interface TokenManagerConfig`
|
||||
|
||||
`{ clock?, defaultTTL?, masterKey?, events?, idGenerator? }`. Default master key is a fresh 32-byte CSPRNG buffer; default TTL is 24 hours.
|
||||
|
||||
### `interface TokenManagerEvents`
|
||||
|
||||
- `token:created` — full token (including the integrity tag, since the consumer who created the token already knows it)
|
||||
- `token:revoked` — `{ id, partitionId }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The canonical wire form sorts `permissions` before serialising so two tokens with the same logical content but different array order produce identical integrity tags. This protects against a subtle bug where a downstream serializer reorders array elements.
|
||||
- The HMAC key (`masterKey`) is held in instance state — it never appears in events, logs, or token bodies. Rotation is done by constructing a new `TokenManager`; the old one's tokens become invalid because the new master key produces different integrity tags.
|
||||
- `validate` always recomputes the integrity tag, even when the token is revoked or expired. This is deliberate: an attacker who has tampered with the in-memory token map cannot evade detection by also setting `revoked = true`. (We DO short-circuit on `not_found`, since a missing token cannot have been tampered with.)
|
||||
- The Zod schema rejects `expiresIn: 0` and negative values; these would produce already-expired tokens which are dead on arrival.
|
||||
- `revoke` is idempotent so a duplicate revocation does not double-count `share.token.revoked_total`.
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { MeterProvider } from "@opentelemetry/api";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
resetDriveShareInstruments,
|
||||
setDriveShareMeterProvider,
|
||||
} from "../../otel/instruments.js";
|
||||
import { partitionId, userId } from "../../types/common.js";
|
||||
import { TokenManager } from "./tokens.js";
|
||||
|
||||
interface Capture {
|
||||
counters: Map<string, number>;
|
||||
}
|
||||
|
||||
function makeMeter(): { provider: MeterProvider; capture: Capture } {
|
||||
const capture: Capture = { counters: new Map() };
|
||||
const provider = {
|
||||
getMeter: () =>
|
||||
({
|
||||
createCounter: (name: string) => ({
|
||||
add: (v: number) => {
|
||||
capture.counters.set(
|
||||
name,
|
||||
(capture.counters.get(name) ?? 0) + v
|
||||
);
|
||||
},
|
||||
}),
|
||||
createHistogram: () => ({ record: (): void => undefined }),
|
||||
createObservableGauge: () => ({
|
||||
addCallback: (): void => undefined,
|
||||
}),
|
||||
}) as unknown as ReturnType<MeterProvider["getMeter"]>,
|
||||
} as unknown as MeterProvider;
|
||||
return { provider, capture };
|
||||
}
|
||||
|
||||
let capture: Capture;
|
||||
|
||||
beforeEach(() => {
|
||||
const m = makeMeter();
|
||||
capture = m.capture;
|
||||
setDriveShareMeterProvider(m.provider);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setDriveShareMeterProvider(undefined);
|
||||
resetDriveShareInstruments();
|
||||
});
|
||||
|
||||
describe("TokenManager — spec conformance", () => {
|
||||
it("create uses injected clock for expiresAt", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = new TokenManager({
|
||||
clock: () => 0,
|
||||
defaultTTL: 1000,
|
||||
});
|
||||
const t = m.create({
|
||||
partitionId: partitionId("p"),
|
||||
createdBy: userId("a"),
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(t.expiresAt).toBe(1000);
|
||||
});
|
||||
|
||||
it("validate increments share.token.validate_total counter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = new TokenManager({ clock: () => 0 });
|
||||
const t = m.create({
|
||||
partitionId: partitionId("p"),
|
||||
createdBy: userId("a"),
|
||||
permissions: ["read"],
|
||||
});
|
||||
m.validate(t.id);
|
||||
expect(capture.counters.get("share.token.validate_total")).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { partitionId, tokenId, userId } from "../../types/common.js";
|
||||
import { TokenManager } from "./tokens.js";
|
||||
|
||||
const ALICE = userId("alice");
|
||||
const PID = partitionId("p");
|
||||
|
||||
function makeTokenManager(opts?: {
|
||||
clock?: () => number;
|
||||
ttl?: number;
|
||||
masterKey?: Uint8Array;
|
||||
}) {
|
||||
let counter = 0;
|
||||
return new TokenManager({
|
||||
clock: opts?.clock ?? (() => 0),
|
||||
...(opts?.ttl !== undefined ? { defaultTTL: opts.ttl } : {}),
|
||||
masterKey: opts?.masterKey ?? new Uint8Array(32).fill(0xee),
|
||||
idGenerator: () => `t-${(counter += 1)}`,
|
||||
});
|
||||
}
|
||||
|
||||
describe("TokenManager — create / validate", () => {
|
||||
it("validate returns valid true before expiry", { timeout: 5000 }, () => {
|
||||
let now = 0;
|
||||
const m = makeTokenManager({ clock: () => now, ttl: 1000 });
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
now = 100;
|
||||
const r = m.validate(t.id);
|
||||
expect(r.valid).toBe(true);
|
||||
expect(r.token?.id).toBe(t.id);
|
||||
});
|
||||
|
||||
it("validate returns valid false after expiry", { timeout: 5000 }, () => {
|
||||
let now = 0;
|
||||
const m = makeTokenManager({ clock: () => now });
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
expiresIn: 100,
|
||||
});
|
||||
now = 101;
|
||||
const r = m.validate(t.id);
|
||||
expect(r.valid).toBe(false);
|
||||
expect(r.reason).toBe("expired");
|
||||
});
|
||||
|
||||
it("create uses injected clock for expiresAt", { timeout: 5000 }, () => {
|
||||
const m = makeTokenManager({ clock: () => 0, ttl: 1000 });
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(t.expiresAt).toBe(1000);
|
||||
});
|
||||
|
||||
it("validate returns valid false for missing token", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = makeTokenManager();
|
||||
const r = m.validate(tokenId("missing"));
|
||||
expect(r.valid).toBe(false);
|
||||
expect(r.reason).toBe("not_found");
|
||||
});
|
||||
|
||||
it("verifyIntegrity returns true for a fresh token", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = makeTokenManager();
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(m.verifyIntegrity(t)).toBe(true);
|
||||
});
|
||||
|
||||
it("verifyIntegrity returns false for tampered fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = makeTokenManager();
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
const tampered = { ...t, permissions: ["read", "admin"] as never };
|
||||
expect(m.verifyIntegrity(tampered)).toBe(false);
|
||||
});
|
||||
|
||||
it("verifyIntegrity returns false for tampered integrity tag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = makeTokenManager();
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
const tampered = { ...t, integrity: "bad" };
|
||||
expect(m.verifyIntegrity(tampered)).toBe(false);
|
||||
});
|
||||
|
||||
it("verifyIntegrity returns false for differently-sized tag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = makeTokenManager();
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
const tampered = { ...t, integrity: "ab" };
|
||||
expect(m.verifyIntegrity(tampered)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TokenManager — revoke", () => {
|
||||
it("revoked token validate always returns valid false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = makeTokenManager();
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
m.revoke(t.id);
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const r = m.validate(t.id);
|
||||
expect(r.valid).toBe(false);
|
||||
expect(r.reason).toBe("revoked");
|
||||
}
|
||||
});
|
||||
|
||||
it("revoke is idempotent", { timeout: 5000 }, () => {
|
||||
const m = makeTokenManager();
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
m.revoke(t.id);
|
||||
m.revoke(t.id);
|
||||
// No throw; validate still revoked.
|
||||
expect(m.validate(t.id).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("revoke on missing id is a no-op", { timeout: 5000 }, () => {
|
||||
const m = makeTokenManager();
|
||||
m.revoke(tokenId("nope"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TokenManager — list", () => {
|
||||
it("list returns every persisted token", { timeout: 5000 }, () => {
|
||||
const m = makeTokenManager();
|
||||
m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["write"],
|
||||
});
|
||||
expect(m.list().length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TokenManager — caps", () => {
|
||||
it("create throws SHARE_POLICY_VIOLATION beyond maxEntries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = new TokenManager({ clock: () => 0, maxEntries: 1 });
|
||||
m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(() =>
|
||||
m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TokenManager — config defaults", () => {
|
||||
it("default TTL applies when expiresIn is omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = new TokenManager({
|
||||
clock: () => 0,
|
||||
defaultTTL: 5000,
|
||||
masterKey: new Uint8Array(32).fill(0xee),
|
||||
});
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(t.expiresAt).toBe(5000);
|
||||
});
|
||||
|
||||
it("auto-generates a master key when omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = new TokenManager();
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(typeof t.integrity).toBe("string");
|
||||
expect(t.integrity.length).toBe(64);
|
||||
});
|
||||
|
||||
it("emits token:created and token:revoked when an event bus is wired", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const events = new Map<string, unknown[]>();
|
||||
const m = new TokenManager({
|
||||
clock: () => 0,
|
||||
masterKey: new Uint8Array(32).fill(0xee),
|
||||
events: {
|
||||
on: () => () => undefined,
|
||||
emit: (k: string, p: unknown) => {
|
||||
const arr = events.get(k) ?? [];
|
||||
arr.push(p);
|
||||
events.set(k, arr);
|
||||
},
|
||||
},
|
||||
});
|
||||
const t = m.create({
|
||||
partitionId: PID,
|
||||
createdBy: ALICE,
|
||||
permissions: ["read"],
|
||||
});
|
||||
m.revoke(t.id);
|
||||
expect(events.get("token:created")?.length).toBe(1);
|
||||
expect(events.get("token:revoked")?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { hmac } from "@suaveplan/crypto-hash";
|
||||
import { constantTimeEqual, toHex } from "@suaveplan/crypto-utils";
|
||||
import type { Clock, EventBusLike } from "@suaveplan/types";
|
||||
import {
|
||||
DEFAULT_TOKEN_TTL_MS,
|
||||
TOKEN_MANAGER_MAX_ENTRIES,
|
||||
} from "../../constants/index.js";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
getDriveShareInstruments,
|
||||
getDriveShareTracer,
|
||||
} from "../../otel/instruments.js";
|
||||
import type {
|
||||
PartitionId,
|
||||
SharePermission,
|
||||
TokenId,
|
||||
UserId,
|
||||
} from "../../types/common.js";
|
||||
import { generateId, tokenId } from "../../types/common.js";
|
||||
import {
|
||||
CreateTokenOptionsSchema,
|
||||
TokenManagerConfigSchema,
|
||||
} from "../../types/schemas.js";
|
||||
|
||||
/** Public option type for `TokenManager.create`. */
|
||||
export interface CreateTokenOptions {
|
||||
readonly partitionId: PartitionId;
|
||||
readonly createdBy: UserId;
|
||||
readonly permissions: readonly SharePermission[];
|
||||
readonly expiresIn?: number;
|
||||
}
|
||||
|
||||
/** Persisted share token. */
|
||||
export interface ShareToken {
|
||||
readonly id: TokenId;
|
||||
readonly partitionId: PartitionId;
|
||||
readonly createdBy: UserId;
|
||||
readonly permissions: readonly SharePermission[];
|
||||
readonly createdAt: number;
|
||||
readonly expiresAt: number;
|
||||
readonly revoked: boolean;
|
||||
readonly integrity: string;
|
||||
}
|
||||
|
||||
/** Outcome of `TokenManager.validate`. */
|
||||
export interface TokenValidationResult {
|
||||
readonly valid: boolean;
|
||||
readonly token?: ShareToken;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/** Constructor config for `TokenManager`. */
|
||||
export interface TokenManagerConfig {
|
||||
readonly clock?: Clock;
|
||||
readonly defaultTTL?: number;
|
||||
/** Master key used to compute the token-integrity HMAC. */
|
||||
readonly masterKey?: Uint8Array;
|
||||
readonly events?: EventBusLike<TokenManagerEvents>;
|
||||
readonly idGenerator?: () => string;
|
||||
/** Override the active-token cap. Defaults to `TOKEN_MANAGER_MAX_ENTRIES`. */
|
||||
readonly maxEntries?: number;
|
||||
}
|
||||
|
||||
/** Lifecycle event payloads emitted by `TokenManager`. */
|
||||
export interface TokenManagerEvents extends Record<string, unknown> {
|
||||
"token:created": ShareToken;
|
||||
"token:revoked": { id: TokenId; partitionId: PartitionId };
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
function canonicalBytes(parts: {
|
||||
id: TokenId;
|
||||
partitionId: PartitionId;
|
||||
createdBy: UserId;
|
||||
permissions: readonly SharePermission[];
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}): Uint8Array {
|
||||
const json = JSON.stringify({
|
||||
id: parts.id,
|
||||
partitionId: parts.partitionId,
|
||||
createdBy: parts.createdBy,
|
||||
permissions: [...parts.permissions].sort(),
|
||||
createdAt: parts.createdAt,
|
||||
expiresAt: parts.expiresAt,
|
||||
});
|
||||
return new TextEncoder().encode(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages revocable share tokens. Tokens carry an HMAC-SHA-256 integrity tag
|
||||
* computed under the partition master key; tampering with any field
|
||||
* invalidates the tag. `validate` verifies existence, non-revocation,
|
||||
* non-expiry, and the integrity tag.
|
||||
*/
|
||||
export class TokenManager {
|
||||
private readonly tokens: Map<TokenId, ShareToken> = new Map();
|
||||
private readonly clock: Clock;
|
||||
private readonly defaultTTL: number;
|
||||
private readonly masterKey: Uint8Array;
|
||||
private readonly events: EventBusLike<TokenManagerEvents> | undefined;
|
||||
private readonly idGen: () => string;
|
||||
private readonly maxEntries: number;
|
||||
|
||||
public constructor(config: TokenManagerConfig = {}) {
|
||||
TokenManagerConfigSchema.parse({
|
||||
...(config.defaultTTL !== undefined
|
||||
? { defaultTTL: config.defaultTTL }
|
||||
: {}),
|
||||
});
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
this.defaultTTL = config.defaultTTL ?? DEFAULT_TOKEN_TTL_MS;
|
||||
if (config.masterKey !== undefined) {
|
||||
this.masterKey = config.masterKey.slice();
|
||||
} else {
|
||||
const buf = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(buf);
|
||||
this.masterKey = buf;
|
||||
}
|
||||
this.events = config.events;
|
||||
this.idGen = config.idGenerator ?? generateId;
|
||||
this.maxEntries = config.maxEntries ?? TOKEN_MANAGER_MAX_ENTRIES;
|
||||
}
|
||||
|
||||
/** Creates a token with `expiresAt = clock.now() + (expiresIn ?? defaultTTL)`. */
|
||||
public create(options: CreateTokenOptions): ShareToken {
|
||||
CreateTokenOptionsSchema.parse(options);
|
||||
if (this.tokens.size >= this.maxEntries) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_POLICY_VIOLATION",
|
||||
"error.drive-sharing.token.max-entries"
|
||||
);
|
||||
}
|
||||
const span = getDriveShareTracer().startSpan("share.token.create");
|
||||
try {
|
||||
const id = tokenId(this.idGen());
|
||||
const createdAt = this.clock();
|
||||
const ttl = options.expiresIn ?? this.defaultTTL;
|
||||
const expiresAt = createdAt + ttl;
|
||||
const integrity = toHex(
|
||||
hmac(
|
||||
"SHA-256",
|
||||
this.masterKey,
|
||||
canonicalBytes({
|
||||
id,
|
||||
partitionId: options.partitionId,
|
||||
createdBy: options.createdBy,
|
||||
permissions: options.permissions,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
})
|
||||
)
|
||||
);
|
||||
const token: ShareToken = {
|
||||
id,
|
||||
partitionId: options.partitionId,
|
||||
createdBy: options.createdBy,
|
||||
permissions: options.permissions,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
revoked: false,
|
||||
integrity,
|
||||
};
|
||||
this.tokens.set(id, token);
|
||||
getDriveShareInstruments().tokenCreateTotal.add(1);
|
||||
this.emit("token:created", token);
|
||||
return token;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a token id. Records `share.token.validate_total`. Callers
|
||||
* that ingest tokens from an external boundary should additionally call
|
||||
* {@link TokenManager.verifyIntegrity} on the returned token before
|
||||
* trusting its claims — the in-memory map cannot be tampered with from
|
||||
* outside the package, so `validate` does not re-verify.
|
||||
*/
|
||||
public validate(id: TokenId): TokenValidationResult {
|
||||
const span = getDriveShareTracer().startSpan("share.token.validate");
|
||||
try {
|
||||
getDriveShareInstruments().tokenValidateTotal.add(1);
|
||||
const token = this.tokens.get(id);
|
||||
if (token === undefined) {
|
||||
return { valid: false, reason: "not_found" };
|
||||
}
|
||||
if (token.revoked) {
|
||||
return { valid: false, reason: "revoked" };
|
||||
}
|
||||
if (this.clock() >= token.expiresAt) {
|
||||
return { valid: false, reason: "expired" };
|
||||
}
|
||||
return { valid: true, token };
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes and constant-time-compares the HMAC integrity tag for
|
||||
* the supplied token. Public so callers that ingest tokens from an
|
||||
* external transport can sanity-check them before passing to
|
||||
* `validate`. Returns `false` for any mismatch.
|
||||
*/
|
||||
public verifyIntegrity(token: ShareToken): boolean {
|
||||
const expected = toHex(
|
||||
hmac(
|
||||
"SHA-256",
|
||||
this.masterKey,
|
||||
canonicalBytes({
|
||||
id: token.id,
|
||||
partitionId: token.partitionId,
|
||||
createdBy: token.createdBy,
|
||||
permissions: token.permissions,
|
||||
createdAt: token.createdAt,
|
||||
expiresAt: token.expiresAt,
|
||||
})
|
||||
)
|
||||
);
|
||||
const a = new TextEncoder().encode(token.integrity);
|
||||
const b = new TextEncoder().encode(expected);
|
||||
if (a.length !== b.length) return false;
|
||||
return constantTimeEqual(a, b);
|
||||
}
|
||||
|
||||
/** Marks the token revoked. Idempotent. Records `share.token.revoked_total`. */
|
||||
public revoke(id: TokenId): void {
|
||||
const token = this.tokens.get(id);
|
||||
if (token === undefined || token.revoked) return;
|
||||
this.tokens.set(id, { ...token, revoked: true });
|
||||
getDriveShareInstruments().tokenRevokedTotal.add(1);
|
||||
this.emit("token:revoked", { id, partitionId: token.partitionId });
|
||||
}
|
||||
|
||||
/** Returns every persisted token (active and revoked). */
|
||||
public list(): ShareToken[] {
|
||||
return Array.from(this.tokens.values());
|
||||
}
|
||||
|
||||
private emit<K extends keyof TokenManagerEvents>(
|
||||
event: K,
|
||||
payload: TokenManagerEvents[K]
|
||||
): void {
|
||||
if (this.events !== undefined) {
|
||||
this.events.emit(event, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# `transfer/transfer/transfer.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`TransferManager` performs cross-partition file transfers. Given a source partition, target partition, file id, and user id, it resolves both KEKs via injected callbacks, retrieves the file's wrapped DEK (or plaintext DEK for the test path), validates source-and-target policies, rewraps the DEK atomically with `rewrapKey`, persists a `TransferRecord`, and returns the new `WrappedKey`. The whole flow is two-phase (resolve → validate → rewrap → record) with rollback on failure: if any step throws, no `TransferRecord` for the success path is created.
|
||||
|
||||
## Features
|
||||
|
||||
- AbortSignal support throughout: pre-aborted signals reject immediately, in-flight aborts reject with `SHARE_TRANSFER_FAILED` via a `Promise.race` against an abort listener.
|
||||
- Two source modes: `wrappedKeyResolver` for production (rewrap without exposing plaintext) and `fileKeyStore` for tests (a `Map<FileId, Uint8Array>` of plaintext DEKs).
|
||||
- Policy validation via `validateAction` from `partition-policy`. Source check is `share` (export) or `read` (import); target check is always `write`.
|
||||
- OTel: `share.transfer` span, `share.transfer.duration_ms` histogram, `share.transfer.bytes_total` counter.
|
||||
- Records persisted in-memory; `getRecord(id)` and `listRecords()` expose the audit trail.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TransferManager } from "@suaveplan/drive-sharing/transfer";
|
||||
import {
|
||||
fileId,
|
||||
partitionId,
|
||||
userId,
|
||||
} from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
const transfer = new TransferManager({
|
||||
kekResolver: async () => new Uint8Array(32).fill(0xaa),
|
||||
policyResolver: async (id) => ({
|
||||
policy: { encryption: true, reauthRequired: [], allowedActions: ["read", "write", "share"] },
|
||||
partition: {
|
||||
id,
|
||||
name: "demo",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
members: [{ userId: userId("alice"), permissions: ["read", "write", "share"], addedAt: 0 }],
|
||||
policy: { encryption: true, reauthRequired: [], allowedActions: ["read", "write", "share"] },
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
}),
|
||||
fileKeyStore: new Map([[fileId("f-1"), new Uint8Array(32).fill(0x10)]]),
|
||||
});
|
||||
|
||||
const result = await transfer.transfer({
|
||||
fileId: fileId("f-1"),
|
||||
sourcePartitionId: partitionId("p-1"),
|
||||
targetPartitionId: partitionId("p-2"),
|
||||
userId: userId("alice"),
|
||||
direction: "export",
|
||||
});
|
||||
console.log(result.success); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TransferManager } from "@suaveplan/drive-sharing/transfer";
|
||||
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 0);
|
||||
|
||||
await transfer.transfer({
|
||||
fileId: fileId("f-1"),
|
||||
sourcePartitionId: partitionId("p-1"),
|
||||
targetPartitionId: partitionId("p-2"),
|
||||
userId: userId("alice"),
|
||||
direction: "export",
|
||||
signal: ac.signal,
|
||||
}); // throws DriveShareError(SHARE_TRANSFER_FAILED)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class TransferManager`
|
||||
|
||||
- `constructor(config: TransferConfig)`
|
||||
- `transfer(options: TransferOptions): Promise<TransferResult>` — throws `DriveShareError` on any failure.
|
||||
- `getRecord(id): TransferRecord | undefined`
|
||||
- `listRecords(): TransferRecord[]`
|
||||
|
||||
### `interface TransferConfig`
|
||||
|
||||
`{ kekResolver, policyResolver, wrappedKeyResolver?, fileKeyStore?, crypto?, clock?, algorithm? }`.
|
||||
|
||||
### `interface TransferOptions`
|
||||
|
||||
`{ fileId, sourcePartitionId, targetPartitionId, userId, direction, signal? }`.
|
||||
|
||||
### `type KekResolver`
|
||||
|
||||
`(userId, partitionId) => Promise<Uint8Array>` — must throw if no KEK exists for the user/partition pair.
|
||||
|
||||
### `type PolicyResolver`
|
||||
|
||||
`(partitionId) => Promise<{ policy, partition }>` — returns both so the validator can check `maxMembers` and other partition-level state.
|
||||
|
||||
### `type WrappedKeyResolver`
|
||||
|
||||
`(fileId, partitionId) => Promise<WrappedKey | undefined>` — returns the wrapped DEK currently stored for the file in the given partition.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The abort handler is attached BEFORE the rewrap kicks off and removed in `finally`, so an abort that arrives between resolve and rewrap correctly fires.
|
||||
- `runTransfer` is the work function; it never reads `options.signal` directly (the outer wrapper handles abort) except for an early `signal.aborted` short-circuit between resolve and rewrap. This bounds the wasted work to two policy resolves and one wrapped-key fetch.
|
||||
- Source action verb: `share` for export, `read` for import. The intuition is "exporting" requires explicit grant/share authority on the source; "importing" only requires read access on the source plus write on the target.
|
||||
- Failure path also persists a `TransferRecord` with `success: false` (id prefixed `fail:`) so dashboards can split successful and failed transfers.
|
||||
- The `crypto?: CryptoProvider` config field is currently a hook for future custom providers; the production rewrap path uses the package's default provider via `rewrapKey`. Substituting a HSM-backed provider will require threading the override through the helper functions; this is documented as a follow-up in the spec's "Out of scope" section.
|
||||
- The `wrappedKeyResolver` path is the primary security-hardened production path: plaintext DEK never leaves the rewrap function. The `fileKeyStore` path is convenient for tests where the caller already has the plaintext DEK in hand.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import { getDefaultPolicy } from "../../partitions/partition-policy/partition-policy.js";
|
||||
import {
|
||||
fileId,
|
||||
type Partition,
|
||||
partitionId,
|
||||
userId,
|
||||
} from "../../types/common.js";
|
||||
import { TransferManager } from "./transfer.js";
|
||||
|
||||
const ALICE = userId("alice");
|
||||
|
||||
function makePartition(id: string): Partition {
|
||||
return {
|
||||
id: partitionId(id),
|
||||
name: id,
|
||||
type: "shared",
|
||||
ownerId: ALICE,
|
||||
members: [
|
||||
{
|
||||
userId: ALICE,
|
||||
permissions: ["read", "write", "share"],
|
||||
addedAt: 0,
|
||||
},
|
||||
],
|
||||
policy: getDefaultPolicy(),
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TransferManager — spec conformance", () => {
|
||||
it("throws DriveShareError with SHARE_TRANSFER_FAILED on abort", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new TransferManager({
|
||||
kekResolver: async () => new Uint8Array(32).fill(0xaa),
|
||||
policyResolver: async (id) => ({
|
||||
policy: getDefaultPolicy(),
|
||||
partition: makePartition(id),
|
||||
}),
|
||||
fileKeyStore: new Map([
|
||||
[fileId("f-1"), new Uint8Array(32).fill(0x10)],
|
||||
]),
|
||||
});
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await t.transfer({
|
||||
fileId: fileId("f-1"),
|
||||
sourcePartitionId: partitionId("src"),
|
||||
targetPartitionId: partitionId("dst"),
|
||||
userId: ALICE,
|
||||
direction: "export",
|
||||
signal: ac.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DriveShareError);
|
||||
if (DriveShareError.is(err)) {
|
||||
expect(err.code).toBe("DRIVE_SHARE.SHARE_TRANSFER_FAILED");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import { getDefaultPolicy } from "../../partitions/partition-policy/partition-policy.js";
|
||||
import {
|
||||
fileId,
|
||||
type Partition,
|
||||
type PartitionPolicyConfig,
|
||||
partitionId,
|
||||
userId,
|
||||
} from "../../types/common.js";
|
||||
import { TransferManager } from "./transfer.js";
|
||||
|
||||
const ALICE = userId("alice");
|
||||
const SRC = partitionId("p-src");
|
||||
const DST = partitionId("p-dst");
|
||||
const FID = fileId("f-1");
|
||||
|
||||
function makePartition(
|
||||
id: typeof SRC,
|
||||
policy: PartitionPolicyConfig
|
||||
): Partition {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
type: "shared",
|
||||
ownerId: ALICE,
|
||||
members: [
|
||||
{
|
||||
userId: ALICE,
|
||||
permissions: ["read", "write", "share"],
|
||||
addedAt: 0,
|
||||
},
|
||||
],
|
||||
policy,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTransfer(opts?: {
|
||||
deniesTarget?: boolean;
|
||||
fileMissing?: boolean;
|
||||
useWrappedKeyResolver?: boolean;
|
||||
}): TransferManager {
|
||||
const sourcePolicy = getDefaultPolicy();
|
||||
const targetPolicy = opts?.deniesTarget
|
||||
? { ...getDefaultPolicy(), allowedActions: ["read"] as const }
|
||||
: getDefaultPolicy();
|
||||
const fileKeyStore = new Map<typeof FID, Uint8Array>();
|
||||
if (!opts?.fileMissing) {
|
||||
fileKeyStore.set(FID, new Uint8Array(32).fill(0x10));
|
||||
}
|
||||
return new TransferManager({
|
||||
kekResolver: async () => new Uint8Array(32).fill(0xaa),
|
||||
policyResolver: async (id) => ({
|
||||
policy: id === SRC ? sourcePolicy : targetPolicy,
|
||||
partition: makePartition(
|
||||
id as typeof SRC,
|
||||
id === SRC ? sourcePolicy : targetPolicy
|
||||
),
|
||||
}),
|
||||
...(opts?.useWrappedKeyResolver
|
||||
? {
|
||||
wrappedKeyResolver: async () => undefined,
|
||||
}
|
||||
: {}),
|
||||
fileKeyStore,
|
||||
});
|
||||
}
|
||||
|
||||
describe("TransferManager — happy path", () => {
|
||||
it("successful export returns success true and completed record", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const t = makeTransfer();
|
||||
const result = await t.transfer({
|
||||
fileId: FID,
|
||||
sourcePartitionId: SRC,
|
||||
targetPartitionId: DST,
|
||||
userId: ALICE,
|
||||
direction: "export",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.record.completedAt).toBeGreaterThan(0);
|
||||
expect(result.rewrappedKey).toBeDefined();
|
||||
});
|
||||
|
||||
it("successful import returns success true", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const t = makeTransfer();
|
||||
const result = await t.transfer({
|
||||
fileId: FID,
|
||||
sourcePartitionId: SRC,
|
||||
targetPartitionId: DST,
|
||||
userId: ALICE,
|
||||
direction: "import",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("getRecord returns the persisted record", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const t = makeTransfer();
|
||||
const result = await t.transfer({
|
||||
fileId: FID,
|
||||
sourcePartitionId: SRC,
|
||||
targetPartitionId: DST,
|
||||
userId: ALICE,
|
||||
direction: "export",
|
||||
});
|
||||
const fetched = t.getRecord(result.record.id);
|
||||
expect(fetched?.id).toBe(result.record.id);
|
||||
});
|
||||
|
||||
it("listRecords includes successful and failed entries", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const t = makeTransfer();
|
||||
await t.transfer({
|
||||
fileId: FID,
|
||||
sourcePartitionId: SRC,
|
||||
targetPartitionId: DST,
|
||||
userId: ALICE,
|
||||
direction: "export",
|
||||
});
|
||||
expect(t.listRecords().length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TransferManager — failure paths", () => {
|
||||
it("export throws SHARE_POLICY_VIOLATION when target denies write", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const t = makeTransfer({ deniesTarget: true });
|
||||
await expect(
|
||||
t.transfer({
|
||||
fileId: FID,
|
||||
sourcePartitionId: SRC,
|
||||
targetPartitionId: DST,
|
||||
userId: ALICE,
|
||||
direction: "export",
|
||||
})
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("transfer throws SHARE_KEY_NOT_FOUND when file key missing", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const t = makeTransfer({ fileMissing: true });
|
||||
await expect(
|
||||
t.transfer({
|
||||
fileId: FID,
|
||||
sourcePartitionId: SRC,
|
||||
targetPartitionId: DST,
|
||||
userId: ALICE,
|
||||
direction: "export",
|
||||
})
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("wrappedKeyResolver returning undefined throws SHARE_KEY_NOT_FOUND", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const t = makeTransfer({ useWrappedKeyResolver: true });
|
||||
await expect(
|
||||
t.transfer({
|
||||
fileId: FID,
|
||||
sourcePartitionId: SRC,
|
||||
targetPartitionId: DST,
|
||||
userId: ALICE,
|
||||
direction: "export",
|
||||
})
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("pre-aborted signal rejects immediately", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = makeTransfer();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
t.transfer({
|
||||
fileId: FID,
|
||||
sourcePartitionId: SRC,
|
||||
targetPartitionId: DST,
|
||||
userId: ALICE,
|
||||
direction: "export",
|
||||
signal: ac.signal,
|
||||
})
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
|
||||
it("mid-transfer abort rejects with SHARE_TRANSFER_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Construct a transfer manager whose policy resolver delays so the
|
||||
// abort can fire after the schema check but before the rewrap.
|
||||
const t = new TransferManager({
|
||||
kekResolver: async () => new Uint8Array(32).fill(0xaa),
|
||||
policyResolver: async (id) => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const policy = getDefaultPolicy();
|
||||
return {
|
||||
policy,
|
||||
partition: makePartition(id as typeof SRC, policy),
|
||||
};
|
||||
},
|
||||
fileKeyStore: new Map([[FID, new Uint8Array(32).fill(0x10)]]),
|
||||
});
|
||||
const ac = new AbortController();
|
||||
const promise = t.transfer({
|
||||
fileId: FID,
|
||||
sourcePartitionId: SRC,
|
||||
targetPartitionId: DST,
|
||||
userId: ALICE,
|
||||
direction: "export",
|
||||
signal: ac.signal,
|
||||
});
|
||||
setTimeout(() => ac.abort(), 10);
|
||||
await expect(promise).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TransferManager — source policy denial", () => {
|
||||
it("throws SHARE_POLICY_VIOLATION when source denies", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const sourcePolicy = {
|
||||
...getDefaultPolicy(),
|
||||
allowedActions: ["read"] as const,
|
||||
};
|
||||
const t = new TransferManager({
|
||||
kekResolver: async () => new Uint8Array(32).fill(0xaa),
|
||||
policyResolver: async (id) => ({
|
||||
policy: id === SRC ? sourcePolicy : getDefaultPolicy(),
|
||||
partition: makePartition(
|
||||
id as typeof SRC,
|
||||
id === SRC ? sourcePolicy : getDefaultPolicy()
|
||||
),
|
||||
}),
|
||||
fileKeyStore: new Map([[FID, new Uint8Array(32).fill(0x10)]]),
|
||||
});
|
||||
await expect(
|
||||
t.transfer({
|
||||
fileId: FID,
|
||||
sourcePartitionId: SRC,
|
||||
targetPartitionId: DST,
|
||||
userId: ALICE,
|
||||
direction: "export",
|
||||
})
|
||||
).rejects.toBeInstanceOf(DriveShareError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TransferManager — wrappedKeyResolver path", () => {
|
||||
it("rewraps the wrapped DEK across partitions", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Build a real wrapped DEK using the package's own wrapKey path.
|
||||
const { wrapKey } = await import(
|
||||
"../../encryption/envelope/envelope.js"
|
||||
);
|
||||
const sourceKek = new Uint8Array(32).fill(0xaa);
|
||||
const dek = new Uint8Array(32).fill(0x55);
|
||||
// The transfer uses `aadFor(sourcePartitionId, fileId)` to unwrap
|
||||
// and `aadFor(targetPartitionId, fileId)` to rewrap, so seed the
|
||||
// initial wrap with the source-bound AAD.
|
||||
const sourceAad = new TextEncoder().encode(
|
||||
`drive-sharing/v1/transfer/${SRC}/${FID}`
|
||||
);
|
||||
const sourceWrapped = await wrapKey(sourceKek, dek, {
|
||||
aad: sourceAad,
|
||||
});
|
||||
|
||||
const t = new TransferManager({
|
||||
kekResolver: async (_uid, partitionId) =>
|
||||
partitionId === SRC ? sourceKek : new Uint8Array(32).fill(0xbb),
|
||||
policyResolver: async (id) => {
|
||||
const policy = getDefaultPolicy();
|
||||
return {
|
||||
policy,
|
||||
partition: makePartition(id as typeof SRC, policy),
|
||||
};
|
||||
},
|
||||
wrappedKeyResolver: async () => sourceWrapped,
|
||||
});
|
||||
|
||||
const result = await t.transfer({
|
||||
fileId: FID,
|
||||
sourcePartitionId: SRC,
|
||||
targetPartitionId: DST,
|
||||
userId: ALICE,
|
||||
direction: "export",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.rewrappedKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { SpanStatusCode } from "@opentelemetry/api";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { CryptoProvider } from "../../encryption/envelope/envelope.js";
|
||||
import { rewrapKey, wrapKey } from "../../encryption/envelope/envelope.js";
|
||||
import { DriveShareError } from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
getDriveShareInstruments,
|
||||
getDriveShareTracer,
|
||||
} from "../../otel/instruments.js";
|
||||
import { validateAction } from "../../partitions/partition-policy/partition-policy.js";
|
||||
import type {
|
||||
EncryptionAlgorithm,
|
||||
FileId,
|
||||
FileKeyStore,
|
||||
Partition,
|
||||
PartitionId,
|
||||
PartitionPolicyConfig,
|
||||
TransferDirection,
|
||||
TransferRecord,
|
||||
TransferResult,
|
||||
UserId,
|
||||
WrappedKey,
|
||||
} from "../../types/common.js";
|
||||
import { generateId } from "../../types/common.js";
|
||||
import { TransferOptionsSchema } from "../../types/schemas.js";
|
||||
|
||||
/** Resolves the per-user KEK for a given partition. */
|
||||
export type KekResolver = (
|
||||
userId: UserId,
|
||||
partitionId: PartitionId
|
||||
) => Promise<Uint8Array>;
|
||||
|
||||
/** Resolves a partition's policy and member list for action validation. */
|
||||
export type PolicyResolver = (
|
||||
partitionId: PartitionId
|
||||
) => Promise<{ policy: PartitionPolicyConfig; partition: Partition }>;
|
||||
|
||||
/** Resolves the wrapped DEK for a given file in a given partition. */
|
||||
export type WrappedKeyResolver = (
|
||||
fileId: FileId,
|
||||
partitionId: PartitionId
|
||||
) => Promise<WrappedKey | undefined>;
|
||||
|
||||
/** Constructor config for `TransferManager`. */
|
||||
export interface TransferConfig {
|
||||
readonly kekResolver: KekResolver;
|
||||
readonly policyResolver: PolicyResolver;
|
||||
readonly wrappedKeyResolver?: WrappedKeyResolver;
|
||||
readonly fileKeyStore?: FileKeyStore;
|
||||
readonly crypto?: CryptoProvider;
|
||||
readonly clock?: Clock;
|
||||
readonly algorithm?: EncryptionAlgorithm;
|
||||
}
|
||||
|
||||
/** Public option type for `TransferManager.transfer`. */
|
||||
export interface TransferOptions {
|
||||
readonly fileId: FileId;
|
||||
readonly sourcePartitionId: PartitionId;
|
||||
readonly targetPartitionId: PartitionId;
|
||||
readonly userId: UserId;
|
||||
readonly direction: TransferDirection;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function aadFor(partitionId: PartitionId, fileId: FileId): Uint8Array {
|
||||
return new TextEncoder().encode(
|
||||
`drive-sharing/v1/transfer/${partitionId}/${fileId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-partition file transfer with two-phase export → validate → import
|
||||
* and rollback on failure. Resolves source and target KEKs via injected
|
||||
* callbacks, retrieves the file DEK, validates source and target policies,
|
||||
* rewraps the DEK atomically, and records a `TransferRecord`.
|
||||
*
|
||||
* `AbortSignal` aborts the in-flight rewrap and rejects with
|
||||
* `SHARE_TRANSFER_FAILED`.
|
||||
*/
|
||||
export class TransferManager {
|
||||
private readonly kekResolver: KekResolver;
|
||||
private readonly policyResolver: PolicyResolver;
|
||||
private readonly wrappedKeyResolver?: WrappedKeyResolver;
|
||||
private readonly fileKeyStore: FileKeyStore;
|
||||
private readonly clock: Clock;
|
||||
private readonly algorithm: EncryptionAlgorithm;
|
||||
private readonly records: Map<string, TransferRecord> = new Map();
|
||||
|
||||
public constructor(config: TransferConfig) {
|
||||
this.kekResolver = config.kekResolver;
|
||||
this.policyResolver = config.policyResolver;
|
||||
if (config.wrappedKeyResolver !== undefined) {
|
||||
this.wrappedKeyResolver = config.wrappedKeyResolver;
|
||||
}
|
||||
this.fileKeyStore = config.fileKeyStore ?? new Map();
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
this.algorithm = config.algorithm ?? "AES-GCM";
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a cross-partition transfer. Honours `options.signal` and
|
||||
* throws `SHARE_TRANSFER_FAILED` if the signal aborts at any point.
|
||||
*/
|
||||
public async transfer(options: TransferOptions): Promise<TransferResult> {
|
||||
TransferOptionsSchema.parse({
|
||||
fileId: options.fileId,
|
||||
sourcePartitionId: options.sourcePartitionId,
|
||||
targetPartitionId: options.targetPartitionId,
|
||||
userId: options.userId,
|
||||
direction: options.direction,
|
||||
});
|
||||
if (options.signal?.aborted) {
|
||||
throw this.transferFailed("aborted");
|
||||
}
|
||||
const span = getDriveShareTracer().startSpan("share.transfer");
|
||||
span.setAttribute("source.partition", options.sourcePartitionId);
|
||||
span.setAttribute("target.partition", options.targetPartitionId);
|
||||
span.setAttribute("direction", options.direction);
|
||||
const start = performance.now();
|
||||
let abortListener: (() => void) | undefined;
|
||||
try {
|
||||
const abortPromise = new Promise<never>((_resolve, reject) => {
|
||||
if (options.signal !== undefined) {
|
||||
abortListener = () => {
|
||||
reject(this.transferFailed("aborted"));
|
||||
};
|
||||
options.signal.addEventListener("abort", abortListener);
|
||||
}
|
||||
});
|
||||
|
||||
const work = this.runTransfer(options);
|
||||
const result = await Promise.race([work, abortPromise]);
|
||||
const elapsed = performance.now() - start;
|
||||
getDriveShareInstruments().transferDurationMs.record(elapsed);
|
||||
return result;
|
||||
} catch (err) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
this.records.set(`fail:${this.clock()}`, {
|
||||
id: `fail:${this.clock()}`,
|
||||
fileId: options.fileId,
|
||||
sourcePartitionId: options.sourcePartitionId,
|
||||
targetPartitionId: options.targetPartitionId,
|
||||
userId: options.userId,
|
||||
direction: options.direction,
|
||||
completedAt: this.clock(),
|
||||
success: false,
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
if (abortListener !== undefined && options.signal !== undefined) {
|
||||
options.signal.removeEventListener("abort", abortListener);
|
||||
}
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the persisted record for a transfer id. */
|
||||
public getRecord(id: string): TransferRecord | undefined {
|
||||
return this.records.get(id);
|
||||
}
|
||||
|
||||
/** Returns every persisted transfer record. */
|
||||
public listRecords(): TransferRecord[] {
|
||||
return Array.from(this.records.values());
|
||||
}
|
||||
|
||||
private async runTransfer(
|
||||
options: TransferOptions
|
||||
): Promise<TransferResult> {
|
||||
const { policy: sourcePolicy, partition: sourcePartition } =
|
||||
await this.policyResolver(options.sourcePartitionId);
|
||||
const { policy: targetPolicy, partition: targetPartition } =
|
||||
await this.policyResolver(options.targetPartitionId);
|
||||
|
||||
const sourceCheck = validateAction(sourcePolicy, {
|
||||
action: options.direction === "export" ? "share" : "read",
|
||||
userId: options.userId,
|
||||
partition: sourcePartition,
|
||||
});
|
||||
if (!sourceCheck.allowed) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_POLICY_VIOLATION",
|
||||
"error.drive-sharing.transfer.source-denied",
|
||||
{ context: { reason: sourceCheck.reason } }
|
||||
);
|
||||
}
|
||||
const targetCheck = validateAction(targetPolicy, {
|
||||
action: "write",
|
||||
userId: options.userId,
|
||||
partition: targetPartition,
|
||||
});
|
||||
if (!targetCheck.allowed) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_POLICY_VIOLATION",
|
||||
"error.drive-sharing.transfer.target-denied",
|
||||
{ context: { reason: targetCheck.reason } }
|
||||
);
|
||||
}
|
||||
|
||||
let rewrappedKey: WrappedKey | undefined;
|
||||
|
||||
if (this.wrappedKeyResolver !== undefined) {
|
||||
const wrapped = await this.wrappedKeyResolver(
|
||||
options.fileId,
|
||||
options.sourcePartitionId
|
||||
);
|
||||
if (wrapped === undefined) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_KEY_NOT_FOUND",
|
||||
"error.drive-sharing.transfer.dek-missing"
|
||||
);
|
||||
}
|
||||
const sourceKek = await this.kekResolver(
|
||||
options.userId,
|
||||
options.sourcePartitionId
|
||||
);
|
||||
const targetKek = await this.kekResolver(
|
||||
options.userId,
|
||||
options.targetPartitionId
|
||||
);
|
||||
rewrappedKey = await rewrapKey(sourceKek, targetKek, wrapped, {
|
||||
oldAad: aadFor(options.sourcePartitionId, options.fileId),
|
||||
newAad: aadFor(options.targetPartitionId, options.fileId),
|
||||
algorithm: this.algorithm,
|
||||
});
|
||||
} else {
|
||||
// Fallback: file key store carries plaintext DEKs (test path).
|
||||
const dek = this.fileKeyStore.get(options.fileId);
|
||||
if (dek === undefined) {
|
||||
throw new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_KEY_NOT_FOUND",
|
||||
"error.drive-sharing.transfer.dek-missing"
|
||||
);
|
||||
}
|
||||
const targetKek = await this.kekResolver(
|
||||
options.userId,
|
||||
options.targetPartitionId
|
||||
);
|
||||
rewrappedKey = await wrapKey(targetKek, dek, {
|
||||
aad: aadFor(options.targetPartitionId, options.fileId),
|
||||
algorithm: this.algorithm,
|
||||
});
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const record: TransferRecord = {
|
||||
id,
|
||||
fileId: options.fileId,
|
||||
sourcePartitionId: options.sourcePartitionId,
|
||||
targetPartitionId: options.targetPartitionId,
|
||||
userId: options.userId,
|
||||
direction: options.direction,
|
||||
completedAt: this.clock(),
|
||||
success: true,
|
||||
};
|
||||
this.records.set(id, record);
|
||||
getDriveShareInstruments().transferBytesTotal.add(
|
||||
rewrappedKey.data.length
|
||||
);
|
||||
return { success: true, record, rewrappedKey };
|
||||
}
|
||||
|
||||
private transferFailed(reason: string): DriveShareError {
|
||||
return new DriveShareError(
|
||||
"DRIVE_SHARE.SHARE_TRANSFER_FAILED",
|
||||
"error.drive-sharing.transfer.failed",
|
||||
{ context: { reason } }
|
||||
);
|
||||
}
|
||||
}
|
||||
92
packages/networking/drive/drive-sharing/src/types/common.md
Normal file
92
packages/networking/drive/drive-sharing/src/types/common.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# `types/common.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the foundational types shared by every other module in `@suaveplan/drive-sharing`. It owns the branded ID types, the wrapped-key wire shape, the partition / member / policy / grant / token / transfer record interfaces, and the `Result<T, E>` discriminated union helper. Centralising these declarations means downstream modules (encryption, partitions, sharing, transfer) consume one canonical type instead of drifting copies, and the public API surface is fully typed at every boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Six branded IDs (`UserId`, `PartitionId`, `GrantId`, `TokenId`, `FileId`, `KeyId`) implemented via the nominal `Brand<T, B>` helper.
|
||||
- `WrappedKey` carries an algorithm tag plus a SHA-256 checksum so consumers can detect tampering without unwrapping.
|
||||
- `EncryptedBundle` separates `nonce`, `ciphertext`, and (optional) `tag` so AEAD primitives that produce them as distinct outputs round-trip cleanly.
|
||||
- `Result<T, E>` and the `ok` / `err` helpers provide a non-throwing return shape for predicates that prefer railway-style flow.
|
||||
- `generateId` accepts an optional RNG for deterministic test runs and falls back to `crypto.randomUUID` (or RFC 4122 v4 if unavailable).
|
||||
- `FileKeyStore` is a `Map<FileId, Uint8Array>` alias used by `TransferManager`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
type UserId,
|
||||
userId,
|
||||
type PartitionId,
|
||||
partitionId,
|
||||
ok,
|
||||
err,
|
||||
} from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
const u: UserId = userId("alice");
|
||||
const p: PartitionId = partitionId("p-001");
|
||||
|
||||
const r = u === ("alice" as UserId) ? ok(u) : err(new Error("mismatch"));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
type Partition,
|
||||
type PartitionPolicyConfig,
|
||||
generateId,
|
||||
partitionId,
|
||||
userId,
|
||||
} from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
// Deterministic IDs in tests via a stub RNG.
|
||||
let counter = 0;
|
||||
const stubRng = () => `id-${(counter += 1)}`;
|
||||
|
||||
const policy: PartitionPolicyConfig = {
|
||||
encryption: true,
|
||||
reauthRequired: ["admin"],
|
||||
allowedActions: ["read", "write"],
|
||||
maxMembers: 100,
|
||||
};
|
||||
|
||||
const partition: Partition = {
|
||||
id: partitionId(generateId(stubRng)),
|
||||
name: "research",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
members: [],
|
||||
policy,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Branded IDs
|
||||
|
||||
`UserId`, `PartitionId`, `GrantId`, `TokenId`, `FileId`, `KeyId` — each `Brand<string, "...">`. Construct via the matching factory function.
|
||||
|
||||
### Factories
|
||||
|
||||
- `userId(value)`, `partitionId(value)`, `grantId(value)`, `tokenId(value)`, `fileId(value)`, `keyId(value)` — type-cast a raw `string` into the branded shape.
|
||||
- `generateId(rng?: () => string): string` — returns a fresh ID. When `rng` is supplied its return value is used verbatim; otherwise `crypto.randomUUID` is preferred and an RFC 4122 v4 fallback is used in environments lacking it.
|
||||
|
||||
### `Result<T, E>`
|
||||
|
||||
Discriminated union. Helpers `ok(value)` and `err(error)` construct each arm.
|
||||
|
||||
### Wire types
|
||||
|
||||
`WrappedKey`, `EncryptedBundle`, `PartitionMember`, `PartitionPolicyConfig`, `Partition`, `PolicyValidationContext`, `PolicyValidationResult`, `TransferRecord`, `TransferResult`. Every shape is `readonly` and immutably typed.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All branded IDs are erased at runtime (`as UserId`); the brand exists only in the type system. This keeps the runtime cost zero while still preventing cross-domain misuse at compile time.
|
||||
- `generateId` deliberately delegates to `crypto.randomUUID` when present so we benefit from the runtime CSPRNG; the manual fallback path is exercised only in legacy environments and is still implemented in terms of `crypto.getRandomValues` (never `Math.random()`).
|
||||
- Every algorithm tag in `EncryptionAlgorithm` (`AES-GCM` / `XChaCha20-Poly1305` / `AES-KW`) is recognised by `envelopeEncrypt` and `wrapKey` — adding a new tag requires updating both modules.
|
||||
- Policy fields use `readonly` arrays to prevent in-place mutation by downstream consumers; mutations must go through `PartitionManager.update`.
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
err,
|
||||
fileId,
|
||||
generateId,
|
||||
ok,
|
||||
type PartitionId,
|
||||
partitionId,
|
||||
type Result,
|
||||
type UserId,
|
||||
userId,
|
||||
} from "./common.js";
|
||||
|
||||
describe("common — branded ID type-distinctness", () => {
|
||||
it("userId and partitionId are not assignable to each other", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const u: UserId = userId("u1");
|
||||
const p: PartitionId = partitionId("p1");
|
||||
expectTypeOf(u).not.toEqualTypeOf<PartitionId>();
|
||||
expectTypeOf(p).not.toEqualTypeOf<UserId>();
|
||||
expect(u).toBe("u1");
|
||||
expect(p).toBe("p1");
|
||||
});
|
||||
|
||||
it("ok result has ok true and correct value", { timeout: 5000 }, () => {
|
||||
const r: Result<number> = ok(42);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expectTypeOf(r.value).toEqualTypeOf<number>();
|
||||
expect(r.value).toBe(42);
|
||||
}
|
||||
});
|
||||
|
||||
it("err result has ok false and correct error", { timeout: 5000 }, () => {
|
||||
const e = new Error("x");
|
||||
const r: Result<number, Error> = err(e);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toBe(e);
|
||||
});
|
||||
|
||||
it("generateId with stub rng returns fixed string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(generateId(() => "fixed-id")).toBe("fixed-id");
|
||||
});
|
||||
|
||||
it("fileId factory returns the same string with brand erased", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(fileId("f-1")).toBe("f-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
err,
|
||||
fileId,
|
||||
generateId,
|
||||
grantId,
|
||||
keyId,
|
||||
ok,
|
||||
partitionId,
|
||||
tokenId,
|
||||
userId,
|
||||
} from "./common.js";
|
||||
|
||||
describe("common — id factories", () => {
|
||||
it("each factory returns its branded string", { timeout: 5000 }, () => {
|
||||
expect(userId("u")).toBe("u");
|
||||
expect(partitionId("p")).toBe("p");
|
||||
expect(grantId("g")).toBe("g");
|
||||
expect(tokenId("t")).toBe("t");
|
||||
expect(fileId("f")).toBe("f");
|
||||
expect(keyId("k")).toBe("k");
|
||||
});
|
||||
});
|
||||
|
||||
describe("common — Result helpers", () => {
|
||||
it("ok wraps a value", { timeout: 5000 }, () => {
|
||||
const r = ok(42);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe(42);
|
||||
});
|
||||
|
||||
it("err wraps an error", { timeout: 5000 }, () => {
|
||||
const e = new Error("nope");
|
||||
const r = err(e);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toBe(e);
|
||||
});
|
||||
});
|
||||
|
||||
describe("common — generateId", () => {
|
||||
it("returns a uuid-shaped string by default", { timeout: 5000 }, () => {
|
||||
const id = generateId();
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
it("uses an injected RNG verbatim", { timeout: 5000 }, () => {
|
||||
expect(generateId(() => "fixed-id")).toBe("fixed-id");
|
||||
});
|
||||
|
||||
it("returns distinct strings on consecutive calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(generateId()).not.toBe(generateId());
|
||||
});
|
||||
|
||||
it("falls back to manual UUID when randomUUID is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Save and stub randomUUID off the global, then restore.
|
||||
const cryptoAsRecord = globalThis.crypto as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const original = cryptoAsRecord["randomUUID"];
|
||||
cryptoAsRecord["randomUUID"] = undefined;
|
||||
try {
|
||||
const id = generateId();
|
||||
expect(id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
} finally {
|
||||
cryptoAsRecord["randomUUID"] = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
209
packages/networking/drive/drive-sharing/src/types/common.ts
Normal file
209
packages/networking/drive/drive-sharing/src/types/common.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Core shared types for `@suaveplan/drive-sharing`.
|
||||
*
|
||||
* This module declares branded ID types, the wrapped-key wire shape,
|
||||
* partition / member / policy / grant / token / transfer record types,
|
||||
* and a tagged-union `Result` helper. All public types in this package
|
||||
* either live here or are composed from these primitives.
|
||||
*/
|
||||
|
||||
/** Nominal-typing brand helper. */
|
||||
export type Brand<T, B extends string> = T & { readonly __brand: B };
|
||||
|
||||
/** Branded user identifier. */
|
||||
export type UserId = Brand<string, "UserId">;
|
||||
/** Branded partition identifier. */
|
||||
export type PartitionId = Brand<string, "PartitionId">;
|
||||
/** Branded grant identifier. */
|
||||
export type GrantId = Brand<string, "GrantId">;
|
||||
/** Branded share-token identifier. */
|
||||
export type TokenId = Brand<string, "TokenId">;
|
||||
/** Branded file identifier. */
|
||||
export type FileId = Brand<string, "FileId">;
|
||||
/** Branded key-material identifier. */
|
||||
export type KeyId = Brand<string, "KeyId">;
|
||||
|
||||
/** Partition classification used by default policy resolution. */
|
||||
export type PartitionType = "personal" | "shared" | "public" | "encrypted";
|
||||
|
||||
/** Permission flags carried by grants and partition members. */
|
||||
export type SharePermission = "read" | "write" | "delete" | "share" | "admin";
|
||||
|
||||
/** Action verbs consumed by `validateAction`. */
|
||||
export type PolicyAction = "read" | "write" | "delete" | "share" | "admin";
|
||||
|
||||
/** AEAD algorithm tag stamped on every wrapped key and envelope bundle. */
|
||||
export type EncryptionAlgorithm = "AES-GCM" | "XChaCha20-Poly1305" | "AES-KW";
|
||||
|
||||
/**
|
||||
* Wrapped-key wire shape. The `data` field carries `nonce ‖ ciphertext ‖ tag`
|
||||
* (or a wrap-mode-specific equivalent); the `algorithm` tag binds the bundle
|
||||
* to a specific AEAD primitive so unwrap can reject mismatches.
|
||||
*/
|
||||
export interface WrappedKey {
|
||||
readonly algorithm: EncryptionAlgorithm;
|
||||
readonly data: Uint8Array;
|
||||
/** SHA-256 hex digest of `data`, used as a tamper-evident checksum. */
|
||||
readonly checksum: string;
|
||||
}
|
||||
|
||||
/** Encrypted-bundle wire shape produced by `envelopeEncrypt`. */
|
||||
export interface EncryptedBundle {
|
||||
readonly algorithm: EncryptionAlgorithm;
|
||||
readonly nonce: Uint8Array;
|
||||
readonly ciphertext: Uint8Array;
|
||||
/**
|
||||
* Associated authenticated data bound at encrypt time. Decrypt rejects
|
||||
* any tampering of `aad` because AEAD primitives include it in the tag.
|
||||
*/
|
||||
readonly aad: Uint8Array;
|
||||
}
|
||||
|
||||
/** Membership entry stored on `Partition.members`. */
|
||||
export interface PartitionMember {
|
||||
readonly userId: UserId;
|
||||
readonly permissions: readonly SharePermission[];
|
||||
readonly addedAt: number;
|
||||
}
|
||||
|
||||
/** Policy bundle attached to every partition. */
|
||||
export interface PartitionPolicyConfig {
|
||||
readonly encryption: boolean;
|
||||
readonly reauthRequired: readonly PolicyAction[];
|
||||
readonly allowedActions: readonly PolicyAction[];
|
||||
readonly maxMembers?: number;
|
||||
readonly ttl?: number;
|
||||
}
|
||||
|
||||
/** Aggregated partition descriptor returned by `PartitionManager.get/list`. */
|
||||
export interface Partition {
|
||||
readonly id: PartitionId;
|
||||
readonly name: string;
|
||||
readonly type: PartitionType;
|
||||
readonly ownerId: UserId;
|
||||
readonly members: readonly PartitionMember[];
|
||||
readonly policy: PartitionPolicyConfig;
|
||||
readonly createdAt: number;
|
||||
readonly updatedAt: number;
|
||||
}
|
||||
|
||||
/** Inputs to the deterministic action validator. */
|
||||
export interface PolicyValidationContext {
|
||||
readonly action: PolicyAction;
|
||||
readonly userId: UserId;
|
||||
readonly partition: Partition;
|
||||
}
|
||||
|
||||
/** Outcome of policy validation. */
|
||||
export interface PolicyValidationResult {
|
||||
readonly allowed: boolean;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/** Generic discriminated-union result type. */
|
||||
export type Result<T, E = Error> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: E };
|
||||
|
||||
/** Constructor for the success arm of `Result`. */
|
||||
export function ok<T>(value: T): Result<T, never> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/** Constructor for the failure arm of `Result`. */
|
||||
export function err<E>(error: E): Result<never, E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
/** Branded-id factory for `UserId`. */
|
||||
export function userId(value: string): UserId {
|
||||
return value as UserId;
|
||||
}
|
||||
|
||||
/** Branded-id factory for `PartitionId`. */
|
||||
export function partitionId(value: string): PartitionId {
|
||||
return value as PartitionId;
|
||||
}
|
||||
|
||||
/** Branded-id factory for `GrantId`. */
|
||||
export function grantId(value: string): GrantId {
|
||||
return value as GrantId;
|
||||
}
|
||||
|
||||
/** Branded-id factory for `TokenId`. */
|
||||
export function tokenId(value: string): TokenId {
|
||||
return value as TokenId;
|
||||
}
|
||||
|
||||
/** Branded-id factory for `FileId`. */
|
||||
export function fileId(value: string): FileId {
|
||||
return value as FileId;
|
||||
}
|
||||
|
||||
/** Branded-id factory for `KeyId`. */
|
||||
export function keyId(value: string): KeyId {
|
||||
return value as KeyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random opaque identifier string. Uses the injected RNG when
|
||||
* provided, otherwise falls back to `crypto.randomUUID`. The default fallback
|
||||
* is intentionally minimal so consumers can substitute a deterministic RNG
|
||||
* for test reproducibility.
|
||||
*/
|
||||
export function generateId(rng?: () => string): string {
|
||||
if (rng !== undefined) return rng();
|
||||
if (
|
||||
typeof globalThis.crypto !== "undefined" &&
|
||||
typeof globalThis.crypto.randomUUID === "function"
|
||||
) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
// Fallback for environments without crypto.randomUUID — RFC 4122 v4 via
|
||||
// getRandomValues. This is functionally equivalent and avoids a hard dep.
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
// Per RFC 4122 §4.4: set version (4) and variant (10). The non-null
|
||||
// assertions are safe because the buffer was just allocated with a fixed
|
||||
// length of 16.
|
||||
const b6 = bytes[6] as number;
|
||||
const b8 = bytes[8] as number;
|
||||
bytes[6] = (b6 & 0x0f) | 0x40;
|
||||
bytes[8] = (b8 & 0x3f) | 0x80;
|
||||
const hex: string[] = [];
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
hex.push((bytes[i] as number).toString(16).padStart(2, "0"));
|
||||
}
|
||||
return [
|
||||
hex.slice(0, 4).join(""),
|
||||
hex.slice(4, 6).join(""),
|
||||
hex.slice(6, 8).join(""),
|
||||
hex.slice(8, 10).join(""),
|
||||
hex.slice(10, 16).join(""),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
/** File-key store interface used by `TransferManager`. */
|
||||
export type FileKeyStore = Map<FileId, Uint8Array>;
|
||||
|
||||
/** Transfer direction tag. */
|
||||
export type TransferDirection = "export" | "import";
|
||||
|
||||
/** Final record persisted by `TransferManager` after every transfer. */
|
||||
export interface TransferRecord {
|
||||
readonly id: string;
|
||||
readonly fileId: FileId;
|
||||
readonly sourcePartitionId: PartitionId;
|
||||
readonly targetPartitionId: PartitionId;
|
||||
readonly userId: UserId;
|
||||
readonly direction: TransferDirection;
|
||||
readonly completedAt: number;
|
||||
readonly success: boolean;
|
||||
}
|
||||
|
||||
/** Outcome of a single `TransferManager.transfer` call. */
|
||||
export interface TransferResult {
|
||||
readonly success: boolean;
|
||||
readonly record: TransferRecord;
|
||||
readonly rewrappedKey?: WrappedKey;
|
||||
}
|
||||
70
packages/networking/drive/drive-sharing/src/types/schemas.md
Normal file
70
packages/networking/drive/drive-sharing/src/types/schemas.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# `types/schemas.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares Zod schemas for every public option type accepted by `@suaveplan/drive-sharing`. Schemas are wired at the public API boundary of every module so the package raises a descriptive `ZodError` before any mutation is performed. Schemas double as the canonical reference for valid inputs and are reused by `tests/property.test.ts` to drive fuzz cases without re-declaring shapes.
|
||||
|
||||
## Features
|
||||
|
||||
- One schema per public option / config type: `WrappedKeySchema`, `PartitionPolicyConfigSchema`, `EnvelopeEncryptOptionsSchema`, `KeyRingConfigSchema`, `CreatePartitionOptionsSchema`, `AddMemberOptionsSchema`, `UpdatePartitionOptionsSchema`, `PartitionManagerOptionsSchema`, `CreateGrantOptionsSchema`, `CreateTokenOptionsSchema`, `TokenManagerConfigSchema`, `TransferOptionsSchema`, `KeyRingExportSchema`.
|
||||
- `WrappedKeySchema` enforces a 64-hex-char SHA-256 checksum so corrupt entries cannot pass without explicit handling.
|
||||
- `AadSchema` rejects empty AAD which is the central security guarantee for envelope encryption.
|
||||
- `KeyRingExportSchema` pins the export wire format to `keyring_format_version: 1` so old or future versions are rejected at parse time.
|
||||
- `PartitionPolicyConfigSchema` accepts only the five canonical action verbs and rejects unknown strings.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CreatePartitionOptionsSchema,
|
||||
} from "@suaveplan/drive-sharing/types/schemas";
|
||||
|
||||
const result = CreatePartitionOptionsSchema.safeParse({
|
||||
name: "research",
|
||||
type: "shared",
|
||||
ownerId: "alice",
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import {
|
||||
PartitionPolicyConfigSchema,
|
||||
KeyRingExportSchema,
|
||||
} from "@suaveplan/drive-sharing/types/schemas";
|
||||
|
||||
// Build a strict policy parser that auto-fills defaults.
|
||||
const StrictPolicy = PartitionPolicyConfigSchema.transform((p) => ({
|
||||
encryption: p.encryption,
|
||||
reauthRequired: p.reauthRequired,
|
||||
allowedActions: p.allowedActions,
|
||||
maxMembers: p.maxMembers ?? 1000,
|
||||
ttl: p.ttl ?? 0,
|
||||
}));
|
||||
|
||||
// Validate an externally-supplied keyring backup file before importing.
|
||||
function ingest(json: unknown): z.infer<typeof KeyRingExportSchema> {
|
||||
return KeyRingExportSchema.parse(json);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Every export is a Zod schema instance. The schemas accept the public option types declared in `types/common.ts` and their narrower wire-format counterparts. Notable contracts:
|
||||
|
||||
- `AadSchema` — rejects empty `Uint8Array`.
|
||||
- `WrappedKeySchema` — `algorithm` is one of `AES-GCM`, `XChaCha20-Poly1305`, `AES-KW`; `data` is a non-empty `Uint8Array`; `checksum` matches `/^[0-9a-f]{64}$/`.
|
||||
- `PartitionPolicyConfigSchema` — `allowedActions` and `reauthRequired` are arrays of the canonical five verbs; unknown verbs raise `ZodError`.
|
||||
- `KeyRingExportSchema` — `keyring_format_version` is the literal `1`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The schemas in this module deliberately avoid `.passthrough()` — extra fields are stripped to prevent attackers from smuggling state into the package.
|
||||
- `Uint8Array` checks rely on `z.instanceof(Uint8Array)` which works under both Node and the browser because the global is the same.
|
||||
- The `partial()` schemas (used by `policy?: Partial<PartitionPolicyConfig>`) preserve the `readonly` nature of the parent type at runtime: missing fields stay missing rather than being filled with `undefined`.
|
||||
- Adding a new field to a schema is non-breaking when the field is optional. Tightening an existing constraint is a major-version bump.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CreatePartitionOptionsSchema,
|
||||
PartitionPolicyConfigSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("schemas — spec conformance", () => {
|
||||
it("CreatePartitionOptionsSchema rejects missing name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = CreatePartitionOptionsSchema.safeParse({
|
||||
type: "shared",
|
||||
ownerId: "alice",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
if (!r.success) {
|
||||
expect(r.error.issues.some((i) => i.path.includes("name"))).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("PartitionPolicyConfigSchema rejects unknown action in allowedActions", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = PartitionPolicyConfigSchema.safeParse({
|
||||
encryption: true,
|
||||
reauthRequired: [],
|
||||
allowedActions: ["read", "invalid"],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
if (!r.success) {
|
||||
expect(
|
||||
r.error.issues.some((i) => i.path.includes("allowedActions"))
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AadSchema,
|
||||
AddMemberOptionsSchema,
|
||||
CreateGrantOptionsSchema,
|
||||
CreatePartitionOptionsSchema,
|
||||
CreateTokenOptionsSchema,
|
||||
EnvelopeEncryptOptionsSchema,
|
||||
KeyRingConfigSchema,
|
||||
KeyRingExportSchema,
|
||||
PartitionPolicyConfigSchema,
|
||||
TokenManagerConfigSchema,
|
||||
TransferOptionsSchema,
|
||||
UpdatePartitionOptionsSchema,
|
||||
WrappedKeySchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("schemas", () => {
|
||||
it("AadSchema rejects empty Uint8Array", { timeout: 5000 }, () => {
|
||||
expect(() => AadSchema.parse(new Uint8Array(0))).toThrow();
|
||||
});
|
||||
|
||||
it("AadSchema accepts non-empty Uint8Array", { timeout: 5000 }, () => {
|
||||
expect(AadSchema.parse(new Uint8Array([1]))).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("WrappedKeySchema rejects bad checksum", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
WrappedKeySchema.parse({
|
||||
algorithm: "AES-GCM",
|
||||
data: new Uint8Array([1]),
|
||||
checksum: "bad",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("WrappedKeySchema accepts valid 64-hex checksum", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ok = WrappedKeySchema.parse({
|
||||
algorithm: "AES-GCM",
|
||||
data: new Uint8Array([1]),
|
||||
checksum: "0".repeat(64),
|
||||
});
|
||||
expect(ok.checksum).toBe("0".repeat(64));
|
||||
});
|
||||
|
||||
it("PartitionPolicyConfigSchema accepts valid config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = PartitionPolicyConfigSchema.parse({
|
||||
encryption: true,
|
||||
reauthRequired: ["admin"],
|
||||
allowedActions: ["read"],
|
||||
});
|
||||
expect(r.encryption).toBe(true);
|
||||
});
|
||||
|
||||
it("EnvelopeEncryptOptionsSchema rejects empty AAD", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
EnvelopeEncryptOptionsSchema.parse({
|
||||
aad: new Uint8Array(0),
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("KeyRingConfigSchema permits an empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(KeyRingConfigSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("CreatePartitionOptionsSchema requires non-empty name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
CreatePartitionOptionsSchema.parse({
|
||||
name: "",
|
||||
type: "shared",
|
||||
ownerId: "alice",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("AddMemberOptionsSchema accepts an empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(AddMemberOptionsSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("UpdatePartitionOptionsSchema accepts a partial policy", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = UpdatePartitionOptionsSchema.parse({
|
||||
policy: { encryption: false },
|
||||
});
|
||||
expect(r.policy?.encryption).toBe(false);
|
||||
});
|
||||
|
||||
it("CreateGrantOptionsSchema rejects empty permissions", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
CreateGrantOptionsSchema.parse({
|
||||
grantorId: "a",
|
||||
granteeId: "b",
|
||||
partitionId: "p",
|
||||
permissions: [],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("CreateTokenOptionsSchema accepts a positive expiresIn", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = CreateTokenOptionsSchema.parse({
|
||||
partitionId: "p",
|
||||
createdBy: "u",
|
||||
permissions: ["read"],
|
||||
expiresIn: 1000,
|
||||
});
|
||||
expect(r.expiresIn).toBe(1000);
|
||||
});
|
||||
|
||||
it("TokenManagerConfigSchema accepts a positive defaultTTL", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(TokenManagerConfigSchema.parse({ defaultTTL: 5000 })).toEqual({
|
||||
defaultTTL: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("TransferOptionsSchema accepts valid options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = TransferOptionsSchema.parse({
|
||||
fileId: "f",
|
||||
sourcePartitionId: "p1",
|
||||
targetPartitionId: "p2",
|
||||
userId: "u",
|
||||
direction: "export",
|
||||
});
|
||||
expect(r.direction).toBe("export");
|
||||
});
|
||||
|
||||
it("KeyRingExportSchema rejects unknown version", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
KeyRingExportSchema.parse({
|
||||
keyring_format_version: 2,
|
||||
entries: [],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
149
packages/networking/drive/drive-sharing/src/types/schemas.ts
Normal file
149
packages/networking/drive/drive-sharing/src/types/schemas.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import * as z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schemas for every public option type in `@suaveplan/drive-sharing`.
|
||||
*
|
||||
* Schemas are wired at the public API boundary of every module so that the
|
||||
* package raises a `ZodError` (with a descriptive issue path) before any
|
||||
* mutation is performed. Each schema is also re-used in property tests.
|
||||
*/
|
||||
|
||||
const policyActionSchema = z.enum([
|
||||
"read",
|
||||
"write",
|
||||
"delete",
|
||||
"share",
|
||||
"admin",
|
||||
]);
|
||||
|
||||
const sharePermissionSchema = z.enum([
|
||||
"read",
|
||||
"write",
|
||||
"delete",
|
||||
"share",
|
||||
"admin",
|
||||
]);
|
||||
|
||||
const partitionTypeSchema = z.enum([
|
||||
"personal",
|
||||
"shared",
|
||||
"public",
|
||||
"encrypted",
|
||||
]);
|
||||
|
||||
const encryptionAlgorithmSchema = z.enum([
|
||||
"AES-GCM",
|
||||
"XChaCha20-Poly1305",
|
||||
"AES-KW",
|
||||
]);
|
||||
|
||||
const uint8ArraySchema = z
|
||||
.instanceof(Uint8Array, { message: "expected Uint8Array" })
|
||||
.refine((b) => b.length > 0, { message: "Uint8Array must be non-empty" });
|
||||
|
||||
const optionalUint8ArraySchema = z
|
||||
.instanceof(Uint8Array, { message: "expected Uint8Array" })
|
||||
.optional();
|
||||
|
||||
/** Zod schema for `WrappedKey`. */
|
||||
export const WrappedKeySchema = z.object({
|
||||
algorithm: encryptionAlgorithmSchema,
|
||||
data: uint8ArraySchema,
|
||||
checksum: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
});
|
||||
|
||||
/** Zod schema for `PartitionPolicyConfig`. */
|
||||
export const PartitionPolicyConfigSchema = z.object({
|
||||
encryption: z.boolean(),
|
||||
reauthRequired: z.array(policyActionSchema),
|
||||
allowedActions: z.array(policyActionSchema),
|
||||
maxMembers: z.number().int().positive().optional(),
|
||||
ttl: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `EnvelopeEncryptOptions.aad`. */
|
||||
export const AadSchema = z
|
||||
.instanceof(Uint8Array, { message: "expected Uint8Array AAD" })
|
||||
.refine((b) => b.length > 0, { message: "AAD must be non-empty" });
|
||||
|
||||
/** Zod schema for `EnvelopeEncryptOptions`. */
|
||||
export const EnvelopeEncryptOptionsSchema = z.object({
|
||||
aad: AadSchema,
|
||||
algorithm: encryptionAlgorithmSchema.optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `KeyRingConfig`. */
|
||||
export const KeyRingConfigSchema = z.object({
|
||||
masterKey: optionalUint8ArraySchema,
|
||||
});
|
||||
|
||||
/** Zod schema for `CreatePartitionOptions`. */
|
||||
export const CreatePartitionOptionsSchema = z.object({
|
||||
name: z.string().min(1, "name is required"),
|
||||
type: partitionTypeSchema,
|
||||
ownerId: z.string().min(1),
|
||||
policy: PartitionPolicyConfigSchema.partial().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `AddMemberOptions`. */
|
||||
export const AddMemberOptionsSchema = z.object({
|
||||
permissions: z.array(sharePermissionSchema).min(1).optional(),
|
||||
wrappedKey: WrappedKeySchema.optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `UpdatePartitionOptions`. */
|
||||
export const UpdatePartitionOptionsSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
policy: PartitionPolicyConfigSchema.partial().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `PartitionManagerConfig` minus the non-serialisable handles. */
|
||||
export const PartitionManagerOptionsSchema = z.object({
|
||||
policy: PartitionPolicyConfigSchema.partial().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `CreateGrantOptions`. */
|
||||
export const CreateGrantOptionsSchema = z.object({
|
||||
grantorId: z.string().min(1),
|
||||
granteeId: z.string().min(1),
|
||||
partitionId: z.string().min(1),
|
||||
permissions: z.array(sharePermissionSchema).min(1),
|
||||
expiresAt: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `CreateTokenOptions`. */
|
||||
export const CreateTokenOptionsSchema = z.object({
|
||||
partitionId: z.string().min(1),
|
||||
createdBy: z.string().min(1),
|
||||
permissions: z.array(sharePermissionSchema).min(1),
|
||||
expiresIn: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `TokenManagerConfig`. */
|
||||
export const TokenManagerConfigSchema = z.object({
|
||||
defaultTTL: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `TransferOptions` (without the runtime-only `signal`). */
|
||||
export const TransferOptionsSchema = z.object({
|
||||
fileId: z.string().min(1),
|
||||
sourcePartitionId: z.string().min(1),
|
||||
targetPartitionId: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
direction: z.enum(["export", "import"]),
|
||||
});
|
||||
|
||||
/**
|
||||
* `KeyRing.export()` wire-format schema. Validates the version tag and the
|
||||
* shape of every entry — `import()` rejects unknown versions and corrupt
|
||||
* entries with `SHARE_KEYRING_FORMAT_UNSUPPORTED`.
|
||||
*/
|
||||
export const KeyRingExportSchema = z.object({
|
||||
keyring_format_version: z.literal(1),
|
||||
entries: z.array(
|
||||
z.object({
|
||||
userId: z.string().min(1),
|
||||
wrappedKey: WrappedKeySchema,
|
||||
})
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
fileId,
|
||||
GrantManager,
|
||||
InMemoryPartitionStore,
|
||||
KeyRing,
|
||||
PartitionManager,
|
||||
TokenManager,
|
||||
TransferManager,
|
||||
userId,
|
||||
wrapKey,
|
||||
} from "../src/index.js";
|
||||
import { getDefaultPolicy } from "../src/partitions/partition-policy/partition-policy.js";
|
||||
|
||||
describe("drive-sharing — end-to-end integration", () => {
|
||||
it("create partition, add member, grant access, transfer file", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Build the full lifecycle stack with deterministic clocks / IDs.
|
||||
const events = new Map<string, unknown[]>();
|
||||
const bus = {
|
||||
on: () => () => undefined,
|
||||
emit: (k: string, p: unknown) => {
|
||||
const arr = events.get(k) ?? [];
|
||||
arr.push(p);
|
||||
events.set(k, arr);
|
||||
},
|
||||
};
|
||||
let counter = 0;
|
||||
const idGen = () => `id-${(counter += 1)}`;
|
||||
|
||||
const masterKey = new Uint8Array(32).fill(0x77);
|
||||
const keyRing = new KeyRing({ masterKey });
|
||||
const store = new InMemoryPartitionStore();
|
||||
const partitions = new PartitionManager({
|
||||
store,
|
||||
keyRing,
|
||||
clock: () => 1_000,
|
||||
idGenerator: idGen,
|
||||
events: bus,
|
||||
});
|
||||
const grants = new GrantManager({
|
||||
clock: () => 1_000,
|
||||
idGenerator: idGen,
|
||||
events: bus,
|
||||
});
|
||||
const tokens = new TokenManager({
|
||||
clock: () => 1_000,
|
||||
defaultTTL: 60_000,
|
||||
masterKey,
|
||||
idGenerator: idGen,
|
||||
});
|
||||
|
||||
// 1. Create source and target partitions.
|
||||
const src = await partitions.create({
|
||||
name: "research",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
});
|
||||
const dst = await partitions.create({
|
||||
name: "review",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
});
|
||||
|
||||
// 2. Add bob as a member of source.
|
||||
const updatedSrc = await partitions.addMember(src.id, userId("bob"), {
|
||||
permissions: ["read", "share"],
|
||||
});
|
||||
expect(updatedSrc.members.length).toBe(1);
|
||||
|
||||
// 3. Mint a grant for bob to read the file.
|
||||
const grant = grants.create({
|
||||
grantorId: userId("alice"),
|
||||
granteeId: userId("bob"),
|
||||
partitionId: src.id,
|
||||
permissions: ["read"],
|
||||
expiresAt: 5_000_000,
|
||||
});
|
||||
const check = grants.check(grant.id, userId("bob"), "read");
|
||||
expect(check.allowed).toBe(true);
|
||||
|
||||
// 4. Mint a share token for the file.
|
||||
const token = tokens.create({
|
||||
partitionId: src.id,
|
||||
createdBy: userId("alice"),
|
||||
permissions: ["read"],
|
||||
});
|
||||
expect(tokens.validate(token.id).valid).toBe(true);
|
||||
|
||||
// 5. Set up transfer with a real wrapped DEK.
|
||||
const sourceKek = new Uint8Array(32).fill(0xaa);
|
||||
const targetKek = new Uint8Array(32).fill(0xbb);
|
||||
const dek = new Uint8Array(32).fill(0xcc);
|
||||
const aadSrc = new TextEncoder().encode(
|
||||
`drive-sharing/v1/transfer/${src.id}/${fileId("f-1")}`
|
||||
);
|
||||
const wrapped = await wrapKey(sourceKek, dek, { aad: aadSrc });
|
||||
|
||||
const transfer = new TransferManager({
|
||||
kekResolver: async (_uid, pid) =>
|
||||
pid === src.id ? sourceKek : targetKek,
|
||||
policyResolver: async (pid) => {
|
||||
const partition =
|
||||
pid === src.id ? updatedSrc : { ...dst, members: [] };
|
||||
return {
|
||||
policy: getDefaultPolicy(),
|
||||
partition,
|
||||
};
|
||||
},
|
||||
wrappedKeyResolver: async () => wrapped,
|
||||
clock: () => 2_000,
|
||||
});
|
||||
|
||||
const result = await transfer.transfer({
|
||||
fileId: fileId("f-1"),
|
||||
sourcePartitionId: src.id,
|
||||
targetPartitionId: dst.id,
|
||||
userId: userId("alice"),
|
||||
direction: "export",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.rewrappedKey).toBeDefined();
|
||||
|
||||
// 6. Verify lifecycle event emission.
|
||||
expect(events.get("partition:created")?.length).toBe(2);
|
||||
expect(events.get("member:added")?.length).toBe(1);
|
||||
expect(events.get("grant:created")?.length).toBe(1);
|
||||
|
||||
// 7. Revoke the grant and confirm subsequent checks are denied.
|
||||
grants.revoke(grant.id);
|
||||
expect(grants.check(grant.id, userId("bob"), "read").allowed).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
138
packages/networking/drive/drive-sharing/tests/property.test.ts
Normal file
138
packages/networking/drive/drive-sharing/tests/property.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
GrantManager,
|
||||
InMemoryPartitionStore,
|
||||
KeyRing,
|
||||
mergePolicies,
|
||||
PartitionManager,
|
||||
partitionId,
|
||||
TokenManager,
|
||||
userId,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("property — grant expiry", () => {
|
||||
it("grant check returns allowed false after advancing clock beyond TTL", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 100_000 }),
|
||||
fc.integer({ min: 1, max: 100 }),
|
||||
(ttl, offset) => {
|
||||
let now = 0;
|
||||
const m = new GrantManager({ clock: () => now });
|
||||
const grant = m.create({
|
||||
grantorId: userId("a"),
|
||||
granteeId: userId("b"),
|
||||
partitionId: partitionId("p"),
|
||||
permissions: ["read"],
|
||||
expiresAt: ttl,
|
||||
});
|
||||
now = ttl + offset;
|
||||
const r = m.check(grant.id, userId("b"), "read");
|
||||
expect(r.allowed).toBe(false);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — token revocation finality", () => {
|
||||
it("revoked token validate always returns valid false", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 100 }), (validateCount) => {
|
||||
const m = new TokenManager({
|
||||
clock: () => 0,
|
||||
masterKey: new Uint8Array(32).fill(0xee),
|
||||
});
|
||||
const t = m.create({
|
||||
partitionId: partitionId("p"),
|
||||
createdBy: userId("a"),
|
||||
permissions: ["read"],
|
||||
});
|
||||
m.revoke(t.id);
|
||||
for (let i = 0; i < validateCount; i += 1) {
|
||||
const r = m.validate(t.id);
|
||||
expect(r.valid).toBe(false);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — policy merge commutativity", () => {
|
||||
it("mergePolicies on disjoint allowedActions yields union", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.uniqueArray(fc.constantFrom("read", "write" as const), {
|
||||
minLength: 0,
|
||||
maxLength: 2,
|
||||
}),
|
||||
fc.uniqueArray(fc.constantFrom("delete", "share" as const), {
|
||||
minLength: 0,
|
||||
maxLength: 2,
|
||||
}),
|
||||
(a, b) => {
|
||||
const aP = {
|
||||
encryption: true,
|
||||
reauthRequired: [],
|
||||
allowedActions: a,
|
||||
};
|
||||
const bP = {
|
||||
encryption: true,
|
||||
reauthRequired: [],
|
||||
allowedActions: b,
|
||||
};
|
||||
const ab = mergePolicies(aP, bP);
|
||||
const ba = mergePolicies(bP, aP);
|
||||
// Same set, order may differ.
|
||||
expect(new Set(ab.allowedActions)).toEqual(
|
||||
new Set(ba.allowedActions)
|
||||
);
|
||||
expect(new Set(ab.allowedActions)).toEqual(
|
||||
new Set([...a, ...b])
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — partition member invariant", () => {
|
||||
it("addMember then removeMember restores member count", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.string({ minLength: 1, maxLength: 16 }),
|
||||
async (name) => {
|
||||
const manager = new PartitionManager({
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing: new KeyRing(),
|
||||
});
|
||||
const p = await manager.create({
|
||||
name,
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
});
|
||||
const before = p.members.length;
|
||||
const after1 = await manager.addMember(p.id, userId("bob"));
|
||||
const after2 = await manager.removeMember(
|
||||
after1.id,
|
||||
userId("bob")
|
||||
);
|
||||
expect(after2.members.length).toBe(before);
|
||||
}
|
||||
),
|
||||
{ numRuns: 25 }
|
||||
);
|
||||
});
|
||||
});
|
||||
18
packages/networking/drive/drive-sharing/tsconfig.lib.json
Normal file
18
packages/networking/drive/drive-sharing/tsconfig.lib.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec-conformance.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
72
packages/networking/drive/drive-sharing/vite.config.ts
Normal file
72
packages/networking/drive/drive-sharing/vite.config.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/drive-sharing",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"error-codes": "src/error-codes/index.ts",
|
||||
"constants/index": "src/constants/index.ts",
|
||||
"types/common": "src/types/common.ts",
|
||||
"types/schemas": "src/types/schemas.ts",
|
||||
"encryption/envelope/envelope":
|
||||
"src/encryption/envelope/envelope.ts",
|
||||
"encryption/key-ring/key-ring":
|
||||
"src/encryption/key-ring/key-ring.ts",
|
||||
"partitions/partition-policy/partition-policy":
|
||||
"src/partitions/partition-policy/partition-policy.ts",
|
||||
"partitions/partition-store/partition-store":
|
||||
"src/partitions/partition-store/partition-store.ts",
|
||||
"partitions/partition-manager/partition-manager":
|
||||
"src/partitions/partition-manager/partition-manager.ts",
|
||||
"sharing/grants/grants": "src/sharing/grants/grants.ts",
|
||||
"sharing/tokens/tokens": "src/sharing/tokens/tokens.ts",
|
||||
"transfer/transfer/transfer":
|
||||
"src/transfer/transfer/transfer.ts",
|
||||
"otel/instruments": "src/otel/instruments.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/crypto",
|
||||
/^@suaveplan\/crypto\//,
|
||||
"@suaveplan/crypto-cipher",
|
||||
/^@suaveplan\/crypto-cipher\//,
|
||||
"@suaveplan/crypto-hash",
|
||||
/^@suaveplan\/crypto-hash\//,
|
||||
"@suaveplan/crypto-utils",
|
||||
/^@suaveplan\/crypto-utils\//,
|
||||
"@suaveplan/drive",
|
||||
/^@suaveplan\/drive\//,
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
5
packages/networking/drive/drive-sharing/vitest.config.ts
Normal file
5
packages/networking/drive/drive-sharing/vitest.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
// drive-sharing ships fast-check property tests and crypto-heavy roundtrips;
|
||||
// bump the timeout so 1k+ iterations comfortably fit on a cold WSL2 worker.
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user