feat: add toolbox contract shell and testkit

This commit is contained in:
2026-07-20 17:53:56 +02:00
commit ce9bddb218
53 changed files with 8950 additions and 0 deletions

192
packages/contract/LICENSE Normal file
View File

@@ -0,0 +1,192 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
Copyright 2026 ADD Ideas
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,21 @@
# @add-ideas/toolbox-contract
Typed, runtime-validated toolbox application manifests and catalogs, plus safe
same-origin context discovery and URL resolution.
```ts
import {
loadToolboxContext,
parseToolboxApp,
} from "@add-ideas/toolbox-contract";
const app = parseToolboxApp(await response.json());
const result = await loadToolboxContext();
```
See the workspace [README](https://git.add-ideas.de/zemion/toolbox-sdk#readme)
for the v1 document formats and full API.
The canonical schemas are also published at
`@add-ideas/toolbox-contract/schemas/toolbox-app.v1.schema.json` and
`@add-ideas/toolbox-contract/schemas/toolbox-catalog.v1.schema.json`.

View File

@@ -0,0 +1,48 @@
{
"name": "@add-ideas/toolbox-contract",
"version": "0.1.0",
"description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
"directory": "packages/contract"
},
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
"keywords": [
"toolbox",
"manifest",
"catalog",
"typescript"
],
"type": "module",
"sideEffects": false,
"engines": {
"node": ">=20"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./schemas/toolbox-app.v1.schema.json": "./dist/schemas/toolbox-app.v1.schema.json",
"./schemas/toolbox-catalog.v1.schema.json": "./dist/schemas/toolbox-catalog.v1.schema.json",
"./package.json": "./package.json"
},
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.mkdirSync('dist/schemas',{recursive:true});for(const name of ['toolbox-app.v1.schema.json','toolbox-catalog.v1.schema.json'])fs.copyFileSync('../../schemas/'+name,'dist/schemas/'+name);fs.copyFileSync('../../LICENSE','LICENSE')\"",
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
"prepack": "npm run build",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"publishConfig": {
"access": "public",
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/"
}
}

View File

@@ -0,0 +1,110 @@
import {
TOOLBOX_META_NAME,
TOOLBOX_QUERY_PARAMETER,
ToolboxError,
type ToolboxDiscovery,
} from "./types.js";
import { requireSameOrigin, resolveWebUrl } from "./urls.js";
interface MetaElementLike {
getAttribute(name: string): string | null;
}
interface DocumentLike {
querySelector(selector: string): MetaElementLike | null;
}
export interface ToolboxDiscoveryOptions {
location?: string | URL;
document?: DocumentLike;
queryParameter?: string;
metaName?: string;
}
function browserLocation(): string | undefined {
return typeof globalThis.location === "undefined"
? undefined
: globalThis.location.href;
}
function browserDocument(): DocumentLike | undefined {
return typeof globalThis.document === "undefined"
? undefined
: globalThis.document;
}
export function discoverToolboxCatalog(
options: ToolboxDiscoveryOptions = {},
): ToolboxDiscovery {
const locationReference = options.location ?? browserLocation();
if (locationReference === undefined) return { status: "none" };
let location: URL;
try {
location = resolveWebUrl(locationReference);
} catch (error) {
return {
status: "error",
error:
error instanceof ToolboxError
? error
: new ToolboxError("invalid-url", "The page URL is invalid", {
cause: error,
}),
};
}
const parameter = options.queryParameter ?? TOOLBOX_QUERY_PARAMETER;
const queryValue = location.searchParams.get(parameter);
const document = options.document ?? browserDocument();
let metaValue: string | null | undefined;
try {
const metaName = (options.metaName ?? TOOLBOX_META_NAME)
.replaceAll("\\", "\\\\")
.replaceAll('"', '\\"');
metaValue = document
?.querySelector(`meta[name="${metaName}"]`)
?.getAttribute("content");
} catch (error) {
return {
status: "error",
error: new ToolboxError("invalid-url", "Toolbox meta discovery failed", {
cause: error,
}),
};
}
const candidate = queryValue ?? metaValue;
if (candidate === null || candidate === undefined) return { status: "none" };
if (candidate.trim().length === 0) {
return {
status: "error",
error: new ToolboxError(
"invalid-url",
`The ${queryValue === null ? "meta" : "query"} toolbox URL is empty`,
),
};
}
try {
const catalogUrl = requireSameOrigin(
resolveWebUrl(candidate, location),
location,
"Discovered toolbox catalog",
);
return {
status: "found",
source: queryValue === null ? "meta" : "query",
catalogUrl,
};
} catch (error) {
return {
status: "error",
error:
error instanceof ToolboxError
? error
: new ToolboxError("invalid-url", "The toolbox URL is invalid", {
cause: error,
}),
};
}
}

View File

@@ -0,0 +1,60 @@
export {
TOOLBOX_META_NAME,
TOOLBOX_QUERY_PARAMETER,
TOOLBOX_SCHEMA_VERSION,
ToolboxError,
ToolboxValidationError,
} from "./types.js";
export type {
ResolvedToolboxAction,
ResolvedToolboxApp,
ResolvedToolboxCatalog,
ResolvedToolboxCatalogEntry,
ResolvedToolboxCatalogExternalEntry,
ResolvedToolboxCatalogManifestEntry,
ResolvedToolboxPrivacy,
ToolboxAction,
ToolboxAppManifest,
ToolboxCatalog,
ToolboxCatalogEntry,
ToolboxCatalogExternalEntry,
ToolboxCatalogManifestEntry,
ToolboxCatalogTheme,
ToolboxContext,
ToolboxContextResult,
ToolboxDiscovery,
ToolboxDiscoverySource,
ToolboxErrorCode,
ToolboxIntegration,
ToolboxLaunchMode,
ToolboxPrivacy,
ToolboxRequirements,
ToolboxSource,
} from "./types.js";
export {
createContextualLink,
contextualizeToolboxLink,
requireSameOrigin,
resolveToolboxApp,
resolveWebUrl,
} from "./urls.js";
export type { ContextualLinkOptions } from "./urls.js";
export {
defineToolboxApp,
defineToolboxCatalog,
parseToolboxApp,
parseToolboxCatalog,
} from "./parse.js";
export { discoverToolboxCatalog } from "./discovery.js";
export type { ToolboxDiscoveryOptions } from "./discovery.js";
export {
fetchToolboxAppManifest,
fetchToolboxCatalog,
loadToolboxCatalog,
loadToolboxContext,
} from "./load.js";
export type {
ToolboxContextLoadOptions,
ToolboxFetch,
ToolboxLoadOptions,
} from "./load.js";

