Initial Commit
This commit is contained in:
110
openspec/changes/biometrics/proposal.md
Normal file
110
openspec/changes/biometrics/proposal.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# OpenSpec Change: biometrics
|
||||
|
||||
**Package**: `@suaveplan/biometrics`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
## Why
|
||||
|
||||
Vaulted needs biometric authentication across desktop (Windows Hello, Touch ID via Electrobun), mobile (Face ID, Android BiometricPrompt via Capacitor), and macOS (Touch ID via `@suaveplan/macos-keychain`). Platform APIs are fragmented and inconsistent; a thin universal facade ensures the Vaulted app and extensions use a single interface regardless of runtime.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `BiometricAuth` class — universal facade with auto-detected or injected delegate
|
||||
- ADDED: `authenticate(reason)` — returns `Promise<boolean>` (never biometric data)
|
||||
- ADDED: `getAvailability()` — returns `BiometricAvailability`
|
||||
- ADDED: `BiometricDelegate` interface — platform implementers implement this
|
||||
- ADDED: `CapacitorBiometricDelegate` — delegates to Capacitor Local Authentication plugin
|
||||
- ADDED: `ElectrobunBiometricDelegate` — delegates to Electrobun native bridge
|
||||
- ADDED: `MacosKeychainBiometricDelegate` — delegates to `@suaveplan/macos-keychain` biometric APIs
|
||||
- ADDED: `NoOpBiometricDelegate` — always returns `false`; used on unsupported platforms
|
||||
- ADDED: `BiometricAvailability` — `{ available: boolean; type?: BiometricType; reason?: UnavailabilityReason }`
|
||||
- ADDED: `BiometricType` — `'touchId' | 'faceId' | 'fingerprint' | 'iris' | 'unknown'`
|
||||
- ADDED: `UnavailabilityReason` — `'noHardware' | 'notEnrolled' | 'notSupported' | 'permissionDenied' | 'unknown'`
|
||||
- ADDED: `BiometricError` extends `SuaveplanError`
|
||||
- ADDED: `BiometricNotAvailableError` extends `BiometricError` (code `BIOMETRIC.NOT_AVAILABLE`)
|
||||
- ADDED: `BiometricHardwareError` extends `BiometricError` (code `BIOMETRIC.HARDWARE_ERROR`)
|
||||
- ADDED: Injectable `clock?: Clock` in `BiometricAuth` options for deterministic timeout testing
|
||||
- ADDED: Zod schemas for availability and options
|
||||
- ADDED: OTel counter `biometric.authenticate.total` (by `outcome`) and histogram `biometric.authenticate.duration_ms`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Biometric enrollment — OS-level concern, not a library responsibility
|
||||
- Secure enclave key generation or keypair creation — defer to `@suaveplan/crypto-browser` and platform plugins
|
||||
- Fallback to device PIN / password — platform-specific behavior, deferred to consumer
|
||||
- WebAuthn / passkey biometric flows — handled by `@suaveplan/webauthn`
|
||||
|
||||
## Gap analysis: sub-standard → enterprise
|
||||
|
||||
Consumer-grade biometric libraries often capture, buffer, or even persist raw biometric data (fingerprints, face geometry) in application memory, violating platform security guidelines and creating catastrophic privacy risk if the device is compromised. They also fail to provide a structured fallback when hardware is unavailable or not enrolled, leaving apps to crash or hang. Enterprise-grade code must never touch raw biometric templates; it must delegate entirely to OS secure enclaves, surface clear availability signals, and offer a deterministic `NoOp` fallback for unsupported platforms.
|
||||
|
||||
## Security model
|
||||
|
||||
- **Threat model**: Compromised host attempting to replay biometric authentication signals; malicious delegates intercepting or spoofing platform responses; side-channel leakage via timing of availability checks.
|
||||
- **Privacy guarantees**: Raw biometric data is never read, stored, or transmitted by this library. All biometric matching occurs inside the platform secure enclave / TEE. Only a boolean result is returned to the application.
|
||||
- **Input validation**: `reason` string length bounded and sanitized; delegate implementations validated at injection time against the `BiometricDelegate` interface contract.
|
||||
- **Side-channel resistance**: Availability checks and authentication calls use constant-time paths where feasible; no correlation between vault item access and biometric prompt timing.
|
||||
- **Key material handling**: No cryptographic keys are derived from biometric data in this package. Key generation is explicitly out of scope and deferred to `@suaveplan/crypto-browser` and platform plugins.
|
||||
|
||||
## Performance SLOs
|
||||
|
||||
- **Latency**: P95 `authenticate` prompt-to-result < 2s on supported hardware; P95 `getAvailability` < 50ms.
|
||||
- **Throughput**: Serial by design — biometric prompts are user-interactive and never batched.
|
||||
- **Memory bounds**: Peak memory < 256 KB per authentication attempt; no image or template buffering.
|
||||
- **Bundle size budget**: Core facade + delegates < 10 kB gzipped; platform-specific delegates tree-shakeable.
|
||||
|
||||
## Observability plan
|
||||
|
||||
- **OTel spans/metrics**: Counter `biometric.authenticate.total` by `outcome`; histogram `biometric.authenticate.duration_ms`; gauge for availability state transitions.
|
||||
- **Structured logging**: JSON logs at `info` for authentication attempts, `warn` for unavailability, `error` for delegate failures; never logs biometric type as a personal identifier.
|
||||
- **Error telemetry**: Distinct codes (`NOT_AVAILABLE`) with platform context and delegate name (anonymized where required).
|
||||
- **Health checks**: Expose `BiometricAuth.isHealthy()` returning false if the active delegate throws repeatedly.
|
||||
|
||||
## Dependency audit
|
||||
|
||||
- **Runtime deps**: `@suaveplan/errors`, `@suaveplan/otel`, and platform-specific peer dependencies (Capacitor, Electrobun, macOS keychain) that are injected, not bundled.
|
||||
- **Supply-chain risk**: No biometric SDKs from unvetted vendors; all delegates are first-party wrappers around platform APIs.
|
||||
- **Tree-shakeability**: `BiometricAuth`, each delegate, and availability utilities are individually tree-shakeable.
|
||||
- **License**: All first-party code MIT-licensed; platform plugins retain their original licenses and are peer dependencies.
|
||||
|
||||
## Operational readiness
|
||||
|
||||
- **Graceful degradation**: `NoOpBiometricDelegate` ensures unsupported platforms receive `false` rather than exceptions; consumers can cascade to PIN/password.
|
||||
- **Resource limits**: Authentication timeout enforced by platform delegate; internal timeout as a failsafe.
|
||||
- **Error recovery**: Delegate exceptions are caught and mapped to `BiometricError`; repeated failures trigger health-check degradation.
|
||||
- **Backpressure**: Not applicable — biometric operations are user-serialized; concurrent calls are queued and deduplicated.
|
||||
- **Version compatibility**: Public API frozen after v1; additive-only changes; deprecated delegates supported for two major versions.
|
||||
- **Platform safety**: Runtime platform detection uses `typeof process !== 'undefined' && process.platform === 'darwin'` with safe fallback to `NoOpBiometricDelegate` to avoid crashes in browser environments without `process`.
|
||||
|
||||
## Verification approach
|
||||
|
||||
- **100% coverage target**: All exported functions, branches, and error paths instrumented in test.
|
||||
- **Property-based tests**: Availability state machine transitions, delegate injection contracts, and error code coverage tested with fast-check invariants.
|
||||
- **Load/stress criteria**: 100 sequential authenticate calls without memory growth > 5%; no handle leaks in test doubles implementing the `BiometricDelegate` interface with deterministic responses.
|
||||
- **Security audit checklist**: Verify no raw biometric data in logs or memory; confirm delegate sandboxing; validate timeout and cancellation paths.
|
||||
- `src/auth/auth.test.ts` — covers `authenticate` success, failure, cancellation, delegate injection
|
||||
- `src/platform/platform.test.ts` — covers platform auto-detection, unsupported platform fallback
|
||||
- `src/delegate/delegate.test.ts` — covers every delegate implementation contract
|
||||
- `src/availability/availability.test.ts` — covers `getAvailability` mapping
|
||||
- `src/errors/errors.spec-conformance.test.ts` — pins error hierarchies and strict-mode behaviors
|
||||
- `tests/dep-graph.test.ts` — pins dependency invariants
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only.
|
||||
|
||||
## Impact
|
||||
|
||||
### Downstream consumers
|
||||
|
||||
No dependents (verified by `bunx turbo ls --filter=...@suaveplan/biometrics`).
|
||||
|
||||
### Breaking changes
|
||||
|
||||
None — additive only.
|
||||
|
||||
### Workspace dep prerequisites
|
||||
|
||||
- `@suaveplan/errors`
|
||||
- `@suaveplan/otel`
|
||||
162
openspec/changes/biometrics/specs/biometrics/spec.md
Normal file
162
openspec/changes/biometrics/specs/biometrics/spec.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Capability Spec Delta: biometrics
|
||||
|
||||
**Package**: `@suaveplan/biometrics`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Platform detection
|
||||
|
||||
`BiometricAuth` SHALL auto-detect the runtime platform on instantiation unless a `BiometricDelegate` is explicitly provided. Detection order:
|
||||
|
||||
1. Capacitor — `typeof (window as any).Capacitor !== 'undefined'`
|
||||
2. Electrobun — `typeof (globalThis as any).electrobun !== 'undefined'`
|
||||
3. macOS keychain — `typeof process !== 'undefined' && process.platform === 'darwin'` (Node/Bun runtime) with safe fallback to `NoOpBiometricDelegate` when `process` is undefined
|
||||
4. Unsupported — default to `NoOpBiometricDelegate`
|
||||
|
||||
#### Scenario: Capacitor runtime detected
|
||||
|
||||
- **WHEN** `BiometricAuth` is instantiated in a Capacitor WebView
|
||||
- **THEN** it uses `CapacitorBiometricDelegate` by default
|
||||
|
||||
#### Scenario: Unsupported platform falls back to noop
|
||||
|
||||
- **WHEN** `BiometricAuth` is instantiated in a plain browser with no native bridge
|
||||
- **THEN** it uses `NoOpBiometricDelegate`
|
||||
|
||||
#### Scenario: Explicit delegate overrides detection
|
||||
|
||||
- **WHEN** `BiometricAuth` is constructed with an explicit `delegate`
|
||||
- **THEN** the provided delegate is used regardless of platform
|
||||
|
||||
**Verified by:** `src/platform/platform.test.ts::"selects Capacitor delegate in Capacitor runtime"` AND `src/platform/platform.test.ts::"falls back to noop delegate on unsupported platform"` AND `src/auth/auth.test.ts::"uses explicit delegate over auto-detected platform"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Delegate pattern
|
||||
|
||||
All platform-specific biometric logic MUST be isolated behind the `BiometricDelegate` interface:
|
||||
|
||||
```typescript
|
||||
interface BiometricDelegate {
|
||||
authenticate(reason: string): Promise<boolean>;
|
||||
getAvailability(): Promise<BiometricAvailability>;
|
||||
}
|
||||
```
|
||||
|
||||
`BiometricAuth` MUST delegate every `authenticate()` and `getAvailability()` call to the active delegate without modification of return values.
|
||||
|
||||
#### Scenario: Auth delegated to active delegate
|
||||
|
||||
- **WHEN** `biometricAuth.authenticate("Unlock vault")` is called
|
||||
- **THEN** the active delegate's `authenticate` method is invoked with the same reason string
|
||||
|
||||
#### Scenario: Availability delegated to active delegate
|
||||
|
||||
- **WHEN** `biometricAuth.getAvailability()` is called
|
||||
- **THEN** the active delegate's `getAvailability` method is invoked and its result returned unmodified
|
||||
|
||||
**Verified by:** `src/delegate/delegate.test.ts::"authenticate delegates to active delegate with exact reason"` AND `src/delegate/delegate.test.ts::"getAvailability returns delegate result unmodified"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Secure-enclave boolean-only results
|
||||
|
||||
`authenticate(reason)` MUST return `Promise<boolean>`:
|
||||
|
||||
- `true` — biometric verification succeeded
|
||||
- `false` — biometric verification failed or was cancelled by the user
|
||||
|
||||
The promise MUST reject with `BiometricError` only for unrecoverable system errors (e.g., hardware malfunction). Under no circumstances MUST biometric templates, fingerprint images, face geometry, match scores, or confidence levels be returned to callers or logged.
|
||||
|
||||
#### Scenario: Successful auth returns true
|
||||
|
||||
- **WHEN** the delegate's biometric prompt succeeds
|
||||
- **THEN** `authenticate` resolves to `true`
|
||||
|
||||
#### Scenario: Cancelled auth returns false
|
||||
|
||||
- **WHEN** the user cancels the biometric prompt
|
||||
- **THEN** `authenticate` resolves to `false`
|
||||
|
||||
#### Scenario: No biometric data exposed
|
||||
|
||||
- **WHEN** `authenticate` is called on any delegate
|
||||
- **THEN** the resolved value is strictly `boolean` and no biometric metadata is present
|
||||
|
||||
**Verified by:** `src/auth/auth.test.ts::"resolves true on successful biometric verification"` AND `src/auth/auth.test.ts::"resolves false when user cancels"` AND `src/auth/auth.spec-conformance.test.ts::"authenticate return type is strictly boolean with no biometric metadata"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Availability querying
|
||||
|
||||
`getAvailability()` MUST return `Promise<BiometricAvailability>`:
|
||||
|
||||
```typescript
|
||||
interface BiometricAvailability {
|
||||
available: boolean;
|
||||
type?: BiometricType;
|
||||
reason?: UnavailabilityReason;
|
||||
}
|
||||
```
|
||||
|
||||
`type` MUST be present when `available` is `true`. `reason` MUST be present when `available` is `false`.
|
||||
|
||||
#### Scenario: Available Face ID
|
||||
|
||||
- **WHEN** `getAvailability` is called on iOS with Face ID enrolled
|
||||
- **THEN** it returns `{ available: true, type: 'faceId' }`
|
||||
|
||||
#### Scenario: No hardware
|
||||
|
||||
- **WHEN** `getAvailability` is called on a device with no biometric hardware
|
||||
- **THEN** it returns `{ available: false, reason: 'noHardware' }`
|
||||
|
||||
#### Scenario: Not enrolled
|
||||
|
||||
- **WHEN** `getAvailability` is called on a device with hardware but no enrolled biometrics
|
||||
- **THEN** it returns `{ available: false, reason: 'notEnrolled' }`
|
||||
|
||||
**Verified by:** `src/availability/availability.test.ts::"returns faceId type when Face ID is available"` AND `src/availability/availability.test.ts::"returns noHardware reason when no biometric hardware exists"` AND `src/availability/availability.test.ts::"returns notEnrolled reason when hardware exists but no biometrics enrolled"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: No biometric data exposure
|
||||
|
||||
No implementation file in the package MAY expose, log, or transmit biometric templates, fingerprint minutiae, face maps, iris patterns, or match scores. Delegate implementations MUST rely exclusively on platform APIs that return boolean success/failure. Any platform API that returns richer data MUST have that data discarded before returning to the caller.
|
||||
|
||||
#### Scenario: Delegate discards rich platform data
|
||||
|
||||
- **WHEN** a platform API returns a detailed result object containing a confidence score
|
||||
- **THEN** the delegate extracts only the boolean success field and discards the rest
|
||||
|
||||
**Verified by:** `src/delegate/delegate.spec-conformance.test.ts::"discards confidence score from platform API result"` AND `src/auth/auth.spec-conformance.test.ts::"no biometric fields appear in error context or logs"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Structured errors
|
||||
|
||||
All thrown errors MUST extend `BiometricError` which extends `SuaveplanError`. Error codes MUST be registered via `registerBiometricErrorCodes()` called as a side-effect of importing `src/index.ts`. The following codes MUST be defined:
|
||||
|
||||
- `BIOMETRIC.NOT_AVAILABLE` — biometric authentication is not available on this platform
|
||||
- `BIOMETRIC.HARDWARE_ERROR` — unrecoverable hardware or system error during authentication
|
||||
|
||||
**Verified by:** `src/errors/errors.test.ts::"all error classes extend BiometricError"` AND `src/errors/errors.spec-conformance.test.ts::"error codes are registered on module import"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Injectable Clock for deterministic timeout testing
|
||||
|
||||
`BiometricAuth` options MUST include an optional `clock?: Clock`. When provided, all authentication timeout deadlines MUST be driven by the injected clock instead of the global time source. This enables deterministic testing of timeout paths without real delays.
|
||||
|
||||
**Verified by:** `src/auth/auth.test.ts::"uses injected clock for authentication timeout"` AND `src/auth/auth.test.ts::"uses injected clock for availability deadline"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 100% coverage
|
||||
|
||||
Vitest thresholds MUST be set to 100/100/100/100. `sideEffects: false` MUST be declared in `package.json`.
|
||||
|
||||
**Verified by:** `vitest.config.ts`
|
||||
77
openspec/changes/biometrics/tasks.md
Normal file
77
openspec/changes/biometrics/tasks.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Tasks — biometrics
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `MIGRATION.md` Section 4.6 for source requirements (skip enterprise — genesis-original)
|
||||
- [ ] 1.2 Read `openspec/specs/biometrics/spec.md` if it exists
|
||||
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [ ] 1.5 Confirm no `## Source mapping` section (genesis-original)
|
||||
- [ ] 1.6 Draft `specs/biometrics/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [ ] 2.1 Scaffold `packages/universal/biometrics/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`
|
||||
- [ ] 2.2 Implement `src/error-codes.ts` — `BIOMETRIC.*` codes via `defineCodes`
|
||||
- [ ] 2.3 Implement `src/errors/biometric-error.ts`, `biometric-not-available-error.ts`, `biometric-hardware-error.ts`
|
||||
- [ ] 2.4 Implement `src/types.ts` — `BiometricAvailability`, `BiometricType`, `UnavailabilityReason`, `AuthenticateOptions`
|
||||
- [ ] 2.5 Implement `src/schemas.ts` — Zod schemas for boundary types
|
||||
- [ ] 2.6 Implement `src/delegate/delegate.ts` — `BiometricDelegate` interface
|
||||
- [ ] 2.7 Implement `src/delegate/capacitor.ts` — `CapacitorBiometricDelegate`
|
||||
- [ ] 2.8 Implement `src/delegate/electrobun.ts` — `ElectrobunBiometricDelegate`
|
||||
- [ ] 2.9 Implement `src/delegate/macos-keychain.ts` — `MacosKeychainBiometricDelegate`
|
||||
- [ ] 2.10 Implement `src/delegate/noop.ts` — `NoOpBiometricDelegate`
|
||||
- [ ] 2.11 Implement `src/platform/platform.ts` — runtime platform detection
|
||||
- [ ] 2.12 Implement `src/auth/auth.ts` — `BiometricAuth` facade
|
||||
- [ ] 2.13 Implement `src/telemetry/meters.ts` — OTel counters and histograms
|
||||
- [ ] 2.14 Wire subpath exports in `package.json` for every feature module
|
||||
- [ ] 2.15 Implement `src/clock.ts` — injectable `Clock` for deterministic timeout testing in `BiometricAuth`
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [ ] 3.1 Unit tests at `src/auth/auth.test.ts` covering every scenario
|
||||
- [ ] 3.2 Unit tests at `src/platform/platform.test.ts` covering every scenario
|
||||
- [ ] 3.3 Unit tests at `src/delegate/delegate.test.ts` covering every scenario
|
||||
- [ ] 3.4 Unit tests at `src/availability/availability.test.ts` covering every scenario
|
||||
- [ ] 3.5 Spec-conformance tests at `src/errors/errors.spec-conformance.test.ts` pinning type-level / observability / strict-mode scenarios
|
||||
- [ ] 3.6 Integration / contract tests where the spec demands them
|
||||
- [ ] 3.7 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [ ] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [ ] 4.2 README links every co-located `.md` under `## Modules`
|
||||
- [ ] 4.3 Update `CLAUDE.md` package listing if this is a new package or removes a package
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/biometrics` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/biometrics` — 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/biometrics` — exit 0, no `error` findings
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/biometrics` — green
|
||||
|
||||
## 6. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
|
||||
- [ ] 6.2 Zod schemas wired at every declared boundary
|
||||
- [ ] 6.3 Every error code in `error-codes.ts` thrown by at least one production path
|
||||
- [ ] 6.4 Strict-mode behaviors tested both on and off
|
||||
- [ ] 6.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [ ] 6.6 Lifecycle dedup tested where claimed (in-flight Promise, idempotent set/load)
|
||||
- [ ] 6.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold spec delta into `openspec/specs/biometrics/spec.md`
|
||||
- [ ] 7.2 Move `openspec/changes/biometrics/` → `openspec/archive/<yyyy-mm-dd>-biometrics/`
|
||||
- [ ] 7.3 Refresh `MANIFEST.md` if package set or deps changed
|
||||
Reference in New Issue
Block a user