Initial Commit
This commit is contained in:
184
openspec/archive/2026-05-25-enterprise-clipboard/design.md
Normal file
184
openspec/archive/2026-05-25-enterprise-clipboard/design.md
Normal 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.
|
||||
102
openspec/archive/2026-05-25-enterprise-clipboard/proposal.md
Normal file
102
openspec/archive/2026-05-25-enterprise-clipboard/proposal.md
Normal 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.
|
||||
@@ -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"`
|
||||
64
openspec/archive/2026-05-25-enterprise-clipboard/tasks.md
Normal file
64
openspec/archive/2026-05-25-enterprise-clipboard/tasks.md
Normal 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/`.
|
||||
Reference in New Issue
Block a user