View File

@@ -0,0 +1,245 @@
import {
discoverToolboxCatalog,
type ToolboxDiscoveryOptions,
} from "./discovery.js";
import { parseToolboxApp, parseToolboxCatalog } from "./parse.js";
import {
ToolboxError,
type ResolvedToolboxCatalog,
type ToolboxAppManifest,
type ToolboxCatalog,
type ToolboxContextResult,
type ToolboxDiscoverySource,
} from "./types.js";
import { requireSameOrigin, resolveToolboxApp, resolveWebUrl } from "./urls.js";
export type ToolboxFetch = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
export interface ToolboxLoadOptions {
fetch?: ToolboxFetch;
signal?: AbortSignal;
baseUrl?: string | URL;
expectedOrigin?: string | URL;
}
function defaultFetch(): ToolboxFetch {
if (typeof globalThis.fetch !== "function") {
throw new ToolboxError(
"fetch-failed",
"No fetch implementation is available",
);
}
return globalThis.fetch.bind(globalThis);
}
function absoluteUrl(reference: string | URL, base?: string | URL): URL {
if (base !== undefined) return resolveWebUrl(reference, base);
if (typeof reference !== "string" || /^[a-z][a-z\d+.-]*:/i.test(reference)) {
return resolveWebUrl(reference);
}
if (typeof globalThis.location !== "undefined") {
return resolveWebUrl(reference, globalThis.location.href);
}
throw new ToolboxError(
"invalid-url",
`A base URL is required for relative URL: ${reference}`,
);
}
async function fetchJson(
url: URL,
options: ToolboxLoadOptions,
): Promise<unknown> {
let response: Response;
try {
response = await (options.fetch ?? defaultFetch())(url, {
headers: { accept: "application/json" },
redirect: "error",
...(options.signal === undefined ? {} : { signal: options.signal }),
});
} catch (cause) {
throw new ToolboxError("fetch-failed", `Could not fetch ${url.href}`, {
cause,
});
}
if (typeof response.url === "string" && response.url !== "") {
requireSameOrigin(
resolveWebUrl(response.url),
url,
`Final response for ${url.href}`,
);
}
if (!response.ok) {
throw new ToolboxError(
"fetch-failed",
`Could not fetch ${url.href}: HTTP ${response.status}`,
);
}
try {
return await response.json();
} catch (cause) {
throw new ToolboxError("fetch-failed", `${url.href} is not valid JSON`, {
cause,
});
}
}
export async function fetchToolboxAppManifest(
manifestUrl: string | URL,
options: ToolboxLoadOptions = {},
): Promise<ToolboxAppManifest> {
const url = absoluteUrl(manifestUrl, options.baseUrl);
if (options.expectedOrigin !== undefined) {
requireSameOrigin(
url,
absoluteUrl(options.expectedOrigin),
"Application manifest",
);
}
return parseToolboxApp(await fetchJson(url, options));
}
export async function fetchToolboxCatalog(
catalogUrl: string | URL,
options: ToolboxLoadOptions = {},
): Promise<ToolboxCatalog> {
const url = absoluteUrl(catalogUrl, options.baseUrl);
if (options.expectedOrigin !== undefined) {
requireSameOrigin(
url,
absoluteUrl(options.expectedOrigin),
"Toolbox catalog",
);
}
return parseToolboxCatalog(await fetchJson(url, options));
}
export async function loadToolboxCatalog(
catalogReference: string | URL,
options: ToolboxLoadOptions = {},
): Promise<ResolvedToolboxCatalog> {
const catalogUrl = absoluteUrl(catalogReference, options.baseUrl);
const expectedOrigin =
options.expectedOrigin === undefined
? catalogUrl
: absoluteUrl(options.expectedOrigin);
requireSameOrigin(catalogUrl, expectedOrigin, "Toolbox catalog");
const catalog = await fetchToolboxCatalog(catalogUrl, {
...options,
expectedOrigin,
});
const apps = await Promise.all(
catalog.apps
.filter((entry) => entry.enabled)
.map(async (entry) => {
if ("entry" in entry) {
return {
kind: "external" as const,
enabled: entry.enabled,
name: entry.name,
entryUrl: resolveWebUrl(entry.entry, catalogUrl),
launch: entry.launch,
};
}
const manifestUrl = requireSameOrigin(
resolveWebUrl(entry.manifest, catalogUrl),
expectedOrigin,
"Catalog application manifest",
);
const manifest = await fetchToolboxAppManifest(manifestUrl, {
...options,
expectedOrigin,
});
return {
kind: "manifest" as const,
enabled: entry.enabled,
app: resolveToolboxApp(manifest, manifestUrl, expectedOrigin),
};
}),
);
const homeUrl = requireSameOrigin(
resolveWebUrl(catalog.home, catalogUrl),
expectedOrigin,
"Toolbox home",
);
return {
catalog,
catalogUrl,
apps,
homeUrl,
};
}
export interface ToolboxContextLoadOptions
extends
ToolboxDiscoveryOptions,
Omit<ToolboxLoadOptions, "baseUrl" | "expectedOrigin"> {
catalogUrl?: string | URL;
}
function asToolboxError(error: unknown): ToolboxError {
return error instanceof ToolboxError
? error
: new ToolboxError(
"fetch-failed",
"The toolbox context could not be loaded",
{
cause: error,
},
);
}
export async function loadToolboxContext(
options: ToolboxContextLoadOptions = {},
): Promise<ToolboxContextResult> {
let source: ToolboxDiscoverySource;
let catalogUrl: URL;
let pageUrl: URL;
if (options.catalogUrl !== undefined) {
source = "explicit";
try {
pageUrl = resolveWebUrl(
options.location ??
(typeof globalThis.location === "undefined"
? options.catalogUrl
: globalThis.location.href),
);
catalogUrl = requireSameOrigin(
resolveWebUrl(options.catalogUrl, pageUrl),
pageUrl,
"Toolbox catalog",
);
} catch (error) {
return { status: "error", error: asToolboxError(error) };
}
} else {
const discovery = discoverToolboxCatalog(options);
if (discovery.status === "none") return { status: "standalone" };
if (discovery.status === "error") return discovery;
source = discovery.source;
catalogUrl = discovery.catalogUrl;
pageUrl = resolveWebUrl(
options.location ??
(typeof globalThis.location === "undefined"
? catalogUrl
: globalThis.location.href),
);
}
try {
const catalog = await loadToolboxCatalog(catalogUrl, {
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
...(options.signal === undefined ? {} : { signal: options.signal }),
expectedOrigin: pageUrl,
});
return { status: "ready", context: { source, catalog } };
} catch (error) {
return { status: "error", error: asToolboxError(error) };
}
}

