Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
# Design — enterprise-clipboard
> Non-normative rationale, algorithms, and file layout for the
> `@suaveplan/clipboard` enterprise migration. Normative requirements live in
> [`./specs/clipboard/spec.md`](./specs/clipboard/spec.md).
## Layout
```text
packages/foundation/data/clipboard/
README.md
package.json
vite.config.ts
vitest.config.ts
tsconfig.lib.json
src/
index.ts # explicit named re-exports
types/
clipboard.types.ts # shared types: ClipboardData/Result/Options/...
errors/
index.ts
errors.ts # CLIPBOARD_CODES + ClipboardError
errors.md
errors.test.ts
observability/
index.ts
observability.ts # createClipboardObservability
observability.md
observability.test.ts
observability.types.ts
security/
index.ts
security.ts # requireSecureContext + isSecure
security.md
security.test.ts
queue/
index.ts
queue.ts # AsyncQueue with FIFO ordering
queue.md
queue.test.ts
sanitizer/
index.ts
sanitizer.ts # default identity + Sanitizer interface
sanitizer.md
sanitizer.test.ts
permissions/
index.ts
permissions.ts # checkPermissions + onPermissionChange
permissions.md
permissions.test.ts
fallback/
index.ts
fallback.ts # textarea+execCommand + paste-event capture
fallback.md
fallback.test.ts
manager/
index.ts
manager.ts # ClipboardManager — orchestrates all features
manager.md
manager.test.ts
```
## Public API
```ts
// CLIPBOARD_CODES
export const CLIPBOARD_CODES = defineCodes("CLIPBOARD", [
"API_UNAVAILABLE", // navigator.clipboard absent
"PERMISSION_DENIED", // PermissionState === "denied"
"FALLBACK_FAILED", // execCommand returned false or threw
"INSECURE_CONTEXT", // window.isSecureContext === false
"NO_CONTENT", // copyRichContent called with empty data
"READ_FAILED", // read* threw
"DOCUMENT_UNAVAILABLE", // document undefined (SSR)
"QUEUE_DRAINED", // queued op cancelled because Manager was disposed
] as const);
// Errors
export class ClipboardError extends SuaveplanError<ClipboardErrorCode> {}
// Manager
export class ClipboardManager {
constructor(opts?: Partial<ClipboardOptions>, nav?: ClipboardNavigator);
copyText(text: string): Promise<ClipboardResult<string>>;
copyRichContent(data: ClipboardData): Promise<ClipboardResult<ClipboardData>>;
readText(): Promise<ClipboardResult<string>>;
readRichContent(): Promise<ClipboardResult<ClipboardData>>;
checkPermissions(): Promise<PermissionResult>;
onPermissionChange(cb: PermissionChangeListener): () => void;
isSupported(): boolean;
isSecure(): boolean;
dispose(): void;
}
```
## Async queue
`AsyncQueue` holds a tail `Promise<void>` and every `enqueue(fn)` call chains:
```ts
class AsyncQueue {
private tail: Promise<unknown> = Promise.resolve();
enqueue<T>(fn: () => Promise<T>): Promise<T> {
const result = this.tail.then(() => fn(), () => fn());
this.tail = result.catch(() => undefined);
return result;
}
drain(): Promise<void> { return this.tail.then(() => undefined, () => undefined); }
}
```
When `serialize: false` is passed, the manager calls `fn()` directly.
## Fallback flow
`copyTextFallback(text)` creates a hidden `<textarea>`, focuses it, runs
`document.execCommand("copy")`, and removes the element in a `finally` block. The
`focusRetry` option causes the manager to await one `focus` event before retrying
once if the first API call rejects with a `NotAllowedError`.
`readTextFallback()` (paste-event fallback) creates a focusable hidden textarea,
listens for `paste`, dispatches focus, and races `Promise<{ value | reject }>` with
a 1500ms timeout. The textarea is removed on success and on timeout.
## Observability
Five instruments on the `@suaveplan/clipboard` meter:
| Instrument | Type | Attributes |
|---|---|---|
| `clipboard_copy_total` | counter | `method=api|fallback`, `kind=text|rich` |
| `clipboard_read_total` | counter | `method=api|fallback`, `kind=text|rich` |
| `clipboard_fallback_used_total` | counter | `reason=no_api|api_threw|insecure_context` |
| `clipboard_error_total` | counter | `code=<CLIPBOARD.*>` |
| `clipboard_op_latency_ms` | histogram | `op=copyText|readText|copyRich|readRich`, `method=api|fallback` |
The histogram is recorded around every public method via `recordOp(observability, op, method, startMs)`.
## Error code → HTTP / retry table
| Code | httpStatus | retryable | Description |
|---|---|---|---|
| `API_UNAVAILABLE` | 501 | false | navigator.clipboard undefined |
| `PERMISSION_DENIED` | 403 | false | Permissions API returned "denied" |
| `FALLBACK_FAILED` | 500 | false | execCommand returned false / threw |
| `INSECURE_CONTEXT` | 400 | false | window.isSecureContext === false |
| `NO_CONTENT` | 400 | false | copyRichContent called with empty `ClipboardData` |
| `READ_FAILED` | 500 | true | API read promise rejected |
| `DOCUMENT_UNAVAILABLE` | 501 | false | SSR / Node — no `document` |
| `QUEUE_DRAINED` | 503 | true | manager disposed mid-flight |
## Test strategy
happy-dom (default Vitest environment) supplies `document`, `navigator`,
`window.isSecureContext`, `ClipboardEvent`, `Blob`. `navigator.clipboard` is provided
as a real shape on a constructor-injected `ClipboardNavigator` parameter — the
default constructor falls back to `navigator` which under happy-dom is the real
object. We never `vi.mock(...)` runtime modules; instead the `ClipboardNavigator`
shape is the seam.
Coverage targets every branch:
- `copyText` happy path, API rejection, API rejection with `useFallback: false`,
document absent, `execCommand → false`, `execCommand throws`, focus retry success,
focus retry exhaustion, queue serialization.
- `copyRichContent` happy paths for text-only, html-only, text+html, image-only,
blobs map, mixed, empty (NO_CONTENT), API failure with fallback, API failure
without fallback, missing `ClipboardItem`.
- `readText` happy path, API rejection, paste-event fallback success, paste-event
fallback timeout, document absent.
- `readRichContent` happy path with text+html, image item, arbitrary MIME blob,
empty items, API rejection, sanitizer applied to html.
- `checkPermissions` granted/denied/prompt, no permissions API, query throws.
- `onPermissionChange` subscribe + receive + unsubscribe + manager dispose.
- `requireSecureContext` true and false.
## Why `foundation/data`
`packages/foundation/data/` is the canonical home for browser-tier data-transfer
primitives (`packer-browser`, `serialize`, `mime`, `templates`, `bitpacker`).
Clipboard is the user-driven data-transfer surface that complements the
programmatic ones in this folder. Adding it to `data/` keeps the plugin set
defined in `scripts/folder-reorg/assignment.yml` stable and avoids inventing a
new plugin solely for one file-VFS-shaped package.

