Files
2026-05-27 23:29:53 +01:00

422 lines
19 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: clipboard (clipboard)
**Package**: `@suaveplan/clipboard`
**Status**: PROPOSED
> 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<ClipboardErrorCode>` 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<ClipboardErrorCode>`.
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 `<textarea>` + `document.execCommand("copy")`
fallback and return `method: "fallback"`. When `useFallback` is false, the API
rejection SHALL propagate as `{ success: false, code: "CLIPBOARD.API_UNAVAILABLE" |
"CLIPBOARD.READ_FAILED" or related, method: "api", error: <message> }`. The fallback
MUST emit `clipboard_fallback_used_total{reason}`.
#### Scenario: api throws → fallback runs and succeeds
- **WHEN** `clipboard.writeText` rejects and `useFallback: true`
- **THEN** `copyText` MUST return `{ success: true, method: "fallback", data }`
and `clipboard_fallback_used_total{reason="api_threw"}` MUST advance.
#### Scenario: api throws → fallback disabled
- **WHEN** `clipboard.writeText` rejects and `useFallback: false`
- **THEN** `copyText` MUST return `{ success: false, method: "api", error: <string>,
code: "CLIPBOARD.READ_FAILED" }`.
#### Scenario: execCommand returns false
- **WHEN** `document.execCommand("copy")` returns false during fallback
- **THEN** `copyText` MUST return `{ success: false, method: "fallback",
code: "CLIPBOARD.FALLBACK_FAILED" }`.
#### Scenario: document is unavailable
- **WHEN** the fallback runs in an environment whose `document` is undefined
- **THEN** `copyText` MUST return `{ success: false, method: "fallback",
code: "CLIPBOARD.DOCUMENT_UNAVAILABLE" }`.
**Verified by:** `src/manager/manager.test.ts::"copyText falls back to execCommand when API throws"` AND `src/manager/manager.test.ts::"copyText reports CLIPBOARD.READ_FAILED when fallback disabled"` AND `src/fallback/fallback.test.ts::"copyTextFallback reports FALLBACK_FAILED when execCommand returns false"` AND `src/fallback/fallback.test.ts::"copyTextFallback reports DOCUMENT_UNAVAILABLE when document missing"`
---
### Requirement: Rich-content copy with image and blobs
`ClipboardManager.copyRichContent(data)` SHALL accept `ClipboardData` with optional
`text`, `html`, `image` (`Blob | ImageBitmap`), and arbitrary `blobs: Record<string,
Blob>` fields. When `ClipboardItem` is available and at least one field is populated,
the call SHALL construct a single `ClipboardItem` whose items map covers every
provided MIME and write it via `navigator.clipboard.write`. When `data` carries no
fields the call MUST return `code: CLIPBOARD.NO_CONTENT`.
#### Scenario: text+html+image are written as one ClipboardItem
- **WHEN** `copyRichContent({ text, html, image: pngBlob })` is called
- **THEN** the resulting `ClipboardItem` MUST contain entries for `text/plain`,
`text/html`, and `image/png` and the returned result MUST be
`{ success: true, method: "api", data }`.
#### Scenario: empty data returns NO_CONTENT
- **WHEN** `copyRichContent({})` is called against a navigator with the API present
- **THEN** the result MUST be `{ success: false, method: "api",
code: "CLIPBOARD.NO_CONTENT" }`.
#### Scenario: api throws with html-only payload → fallback rejects with NO_CONTENT
- **WHEN** `clipboard.write` rejects, `useFallback: true`, and `data` carries only
`html` (no `text`)
- **THEN** `copyRichContent` MUST return `{ success: false, method: "fallback",
code: "CLIPBOARD.NO_CONTENT" }` because the fallback path requires `text`.
**Verified by:** `src/manager/manager.test.ts::"copyRichContent writes text html and image in one ClipboardItem"` AND `src/manager/manager.test.ts::"copyRichContent returns NO_CONTENT for empty data"` AND `src/manager/manager.test.ts::"copyRichContent fallback rejects html-only payload as NO_CONTENT"`
---
### Requirement: Rich-content read with image and blobs
`ClipboardManager.readRichContent()` SHALL invoke `navigator.clipboard.read()`,
iterate every returned `ClipboardItem`, and populate `ClipboardData` with `text`,
`html`, `image` (the first `image/*` blob), and `blobs` (all non-text non-image
MIME → Blob). When the API is unavailable the call MUST return `code:
CLIPBOARD.API_UNAVAILABLE`. When the API throws the call MUST return `code:
CLIPBOARD.READ_FAILED`.
#### Scenario: image and arbitrary blob are surfaced
- **WHEN** `clipboard.read` yields one item with `image/png` and one with
`application/json` MIME entries
- **THEN** the result `data` MUST contain `image: Blob<image/png>` and
`blobs["application/json"]` MUST be the JSON blob.
#### Scenario: API unavailable
- **WHEN** `clipboard` is `undefined`
- **THEN** the result MUST be `{ success: false, method: "fallback",
code: "CLIPBOARD.API_UNAVAILABLE" }`.
**Verified by:** `src/manager/manager.test.ts::"readRichContent surfaces image and arbitrary blob fields"` AND `src/manager/manager.test.ts::"readRichContent reports API_UNAVAILABLE when clipboard absent"`
---
### Requirement: HTML sanitizer hook
When `options.sanitizer` is provided, `readRichContent` MUST apply the sanitizer to
every `text/html` payload before returning. The sanitizer MUST be invoked exactly
once per read with the raw string and its return value MUST replace `data.html`.
#### Scenario: sanitizer strips html
- **WHEN** a sanitizer mapping `(html) => html.replace(/<script[^>]*>.*?<\/script>/g, "")`
is supplied and the clipboard payload is `<p>ok</p><script>x</script>`
- **THEN** the returned `data.html` MUST equal `"<p>ok</p>"`.
**Verified by:** `src/manager/manager.test.ts::"readRichContent applies sanitizer to html payload"`
---
### Requirement: Paste-event read fallback
When the Clipboard API is unavailable, `readText` SHALL attempt a paste-event-based
fallback that creates a hidden focusable textarea, listens for `paste`, dispatches
focus, and resolves on the first `paste` event. If no paste arrives within
`options.pasteTimeoutMs` (default 1500) the call MUST return `code:
CLIPBOARD.READ_FAILED`.
#### Scenario: paste-event delivers value
- **WHEN** the fallback is running and a synthetic `paste` event carrying
`text/plain` data is dispatched
- **THEN** `readText` MUST return `{ success: true, method: "fallback", data: <text> }`.
#### Scenario: paste-event times out
- **WHEN** no paste event arrives within the configured timeout
- **THEN** `readText` MUST return `{ success: false, method: "fallback",
code: "CLIPBOARD.READ_FAILED" }`.
**Verified by:** `src/fallback/fallback.test.ts::"readTextFallback returns pasted value when paste event fires"` AND `src/fallback/fallback.test.ts::"readTextFallback returns READ_FAILED on timeout"`
---
### Requirement: Async queue serializes manager calls
When `options.serialize` is true (the default), all `copyText`, `copyRichContent`,
`readText`, and `readRichContent` calls SHALL be queued through the manager's
internal `AsyncQueue` so they execute in arrival order. When `serialize: false`,
calls SHALL execute concurrently without queuing.
#### Scenario: serial dispatch yields arrival order
- **WHEN** three `copyText` calls are issued without awaiting and the underlying
`writeText` resolves out of arrival order (B → C → A)
- **THEN** the calls MUST still resolve with `data` matching their own input
arguments and the `writeText` invocation order MUST equal the arrival order
`["A","B","C"]`.
#### Scenario: serialize=false runs concurrently
- **WHEN** `serialize: false` is set and two `copyText` calls are issued in parallel
- **THEN** both MUST resolve and the underlying `writeText` MUST have been entered
before either completed.
**Verified by:** `src/manager/manager.test.ts::"copyText calls execute in arrival order when serialize is true"` AND `src/manager/manager.test.ts::"copyText calls run concurrently when serialize is false"`
---
### Requirement: Secure-context gate on copy
When `options.requireSecureContext` is true (default false) and the host window
reports `isSecureContext === false`, every `copyText` / `copyRichContent` call SHALL
short-circuit with `code: CLIPBOARD.INSECURE_CONTEXT` without invoking the
Clipboard API or fallback.
#### Scenario: insecure context blocks copy
- **WHEN** `requireSecureContext: true` and the injected window flags
`isSecureContext: false`
- **THEN** `copyText("x")` MUST return `{ success: false, method: "api",
code: "CLIPBOARD.INSECURE_CONTEXT" }`.
**Verified by:** `src/manager/manager.test.ts::"copyText short-circuits with INSECURE_CONTEXT when requireSecureContext is true"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL expose `createClipboardObservability(opts?)` returning four
counters (`copyTotal`, `readTotal`, `fallbackUsedTotal`, `errorTotal`) and one
histogram (`opLatencyMs`). The manager SHALL record into every instrument across
its public methods. The observability bundle MUST default to no-op meter/tracer
when no provider is supplied.
#### Scenario: error counter advances on failure
- **WHEN** `copyText` fails with `CLIPBOARD.API_UNAVAILABLE`
- **THEN** `clipboard_error_total{code="CLIPBOARD.API_UNAVAILABLE"}` MUST advance
by 1.
#### Scenario: latency histogram records per op
- **WHEN** a successful `copyText` completes
- **THEN** `clipboard_op_latency_ms{op="copyText", method="api"}` MUST receive at
least one observation.
**Verified by:** `src/observability/observability.test.ts::"createClipboardObservability returns wired counters and histogram"` AND `src/manager/manager.test.ts::"clipboard_error_total advances when copyText fails"` AND `src/manager/manager.test.ts::"clipboard_op_latency_ms records observations for copyText"`
---
### Requirement: Injectable navigator and options bag
`ClipboardManager` SHALL accept a partial `ClipboardOptions` bag and a
`ClipboardNavigator` shape as constructor arguments so unit tests and SSR consumers
can drive the manager without a real browser. Defaults SHALL merge with
`DEFAULT_CLIPBOARD_OPTIONS` and an absent navigator SHALL fall back to the global
`navigator` when defined and to an empty object otherwise.
#### Scenario: defaults merge with overrides
- **WHEN** the manager is constructed with `{ useFallback: false }`
- **THEN** the active options MUST report `useFallback: false`,
`serialize: true` (default), and the other default fields unchanged.
#### Scenario: missing global navigator does not throw
- **WHEN** the manager is constructed with no `nav` and no `navigator` global
- **THEN** `isSupported()` MUST return false and `copyText` MUST return
`code: CLIPBOARD.API_UNAVAILABLE` (with fallback if available).
**Verified by:** `src/manager/manager.test.ts::"options merge with DEFAULT_CLIPBOARD_OPTIONS"` AND `src/manager/manager.test.ts::"manager survives missing navigator global"`
---
### Requirement: Tree-shakeable subpath exports
The package MUST publish subpath exports for every feature module so consumers can
import only the surface they need: `./manager/manager`, `./fallback/fallback`,
`./permissions/permissions`, `./queue/queue`, `./observability/observability`,
`./errors/errors`, `./security/security`, `./sanitizer/sanitizer`, `./types`. Each
subpath MUST resolve to its dist `.js` + `.d.ts`.
#### Scenario: subpaths resolve
- **WHEN** a consumer reads `package.json#exports`
- **THEN** at least nine subpaths beyond `"."` MUST be present and every entry MUST
declare both `types` and `default`.
**Verified by:** `src/index.test.ts::"package.json exposes one subpath per feature module"`
---
### Requirement: Tier purity — no Node-only APIs
The package MUST NOT import from `node:*`, MUST NOT require any Node-only global
(`process.*`, `Buffer`), and MUST declare `"browser": "./dist/index.js"` in
`package.json`. The runtime MUST work in browsers, happy-dom unit tests, and
SSR (graceful degradation to `code: DOCUMENT_UNAVAILABLE` outside a DOM).
#### Scenario: SSR-safe import
- **WHEN** the package is imported in an environment without `document` and without
`navigator`
- **THEN** `new ClipboardManager()` MUST not throw, `isSupported()` MUST return
`false`, and `copyText` MUST settle with `code: CLIPBOARD.DOCUMENT_UNAVAILABLE`.
**Verified by:** `src/index.test.ts::"package has no node imports"` AND `src/manager/manager.test.ts::"ClipboardManager constructor is SSR-safe"`