View File

@@ -0,0 +1,528 @@
import {
TOOLBOX_SCHEMA_VERSION,
ToolboxValidationError,
type ToolboxAction,
type ToolboxAppManifest,
type ToolboxCatalog,
type ToolboxCatalogEntry,
type ToolboxCatalogExternalEntry,
type ToolboxCatalogManifestEntry,
type ToolboxCatalogTheme,
type ToolboxIntegration,
type ToolboxLaunchMode,
type ToolboxPrivacy,
type ToolboxRequirements,
type ToolboxSource,
} from "./types.js";
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/";
function recordAt(
value: unknown,
path: string,
issues: string[],
): UnknownRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
issues.push(`${path} must be an object`);
return {};
}
return value as UnknownRecord;
}
function stringAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
options: { optional?: boolean; allowEmpty?: boolean } = {},
): string | undefined {
if (!(key in object) && options.optional) return undefined;
const value = object[key];
if (typeof value !== "string") {
issues.push(`${path}.${key} must be a string`);
return undefined;
}
if (!options.allowEmpty && value.trim().length === 0) {
issues.push(`${path}.${key} must not be empty`);
return undefined;
}
return value;
}
function booleanAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
optional = false,
): boolean | undefined {
if (!(key in object) && optional) return undefined;
const value = object[key];
if (typeof value !== "boolean") {
issues.push(`${path}.${key} must be a boolean`);
return undefined;
}
return value;
}
function enumAt<const T extends string>(
object: UnknownRecord,
key: string,
allowed: readonly T[],
path: string,
issues: string[],
): T | undefined {
const value = stringAt(object, key, path, issues);
if (value === undefined) return undefined;
if (!allowed.includes(value as T)) {
issues.push(`${path}.${key} must be one of ${allowed.join(", ")}`);
return undefined;
}
return value as T;
}
function idAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
): string | undefined {
const value = stringAt(object, key, path, issues);
if (value !== undefined && !ID_PATTERN.test(value)) {
issues.push(`${path}.${key} must be a lowercase toolbox id`);
return undefined;
}
return value;
}
function referenceAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
optional = false,
): string | undefined {
const value = stringAt(object, key, path, issues, { optional });
if (value === undefined) return undefined;
if (value.includes("\\")) {
issues.push(`${path}.${key} must use URL path separators`);
return undefined;
}
try {
const url = new URL(value, REFERENCE_BASE);
if (!WEB_PROTOCOLS.has(url.protocol) || url.username || url.password) {
issues.push(`${path}.${key} must be an HTTP(S) URL reference`);
return undefined;
}
} catch {
issues.push(`${path}.${key} must be a valid URL reference`);
return undefined;
}
return value;
}
function absoluteReferenceAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
): string | undefined {
const value = referenceAt(object, key, path, issues);
if (value === undefined) return undefined;
try {
const url = new URL(value);
if (!WEB_PROTOCOLS.has(url.protocol) || url.username || url.password) {
throw new Error("Unsupported URL");
}
} catch {
issues.push(`${path}.${key} must be an absolute HTTP(S) URL`);
return undefined;
}
return value;
}
function schemaVersionAt(
object: UnknownRecord,
path: string,
issues: string[],
): void {
if (object.schemaVersion !== TOOLBOX_SCHEMA_VERSION) {
issues.push(`${path}.schemaVersion must be ${TOOLBOX_SCHEMA_VERSION}`);
}
}
function stringListAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
): readonly string[] {
const value = object[key];
if (!Array.isArray(value)) {
issues.push(`${path}.${key} must be an array`);
return [];
}
const seen = new Set<string>();
return value.flatMap((item, index) => {
if (typeof item !== "string" || item.trim().length === 0) {
issues.push(`${path}.${key}[${index}] must be a non-empty string`);
return [];
}
if (seen.has(item)) {
issues.push(`${path}.${key}[${index}] must be unique`);
return [];
}
seen.add(item);
return [item];
});
}
function parsePrivacy(
value: unknown,
path: string,
issues: string[],
): ToolboxPrivacy | undefined {
const object = recordAt(value, path, issues);
const processing = enumAt(
object,
"processing",
["local", "remote", "mixed"] as const,
path,
issues,
);
const fileUploads = booleanAt(object, "fileUploads", path, issues);
const telemetry = booleanAt(object, "telemetry", path, issues);
const label = stringAt(object, "label", path, issues, { optional: true });
const url = referenceAt(object, "url", path, issues, true);
if (
processing === undefined ||
fileUploads === undefined ||
telemetry === undefined
) {
return undefined;
}
return {
processing,
fileUploads,
telemetry,
...(label === undefined ? {} : { label }),
...(url === undefined ? {} : { url }),
};
}
function parseIntegration(
value: unknown,
path: string,
issues: string[],
): ToolboxIntegration | undefined {
const object = recordAt(value, path, issues);
if (object.contextVersion !== 1) {
issues.push(`${path}.contextVersion must be 1`);
}
const launchModes = object.launchModes;
const modes: ToolboxLaunchMode[] = [];
if (!Array.isArray(launchModes) || launchModes.length === 0) {
issues.push(`${path}.launchModes must be a non-empty array`);
} else {
const seen = new Set<string>();
launchModes.forEach((mode, index) => {
if (mode !== "navigate" && mode !== "new-tab") {
issues.push(`${path}.launchModes[${index}] is invalid`);
} else if (seen.has(mode)) {
issues.push(`${path}.launchModes[${index}] must be unique`);
} else {
seen.add(mode);
modes.push(mode);
}
});
}
const embedding = enumAt(
object,
"embedding",
["unsupported", "supported"] as const,
path,
issues,
);
return object.contextVersion === 1 &&
modes.length > 0 &&
embedding !== undefined
? { contextVersion: 1, launchModes: modes, embedding }
: undefined;
}
function parseRequirements(
value: unknown,
path: string,
issues: string[],
): ToolboxRequirements | undefined {
const object = recordAt(value, path, issues);
const secureContext = booleanAt(object, "secureContext", path, issues);
const workers = booleanAt(object, "workers", path, issues);
const indexedDb = booleanAt(object, "indexedDb", path, issues);
const crossOriginIsolated = booleanAt(
object,
"crossOriginIsolated",
path,
issues,
);
const topLevelContext = booleanAt(
object,
"topLevelContext",
path,
issues,
true,
);
if (
secureContext === undefined ||
workers === undefined ||
indexedDb === undefined ||
crossOriginIsolated === undefined
) {
return undefined;
}
return {
secureContext,
workers,
indexedDb,
crossOriginIsolated,
...(topLevelContext === undefined ? {} : { topLevelContext }),
};
}
function parseSource(
value: unknown,
path: string,
issues: string[],
): ToolboxSource | undefined {
const object = recordAt(value, path, issues);
const repository = absoluteReferenceAt(object, "repository", path, issues);
const license = stringAt(object, "license", path, issues);
return repository === undefined || license === undefined
? undefined
: { repository, license };
}
function parseActions(
value: unknown,
path: string,
issues: string[],
): readonly ToolboxAction[] {
if (!Array.isArray(value)) {
issues.push(`${path} must be an array`);
return [];
}
const ids = new Set<string>();
return value.flatMap((item, index) => {
const itemPath = `${path}[${index}]`;
const object = recordAt(item, itemPath, issues);
const id = idAt(object, "id", itemPath, issues);
const label = stringAt(object, "label", itemPath, issues);
const url = referenceAt(object, "url", itemPath, issues);
if (id !== undefined) {
if (ids.has(id)) issues.push(`${itemPath}.id must be unique`);
ids.add(id);
}
return id === undefined || label === undefined || url === undefined
? []
: [{ id, label, url }];
});
}
function parseAssets(
value: unknown,
path: string,
issues: string[],
): readonly string[] {
if (!Array.isArray(value)) {
issues.push(`${path} must be an array`);
return [];
}
const seen = new Set<string>();
return value.flatMap((item, index) => {
const holder = { value: item };
const reference = referenceAt(holder, "value", `${path}[${index}]`, issues);
if (reference === undefined) return [];
if (seen.has(reference)) {
issues.push(`${path}[${index}] must be unique`);
return [];
}
seen.add(reference);
return [reference];
});
}
export function parseToolboxApp(value: unknown): ToolboxAppManifest {
const issues: string[] = [];
const object = recordAt(value, "$", issues);
schemaVersionAt(object, "$", issues);
const id = idAt(object, "id", "$", issues);
const name = stringAt(object, "name", "$", issues);
const version = stringAt(object, "version", "$", issues);
const description = stringAt(object, "description", "$", issues, {
allowEmpty: true,
});
const entry = referenceAt(object, "entry", "$", issues);
const icon = referenceAt(object, "icon", "$", issues);
const categories = stringListAt(object, "categories", "$", issues);
const tags = stringListAt(object, "tags", "$", issues);
const integration = parseIntegration(
object.integration,
"$.integration",
issues,
);
const requirements = parseRequirements(
object.requirements,
"$.requirements",
issues,
);
const privacy = parsePrivacy(object.privacy, "$.privacy", issues);
const source =
"source" in object
? parseSource(object.source, "$.source", issues)
: undefined;
const actions =
"actions" in object
? parseActions(object.actions, "$.actions", issues)
: undefined;
const assets =
"assets" in object
? parseAssets(object.assets, "$.assets", issues)
: undefined;
if (
issues.length > 0 ||
id === undefined ||
name === undefined ||
version === undefined ||
description === undefined ||
entry === undefined ||
icon === undefined ||
integration === undefined ||
requirements === undefined ||
privacy === undefined
) {
throw new ToolboxValidationError("invalid-app", issues);
}
return {
schemaVersion: TOOLBOX_SCHEMA_VERSION,
id,
name,
version,
description,
entry,
icon,
categories,
tags,
integration,
requirements,
privacy,
...(source === undefined ? {} : { source }),
...(actions === undefined ? {} : { actions }),
...(assets === undefined ? {} : { assets }),
};
}
function parseCatalogEntry(
value: unknown,
path: string,
issues: string[],
): ToolboxCatalogEntry | undefined {
const object = recordAt(value, path, issues);
const enabled = booleanAt(object, "enabled", path, issues);
if ("manifest" in object) {
const manifest = referenceAt(object, "manifest", path, issues);
if ("entry" in object) {
issues.push(`${path} cannot contain both manifest and entry`);
}
return manifest === undefined || enabled === undefined
? undefined
: ({ manifest, enabled } satisfies ToolboxCatalogManifestEntry);
}
const name = stringAt(object, "name", path, issues);
const entry = referenceAt(object, "entry", path, issues);
const launch = enumAt(
object,
"launch",
["navigate", "new-tab"] as const,
path,
issues,
);
return name === undefined ||
entry === undefined ||
launch === undefined ||
enabled === undefined
? undefined
: ({ name, entry, launch, enabled } satisfies ToolboxCatalogExternalEntry);
}
function parseTheme(
value: unknown,
path: string,
issues: string[],
): ToolboxCatalogTheme | undefined {
const object = recordAt(value, path, issues);
const mode = enumAt(
object,
"mode",
["light", "dark", "system"] as const,
path,
issues,
);
const brand = stringAt(object, "brand", path, issues);
return mode === undefined || brand === undefined
? undefined
: { mode, brand };
}
export function parseToolboxCatalog(value: unknown): ToolboxCatalog {
const issues: string[] = [];
const object = recordAt(value, "$", issues);
schemaVersionAt(object, "$", issues);
const id = idAt(object, "id", "$", issues);
const name = stringAt(object, "name", "$", issues);
const home = referenceAt(object, "home", "$", issues);
const theme = parseTheme(object.theme, "$.theme", issues);
const appsValue = object.apps;
let apps: readonly ToolboxCatalogEntry[] = [];
if (!Array.isArray(appsValue)) {
issues.push("$.apps must be an array");
} else {
apps = appsValue.flatMap((entry, index) => {
const parsed = parseCatalogEntry(entry, `$.apps[${index}]`, issues);
return parsed === undefined ? [] : [parsed];
});
}
if (
issues.length > 0 ||
id === undefined ||
name === undefined ||
home === undefined ||
theme === undefined
) {
throw new ToolboxValidationError("invalid-catalog", issues);
}
return {
schemaVersion: TOOLBOX_SCHEMA_VERSION,
id,
name,
home,
theme,
apps,
};
}
export function defineToolboxApp<const T extends ToolboxAppManifest>(
app: T,
): T {
return app;
}
export function defineToolboxCatalog<const T extends ToolboxCatalog>(
catalog: T,
): T {
return catalog;
}

