Initial Commit
This commit is contained in:
421
openspec/specs/clipboard/spec.md
Normal file
421
openspec/specs/clipboard/spec.md
Normal file
@@ -0,0 +1,421 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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<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"`
|
||||
Reference in New Issue
Block a user