View File

@@ -0,0 +1,102 @@
# Change: enterprise-clipboard
- **Package**: `@suaveplan/clipboard`
- **Status**: PROPOSED
- **Design**: ./design.md
- **Spec delta**: ./specs/clipboard/spec.md
- **Upstream**: `../suaveplan/packages/browser/clipboard` (1.0.3)
- **Enterprise audit**: `../suaveplan/enterprise/clipboard.md`
## Why
Genesis is migrating `@suaveplan/clipboard` from the legacy `packages/browser/clipboard/`
checkout into the canonical `packages/foundation/data/clipboard/` location at enterprise
quality. The upstream package shipped at 1.0.3 with a thin `ClipboardManager` facade over
the browser Clipboard API plus a textarea+`execCommand` fallback, but the enterprise audit
flagged seven gaps that block production use:
1. No image / arbitrary-blob clipboard I/O — `ClipboardItem` is only used for
`text/plain` and `text/html`.
2. Errors are hand-crafted strings (`"Clipboard API failed"`) with no `@suaveplan/error`
integration, no error code registry, no programmatic discrimination.
3. No secure-context guard — calling code outside HTTPS / `localhost` falls through to a
generic failure with no diagnostic signal.
4. No paste-event fallback for `readText` in non-API contexts; the only outcome is
`"Reading clipboard requires Clipboard API"`.
5. No permission-state subscription — UIs cannot react to `granted`/`denied` transitions.
6. No OTel instrumentation — production cannot measure copy/read volume, fallback usage,
error rate, or operation latency.
7. No async serialization — concurrent `copy` / `read` calls race and the trailing call
wins non-deterministically.
The greenfield rewrite addresses every gap while preserving the upstream public surface
(`ClipboardManager`, `ClipboardNavigator`, `ClipboardData`, `ClipboardOptions`,
`ClipboardResult`, `PermissionResult`, `DEFAULT_CLIPBOARD_OPTIONS`) so downstream
consumers continue to compile against the same import paths.
## What changes
- **Add**: `@suaveplan/clipboard` at version `0.1.0` under
`packages/foundation/data/clipboard/` with full enterprise surface — typed errors via
`@suaveplan/error`, OTel counters + histogram, async queue serializer, secure-context
guard, image / blob copy + read via `ClipboardItem`, paste-event fallback, permission
change subscription, focus-aware retry, MIME-keyed format negotiation, paste sanitizer
hook, and a runtime-injectable `ClipboardAdapter` / `ClipboardNavigator` seam.
- **Add**: error-code namespace `CLIPBOARD.*` with eight stable codes
(`API_UNAVAILABLE`, `PERMISSION_DENIED`, `FALLBACK_FAILED`, `INSECURE_CONTEXT`,
`NO_CONTENT`, `READ_FAILED`, `DOCUMENT_UNAVAILABLE`, `QUEUE_DRAINED`).
- **Add**: OTel instruments — counters `clipboard_copy_total`, `clipboard_read_total`,
`clipboard_fallback_used_total`, `clipboard_error_total` and histogram
`clipboard_op_latency_ms`.
- **Add**: subpath exports per feature module (`./manager/manager`,
`./fallback/fallback`, `./permissions/permissions`, `./queue/queue`,
`./observability/observability`, `./errors/errors`, `./security/security`,
`./sanitizer/sanitizer`, `./types`).
## Impact
### Affected dependents
`bunx turbo ls --filter=...@suaveplan/clipboard` — empty (clipboard is a leaf migration;
no current genesis package depends on it).
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/clipboard`).
Future React consumers (`react-ui`, `react-command-palette`) will adopt this package via
a follow-up change once they migrate; no work is required in this proposal.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `ClipboardManager` facade | ✅ | ✅ | Same constructor signature; options bag + injectable `ClipboardNavigator`. |
| `copyText(text)` | ✅ | ✅ | Now wraps every call in `withQueue` and emits OTel metrics. |
| `readText()` | ✅ | ⬆️ | Paste-event fallback added when no Clipboard API; `requireSecureContext` gate added. |
| `copyRichContent({text,html})` | ✅ | ⬆️ | Extended with `image: Blob`, `blobs: Record<string, Blob>`, TrustedHTML accepted, MIME map. |
| `readRichContent()` | ✅ | ⬆️ | Now returns `image` and `blobs` fields, not just text/html. |
| `checkPermissions()` | ✅ | ✅ | Same shape; resilient to permission-query failures. |
| `isSupported()` | ✅ | ✅ | Same semantics. |
| `DEFAULT_CLIPBOARD_OPTIONS` | ✅ | ⬆️ | Extended with `requireSecureContext`, `serialize`, `sanitizer`, `focusRetry`. |
| `execCommand` fallback | ✅ | ✅ | Retained; emits `clipboard_fallback_used_total{reason}` counter. |
| Permission change subscription | 🚫 | ⬆️ | NEW — `onPermissionChange(cb)` returning unsubscribe. |
| Image / blob copy + read | 🚫 | ⬆️ | NEW — `ClipboardItem` with PNG / arbitrary MIME blobs. |
| Async queue serializer | 🚫 | ⬆️ | NEW — `serialize: true` default; concurrent calls execute in arrival order. |
| Secure-context guard | 🚫 | ⬆️ | NEW — `requireSecureContext()` throws `CLIPBOARD.INSECURE_CONTEXT`. |
| Focus-aware retry | 🚫 | ⬆️ | NEW — on `NotAllowedError` "Document not focused", awaits next focus once. |
| Paste-event fallback | 🚫 | ⬆️ | NEW — `readTextFallback` orchestrates focus + paste capture. |
| Paste sanitizer hook | 🚫 | ⬆️ | NEW — `options.sanitizer(html) => string` runs before `readRichContent` returns. |
| Typed errors with codes | 🚫 | ⬆️ | NEW — `ClipboardError` extends `SuaveplanError`; all paths use codes. |
| OTel counters + histogram | 🚫 | ⬆️ | NEW — five instruments wired into every public method. |
| Playwright proof | 🚫 | 🚫 | Scoped out: greenfield package starts with happy-dom unit tests at 100% coverage; Playwright spec lands with `react-ui` adoption follow-up so the host harness exists. |
## Migration
This is a 0.1.0 first landing — no prior genesis consumers. Downstream waves that adopted
the upstream surface from `../suaveplan/packages/browser/clipboard` should:
1. Update imports from `@suaveplan/clipboard` (path stays the same).
2. Treat `ClipboardResult.error` as `string | undefined` per the upstream contract, but
prefer matching on `ClipboardResult.code` (new field) against `CLIPBOARD_CODES.*`.
3. Adopt `withQueue`-based serialization automatically — no opt-in required (default
`serialize: true`); set `serialize: false` to restore upstream's race-allowed
behavior.

View File

@@ -0,0 +1,421 @@
<!-- 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"`