View File

@@ -0,0 +1,178 @@
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 interface ToolboxAction {
id: string;
label: string;
url: string;
}
export interface ToolboxPrivacy {
processing: "local" | "remote" | "mixed";
fileUploads: boolean;
telemetry: boolean;
label?: string;
url?: string;
}
export type ToolboxLaunchMode = "navigate" | "new-tab";
export interface ToolboxIntegration {
contextVersion: 1;
launchModes: readonly ToolboxLaunchMode[];
embedding: "unsupported" | "supported";
}
export interface ToolboxRequirements {
secureContext: boolean;
workers: boolean;
indexedDb: boolean;
crossOriginIsolated: boolean;
topLevelContext?: boolean;
}
export interface ToolboxSource {
repository: string;
license: string;
}
export interface ToolboxAppManifest {
schemaVersion: typeof TOOLBOX_SCHEMA_VERSION;
id: string;
name: string;
version: string;
description: string;
entry: string;
icon: string;
categories: readonly string[];
tags: readonly string[];
integration: ToolboxIntegration;
requirements: ToolboxRequirements;
privacy: ToolboxPrivacy;
source?: ToolboxSource;
actions?: readonly ToolboxAction[];
assets?: readonly string[];
}
export interface ToolboxCatalogManifestEntry {
manifest: string;
enabled: boolean;
}
export interface ToolboxCatalogExternalEntry {
name: string;
entry: string;
launch: ToolboxLaunchMode;
enabled: boolean;
}
export type ToolboxCatalogEntry =
ToolboxCatalogManifestEntry | ToolboxCatalogExternalEntry;
export interface ToolboxCatalogTheme {
mode: "light" | "dark" | "system";
brand: string;
}
export interface ToolboxCatalog {
schemaVersion: typeof TOOLBOX_SCHEMA_VERSION;
id: string;
name: string;
home: string;
theme: ToolboxCatalogTheme;
apps: readonly ToolboxCatalogEntry[];
}
export interface ResolvedToolboxAction extends Omit<ToolboxAction, "url"> {
url: URL;
}
export interface ResolvedToolboxPrivacy extends Omit<ToolboxPrivacy, "url"> {
url?: URL;
}
export interface ResolvedToolboxApp {
manifest: ToolboxAppManifest;
manifestUrl: URL;
entryUrl: URL;
actions: readonly ResolvedToolboxAction[];
assetUrls: readonly URL[];
iconUrl: URL;
privacy: ResolvedToolboxPrivacy;
}
export interface ResolvedToolboxCatalogManifestEntry {
kind: "manifest";
enabled: boolean;
app: ResolvedToolboxApp;
}
export interface ResolvedToolboxCatalogExternalEntry {
kind: "external";
enabled: boolean;
name: string;
entryUrl: URL;
launch: ToolboxLaunchMode;
}
export type ResolvedToolboxCatalogEntry =
ResolvedToolboxCatalogManifestEntry | ResolvedToolboxCatalogExternalEntry;
export interface ResolvedToolboxCatalog {
catalog: ToolboxCatalog;
catalogUrl: URL;
apps: readonly ResolvedToolboxCatalogEntry[];
homeUrl: URL;
}
export type ToolboxDiscoverySource = "query" | "meta" | "explicit";
export interface ToolboxContext {
source: ToolboxDiscoverySource;
catalog: ResolvedToolboxCatalog;
}
export type ToolboxErrorCode =
| "invalid-app"
| "invalid-catalog"
| "invalid-url"
| "cross-origin"
| "fetch-failed";
export class ToolboxError extends Error {
readonly code: ToolboxErrorCode;
constructor(code: ToolboxErrorCode, message: string, options?: ErrorOptions) {
super(message, options);
this.name = "ToolboxError";
this.code = code;
}
}
export class ToolboxValidationError extends ToolboxError {
readonly issues: readonly string[];
constructor(
code: Extract<ToolboxErrorCode, "invalid-app" | "invalid-catalog">,
issues: readonly string[],
) {
super(code, `Invalid toolbox document: ${issues.join("; ")}`);
this.name = "ToolboxValidationError";
this.issues = issues;
}
}
export type ToolboxDiscovery =
| { status: "none" }
| {
status: "found";
source: Exclude<ToolboxDiscoverySource, "explicit">;
catalogUrl: URL;
}
| { status: "error"; error: ToolboxError };
export type ToolboxContextResult =
| { status: "standalone" }
| { status: "ready"; context: ToolboxContext }
| { status: "error"; error: ToolboxError };

