Files
suaveplan/openspec/changes/qrcode-scan/specs/qrcode-scan/spec.md
2026-05-27 23:29:53 +01:00

6.5 KiB

Capability Spec Delta: qrcode-scan

Package: @suaveplan/qrcode (/scan subpath) Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions.

ADDED Requirements

Requirement: Image decode from multiple input types

scanFromImageData(input) MUST accept ImageData, Uint8Array, Blob, or File. It MUST decode the QR code matrix and return the raw payload string. If no QR code is found, QrScanError (code QRCODE.DECODE_FAILED) MUST be thrown.

Scenario: Decode from ImageData

  • WHEN scanFromImageData receives an ImageData object containing a valid QR code
  • THEN it returns the decoded string payload

Scenario: Decode from File

  • WHEN scanFromImageData receives a File object containing a valid QR code
  • THEN it returns the decoded string payload

Scenario: No QR code found

  • WHEN scanFromImageData receives an image with no QR code
  • THEN it throws QrScanError with code QRCODE.DECODE_FAILED

Verified by: src/scan/scan.test.ts::"decodes QR code from ImageData" AND src/scan/scan.test.ts::"decodes QR code from File" AND src/scan/scan.test.ts::"throws QrScanError when no QR code is found"


Requirement: Camera stream access

scanFromCameraStream(stream, options?) MUST accept a MediaStream and extract video frames at a configurable interval (default 250 ms). It MUST attempt to decode a QR code from each frame. On first successful decode, it MUST return the payload and stop frame extraction. If options.signal aborts, it MUST reject with an abort error. If the stream ends without a successful decode, it MUST throw QrScanError (code QRCODE.DECODE_FAILED).

Scenario: Successful decode from camera stream

  • WHEN scanFromCameraStream receives a stream where one frame contains a valid QR code
  • THEN it returns the payload after processing that frame

Scenario: Abort signal stops scanning

  • WHEN options.signal aborts before a QR code is found
  • THEN the promise rejects with an abort error

Scenario: Stream ends without decode

  • WHEN the stream ends and no frame contained a QR code
  • THEN it throws QrScanError with code QRCODE.DECODE_FAILED

Verified by: src/scan/scan-camera.test.ts::"returns payload when QR code appears in stream" AND src/scan/scan-camera.test.ts::"rejects when abort signal fires before decode" AND src/scan/scan-camera.test.ts::"throws QrScanError when stream ends without finding QR code"


Requirement: Injectable Clock for deterministic camera timing

scanFromCameraStream options MUST include an optional clock?: Clock. When provided, frame extraction intervals and timeout deadlines MUST be driven by the injected clock instead of the global time source.

Verified by: src/scan/scan-camera.test.ts::"uses injected clock for frame intervals" AND src/scan/scan-camera.test.ts::"uses injected clock for stream timeout"


Requirement: TOTP URI extraction

extractTotpUri(data) MUST parse otpauth://totp/ URIs per the de-facto Google Authenticator Key URI Format. It MUST return a TotpConfig with:

  • secret (required) — base32-encoded secret
  • issuer (optional) — issuer name
  • account (optional) — account name
  • algorithm (optional, default SHA1) — SHA1 | SHA256 | SHA512
  • digits (optional, default 6) — 6 | 7 | 8
  • period (optional, default 30) — seconds as integer

If secret is missing or the URI scheme is not otpauth://totp/, TotpUriParseError (code QRCODE.TOTP_PARSE_ERROR) MUST be thrown with context.rawUri populated.

Scenario: Valid TOTP URI

  • WHEN extractTotpUri receives "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example"
  • THEN it returns a TotpConfig with all fields correctly populated

Scenario: Missing secret

  • WHEN extractTotpUri receives "otpauth://totp/Example:alice@google.com?issuer=Example"
  • THEN it throws TotpUriParseError

Scenario: Invalid scheme

  • WHEN extractTotpUri receives "https://example.com"
  • THEN it throws TotpUriParseError

Verified by: src/scan/totp-extract.test.ts::"parses valid otpauth URI into TotpConfig" AND src/scan/totp-extract.test.ts::"throws TotpUriParseError when secret is missing" AND src/scan/totp-extract.test.ts::"throws TotpUriParseError for non-otpauth scheme"


Requirement: Subpath export configuration

The package.json exports map MUST include an entry for ./scan/scan so consumers can import scan functionality without pulling in generation code:

"./scan/scan": {
  "import": { "types": "./dist/scan/scan.d.ts", "default": "./dist/scan/scan.js" }
}

Scenario: Tree-shakeable import

  • WHEN a consumer imports from @suaveplan/qrcode/scan/scan
  • THEN only scan-related code is included in the bundle

Verified by: tests/dep-graph.test.ts::"scan subpath is tree-shakeable and does not import generator modules"


Requirement: Structured errors

All scan-related thrown errors MUST extend QrScanError which extends QrError. The following codes MUST be defined in the existing qrcode error code registry:

  • QRCODE.CAMERA_ACCESS_DENIED — camera permission was denied or stream could not be obtained
  • QRCODE.TOTP_PARSE_ERROR — the decoded data is not a valid otpauth://totp/ URI

QrScanError, CameraAccessError, and TotpUriParseError MUST be exported from the ./scan/scan subpath.

Verified by: src/scan/scan.spec-conformance.test.ts::"all scan errors extend QrScanError" AND src/scan/scan.spec-conformance.test.ts::"camera access error code is registered"


Requirement: Observability

The following metrics MUST be emitted via @opentelemetry/api using meter name @suaveplan/qrcode:

  • Counter qrcode.scan.total with attributes source (image / camera) and outcome (success / error / not_found)
  • Histogram qrcode.scan.duration_ms recorded on every scanFromImageData and scanFromCameraStream call

All instruments MUST be no-ops when no OTel metric exporter is configured.

Verified by: src/scan/scan.spec-conformance.test.ts::"emits scan total counter with correct attributes on success" AND src/scan/scan.spec-conformance.test.ts::"emits scan duration histogram on every call"


Requirement: 100% coverage

Vitest thresholds for the @suaveplan/qrcode package MUST remain at 100/100/100/100 after adding the scan subpath. No .skip or .only is permitted in src/scan/.

Verified by: vitest.config.ts AND src/scan/scan.test.ts (all branches covered)