# Capability Spec: clipboard **Package**: `@suaveplan/clipboard` **Status**: ARCHIVED (2026-05-25 enterprise-clipboard) > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Stable error code registry The package SHALL expose a `CLIPBOARD_CODES` constant created via `defineCodes("CLIPBOARD", [...] as const)` containing exactly eight stable codes: `API_UNAVAILABLE`, `PERMISSION_DENIED`, `FALLBACK_FAILED`, `INSECURE_CONTEXT`, `NO_CONTENT`, `READ_FAILED`, `DOCUMENT_UNAVAILABLE`, `QUEUE_DRAINED`. Each code MUST have a metadata record (`httpStatus`, `retryable`, `severity`, `description`) and a class `ClipboardError` extending `SuaveplanError` MUST be constructible with each code. #### Scenario: registry shape is frozen - **WHEN** a consumer reads `CLIPBOARD_CODES` - **THEN** the returned object MUST contain all eight expected keys with namespaced string values (`CLIPBOARD.API_UNAVAILABLE`, …) and MUST be `Object.isFrozen`. #### Scenario: error metadata is queryable per code - **WHEN** a consumer reads `CLIPBOARD_ERROR_METADATA[CLIPBOARD_CODES.INSECURE_CONTEXT]` - **THEN** the returned record MUST report `httpStatus: 400`, `retryable: false`, `severity: "error"`, and a non-empty `description`. **Verified by:** `src/errors/errors.test.ts::"CLIPBOARD_CODES has all eight stable codes"` AND `src/errors/errors.test.ts::"CLIPBOARD_ERROR_METADATA reports correct shape per code"` --- ### Requirement: ClipboardError extends SuaveplanError The package SHALL export `ClipboardError extends SuaveplanError`. Constructing `new ClipboardError(CLIPBOARD_CODES.API_UNAVAILABLE, "msg")` MUST yield an instance whose `.code === "CLIPBOARD.API_UNAVAILABLE"`, whose `.message === "msg"`, and which satisfies both `instanceof ClipboardError` and `instanceof Error`. #### Scenario: round-trips through cause chain - **WHEN** a `ClipboardError` is constructed with `cause: nativeErr` - **THEN** `error.cause` MUST return the original native error and `error.code` MUST preserve the namespaced code. **Verified by:** `src/errors/errors.test.ts::"ClipboardError preserves code and cause"` --- ### Requirement: Secure-context guard The package SHALL expose `isSecure(window?)` and `requireSecureContext(window?)`. The former MUST return `window.isSecureContext` when the supplied window exposes that field and `true` when running outside a window (so universal SSR call sites do not throw). The latter MUST throw `ClipboardError(CLIPBOARD_CODES.INSECURE_CONTEXT)` when the window reports `isSecureContext === false`. #### Scenario: insecure-context detection - **WHEN** `requireSecureContext` is invoked with `{ isSecureContext: false }` - **THEN** a `ClipboardError` with code `CLIPBOARD.INSECURE_CONTEXT` MUST be thrown. #### Scenario: secure context passes through - **WHEN** `requireSecureContext` is invoked with `{ isSecureContext: true }` - **THEN** the call MUST return without throwing. **Verified by:** `src/security/security.test.ts::"requireSecureContext throws INSECURE_CONTEXT when window flags insecure"` AND `src/security/security.test.ts::"requireSecureContext returns silently for secure context"` --- ### Requirement: AsyncQueue serialization The package SHALL expose an `AsyncQueue` whose `enqueue(fn)` chains every operation to the prior one such that callbacks execute in arrival order regardless of individual rejection. A rejection MUST NOT prevent the next queued operation from running. `drain()` MUST return a promise that resolves once the current tail settles. #### Scenario: concurrent enqueues run in arrival order - **WHEN** three callbacks `A`, `B`, `C` are enqueued in that order, each writing its identifier to a shared array before resolving - **THEN** the resulting array MUST equal `["A", "B", "C"]` and every `enqueue` promise MUST resolve with its own callback's return value. #### Scenario: rejection does not stall queue - **WHEN** the first enqueued callback rejects synchronously - **THEN** the second enqueued callback MUST still execute and its returned promise MUST resolve with the second callback's value. **Verified by:** `src/queue/queue.test.ts::"enqueue preserves arrival order"` AND `src/queue/queue.test.ts::"rejection does not stall later operations"` --- ### Requirement: Permission state probe `ClipboardManager.checkPermissions()` SHALL return `{ read, write }` populated from `navigator.permissions.query({name: "clipboard-read"|"clipboard-write"})`. When the permissions API is missing the manager MUST return `{ read: "prompt", write: "prompt" }`. When a query rejects the manager MUST treat that slot as `"prompt"` and MUST NOT re-throw. #### Scenario: granted/prompt reflection - **WHEN** `permissions.query` returns `granted` for `clipboard-read` and `prompt` for `clipboard-write` - **THEN** `checkPermissions()` MUST return `{ read: "granted", write: "prompt" }`. #### Scenario: missing permissions API - **WHEN** the injected navigator has no `permissions` field - **THEN** `checkPermissions()` MUST resolve to `{ read: "prompt", write: "prompt" }`. **Verified by:** `src/permissions/permissions.test.ts::"checkPermissions reflects granted and prompt states"` AND `src/permissions/permissions.test.ts::"checkPermissions returns prompt defaults when API absent"` --- ### Requirement: Permission change subscription `ClipboardManager.onPermissionChange(cb)` SHALL subscribe `cb` to changes on both `clipboard-read` and `clipboard-write` `PermissionStatus` objects and return an unsubscribe function. The subscription MUST invoke `cb(next, name)` whenever either status fires `change`, and the unsubscribe MUST detach both listeners. #### Scenario: change event delivery and unsubscribe - **WHEN** a consumer subscribes via `onPermissionChange(cb)` and the read `PermissionStatus.state` transitions from `prompt` to `granted` - **THEN** `cb` MUST be invoked exactly once with `({read, write}, "clipboard-read")` reflecting the new state; after calling the returned unsubscribe and triggering a second change, `cb` MUST NOT be invoked again. **Verified by:** `src/permissions/permissions.test.ts::"onPermissionChange delivers updates and unsubscribes cleanly"` --- ### Requirement: Plain-text copy via Clipboard API `ClipboardManager.copyText(text)` SHALL invoke `navigator.clipboard.writeText` when the API is present and return `{ success: true, data: text, method: "api" }`. The operation MUST emit `clipboard_copy_total{method=api, kind=text}` once and record `clipboard_op_latency_ms{op=copyText, method=api}`. #### Scenario: writeText resolves and returns api result - **WHEN** `copyText("hello")` is called against a navigator whose `clipboard.writeText` resolves - **THEN** the result MUST equal `{ success: true, data: "hello", method: "api" }` and `clipboard_copy_total{method=api, kind=text}` MUST have advanced by 1. **Verified by:** `src/manager/manager.test.ts::"copyText writes via Clipboard API and emits api metrics"` --- ### Requirement: Plain-text copy fallback When the Clipboard API is unavailable or rejects and `options.useFallback` is true (the default), `copyText` SHALL invoke a hidden `