Release Toolbox SDK 0.3.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 00:58:50 +02:00
parent ef2dab4b46
commit bc91659a42
18 changed files with 1225 additions and 21 deletions
+23
View File
@@ -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.
+1 -1
View File
@@ -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": {
+23
View File
@@ -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,
+128
View File
@@ -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<string, unknown>;
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<string>();
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 }),
+477
View File
@@ -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<Record<string, unknown>>;
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<void>;
take(
token: string,
expectedTargetAppId: string,
now: number,
): Promise<ToolboxTransfer | undefined>;
deleteExpired(now: number): Promise<number>;
}
export interface ToolboxTransferOptions {
store?: ToolboxTransferStore;
indexedDB?: IDBFactory;
crypto?: Pick<Crypto, "getRandomValues">;
now?: () => number;
}
export interface ToolboxTransferUrlOptions {
location?: string | URL;
}
function openDatabase(factory: IDBFactory): Promise<IDBDatabase> {
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<T>(request: IDBRequest<T>): Promise<T> {
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<void> {
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<void> {
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<ToolboxTransfer | undefined> {
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<number> {
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<void>((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<Crypto, "getRandomValues">): 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<ToolboxTransfer> {
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<ToolboxTransfer | undefined> {
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<number> {
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;
}
+20
View File
@@ -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[];
+84
View File
@@ -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({
@@ -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) => {
+202
View File
@@ -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<string, ToolboxTransfer>();
async put(transfer: ToolboxTransfer): Promise<void> {
this.values.set(transfer.token, transfer);
}
async take(
token: string,
expectedTargetAppId: string,
now: number,
): Promise<ToolboxTransfer | undefined> {
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<number> {
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<T extends ArrayBufferView | null>(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);
});
});
+2 -2
View File
@@ -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')\"",
+10 -4
View File
@@ -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", () => {
</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", {
+2 -2
View File
@@ -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')\"",