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

19 KiB

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"