diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..73c7a4e --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -0,0 +1,35 @@ +name: Verify + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: verify-${{ gitea.repository }}-${{ gitea.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CI: "true" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: Select declared npm version + run: npm install --global npm@11.17.0 + - name: Install dependencies + run: npm ci + - name: Audit runtime dependencies + run: npm audit --omit=dev --audit-level=moderate + - name: Check, test, and build + run: npm run check diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e294e..b925a79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the independently versioned Toolbox SDK packages are recorded here. The packages currently share one release version. +## 0.3.0 — 2026-09-02 + +### Added + +- Runtime-validated manifest I/O and progressive browser-capability profiles. +- Bounded, same-origin, target-routed, expiring, one-time artifact handoffs + through IndexedDB, with versioned operation evidence and opaque tokens. + +### Security + +- Reject unsafe artifact names, malformed descriptors, oversized evidence, + excessive file counts and cross-origin handoff destinations before storage or + navigation. +- Document the origin-wide IndexedDB trust boundary explicitly: target app ids + route transfers but cannot authorize mutually untrusted same-origin code. + ## 0.2.3 — 2026-07-27 ### Changed diff --git a/README.md b/README.md index c51c5b0..2358b97 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ tools. An app always works by itself. When it receives a trusted same-origin catalog URL, the same app gains a toolbox home link and an app switcher without becoming coupled to a portal or client-side router. -Version `0.2.3` contains three publish-ready packages: +Version `0.3.0` contains three publish-ready packages: - `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context discovery/loading, resolved URLs, and contextual link helpers. @@ -57,6 +57,14 @@ Deploy `toolbox-app.json` beside the application entry. The canonical schema is "indexedDb": true, "crossOriginIsolated": false }, + "io": { + "accepts": [{ "mediaType": "application/pdf", "extensions": [".pdf"] }], + "produces": [{ "mediaType": "application/pdf", "extensions": [".pdf"] }] + }, + "capabilities": { + "required": ["workers"], + "optional": ["file-system-access"] + }, "privacy": { "processing": "local", "fileUploads": false, @@ -69,12 +77,21 @@ Deploy `toolbox-app.json` beside the application entry. The canonical schema is } ``` -`source`, `privacy.label`, `privacy.url`, `requirements.topLevelContext`, -`actions`, and `assets` are optional v1 additions. `toolbox-check` verifies the -entry, icon, and every declared asset. Runtime parsers validate every known +`source`, `privacy.label`, `privacy.url`, `requirements.topLevelContext`, `io`, +`capabilities`, `actions`, and `assets` are optional v1 additions. `io` +advertises accepted and produced media types/extensions; `capabilities` +describes required and progressive browser features. `toolbox-check` verifies +the entry, icon, and every declared asset. Runtime parsers validate every known field, require `schemaVersion: 1`, and deliberately discard unknown fields so future optional additions do not break v1 consumers. +When a manifest includes `capabilities`, worker declarations are cross-checked: +`requirements.workers: true` means the app cannot run without workers and +therefore requires `"workers"` in `capabilities.required`. Apps with a +main-thread fallback set the requirement to `false` and may list `"workers"` in +`capabilities.optional` instead. Legacy v1 manifests without a capability +profile remain valid. + For typed source definitions, use the literal-preserving identity helper: ```ts @@ -88,6 +105,24 @@ export const manifest = defineToolboxApp({ Use `parseToolboxApp(unknownValue)` at trust boundaries; `defineToolboxApp()` is compile-time only and does not replace runtime parsing. +## Explicit local artifact handoff + +`createToolboxTransfer()` stores bounded `Blob` objects in same-origin IndexedDB +using a cryptographic, short-lived token routed to one target app. Only that +opaque token is added to the target URL by `createToolboxTransferUrl()`. The +target calls `consumeToolboxTransfer()` and the record is atomically deleted. +File bytes are neither uploaded nor placed in URLs, cross-origin destinations +are rejected, and transfers expire after fifteen minutes by default. File +descriptors, evidence, counts, names, sizes, and lifetimes are bounded before +IndexedDB receives them. + +The target app id is a routing/integrity check in this API, not a browser access +control boundary. IndexedDB is shared by the entire origin, so any script +running on that origin can open the transfer database directly and read or +delete its records. Deploy only mutually trusted, reviewed Toolbox apps on one +origin; an untrusted app must use a separate origin and cannot participate in +this same-origin handoff. + ## Catalog v1 The canonical schema is diff --git a/package-lock.json b/package-lock.json index 3c3d0b2..0c9d21b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@add-ideas/toolbox-sdk-workspace", - "version": "0.2.3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@add-ideas/toolbox-sdk-workspace", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -3946,7 +3946,7 @@ }, "packages/contract": { "name": "@add-ideas/toolbox-contract", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "engines": { "node": ">=20" @@ -3954,10 +3954,10 @@ }, "packages/shell-react": { "name": "@add-ideas/toolbox-shell-react", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "peerDependencies": { "react": ">=18 <20", @@ -3966,10 +3966,10 @@ }, "packages/testkit": { "name": "@add-ideas/toolbox-testkit", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "bin": { "toolbox-check": "dist/cli.js" diff --git a/package.json b/package.json index 5547591..2138a26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-sdk-workspace", - "version": "0.2.3", + "version": "0.3.0", "private": true, "description": "A small, framework-neutral toolbox contract with a React application shell.", "license": "Apache-2.0", diff --git a/packages/contract/README.md b/packages/contract/README.md index 529600b..b2a975f 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -17,6 +17,29 @@ The package also owns the versioned same-origin browser preference contract. Use `readToolboxPreferences()` and `writeToolboxPreferences()` to share pinned apps, ordering, visibility, and light/dark/system mode with the Toolbox portal. +Applications can advertise accepted and produced formats through the optional +`io` manifest profile, and required or optional browser features through +`capabilities`. These fields are runtime validated while remaining compatible +with existing v1 manifests. When the capability profile is present, a manifest +that sets `requirements.workers` to `true` must also list `"workers"` in +`capabilities.required`; progressive worker enhancements belong in +`capabilities.optional` with the requirement set to `false`. Legacy v1 manifests +without `capabilities` remain valid. + +The package also provides an explicit local artifact handoff. A sender stores +one or more bounded `Blob` objects in same-origin IndexedDB with a +cryptographic, short-lived token and a target routing label. +`createToolboxTransferUrl()` places only the opaque token in the target URL; +`consumeToolboxTransfer()` atomically consumes it once. No file bytes are put in +a URL, uploaded, or persisted after consumption. Destination URLs must remain +same-origin, and descriptors, evidence, file counts, total bytes, and lifetimes +are validated and bounded before storage. + +All code on one origin is inside the trust boundary: same-origin scripts can +open IndexedDB without using this API, so `targetAppId` is not authorization. +Host mutually untrusted apps on distinct origins; they deliberately cannot use +this transfer mechanism. + See the workspace [README](https://git.add-ideas.de/lotobo/toolbox-sdk#readme) for the v1 document formats and full API. diff --git a/packages/contract/package.json b/packages/contract/package.json index 72801f7..93bb7db 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-contract", - "version": "0.2.3", + "version": "0.3.0", "description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.", "license": "Apache-2.0", "repository": { diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 5bed3a4..5a5f03c 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -1,6 +1,8 @@ export { TOOLBOX_META_NAME, TOOLBOX_QUERY_PARAMETER, + TOOLBOX_TRANSFER_QUERY_PARAMETER, + TOOLBOX_ARTIFACT_VERSION, TOOLBOX_SCHEMA_VERSION, ToolboxError, ToolboxValidationError, @@ -26,6 +28,9 @@ export type { ToolboxDiscoverySource, ToolboxErrorCode, ToolboxIntegration, + ToolboxFormat, + ToolboxIoProfile, + ToolboxCapabilityProfile, ToolboxLaunchMode, ToolboxPrivacy, ToolboxRequirements, @@ -53,6 +58,24 @@ export { loadToolboxCatalog, loadToolboxContext, } from "./load.js"; +export { + consumeToolboxTransfer, + createToolboxTransfer, + createToolboxTransferUrl, + deleteExpiredToolboxTransfers, + readToolboxTransferToken, +} from "./transfer.js"; +export type { + ToolboxArtifactDescriptor, + ToolboxArtifactEvidence, + ToolboxArtifactFile, + ToolboxArtifactSource, + ToolboxTransfer, + ToolboxTransferCreateInput, + ToolboxTransferOptions, + ToolboxTransferStore, + ToolboxTransferUrlOptions, +} from "./transfer.js"; export { TOOLBOX_PREFERENCES_KEY, defaultToolboxPreferences, diff --git a/packages/contract/src/parse.ts b/packages/contract/src/parse.ts index 9b54683..42b1cc6 100644 --- a/packages/contract/src/parse.ts +++ b/packages/contract/src/parse.ts @@ -9,6 +9,9 @@ import { type ToolboxCatalogManifestEntry, type ToolboxCatalogTheme, type ToolboxIntegration, + type ToolboxIoProfile, + type ToolboxFormat, + type ToolboxCapabilityProfile, type ToolboxLaunchMode, type ToolboxPrivacy, type ToolboxRequirements, @@ -21,6 +24,9 @@ type UnknownRecord = Record; const ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; const WEB_PROTOCOLS = new Set(["http:", "https:"]); const REFERENCE_BASE = "https://toolbox.invalid/"; +const MEDIA_TYPE_PATTERN = + /^(?:\*|[a-z0-9!#$&^_.+-]+)\/(?:\*|[a-z0-9!#$&^_.+-]+)$/iu; +const EXTENSION_PATTERN = /^\.[a-z0-9][a-z0-9._+-]*$/iu; function recordAt( value: unknown, @@ -186,6 +192,107 @@ function stringListAt( }); } +function identifierListAt( + object: UnknownRecord, + key: string, + path: string, + issues: string[], +): readonly string[] { + const values = stringListAt(object, key, path, issues); + values.forEach((value, index) => { + if (!ID_PATTERN.test(value)) { + issues.push(`${path}.${key}[${index}] must be a lowercase toolbox id`); + } + }); + return values.filter((value) => ID_PATTERN.test(value)); +} + +function parseFormats( + value: unknown, + path: string, + issues: string[], +): readonly ToolboxFormat[] { + if (!Array.isArray(value)) { + issues.push(`${path} must be an array`); + return []; + } + const identities = new Set(); + return value.flatMap((item, index) => { + const itemPath = `${path}[${index}]`; + const object = recordAt(item, itemPath, issues); + const mediaType = stringAt(object, "mediaType", itemPath, issues); + const extensions = stringListAt(object, "extensions", itemPath, issues); + const label = stringAt(object, "label", itemPath, issues, { + optional: true, + }); + if (mediaType !== undefined && !MEDIA_TYPE_PATTERN.test(mediaType)) { + issues.push(`${itemPath}.mediaType must be an Internet media type`); + } + extensions.forEach((extension, extensionIndex) => { + if (!EXTENSION_PATTERN.test(extension)) { + issues.push( + `${itemPath}.extensions[${extensionIndex}] must start with a dot`, + ); + } + }); + if ( + mediaType === undefined || + !MEDIA_TYPE_PATTERN.test(mediaType) || + extensions.some((extension) => !EXTENSION_PATTERN.test(extension)) + ) { + return []; + } + const normalizedMediaType = mediaType.toLowerCase(); + const normalizedExtensions = extensions.map((extension) => + extension.toLowerCase(), + ); + const identity = `${normalizedMediaType}\u0000${normalizedExtensions.join(",")}`; + if (identities.has(identity)) { + issues.push(`${itemPath} duplicates an earlier format`); + return []; + } + identities.add(identity); + return [ + { + mediaType: normalizedMediaType, + extensions: normalizedExtensions, + ...(label === undefined ? {} : { label }), + }, + ]; + }); +} + +function parseIoProfile( + value: unknown, + path: string, + issues: string[], +): ToolboxIoProfile | undefined { + const object = recordAt(value, path, issues); + const accepts = parseFormats(object.accepts, `${path}.accepts`, issues); + const produces = parseFormats(object.produces, `${path}.produces`, issues); + return { accepts, produces }; +} + +function parseCapabilities( + value: unknown, + path: string, + issues: string[], +): ToolboxCapabilityProfile | undefined { + const object = recordAt(value, path, issues); + const required = identifierListAt(object, "required", path, issues); + const optional = identifierListAt(object, "optional", path, issues); + const requiredSet = new Set(required); + optional.forEach((capability, index) => { + if (requiredSet.has(capability)) { + issues.push(`${path}.optional[${index}] is already required`); + } + }); + return { + required, + optional: optional.filter((item) => !requiredSet.has(item)), + }; +} + function parsePrivacy( value: unknown, path: string, @@ -384,6 +491,25 @@ export function parseToolboxApp(value: unknown): ToolboxAppManifest { "$.requirements", issues, ); + const io = + "io" in object ? parseIoProfile(object.io, "$.io", issues) : undefined; + const capabilities = + "capabilities" in object + ? parseCapabilities(object.capabilities, "$.capabilities", issues) + : undefined; + if (requirements !== undefined && capabilities !== undefined) { + const workersRequired = capabilities.required.includes("workers"); + if (requirements.workers && !workersRequired) { + issues.push( + "$.capabilities.required must include workers when $.requirements.workers is true", + ); + } + if (!requirements.workers && workersRequired) { + issues.push( + "$.capabilities.required must not include workers when $.requirements.workers is false", + ); + } + } const privacy = parsePrivacy(object.privacy, "$.privacy", issues); const source = "source" in object @@ -424,6 +550,8 @@ export function parseToolboxApp(value: unknown): ToolboxAppManifest { tags, integration, requirements, + ...(io === undefined ? {} : { io }), + ...(capabilities === undefined ? {} : { capabilities }), privacy, ...(source === undefined ? {} : { source }), ...(actions === undefined ? {} : { actions }), diff --git a/packages/contract/src/transfer.ts b/packages/contract/src/transfer.ts new file mode 100644 index 0000000..5fdeb11 --- /dev/null +++ b/packages/contract/src/transfer.ts @@ -0,0 +1,477 @@ +import { + TOOLBOX_ARTIFACT_VERSION, + TOOLBOX_TRANSFER_QUERY_PARAMETER, +} from "./types.js"; + +const DATABASE_NAME = "add-ideas-toolbox-transfers-v1"; +const STORE_NAME = "transfers"; +const DEFAULT_TTL_MS = 15 * 60 * 1000; +const MAX_TTL_MS = 60 * 60 * 1000; +const DEFAULT_MAX_BYTES = 512 * 1024 * 1024; +const MAX_FILES = 128; +const MAX_NAME_CHARACTERS = 255; +const MAX_EVIDENCE_BYTES = 1024 * 1024; +const APP_ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{22}$/; +const MEDIA_TYPE_PATTERN = + /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(?:\s*;\s*[a-z0-9!#$&^_.+-]+=(?:[a-z0-9!#$&^_.+-]+|"[^"]*"))*$/iu; + +export interface ToolboxArtifactSource { + appId: string; + appVersion?: string; +} + +export interface ToolboxArtifactDescriptor { + name: string; + mediaType: string; + size: number; + lastModified?: number; + sha256?: string; +} + +export interface ToolboxArtifactFile extends ToolboxArtifactDescriptor { + blob: Blob; +} + +export interface ToolboxArtifactEvidence { + formatVersion: string; + operation?: string; + engine?: string; + settings?: Readonly>; + warnings?: readonly string[]; +} + +export interface ToolboxTransfer { + artifactVersion: typeof TOOLBOX_ARTIFACT_VERSION; + token: string; + source: ToolboxArtifactSource; + targetAppId: string; + createdAt: number; + expiresAt: number; + files: readonly ToolboxArtifactFile[]; + evidence?: ToolboxArtifactEvidence; +} + +export interface ToolboxTransferCreateInput { + source: ToolboxArtifactSource; + targetAppId: string; + files: readonly ToolboxArtifactFile[]; + evidence?: ToolboxArtifactEvidence; + ttlMs?: number; + maxBytes?: number; +} + +export interface ToolboxTransferStore { + put(transfer: ToolboxTransfer): Promise; + take( + token: string, + expectedTargetAppId: string, + now: number, + ): Promise; + deleteExpired(now: number): Promise; +} + +export interface ToolboxTransferOptions { + store?: ToolboxTransferStore; + indexedDB?: IDBFactory; + crypto?: Pick; + now?: () => number; +} + +export interface ToolboxTransferUrlOptions { + location?: string | URL; +} + +function openDatabase(factory: IDBFactory): Promise { + return new Promise((resolve, reject) => { + const request = factory.open(DATABASE_NAME, 1); + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(STORE_NAME)) { + const store = database.createObjectStore(STORE_NAME, { + keyPath: "token", + }); + store.createIndex("expiresAt", "expiresAt"); + } + }; + request.onerror = () => + reject(request.error ?? new Error("IndexedDB open failed")); + request.onblocked = () => reject(new Error("IndexedDB upgrade is blocked")); + request.onsuccess = () => resolve(request.result); + }); +} + +function requestResult(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onerror = () => + reject(request.error ?? new Error("IndexedDB request failed")); + request.onsuccess = () => resolve(request.result); + }); +} + +function transactionComplete(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = () => + reject(transaction.error ?? new Error("IndexedDB transaction aborted")); + transaction.onerror = () => + reject(transaction.error ?? new Error("IndexedDB transaction failed")); + }); +} + +class IndexedDbTransferStore implements ToolboxTransferStore { + constructor(private readonly factory: IDBFactory) {} + + async put(transfer: ToolboxTransfer): Promise { + const database = await openDatabase(this.factory); + try { + const transaction = database.transaction(STORE_NAME, "readwrite"); + transaction.objectStore(STORE_NAME).put(transfer); + await transactionComplete(transaction); + } finally { + database.close(); + } + } + + async take( + token: string, + expectedTargetAppId: string, + now: number, + ): Promise { + const database = await openDatabase(this.factory); + try { + const transaction = database.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const value = await requestResult(store.get(token)); + const transfer = value as ToolboxTransfer | undefined; + if ( + transfer !== undefined && + transfer.targetAppId !== expectedTargetAppId + ) { + transaction.abort(); + throw new Error( + "Artifact transfer was addressed to another application", + ); + } + if (transfer !== undefined) store.delete(token); + await transactionComplete(transaction); + return transfer === undefined || transfer.expiresAt <= now + ? undefined + : transfer; + } finally { + database.close(); + } + } + + async deleteExpired(now: number): Promise { + const database = await openDatabase(this.factory); + let count = 0; + try { + const transaction = database.transaction(STORE_NAME, "readwrite"); + const index = transaction.objectStore(STORE_NAME).index("expiresAt"); + await new Promise((resolve, reject) => { + const request = index.openCursor(IDBKeyRange.upperBound(now)); + request.onerror = () => + reject(request.error ?? new Error("IndexedDB cursor failed")); + request.onsuccess = () => { + const cursor = request.result; + if (cursor === null) { + resolve(); + return; + } + cursor.delete(); + count += 1; + cursor.continue(); + }; + }); + await transactionComplete(transaction); + return count; + } finally { + database.close(); + } + } +} + +function defaultStore(options: ToolboxTransferOptions): ToolboxTransferStore { + const factory = options.indexedDB ?? globalThis.indexedDB; + if (factory === undefined) throw new Error("IndexedDB is not available"); + return new IndexedDbTransferStore(factory); +} + +function assertAppId(value: string, label: string): void { + if (!APP_ID_PATTERN.test(value)) + throw new TypeError(`${label} is not a toolbox app id`); +} + +function normalizedFile(file: ToolboxArtifactFile): ToolboxArtifactFile { + if (!(file.blob instanceof Blob)) + throw new TypeError("Artifact file blob is invalid"); + const hasControlCharacter = Array.from(file.name).some((character) => { + const point = character.codePointAt(0)!; + return point <= 0x1f || point === 0x7f; + }); + if ( + file.name.trim() === "" || + Array.from(file.name).length > MAX_NAME_CHARACTERS || + hasControlCharacter || + file.name.includes("/") || + file.name.includes("\\") + ) { + throw new TypeError("Artifact file name is invalid"); + } + if ( + !Number.isSafeInteger(file.size) || + file.size < 0 || + file.size !== file.blob.size + ) { + throw new TypeError(`Artifact size does not match ${file.name}`); + } + if (file.sha256 !== undefined && !/^[a-f0-9]{64}$/u.test(file.sha256)) { + throw new TypeError(`Artifact SHA-256 is invalid for ${file.name}`); + } + if ( + file.lastModified !== undefined && + (!Number.isSafeInteger(file.lastModified) || file.lastModified < 0) + ) { + throw new TypeError( + `Artifact modification time is invalid for ${file.name}`, + ); + } + const mediaType = file.mediaType || "application/octet-stream"; + if (mediaType.length > 255 || !MEDIA_TYPE_PATTERN.test(mediaType)) { + throw new TypeError(`Artifact media type is invalid for ${file.name}`); + } + return { ...file, mediaType }; +} + +function normalizedEvidence( + evidence: ToolboxArtifactEvidence | undefined, +): ToolboxArtifactEvidence | undefined { + if (evidence === undefined) return undefined; + if ( + typeof evidence.formatVersion !== "string" || + evidence.formatVersion.trim() === "" || + evidence.formatVersion.length > 64 + ) { + throw new TypeError("Artifact evidence formatVersion is invalid"); + } + for (const [label, value] of [ + ["operation", evidence.operation], + ["engine", evidence.engine], + ] as const) { + if ( + value !== undefined && + (typeof value !== "string" || value.length > 256) + ) { + throw new TypeError(`Artifact evidence ${label} is invalid`); + } + } + if ( + evidence.warnings !== undefined && + (!Array.isArray(evidence.warnings) || + evidence.warnings.length > 100 || + evidence.warnings.some( + (warning) => typeof warning !== "string" || warning.length > 1_024, + )) + ) { + throw new TypeError("Artifact evidence warnings are invalid"); + } + let serialized: string; + try { + serialized = JSON.stringify(evidence); + } catch { + throw new TypeError("Artifact evidence must be JSON-serializable"); + } + if ( + serialized === undefined || + new TextEncoder().encode(serialized).byteLength > MAX_EVIDENCE_BYTES + ) { + throw new RangeError("Artifact evidence exceeds the 1 MiB limit"); + } + return evidence; +} + +function tokenFrom(random: Pick): string { + const bytes = random.getRandomValues(new Uint8Array(16)); + let binary = ""; + bytes.forEach((value) => { + binary += String.fromCharCode(value); + }); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/u, ""); +} + +function validatedStoredTransfer( + value: ToolboxTransfer, + token: string, + expectedTargetAppId: string, + now: number, +): ToolboxTransfer { + if ( + value.artifactVersion !== TOOLBOX_ARTIFACT_VERSION || + value.token !== token || + value.targetAppId !== expectedTargetAppId + ) { + throw new TypeError("Stored artifact transfer identity is invalid"); + } + if (typeof value.source?.appId !== "string") { + throw new TypeError("Stored source app id is invalid"); + } + assertAppId(value.source.appId, "Stored source app id"); + if ( + value.source.appVersion !== undefined && + (typeof value.source.appVersion !== "string" || + value.source.appVersion.trim() === "" || + value.source.appVersion.length > 64) + ) { + throw new TypeError("Stored source app version is invalid"); + } + if ( + !Number.isSafeInteger(value.createdAt) || + !Number.isSafeInteger(value.expiresAt) || + value.createdAt < 0 || + value.expiresAt <= value.createdAt || + value.expiresAt - value.createdAt > MAX_TTL_MS || + value.expiresAt <= now + ) { + throw new TypeError("Stored artifact transfer lifetime is invalid"); + } + if ( + !Array.isArray(value.files) || + value.files.length === 0 || + value.files.length > MAX_FILES + ) { + throw new RangeError("Stored artifact transfer file count is invalid"); + } + const files = value.files.map(normalizedFile); + const totalBytes = files.reduce((total, file) => total + file.size, 0); + if (!Number.isSafeInteger(totalBytes) || totalBytes > DEFAULT_MAX_BYTES) { + throw new RangeError("Stored artifact transfer exceeds the byte limit"); + } + const evidence = normalizedEvidence(value.evidence); + return { + ...value, + source: { ...value.source }, + files, + ...(evidence === undefined ? {} : { evidence }), + }; +} + +export async function createToolboxTransfer( + input: ToolboxTransferCreateInput, + options: ToolboxTransferOptions = {}, +): Promise { + assertAppId(input.source.appId, "Source app id"); + assertAppId(input.targetAppId, "Target app id"); + if ( + input.source.appVersion !== undefined && + (input.source.appVersion.trim() === "" || + input.source.appVersion.length > 64) + ) { + throw new TypeError("Source app version is invalid"); + } + if (input.files.length === 0 || input.files.length > MAX_FILES) { + throw new RangeError(`Artifact transfers require 1–${MAX_FILES} files`); + } + const files = input.files.map(normalizedFile); + const evidence = normalizedEvidence(input.evidence); + const totalBytes = files.reduce((total, file) => total + file.size, 0); + const maxBytes = input.maxBytes ?? DEFAULT_MAX_BYTES; + if ( + !Number.isSafeInteger(maxBytes) || + maxBytes <= 0 || + maxBytes > DEFAULT_MAX_BYTES + ) { + throw new RangeError("Artifact transfer byte limit is invalid"); + } + if (totalBytes > maxBytes) + throw new RangeError( + `Artifact transfer exceeds the ${maxBytes}-byte limit`, + ); + const ttlMs = input.ttlMs ?? DEFAULT_TTL_MS; + if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > MAX_TTL_MS) { + throw new RangeError(`Artifact TTL must be between 1 and ${MAX_TTL_MS} ms`); + } + const now = (options.now ?? Date.now)(); + if ( + !Number.isSafeInteger(now) || + now < 0 || + now + ttlMs > Number.MAX_SAFE_INTEGER + ) + throw new RangeError("Artifact transfer creation time is invalid"); + const random = options.crypto ?? globalThis.crypto; + if (random === undefined) + throw new Error("Cryptographic randomness is not available"); + const transfer: ToolboxTransfer = { + artifactVersion: TOOLBOX_ARTIFACT_VERSION, + token: tokenFrom(random), + source: input.source, + targetAppId: input.targetAppId, + createdAt: now, + expiresAt: now + ttlMs, + files, + ...(evidence === undefined ? {} : { evidence }), + }; + await (options.store ?? defaultStore(options)).put(transfer); + return transfer; +} + +export async function consumeToolboxTransfer( + token: string, + expectedTargetAppId: string, + options: ToolboxTransferOptions = {}, +): Promise { + if (!TOKEN_PATTERN.test(token)) + throw new TypeError("Artifact token is invalid"); + assertAppId(expectedTargetAppId, "Target app id"); + const now = (options.now ?? Date.now)(); + const transfer = await (options.store ?? defaultStore(options)).take( + token, + expectedTargetAppId, + now, + ); + if (transfer === undefined) return undefined; + return validatedStoredTransfer(transfer, token, expectedTargetAppId, now); +} + +export async function deleteExpiredToolboxTransfers( + options: ToolboxTransferOptions = {}, +): Promise { + return (options.store ?? defaultStore(options)).deleteExpired( + (options.now ?? Date.now)(), + ); +} + +export function createToolboxTransferUrl( + target: string | URL, + token: string, + options: ToolboxTransferUrlOptions = {}, +): URL { + if (!TOKEN_PATTERN.test(token)) + throw new TypeError("Artifact token is invalid"); + const location = options.location ?? globalThis.location?.href; + if (location === undefined) { + throw new Error("Current location is required for a safe artifact handoff"); + } + const base = new URL(location); + const url = new URL(target, base); + if (url.origin !== base.origin) { + throw new TypeError("Artifact transfers must remain on the current origin"); + } + url.searchParams.set(TOOLBOX_TRANSFER_QUERY_PARAMETER, token); + return url; +} + +export function readToolboxTransferToken( + location: string | URL = globalThis.location.href, +): string | undefined { + const token = new URL(location).searchParams.get( + TOOLBOX_TRANSFER_QUERY_PARAMETER, + ); + if (token === null) return undefined; + if (!TOKEN_PATTERN.test(token)) + throw new TypeError("Artifact token is invalid"); + return token; +} diff --git a/packages/contract/src/types.ts b/packages/contract/src/types.ts index 699773e..b955c24 100644 --- a/packages/contract/src/types.ts +++ b/packages/contract/src/types.ts @@ -1,6 +1,8 @@ export const TOOLBOX_SCHEMA_VERSION = 1 as const; export const TOOLBOX_QUERY_PARAMETER = "toolbox" as const; export const TOOLBOX_META_NAME = "toolbox" as const; +export const TOOLBOX_TRANSFER_QUERY_PARAMETER = "toolbox-transfer" as const; +export const TOOLBOX_ARTIFACT_VERSION = 1 as const; export interface ToolboxAction { id: string; @@ -32,6 +34,22 @@ export interface ToolboxRequirements { topLevelContext?: boolean; } +export interface ToolboxFormat { + mediaType: string; + extensions: readonly string[]; + label?: string; +} + +export interface ToolboxIoProfile { + accepts: readonly ToolboxFormat[]; + produces: readonly ToolboxFormat[]; +} + +export interface ToolboxCapabilityProfile { + required: readonly string[]; + optional: readonly string[]; +} + export interface ToolboxSource { repository: string; license: string; @@ -49,6 +67,8 @@ export interface ToolboxAppManifest { tags: readonly string[]; integration: ToolboxIntegration; requirements: ToolboxRequirements; + io?: ToolboxIoProfile; + capabilities?: ToolboxCapabilityProfile; privacy: ToolboxPrivacy; source?: ToolboxSource; actions?: readonly ToolboxAction[]; diff --git a/packages/contract/test/contract.test.ts b/packages/contract/test/contract.test.ts index c3c00a5..6bf5897 100644 --- a/packages/contract/test/contract.test.ts +++ b/packages/contract/test/contract.test.ts @@ -33,6 +33,10 @@ const app = ( indexedDb: true, crossOriginIsolated: false, }, + capabilities: { + required: ["workers"], + optional: [], + }, privacy: { processing: "local", fileUploads: false, @@ -117,6 +121,86 @@ describe("v1 runtime parsing", () => { ).toThrow(/absolute HTTP\(S\) URL/u); }); + it("normalizes declared formats and capability profiles", () => { + const parsed = parseToolboxApp( + app({ + io: { + accepts: [ + { + mediaType: "Application/PDF", + extensions: [".PDF"], + label: "PDF", + }, + ], + produces: [{ mediaType: "image/*", extensions: [".png"] }], + }, + capabilities: { + required: ["workers"], + optional: ["file-system-access"], + }, + }), + ); + expect(parsed.io?.accepts[0]).toEqual({ + mediaType: "application/pdf", + extensions: [".pdf"], + label: "PDF", + }); + expect(parsed.capabilities).toEqual({ + required: ["workers"], + optional: ["file-system-access"], + }); + }); + + it("rejects malformed formats and duplicate capabilities", () => { + expect(() => + parseToolboxApp( + app({ + io: { + accepts: [{ mediaType: "pdf", extensions: ["pdf"] }], + produces: [], + }, + }), + ), + ).toThrow(/mediaType|start with a dot/u); + expect(() => + parseToolboxApp( + app({ + capabilities: { required: ["workers"], optional: ["workers"] }, + }), + ), + ).toThrow(/already required/u); + }); + + it("keeps worker requirements and required capabilities consistent", () => { + const legacyManifest = app(); + delete legacyManifest.capabilities; + expect(parseToolboxApp(legacyManifest).requirements.workers).toBe(true); + + expect(() => + parseToolboxApp( + app({ + capabilities: { required: [], optional: ["workers"] }, + }), + ), + ).toThrow(/required must include workers.*requirements\.workers is true/u); + + expect(() => + parseToolboxApp( + app({ + requirements: { + secureContext: true, + workers: false, + indexedDb: true, + crossOriginIsolated: false, + }, + capabilities: { required: ["workers"], optional: [] }, + }), + ), + ).toThrow( + /required must not include workers.*requirements\.workers is false/u, + ); + }); + it("parses manifest references and external inline catalog entries", () => { const parsed = parseToolboxCatalog( catalog({ diff --git a/packages/contract/test/schema-parity.test.ts b/packages/contract/test/schema-parity.test.ts index e878139..1371ef4 100644 --- a/packages/contract/test/schema-parity.test.ts +++ b/packages/contract/test/schema-parity.test.ts @@ -124,6 +124,38 @@ describe("canonical schema and runtime parser parity", () => { source: { repository: "HTTPS:example.test", license: "MIT" }, }), ], + [ + "a required worker capability paired with a worker requirement", + () => ({ + ...validApp(), + requirements: { + secureContext: false, + workers: true, + indexedDb: false, + crossOriginIsolated: false, + }, + capabilities: { required: ["workers"], optional: [] }, + }), + ], + [ + "an optional worker capability without a worker requirement", + () => ({ + ...validApp(), + capabilities: { required: [], optional: ["workers"] }, + }), + ], + [ + "a legacy worker requirement without a capability profile", + () => ({ + ...validApp(), + requirements: { + secureContext: false, + workers: true, + indexedDb: false, + crossOriginIsolated: false, + }, + }), + ], ]; it.each(acceptedApps)("accepts %s", (_label, fixture) => { @@ -175,6 +207,26 @@ describe("canonical schema and runtime parser parity", () => { actions: [{ id: "docs", label: " ", url: "./docs" }], }), ], + [ + "a worker requirement without a required worker capability", + () => ({ + ...validApp(), + requirements: { + secureContext: false, + workers: true, + indexedDb: false, + crossOriginIsolated: false, + }, + capabilities: { required: [], optional: ["workers"] }, + }), + ], + [ + "a required worker capability with workers disabled", + () => ({ + ...validApp(), + capabilities: { required: ["workers"], optional: [] }, + }), + ], ]; it.each(rejectedApps)("rejects %s", (_label, fixture) => { diff --git a/packages/contract/test/transfer.test.ts b/packages/contract/test/transfer.test.ts new file mode 100644 index 0000000..bf5e565 --- /dev/null +++ b/packages/contract/test/transfer.test.ts @@ -0,0 +1,202 @@ +import { + consumeToolboxTransfer, + createToolboxTransfer, + createToolboxTransferUrl, + readToolboxTransferToken, + type ToolboxTransfer, + type ToolboxTransferStore, +} from "../src/index.js"; +import { describe, expect, it } from "vitest"; + +class MemoryStore implements ToolboxTransferStore { + readonly values = new Map(); + + async put(transfer: ToolboxTransfer): Promise { + this.values.set(transfer.token, transfer); + } + + async take( + token: string, + expectedTargetAppId: string, + now: number, + ): Promise { + const value = this.values.get(token); + if (value !== undefined && value.targetAppId !== expectedTargetAppId) { + throw new Error("Artifact transfer was addressed to another application"); + } + this.values.delete(token); + return value === undefined || value.expiresAt <= now ? undefined : value; + } + + async deleteExpired(now: number): Promise { + let count = 0; + for (const [token, value] of this.values) { + if (value.expiresAt <= now) { + this.values.delete(token); + count += 1; + } + } + return count; + } +} + +const deterministicCrypto = { + getRandomValues(array: T): T { + if (array instanceof Uint8Array) + array.forEach((_value, index) => (array[index] = index)); + return array; + }, +}; + +describe("one-time artifact transfers", () => { + it("stores, addresses and consumes an artifact exactly once", async () => { + const store = new MemoryStore(); + const blob = new Blob(["hello"], { type: "text/plain" }); + const transfer = await createToolboxTransfer( + { + source: { appId: "de.add-ideas.file-tools", appVersion: "1.0.0" }, + targetAppId: "de.add-ideas.text-tools", + files: [ + { blob, name: "hello.txt", mediaType: blob.type, size: blob.size }, + ], + }, + { store, crypto: deterministicCrypto, now: () => 1_000 }, + ); + expect(transfer.token).toHaveLength(22); + const url = createToolboxTransferUrl( + "https://tools.test/apps/text/", + transfer.token, + { location: "https://tools.test/apps/file/" }, + ); + expect(readToolboxTransferToken(url)).toBe(transfer.token); + const consumed = await consumeToolboxTransfer( + transfer.token, + "de.add-ideas.text-tools", + { store, now: () => 2_000 }, + ); + expect(await consumed?.files[0]?.blob.text()).toBe("hello"); + expect( + await consumeToolboxTransfer(transfer.token, "de.add-ideas.text-tools", { + store, + now: () => 2_000, + }), + ).toBeUndefined(); + }); + + it("rejects expired, oversized and wrongly addressed transfers", async () => { + const store = new MemoryStore(); + const blob = new Blob(["hello"]); + const transfer = await createToolboxTransfer( + { + source: { appId: "source" }, + targetAppId: "target", + ttlMs: 10, + files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }], + }, + { store, crypto: deterministicCrypto, now: () => 10 }, + ); + await expect( + consumeToolboxTransfer(transfer.token, "target", { + store, + now: () => 20, + }), + ).resolves.toBeUndefined(); + await expect( + createToolboxTransfer( + { + source: { appId: "source" }, + targetAppId: "target", + maxBytes: 4, + files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }], + }, + { store, crypto: deterministicCrypto }, + ), + ).rejects.toThrow(/exceeds/u); + await expect( + createToolboxTransfer( + { + source: { appId: "source" }, + targetAppId: "target", + maxBytes: 512 * 1024 * 1024 + 1, + files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }], + }, + { store, crypto: deterministicCrypto }, + ), + ).rejects.toThrow(/invalid/iu); + await expect( + createToolboxTransfer( + { + source: { appId: "source" }, + targetAppId: "target", + files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }], + }, + { store, crypto: deterministicCrypto, now: () => Number.NaN }, + ), + ).rejects.toThrow(/creation time/iu); + + const addressed = await createToolboxTransfer( + { + source: { appId: "source" }, + targetAppId: "target", + files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }], + }, + { store, crypto: deterministicCrypto }, + ); + await expect( + consumeToolboxTransfer(addressed.token, "another-target", { store }), + ).rejects.toThrow(/another application/u); + }); + + it("keeps opaque handoff tokens on-origin and rejects unsafe descriptors", async () => { + const store = new MemoryStore(); + const blob = new Blob(["hello"]); + await expect( + createToolboxTransfer( + { + source: { appId: "source" }, + targetAppId: "target", + files: [ + { + blob, + name: "../hello.txt", + mediaType: "text/plain", + size: blob.size, + }, + ], + }, + { store, crypto: deterministicCrypto }, + ), + ).rejects.toThrow(/name is invalid/u); + expect(() => + createToolboxTransferUrl( + "https://other.test/apps/text/", + "AAECAwQFBgcICQoLDA0ODw", + { location: "https://tools.test/apps/file/" }, + ), + ).toThrow(/current origin/u); + }); + + it("revalidates same-origin storage records at the consuming trust boundary", async () => { + const store = new MemoryStore(); + const blob = new Blob(["hello"]); + const transfer = await createToolboxTransfer( + { + source: { appId: "source" }, + targetAppId: "target", + files: [{ blob, name: "hello.txt", mediaType: "text/plain", size: 5 }], + }, + { store, crypto: deterministicCrypto, now: () => 100 }, + ); + store.values.set(transfer.token, { + ...transfer, + artifactVersion: 99 as 1, + }); + await expect( + consumeToolboxTransfer(transfer.token, "target", { + store, + now: () => 200, + }), + ).rejects.toThrow(/identity is invalid/u); + expect(store.values.has(transfer.token)).toBe(false); + }); +}); diff --git a/packages/shell-react/package.json b/packages/shell-react/package.json index 1d09c41..3a6323f 100644 --- a/packages/shell-react/package.json +++ b/packages/shell-react/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-shell-react", - "version": "0.2.3", + "version": "0.3.0", "description": "A lightweight React application shell for toolbox-compatible browser applications.", "license": "Apache-2.0", "repository": { @@ -38,7 +38,7 @@ "react-dom": ">=18 <20" }, "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "scripts": { "build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.copyFileSync('src/styles.css','dist/styles.css');fs.copyFileSync('../../LICENSE','LICENSE')\"", diff --git a/packages/shell-react/test/AppShell.test.tsx b/packages/shell-react/test/AppShell.test.tsx index b4c84bb..d5db341 100644 --- a/packages/shell-react/test/AppShell.test.tsx +++ b/packages/shell-react/test/AppShell.test.tsx @@ -38,6 +38,10 @@ const currentApp: ToolboxAppManifest = { indexedDb: true, crossOriginIsolated: false, }, + capabilities: { + required: ["workers"], + optional: [], + }, privacy: { processing: "local", fileUploads: false, @@ -184,10 +188,12 @@ describe("AppShell", () => { , ); - await waitFor(() => - expect( - document.querySelector("[data-toolbox-context='connected']"), - ).toBeInTheDocument(), + await waitFor( + () => + expect( + document.querySelector("[data-toolbox-context='connected']"), + ).toBeInTheDocument(), + { timeout: 4_000 }, ); fireEvent.click(screen.getByRole("button", { name: "Apps" })); const navigation = screen.getByRole("navigation", { diff --git a/packages/testkit/package.json b/packages/testkit/package.json index 5ba4d04..e30ce3b 100644 --- a/packages/testkit/package.json +++ b/packages/testkit/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-testkit", - "version": "0.2.3", + "version": "0.3.0", "description": "Manifest, asset, and nested-deployment smoke checks for built toolbox applications.", "license": "Apache-2.0", "repository": { @@ -37,7 +37,7 @@ }, "types": "./dist/index.d.ts", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "scripts": { "build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"", diff --git a/schemas/toolbox-app.v1.schema.json b/schemas/toolbox-app.v1.schema.json index b8a7a11..cad4ab2 100644 --- a/schemas/toolbox-app.v1.schema.json +++ b/schemas/toolbox-app.v1.schema.json @@ -116,6 +116,24 @@ }, "additionalProperties": true }, + "io": { + "type": "object", + "required": ["accepts", "produces"], + "properties": { + "accepts": { "$ref": "#/$defs/formats" }, + "produces": { "$ref": "#/$defs/formats" } + }, + "additionalProperties": true + }, + "capabilities": { + "type": "object", + "required": ["required", "optional"], + "properties": { + "required": { "$ref": "#/$defs/identifierList" }, + "optional": { "$ref": "#/$defs/identifierList" } + }, + "additionalProperties": true + }, "source": { "type": "object", "required": ["repository", "license"], @@ -157,6 +175,60 @@ "uniqueItems": true } }, + "allOf": [ + { + "if": { + "required": ["requirements", "capabilities"], + "properties": { + "requirements": { + "type": "object", + "required": ["workers"], + "properties": { "workers": { "const": true } } + } + } + }, + "then": { + "properties": { + "capabilities": { + "type": "object", + "required": ["required"], + "properties": { + "required": { + "type": "array", + "contains": { "const": "workers" } + } + } + } + } + } + }, + { + "if": { + "required": ["capabilities"], + "properties": { + "capabilities": { + "type": "object", + "required": ["required"], + "properties": { + "required": { + "type": "array", + "contains": { "const": "workers" } + } + } + } + } + }, + "then": { + "properties": { + "requirements": { + "type": "object", + "required": ["workers"], + "properties": { "workers": { "const": true } } + } + } + } + } + ], "$defs": { "urlReference": { "type": "string", @@ -218,6 +290,37 @@ "items": { "$ref": "#/$defs/nonEmptyString" } + }, + "identifierList": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$" + } + }, + "formats": { + "type": "array", + "items": { + "type": "object", + "required": ["mediaType", "extensions"], + "properties": { + "mediaType": { + "type": "string", + "pattern": "^(?:\\*|[A-Za-z0-9!#$&^_.+-]+)/(?:\\*|[A-Za-z0-9!#$&^_.+-]+)$" + }, + "extensions": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^\\.[A-Za-z0-9][A-Za-z0-9._+-]*$" + } + }, + "label": { "$ref": "#/$defs/nonEmptyString" } + }, + "additionalProperties": true + } } }, "additionalProperties": true