View File

@@ -0,0 +1,131 @@
import {
TOOLBOX_QUERY_PARAMETER,
ToolboxError,
type ResolvedToolboxAction,
type ResolvedToolboxApp,
type ResolvedToolboxPrivacy,
type ToolboxAppManifest,
} from "./types.js";
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
export function resolveWebUrl(
reference: string | URL,
base?: string | URL,
): URL {
let url: URL;
try {
url = base === undefined ? new URL(reference) : new URL(reference, base);
} catch (cause) {
throw new ToolboxError("invalid-url", `Invalid URL: ${String(reference)}`, {
cause,
});
}
if (!WEB_PROTOCOLS.has(url.protocol) || url.username || url.password) {
throw new ToolboxError(
"invalid-url",
`Only credential-free HTTP(S) URLs are supported: ${url.href}`,
);
}
return url;
}
export function requireSameOrigin(
url: URL,
expected: URL,
label = "Toolbox URL",
): URL {
if (url.origin !== expected.origin) {
throw new ToolboxError(
"cross-origin",
`${label} must share the page origin (${expected.origin})`,
);
}
return url;
}
export function resolveToolboxApp(
manifest: ToolboxAppManifest,
manifestUrl: string | URL,
expectedOrigin?: string | URL,
): ResolvedToolboxApp {
const resolvedManifestUrl = resolveWebUrl(manifestUrl);
const origin =
expectedOrigin === undefined
? resolvedManifestUrl
: resolveWebUrl(expectedOrigin);
requireSameOrigin(resolvedManifestUrl, origin, "Application manifest");
const localUrl = (reference: string, label: string): URL =>
requireSameOrigin(
resolveWebUrl(reference, resolvedManifestUrl),
origin,
label,
);
const externalUrl = (reference: string): URL =>
resolveWebUrl(reference, resolvedManifestUrl);
const actions: readonly ResolvedToolboxAction[] = (
manifest.actions ?? []
).map((action) => ({
...action,
url: externalUrl(action.url),
}));
const privacy: ResolvedToolboxPrivacy = {
processing: manifest.privacy.processing,
fileUploads: manifest.privacy.fileUploads,
telemetry: manifest.privacy.telemetry,
...(manifest.privacy.label === undefined
? {}
: { label: manifest.privacy.label }),
...(manifest.privacy.url === undefined
? {}
: { url: externalUrl(manifest.privacy.url) }),
};
return {
manifest,
manifestUrl: resolvedManifestUrl,
entryUrl: localUrl(manifest.entry, "Application entry"),
actions,
assetUrls: (manifest.assets ?? []).map((asset) =>
localUrl(asset, "Application asset"),
),
iconUrl: localUrl(manifest.icon, "Application icon"),
privacy,
};
}
export interface ContextualLinkOptions {
location?: string | URL;
parameter?: string;
}
export function contextualizeToolboxLink(
target: string | URL,
catalog: string | URL,
options: ContextualLinkOptions = {},
): string {
const location = resolveWebUrl(
options.location ??
(typeof globalThis.location === "undefined"
? "http://localhost/"
: globalThis.location.href),
);
const targetUrl = resolveWebUrl(target, location);
if (targetUrl.origin !== location.origin) return targetUrl.href;
const catalogUrl = requireSameOrigin(
resolveWebUrl(catalog, location),
location,
"Toolbox catalog",
);
targetUrl.searchParams.set(
options.parameter ?? TOOLBOX_QUERY_PARAMETER,
catalogUrl.href,
);
return targetUrl.href;
}
export const createContextualLink = contextualizeToolboxLink;