View File

@@ -0,0 +1,64 @@
# Tasks — enterprise-clipboard
> Implementation checklist for migrating `@suaveplan/clipboard` from
> `../suaveplan/packages/browser/clipboard` (1.0.3) into
> `packages/foundation/data/clipboard/` at 0.1.0 with enterprise surface.
## Spec-inventory checklist
Spec delta contains 17 `### Requirement` blocks. Each item below maps 1:1 to a spec
requirement and is checked off as the implementation lands.
- [x] R1 Stable error code registry — `src/errors/errors.ts`
- [x] R2 ClipboardError extends SuaveplanError — `src/errors/errors.ts`
- [x] R3 Secure-context guard — `src/security/security.ts`
- [x] R4 AsyncQueue serialization — `src/queue/queue.ts`
- [x] R5 Permission state probe — `src/permissions/permissions.ts`
- [x] R6 Permission change subscription — `src/permissions/permissions.ts`
- [x] R7 Plain-text copy via Clipboard API — `src/manager/manager.ts`
- [x] R8 Plain-text copy fallback — `src/manager/manager.ts` + `src/fallback/fallback.ts`
- [x] R9 Rich-content copy with image and blobs — `src/manager/manager.ts`
- [x] R10 Rich-content read with image and blobs — `src/manager/manager.ts`
- [x] R11 HTML sanitizer hook — `src/manager/manager.ts` + `src/sanitizer/sanitizer.ts`
- [x] R12 Paste-event read fallback — `src/fallback/fallback.ts`
- [x] R13 Async queue serializes manager calls — `src/manager/manager.ts`
- [x] R14 Secure-context gate on copy — `src/manager/manager.ts`
- [x] R15 OpenTelemetry instrumentation — `src/observability/observability.ts`
- [x] R16 Injectable navigator and options bag — `src/manager/manager.ts`
- [x] R17 Tree-shakeable subpath exports — `package.json`
## Steps
1. Scaffold `packages/foundation/data/clipboard/` with `package.json`, `vite.config.ts`,
`vitest.config.ts`, `tsconfig.lib.json`, `README.md`.
2. Author shared types in `src/types/clipboard.types.ts`.
3. Implement `src/errors/errors.ts` with `CLIPBOARD_CODES`, metadata,
`ClipboardError`. Add `errors.md` + `errors.test.ts`.
4. Implement `src/security/security.ts` (`isSecure`, `requireSecureContext`).
5. Implement `src/queue/queue.ts` (`AsyncQueue`).
6. Implement `src/sanitizer/sanitizer.ts` (default identity + interface).
7. Implement `src/observability/observability.ts` —
`createClipboardObservability` + types module.
8. Implement `src/permissions/permissions.ts` — probe + change subscription.
9. Implement `src/fallback/fallback.ts` — textarea+execCommand copy + paste-event
read fallback.
10. Implement `src/manager/manager.ts` — `ClipboardManager` orchestrating queue,
secure-context, observability, permissions, fallback, sanitizer.
11. Wire `src/index.ts` with explicit named re-exports.
12. Write co-located `.md` for every implementation file (≥200 words, six required
sections, with simple + advanced examples that compile).
13. Write tests at 100% line/branch/function/statement coverage in
`vitest.config.ts` (`provider: 'istanbul'`, `environment: 'happy-dom'`,
`pool: 'forks'`).
14. Add `src/index.test.ts` covering package-level invariants (subpath exports
present, no node imports).
15. Add subpath exports for every feature module in `package.json` `exports` map.
16. Update `scripts/folder-reorg/assignment.yml` to list `clipboard` under the
`data` plugin.
17. Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
18. Run gates: `bunx turbo lint typecheck test build --filter=@suaveplan/clipboard`
and `bunx turbo test:coverage --filter=@suaveplan/clipboard`.
19. Flip `@suaveplan/clipboard` `–` → `✅` in `STATUS.md`.
20. Fold `specs/clipboard/spec.md` into `openspec/specs/clipboard/spec.md`.
21. Move `openspec/changes/enterprise-clipboard/` →
`openspec/archive/2026-05-25-enterprise-clipboard/`.