feat: add toolbox contract shell and testkit
This commit is contained in:
192
packages/contract/LICENSE
Normal file
192
packages/contract/LICENSE
Normal 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.
|
||||
21
packages/contract/README.md
Normal file
21
packages/contract/README.md
Normal 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`.
|
||||
48
packages/contract/package.json
Normal file
48
packages/contract/package.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
110
packages/contract/src/discovery.ts
Normal file
110
packages/contract/src/discovery.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
60
packages/contract/src/index.ts
Normal file
60
packages/contract/src/index.ts
Normal 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";
|
||||
245
packages/contract/src/load.ts
Normal file
245
packages/contract/src/load.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
528
packages/contract/src/parse.ts
Normal file
528
packages/contract/src/parse.ts
Normal 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;
|
||||
}
|
||||
178
packages/contract/src/types.ts
Normal file
178
packages/contract/src/types.ts
Normal 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 };
|
||||
131
packages/contract/src/urls.ts
Normal file
131
packages/contract/src/urls.ts
Normal 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;
|
||||
288
packages/contract/test/contract.test.ts
Normal file
288
packages/contract/test/contract.test.ts
Normal 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;
|
||||
261
packages/contract/test/schema-parity.test.ts
Normal file
261
packages/contract/test/schema-parity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
7
packages/contract/tsconfig.build.json
Normal file
7
packages/contract/tsconfig.build.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["test"]
|
||||
}
|
||||
8
packages/contract/tsconfig.json
Normal file
8
packages/contract/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user