View File

@@ -0,0 +1,288 @@
import {
ToolboxValidationError,
contextualizeToolboxLink,
discoverToolboxCatalog,
loadToolboxContext,
parseToolboxApp,
parseToolboxCatalog,
type ToolboxAppManifest,
} from "../src/index.js";
import { describe, expect, it, vi } from "vitest";
const app = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
schemaVersion: 1,
id: "de.add-ideas.pdf-tools",
name: "PDF Workbench",
version: "1.2.3",
description: "Local PDF tools",
entry: "./index.html",
icon: "./icon.svg",
categories: ["documents", "pdf"],
tags: ["merge", "split"],
integration: {
contextVersion: 1,
launchModes: ["navigate", "new-tab"],
embedding: "unsupported",
},
requirements: {
secureContext: true,
workers: true,
indexedDb: true,
crossOriginIsolated: false,
},
privacy: {
processing: "local",
fileUploads: false,
telemetry: false,
},
source: {
repository: "https://git.example.test/pdf-tools",
license: "AGPL-3.0-only",
},
...overrides,
});
const catalog = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
schemaVersion: 1,
id: "de.add-ideas.toolbox",
name: "Toolbox",
home: "./",
theme: { mode: "system", brand: "add·ideas" },
apps: [{ manifest: "./pdf/toolbox-app.json", enabled: true }],
...overrides,
});
const json = (value: unknown, init: ResponseInit = {}): Response =>
new Response(JSON.stringify(value), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
describe("v1 runtime parsing", () => {
it("parses the full app contract and drops unknown optional fields", () => {
const parsed = parseToolboxApp(
app({
futureField: "ignored",
privacy: {
processing: "local",
fileUploads: false,
telemetry: false,
futurePrivacyField: true,
},
}),
);
expect(parsed).toMatchObject({
schemaVersion: 1,
id: "de.add-ideas.pdf-tools",
privacy: { processing: "local", fileUploads: false, telemetry: false },
});
expect(parsed).not.toHaveProperty("futureField");
expect(parsed.privacy).not.toHaveProperty("futurePrivacyField");
});
it("rejects unsupported versions and malformed known optional fields", () => {
expect(() => parseToolboxApp(app({ schemaVersion: 2 }))).toThrow(
ToolboxValidationError,
);
expect(() =>
parseToolboxApp(
app({
requirements: {
secureContext: true,
workers: true,
indexedDb: true,
crossOriginIsolated: false,
topLevelContext: "sometimes",
},
}),
),
).toThrow(/topLevelContext must be a boolean/u);
});
it("accepts omitted source metadata but validates it when supplied", () => {
const withoutSource = app();
delete withoutSource.source;
expect(parseToolboxApp(withoutSource)).not.toHaveProperty("source");
expect(() =>
parseToolboxApp(
app({ source: { repository: "../repository", license: "MIT" } }),
),
).toThrow(/absolute HTTP\(S\) URL/u);
});
it("parses manifest references and external inline catalog entries", () => {
const parsed = parseToolboxCatalog(
catalog({
futureField: 1,
apps: [
{ manifest: "./pdf/toolbox-app.json", enabled: true, future: true },
{
name: "External docs",
entry: "https://docs.example.test/",
launch: "new-tab",
enabled: true,
},
],
}),
);
expect(parsed.apps).toHaveLength(2);
expect(parsed.apps[1]).toEqual({
name: "External docs",
entry: "https://docs.example.test/",
launch: "new-tab",
enabled: true,
});
expect(parsed).not.toHaveProperty("futureField");
});
});
describe("same-origin discovery", () => {
it("prefers a same-origin query context over meta context", () => {
const result = discoverToolboxCatalog({
location: "https://tools.example.test/pdf/?toolbox=%2Fcatalog.json",
document: {
querySelector: () => ({
getAttribute: () => "/meta-catalog.json",
}),
},
});
expect(result.status).toBe("found");
if (result.status === "found") {
expect(result.source).toBe("query");
expect(result.catalogUrl.href).toBe(
"https://tools.example.test/catalog.json",
);
}
});
it("rejects a cross-origin query without falling through to meta", () => {
const result = discoverToolboxCatalog({
location:
"https://tools.example.test/pdf/?toolbox=https%3A%2F%2Fevil.test%2Fcatalog.json",
document: {
querySelector: () => ({ getAttribute: () => "/safe.json" }),
},
});
expect(result.status).toBe("error");
if (result.status === "error")
expect(result.error.code).toBe("cross-origin");
});
});
describe("loading and links", () => {
it("loads a catalog, resolves manifests and URLs, and supports external apps", async () => {
const fetch = vi.fn(
async (input: string | URL | Request, init?: RequestInit) => {
void init;
const url = new URL(input instanceof Request ? input.url : input);
if (url.pathname === "/catalog.json") {
return json(
catalog({
apps: [
{ manifest: "./pdf/toolbox-app.json", enabled: true },
{ manifest: "./disabled/toolbox-app.json", enabled: false },
{
name: "Documentation",
entry: "https://docs.example.test/",
launch: "new-tab",
enabled: true,
},
],
}),
);
}
if (url.pathname === "/pdf/toolbox-app.json") return json(app());
if (url.pathname === "/disabled/toolbox-app.json") {
throw new Error("Disabled manifests must not be fetched");
}
return new Response("Not found", { status: 404 });
},
);
const result = await loadToolboxContext({
location: "https://tools.example.test/pdf/?toolbox=/catalog.json",
fetch,
});
for (const [, init] of fetch.mock.calls) {
expect(init).toMatchObject({ redirect: "error" });
}
expect(result.status).toBe("ready");
if (result.status === "ready") {
expect(result.context.catalog.homeUrl.href).toBe(
"https://tools.example.test/",
);
expect(result.context.catalog.apps[0]?.kind).toBe("manifest");
const first = result.context.catalog.apps[0];
if (first?.kind === "manifest") {
expect(first.app.entryUrl.href).toBe(
"https://tools.example.test/pdf/index.html",
);
}
expect(result.context.catalog.apps[1]).toMatchObject({
kind: "external",
launch: "new-tab",
});
}
});
it("returns a graceful error result for invalid JSON and fetch failure", async () => {
const invalid = await loadToolboxContext({
location: "https://tools.example.test/?toolbox=/catalog.json",
fetch: async () => json(catalog({ schemaVersion: 2 })),
});
expect(invalid.status).toBe("error");
const unavailable = await loadToolboxContext({
location: "https://tools.example.test/?toolbox=/catalog.json",
fetch: async () => new Response("No", { status: 503 }),
});
expect(unavailable.status).toBe("error");
if (unavailable.status === "error") {
expect(unavailable.error.code).toBe("fetch-failed");
}
});
it("rejects a fetch redirected to a cross-origin response URL", async () => {
const response = json(catalog());
Object.defineProperty(response, "url", {
value: "https://evil.example/toolbox.catalog.json",
});
const result = await loadToolboxContext({
location: "https://tools.example.test/?toolbox=/catalog.json",
fetch: async () => response,
});
expect(result.status).toBe("error");
if (result.status === "error")
expect(result.error.code).toBe("cross-origin");
});
it("adds context to same-origin links but leaves external links alone", () => {
expect(
new URL(
contextualizeToolboxLink("./other/", "/catalog.json", {
location: "https://tools.example.test/pdf/",
}),
).searchParams.get("toolbox"),
).toBe("https://tools.example.test/catalog.json");
expect(
contextualizeToolboxLink("https://docs.example.test/", "/catalog.json", {
location: "https://tools.example.test/pdf/",
}),
).toBe("https://docs.example.test/");
});
});
export const validAppFixture = app() as unknown as ToolboxAppManifest;

View File

@@ -0,0 +1,261 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { parseToolboxApp, parseToolboxCatalog } from "../src/index.js";
const appSchema = JSON.parse(
readFileSync(
new URL("../../../schemas/toolbox-app.v1.schema.json", import.meta.url),
"utf8",
),
) as object;
const catalogSchema = JSON.parse(
readFileSync(
new URL("../../../schemas/toolbox-catalog.v1.schema.json", import.meta.url),
"utf8",
),
) as object;
const ajv = new Ajv2020({
allErrors: true,
strict: true,
// `not: { required: [...] }` intentionally names the opposite union arm.
strictRequired: false,
});
const validateApp = ajv.compile(appSchema);
const validateCatalog = ajv.compile(catalogSchema);
type Parser = (value: unknown) => unknown;
function runtimeAccepts(parser: Parser, value: unknown): boolean {
try {
parser(value);
return true;
} catch {
return false;
}
}
function expectParity(
validate: ValidateFunction,
parser: Parser,
value: unknown,
expected: boolean,
): void {
const schemaAccepted = validate(value);
expect(
schemaAccepted,
ajv.errorsText(validate.errors, { separator: "\n" }),
).toBe(expected);
expect(runtimeAccepts(parser, value)).toBe(expected);
}
const validApp = (): Record<string, unknown> => ({
schemaVersion: 1,
id: "de.add-ideas.example",
name: "Example",
version: "1.2.3",
description: "Example application",
entry: "./",
icon: "./icon.svg",
categories: ["documents"],
tags: ["example"],
integration: {
contextVersion: 1,
launchModes: ["navigate"],
embedding: "unsupported",
},
requirements: {
secureContext: false,
workers: false,
indexedDb: false,
crossOriginIsolated: false,
},
privacy: {
processing: "local",
fileUploads: false,
telemetry: false,
},
source: {
repository: "https://example.test/source",
license: "MIT",
},
});
const validCatalog = (): Record<string, unknown> => ({
schemaVersion: 1,
id: "de.add-ideas.toolbox",
name: "Example Toolbox",
home: "./",
theme: { mode: "system", brand: "Example" },
apps: [{ manifest: "./apps/example/toolbox-app.json", enabled: true }],
});
describe("canonical schema and runtime parser parity", () => {
const acceptedApps: Array<[string, () => Record<string, unknown>]> = [
["the baseline app", validApp],
[
"relative references containing spaces and path @ signs",
() => ({
...validApp(),
entry: " ./nested app/?mode=1#result ",
actions: [{ id: "docs", label: "Docs", url: "./people/@me" }],
}),
],
[
"case-insensitive and compact absolute HTTP URLs",
() => ({
...validApp(),
source: { repository: "HTTPS:example.test", license: "MIT" },
}),
],
];
it.each(acceptedApps)("accepts %s", (_label, fixture) => {
expectParity(validateApp, parseToolboxApp, fixture(), true);
});
const rejectedApps: Array<[string, () => Record<string, unknown>]> = [
["a whitespace-only name", () => ({ ...validApp(), name: " \t " })],
[
"a whitespace-only list item",
() => ({ ...validApp(), categories: ["documents", " \n "] }),
],
[
"a non-web entry scheme",
() => ({ ...validApp(), entry: "javascript:alert(1)" }),
],
["a backslash path", () => ({ ...validApp(), icon: ".\\icon.svg" })],
["an empty HTTP authority", () => ({ ...validApp(), entry: "https://" })],
[
"a credential-bearing privacy URL",
() => ({
...validApp(),
privacy: {
processing: "local",
fileUploads: false,
telemetry: false,
url: "https://user:secret@example.test/privacy",
},
}),
],
[
"a relative source repository",
() => ({
...validApp(),
source: { repository: "../source", license: "MIT" },
}),
],
[
"a compact credential-bearing source repository",
() => ({
...validApp(),
source: { repository: "https:user@example.test", license: "MIT" },
}),
],
[
"a whitespace-only action label",
() => ({
...validApp(),
actions: [{ id: "docs", label: " ", url: "./docs" }],
}),
],
];
it.each(rejectedApps)("rejects %s", (_label, fixture) => {
expectParity(validateApp, parseToolboxApp, fixture(), false);
});
const acceptedCatalogs: Array<[string, () => Record<string, unknown>]> = [
["the baseline catalog", validCatalog],
[
"an uppercase HTTPS manifest URL",
() => ({
...validCatalog(),
apps: [
{
manifest: "HTTPS://example.test/apps/example/toolbox-app.json",
enabled: true,
},
],
}),
],
];
it.each(acceptedCatalogs)("accepts %s", (_label, fixture) => {
expectParity(validateCatalog, parseToolboxCatalog, fixture(), true);
});
const rejectedCatalogs: Array<[string, () => Record<string, unknown>]> = [
[
"a whitespace-only catalog name",
() => ({ ...validCatalog(), name: " " }),
],
[
"a whitespace-only brand",
() => ({
...validCatalog(),
theme: { mode: "system", brand: "\t" },
}),
],
[
"a non-web home URL",
() => ({ ...validCatalog(), home: "file:///tmp/toolbox" }),
],
[
"a backslash manifest path",
() => ({
...validCatalog(),
apps: [{ manifest: ".\\apps\\toolbox-app.json", enabled: true }],
}),
],
[
"a credential-bearing manifest URL",
() => ({
...validCatalog(),
apps: [
{
manifest: "https://user:secret@example.test/toolbox-app.json",
enabled: true,
},
],
}),
],
[
"a non-web external entry",
() => ({
...validCatalog(),
apps: [
{
name: "External",
entry: "data:text/html,hello",
launch: "new-tab",
enabled: true,
},
],
}),
],
[
"a whitespace-only external name",
() => ({
...validCatalog(),
apps: [
{
name: " ",
entry: "https://example.test/",
launch: "new-tab",
enabled: true,
},
],
}),
],
];
it.each(rejectedCatalogs)("rejects %s", (_label, fixture) => {
expectParity(validateCatalog, parseToolboxCatalog, fixture(), false);
});
});

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false
},
"exclude": ["test"]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}