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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
coverage/
*.tsbuildinfo
.DS_Store

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
dist
coverage
node_modules
package-lock.json

4
.prettierrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"proseWrap": "always",
"trailingComma": "all"
}

21
CHANGELOG.md Normal file
View File

@@ -0,0 +1,21 @@
# Changelog
All notable changes to the independently versioned Toolbox SDK packages are
recorded here. The packages currently share one release version.
## 0.1.0 — 2026-07-20
### Added
- `@add-ideas/toolbox-contract` with v1 app and catalog types, published JSON
Schemas, strict forward-compatible runtime parsing, same-origin context
discovery, hydrated catalog loading, resolved URL helpers, and graceful result
types.
- `@add-ideas/toolbox-shell-react` with the standalone-safe `AppShell`, built-in
light/dark/system theme, app metadata and privacy display, actions, toolbox
home, and accessible full-page app switching.
- `@add-ideas/toolbox-testkit` with the `toolbox-check` CLI for manifest/assets,
unsafe-path detection, nested-prefix resource checks, and standalone/context
HTTP smoke tests.
- React integration and manifest/catalog examples, Vitest/jsdom coverage, and
workspace build, typecheck, lint, formatting, and package scripts.

192
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.

231
README.md Normal file
View File

@@ -0,0 +1,231 @@
# Toolbox SDK
Toolbox SDK defines a small, versioned contract for independently deployed web
tools. An app always works by itself. When it receives a trusted same-origin
catalog URL, the same app gains a toolbox home link and an app switcher without
becoming coupled to a portal or client-side router.
Version `0.1.0` contains three publish-ready packages:
- `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context
discovery/loading, resolved URLs, and contextual link helpers.
- `@add-ideas/toolbox-shell-react``AppShell` plus the v1 CSS theme.
- `@add-ideas/toolbox-testkit` — the `toolbox-check <dist>` build validation and
nested-path smoke-test CLI.
The project is licensed under Apache-2.0. That permissive license was chosen in
particular for its explicit patent grant.
## Install
The repository includes a token-free `.npmrc` pointing the `@add-ideas` scope at
the ADD Ideas package registry. Supply registry credentials through your normal
user/CI npm configuration; never add them to this repository.
```sh
npm install @add-ideas/toolbox-contract @add-ideas/toolbox-shell-react
npm install --save-dev @add-ideas/toolbox-testkit
```
React and ReactDOM are peer dependencies of the shell (`>=18 <20`).
## Application manifest v1
Deploy `toolbox-app.json` beside the application entry. The canonical schema is
[`schemas/toolbox-app.v1.schema.json`](./schemas/toolbox-app.v1.schema.json).
```json
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.pdf-tools",
"name": "PDF Workbench",
"version": "0.3.4",
"description": "Page-level PDF operations performed locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["documents", "pdf"],
"tags": ["merge", "split", "rotate"],
"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.add-ideas.de/zemion/pdf-tools",
"license": "AGPL-3.0-only"
}
}
```
`source`, `privacy.label`, `privacy.url`, `requirements.topLevelContext`,
`actions`, and `assets` are optional v1 additions. `toolbox-check` verifies the
entry, icon, and every declared asset. Runtime parsers validate every known
field, require `schemaVersion: 1`, and deliberately discard unknown fields so
future optional additions do not break v1 consumers.
For typed source definitions, use the literal-preserving identity helper:
```ts
import { defineToolboxApp } from "@add-ideas/toolbox-contract";
export const manifest = defineToolboxApp({
// The same fields as toolbox-app.json.
});
```
Use `parseToolboxApp(unknownValue)` at trust boundaries; `defineToolboxApp()` is
compile-time only and does not replace runtime parsing.
## Catalog v1
The canonical schema is
[`schemas/toolbox-catalog.v1.schema.json`](./schemas/toolbox-catalog.v1.schema.json).
A catalog owns its home and theme. Manifest references resolve relative to the
catalog. An external entry may be declared inline when no toolbox manifest is
available.
```json
{
"schemaVersion": 1,
"id": "de.add-ideas.toolbox",
"name": "add·ideas Toolbox",
"home": "./",
"theme": { "mode": "system", "brand": "add·ideas" },
"apps": [
{ "manifest": "./apps/pdf/toolbox-app.json", "enabled": true },
{
"name": "Documentation",
"entry": "https://docs.example.org/",
"launch": "new-tab",
"enabled": true
}
]
}
```
Disabled entries remain in the parsed catalog but are not fetched or exposed in
the resolved app list. `loadToolboxCatalog()` fetches enabled manifests and
returns resolved manifest, entry, icon, asset, action, privacy, home, and
external-entry URLs.
## Context discovery and security
An app discovers context in this order:
1. `?toolbox=/toolbox.catalog.json`
2. `<meta name="toolbox" content="/toolbox.catalog.json">`
3. standalone mode when neither exists
The query parameter takes precedence. Discovered catalog URLs and their manifest
documents must share the current page origin; cross-origin values are rejected
before fetch. Credential-bearing and non-HTTP(S) URLs are rejected. Unknown,
invalid, or unavailable context is returned as a discriminated error, not an
uncaught rejection:
```ts
import { loadToolboxContext } from "@add-ideas/toolbox-contract";
const result = await loadToolboxContext();
if (result.status === "ready") {
console.log(result.context.catalog.apps);
} else if (result.status === "standalone") {
console.log("No toolbox requested");
} else {
console.warn(result.error.code, result.error.message);
}
```
`contextualizeToolboxLink(target, catalog, { location })` (also exported as
`createContextualLink`) adds the context parameter only to same-origin targets.
External targets are resolved but never decorated with the context URL.
## React shell
```tsx
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
<AppShell
app={manifest}
manifestUrl="/toolbox-app.json"
appActions={<a href="./help/">Help</a>}
onContextError={(error) => console.warn(error)}
>
<Application />
</AppShell>;
```
The shell always renders app identity as an `h1`, version, derived privacy
facts, manifest actions, and `appActions`. With valid context it additionally
renders the catalog brand/home and a switcher at the accessible navigation
landmark `Toolbox applications`. Destinations are ordinary links, so switching
performs full-page navigation. On missing, cross-origin, invalid, or unavailable
context the shell quietly remains usable in standalone mode; `onContextError` is
optional observability.
CSS custom properties prefixed with `--toolbox-` are the v1 theme surface. The
catalog `light`, `dark`, or `system` mode selects the built-in palette.
## Validate a build
```json
{
"scripts": {
"toolbox:check": "toolbox-check dist"
}
}
```
`toolbox-check` strictly parses `dist/toolbox-app.json`, checks its id and
SemVer version, rejects absolute/traversing/escaping local asset paths, verifies
the entry/icon/assets, serves the build from a deep nested prefix, and
smoke-fetches both standalone and `?toolbox=/toolbox.catalog.json` modes. It
also follows local script, stylesheet, image, icon, and web-manifest references
in the entry HTML (including web-manifest icons), rejecting root-absolute
references that would break a nested deployment. It uses Node fetch and a
temporary in-process HTTP server; browser behavior is covered by the shell's
jsdom tests.
## Contract API
The main exports are:
- Definitions: `ToolboxAppManifest`, `ToolboxCatalog`, their nested and resolved
types, `defineToolboxApp()`, and `defineToolboxCatalog()`.
- Validation: `parseToolboxApp()`, `parseToolboxCatalog()`,
`ToolboxValidationError`, and `ToolboxError`.
- Discovery/loading: `discoverToolboxCatalog()`, `fetchToolboxAppManifest()`,
`fetchToolboxCatalog()`, `loadToolboxCatalog()`, and `loadToolboxContext()`.
- URLs: `resolveWebUrl()`, `resolveToolboxApp()`, `requireSameOrigin()`, and
`contextualizeToolboxLink()`.
See each emitted `.d.ts` file for the complete signatures.
## Develop
Requires Node 20 or newer.
```sh
npm install
npm run check
```
`npm run check` runs strict TypeScript checks, ESLint, Prettier verification,
Vitest/jsdom tests, and all package builds. The workspace uses current versions
aligned with the consuming apps: React 19.2, TypeScript 6, Vitest 4, and
ESLint 10.
Package publication is intentionally separate from the build. Authenticate in
the caller environment, then use npm workspace publishing commands after review.

31
eslint.config.js Normal file
View File

@@ -0,0 +1,31 @@
import eslint from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["**/dist/**", "**/coverage/**", "**/node_modules/**"],
},
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
"@typescript-eslint/consistent-type-imports": "error",
},
},
{
files: ["**/*.tsx"],
plugins: {
"react-hooks": reactHooks,
},
rules: reactHooks.configs.recommended.rules,
},
);

View File

@@ -0,0 +1,15 @@
# React integration example
The source files show the intended integration points for an existing React
application. Copy `public/toolbox-app.json` into the build unchanged (or inject
the package version during the build), import the shell CSS once, and wrap the
application in `AppShell`.
In the built application, run:
```sh
toolbox-check dist
```
The adjacent example catalog is illustrative. A production catalog normally
lives at the toolbox home and points to each deployed app manifest.

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Example Tool">
<rect width="64" height="64" rx="14" fill="#3156d3" />
<path d="M18 20h28v7H18zm0 12h28v7H18zm0 12h18v7H18z" fill="#fff" />
</svg>

After

Width:  |  Height:  |  Size: 233 B

View File

@@ -0,0 +1,32 @@
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.example-tool",
"name": "Example Tool",
"version": "0.1.0",
"description": "A minimal toolbox-compatible React application.",
"entry": "./",
"icon": "./icon.svg",
"categories": ["examples"],
"tags": ["react", "toolbox"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": false,
"indexedDb": false,
"crossOriginIsolated": false
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false
},
"source": {
"repository": "https://git.add-ideas.de/zemion/toolbox-sdk",
"license": "Apache-2.0"
}
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.example-toolbox",
"name": "Example Toolbox",
"home": "./",
"theme": {
"mode": "system",
"brand": "Example Toolbox"
},
"apps": [
{
"manifest": "./toolbox-app.json",
"enabled": true
},
{
"name": "ADD Ideas",
"entry": "https://add-ideas.de/",
"launch": "new-tab",
"enabled": true
}
]
}

View File

@@ -0,0 +1,19 @@
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import { toolboxApp } from "./toolbox-app.js";
export function App() {
return (
<AppShell app={toolboxApp} appActions={<a href="./help/">Help</a>}>
<section>
<h2>Ready</h2>
<p>
Open this app directly for standalone mode, or add
<code>?toolbox=/toolbox.catalog.json</code> to connect it to a
same-origin catalog.
</p>
</section>
</AppShell>
);
}

View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
const root = document.querySelector("#root");
if (!root) throw new Error("Missing #root element");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,33 @@
import { defineToolboxApp } from "@add-ideas/toolbox-contract";
export const toolboxApp = defineToolboxApp({
schemaVersion: 1,
id: "de.add-ideas.example-tool",
name: "Example Tool",
version: "0.1.0",
description: "A minimal toolbox-compatible React application.",
entry: "./",
icon: "./icon.svg",
categories: ["examples"],
tags: ["react", "toolbox"],
integration: {
contextVersion: 1,
launchModes: ["navigate", "new-tab"],
embedding: "unsupported",
},
requirements: {
secureContext: false,
workers: false,
indexedDb: false,
crossOriginIsolated: false,
},
privacy: {
processing: "local",
fileUploads: false,
telemetry: false,
},
source: {
repository: "https://git.add-ideas.de/zemion/toolbox-sdk",
license: "Apache-2.0",
},
});

3982
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "@add-ideas/toolbox-sdk-workspace",
"version": "0.1.0",
"private": true,
"description": "A small, framework-neutral toolbox contract with a React application shell.",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git"
},
"type": "module",
"workspaces": [
"packages/*"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "npm run build -w @add-ideas/toolbox-contract && npm run build -w @add-ideas/toolbox-shell-react && npm run build -w @add-ideas/toolbox-testkit",
"clean": "npm run clean --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint . --max-warnings=0",
"format": "prettier --check .",
"format:write": "prettier --write .",
"check": "npm run typecheck && npm run lint && npm run format && npm test && npm run build"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.8.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"ajv": "^8.20.0",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3",
"vitest": "^4.1.6"
},
"packageManager": "npm@11.17.0"
}

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"]
}

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,15 @@
# @add-ideas/toolbox-shell-react
```tsx
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
<AppShell app={manifest} appActions={<a href="/help">Help</a>}>
<App />
</AppShell>;
```
`AppShell` discovers `?toolbox=...` or `<meta name="toolbox">`, but continues in
standalone mode if context is absent, unavailable, cross-origin, or invalid. Its
app switcher is exposed as the accessible navigation landmark
`Toolbox applications` and all destinations use ordinary document navigation.

View File

@@ -0,0 +1,53 @@
{
"name": "@add-ideas/toolbox-shell-react",
"version": "0.1.0",
"description": "A lightweight React application shell for toolbox-compatible browser applications.",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
"directory": "packages/shell-react"
},
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
"keywords": [
"toolbox",
"react",
"application-shell",
"typescript"
],
"type": "module",
"sideEffects": [
"**/*.css"
],
"files": [
"dist",
"README.md",
"LICENSE"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./styles.css": "./dist/styles.css",
"./package.json": "./package.json"
},
"types": "./dist/index.d.ts",
"peerDependencies": {
"react": ">=18 <20",
"react-dom": ">=18 <20"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.1.0"
},
"scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.copyFileSync('src/styles.css','dist/styles.css');fs.copyFileSync('../../LICENSE','LICENSE')\"",
"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,231 @@
import {
contextualizeToolboxLink,
loadToolboxContext,
resolveWebUrl,
type ToolboxAppManifest,
type ToolboxContext,
type ToolboxContextLoadOptions,
type ToolboxError,
} from "@add-ideas/toolbox-contract";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
export interface AppShellProps {
app: ToolboxAppManifest;
children: ReactNode;
appActions?: ReactNode;
className?: string;
manifestUrl?: string | URL;
contextOptions?: Omit<ToolboxContextLoadOptions, "signal">;
onContextError?: (error: ToolboxError) => void;
}
function currentHref(): string {
return typeof globalThis.location === "undefined"
? "http://localhost/"
: globalThis.location.href;
}
function safeHref(reference: string, base: string | URL): string | undefined {
try {
return resolveWebUrl(reference, base).href;
} catch {
return undefined;
}
}
function privacyText(app: ToolboxAppManifest): string {
if (app.privacy.label) return app.privacy.label;
const processing =
app.privacy.processing === "local"
? "Local processing"
: app.privacy.processing === "remote"
? "Remote processing"
: "Local and remote processing";
const details = [
app.privacy.fileUploads ? "uploads files" : "no file uploads",
app.privacy.telemetry ? "uses telemetry" : "no telemetry",
];
return `${processing} · ${details.join(" · ")}`;
}
export function AppShell({
app,
children,
appActions,
className,
manifestUrl,
contextOptions,
onContextError,
}: AppShellProps) {
const [context, setContext] = useState<ToolboxContext | null>(null);
const [contextState, setContextState] = useState<"standalone" | "connected">(
"standalone",
);
const errorHandler = useRef(onContextError);
useEffect(() => {
errorHandler.current = onContextError;
}, [onContextError]);
const contextCatalog = contextOptions?.catalogUrl;
const contextDocument = contextOptions?.document;
const contextFetch = contextOptions?.fetch;
const contextLocation = contextOptions?.location;
const contextMetaName = contextOptions?.metaName;
const contextQueryParameter = contextOptions?.queryParameter;
useEffect(() => {
const controller = new AbortController();
void loadToolboxContext({
...(contextCatalog === undefined ? {} : { catalogUrl: contextCatalog }),
...(contextDocument === undefined ? {} : { document: contextDocument }),
...(contextFetch === undefined ? {} : { fetch: contextFetch }),
...(contextLocation === undefined ? {} : { location: contextLocation }),
...(contextMetaName === undefined ? {} : { metaName: contextMetaName }),
...(contextQueryParameter === undefined
? {}
: { queryParameter: contextQueryParameter }),
signal: controller.signal,
}).then((result) => {
if (controller.signal.aborted) return;
if (result.status === "ready") {
setContext(result.context);
setContextState("connected");
} else {
setContext(null);
setContextState("standalone");
if (result.status === "error") errorHandler.current?.(result.error);
}
});
return () => {
controller.abort();
};
}, [
contextCatalog,
contextDocument,
contextFetch,
contextLocation,
contextMetaName,
contextQueryParameter,
]);
const pageUrl = contextLocation ?? currentHref();
const appManifestUrl = useMemo(
() =>
manifestUrl ??
safeHref("toolbox-app.json", pageUrl) ??
"http://localhost/toolbox-app.json",
[manifestUrl, pageUrl],
);
const privacyHref = app.privacy.url
? safeHref(app.privacy.url, appManifestUrl)
: undefined;
const enabledApps =
context?.catalog.apps.filter((entry) => entry.enabled) ?? [];
const theme = context?.catalog.catalog.theme.mode ?? "system";
const rootClassName = ["toolbox-shell", className].filter(Boolean).join(" ");
return (
<div
className={rootClassName}
data-toolbox-context={contextState}
data-toolbox-theme={theme}
>
<header className="toolbox-shell__header">
<div className="toolbox-shell__bar">
{context ? (
<a
className="toolbox-shell__home"
href={context.catalog.homeUrl.href}
>
{context.catalog.catalog.theme.brand}
</a>
) : null}
<div className="toolbox-shell__identity">
<img
className="toolbox-shell__icon"
src={safeHref(app.icon, appManifestUrl)}
alt=""
width="32"
height="32"
/>
<div>
<h1 className="toolbox-shell__name">{app.name}</h1>
<span className="toolbox-shell__description">
{app.description}
</span>
</div>
</div>
<div className="toolbox-shell__actions">
{app.actions?.map((action) => {
const href = safeHref(action.url, appManifestUrl);
return href ? (
<a key={action.id} href={href}>
{action.label}
</a>
) : null;
})}
{appActions}
</div>
<div className="toolbox-shell__meta">
<span aria-label={`Version ${app.version}`}>v{app.version}</span>
{privacyHref ? (
<a href={privacyHref}>{privacyText(app)}</a>
) : (
<span>{privacyText(app)}</span>
)}
</div>
{context && enabledApps.length > 0 ? (
<details className="toolbox-shell__switcher">
<summary>Apps</summary>
<nav aria-label="Toolbox applications">
<ul>
{enabledApps.map((entry) => {
if (entry.kind === "external") {
return (
<li
key={`external:${entry.name}:${entry.entryUrl.href}`}
>
<a
href={entry.entryUrl.href}
{...(entry.launch === "new-tab"
? { target: "_blank", rel: "noreferrer" }
: {})}
>
{entry.name}
</a>
</li>
);
}
const manifest = entry.app.manifest;
return (
<li key={manifest.id}>
<a
href={contextualizeToolboxLink(
entry.app.entryUrl,
context.catalog.catalogUrl,
{ location: pageUrl },
)}
aria-current={
manifest.id === app.id ? "page" : undefined
}
>
{manifest.name}
</a>
</li>
);
})}
</ul>
</nav>
</details>
) : null}
</div>
</header>
<main className="toolbox-shell__main">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { AppShell } from "./AppShell.js";
export type { AppShellProps } from "./AppShell.js";

View File

@@ -0,0 +1,202 @@
:root {
--toolbox-font:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
--toolbox-background: #f5f7fa;
--toolbox-surface: #ffffff;
--toolbox-text: #172033;
--toolbox-muted: #657086;
--toolbox-border: #dce2ea;
--toolbox-accent: #3156d3;
--toolbox-accent-contrast: #ffffff;
--toolbox-radius: 0.65rem;
--toolbox-shadow: 0 1px 2px rgb(18 28 45 / 8%);
}
[data-toolbox-theme="dark"] {
--toolbox-background: #111722;
--toolbox-surface: #192130;
--toolbox-text: #edf1f7;
--toolbox-muted: #aab4c5;
--toolbox-border: #303b4d;
--toolbox-accent: #9db2ff;
--toolbox-accent-contrast: #111722;
}
@media (prefers-color-scheme: dark) {
[data-toolbox-theme="system"] {
--toolbox-background: #111722;
--toolbox-surface: #192130;
--toolbox-text: #edf1f7;
--toolbox-muted: #aab4c5;
--toolbox-border: #303b4d;
--toolbox-accent: #9db2ff;
--toolbox-accent-contrast: #111722;
}
}
.toolbox-shell {
min-height: 100vh;
color: var(--toolbox-text);
background: var(--toolbox-background);
font-family: var(--toolbox-font);
}
.toolbox-shell *,
.toolbox-shell *::before,
.toolbox-shell *::after {
box-sizing: border-box;
}
.toolbox-shell__header {
position: relative;
z-index: 10;
border-bottom: 1px solid var(--toolbox-border);
background: var(--toolbox-surface);
box-shadow: var(--toolbox-shadow);
}
.toolbox-shell__bar {
display: flex;
min-height: 4.25rem;
align-items: center;
gap: 1rem;
max-width: 90rem;
margin: 0 auto;
padding: 0.75rem 1rem;
}
.toolbox-shell a {
color: var(--toolbox-accent);
text-decoration: none;
}
.toolbox-shell a:hover {
text-decoration: underline;
}
.toolbox-shell__home {
flex: none;
font-weight: 700;
}
.toolbox-shell__identity {
display: flex;
min-width: 12rem;
align-items: center;
gap: 0.65rem;
}
.toolbox-shell__identity > div {
display: grid;
gap: 0.1rem;
}
.toolbox-shell__icon {
flex: none;
border-radius: 0.35rem;
}
.toolbox-shell__name {
margin: 0;
font-size: 1rem;
line-height: 1.2;
}
.toolbox-shell__description {
color: var(--toolbox-muted);
font-size: 0.8rem;
line-height: 1.25;
}
.toolbox-shell__actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
}
.toolbox-shell__meta {
display: grid;
flex: none;
gap: 0.15rem;
color: var(--toolbox-muted);
font-size: 0.75rem;
text-align: right;
}
.toolbox-shell__switcher {
position: relative;
flex: none;
}
.toolbox-shell__switcher summary {
padding: 0.45rem 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: var(--toolbox-radius);
cursor: pointer;
font-weight: 600;
list-style: none;
}
.toolbox-shell__switcher summary::-webkit-details-marker {
display: none;
}
.toolbox-shell__switcher nav {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
width: max-content;
min-width: 12rem;
padding: 0.4rem;
border: 1px solid var(--toolbox-border);
border-radius: var(--toolbox-radius);
background: var(--toolbox-surface);
box-shadow: 0 0.75rem 2rem rgb(18 28 45 / 14%);
}
.toolbox-shell__switcher ul {
display: grid;
gap: 0.15rem;
margin: 0;
padding: 0;
list-style: none;
}
.toolbox-shell__switcher a {
display: block;
padding: 0.45rem 0.6rem;
border-radius: 0.4rem;
}
.toolbox-shell__switcher a[aria-current="page"] {
color: var(--toolbox-accent-contrast);
background: var(--toolbox-accent);
}
.toolbox-shell__main {
width: min(100%, 90rem);
margin: 0 auto;
padding: 1rem;
}
@media (max-width: 52rem) {
.toolbox-shell__bar {
flex-wrap: wrap;
}
.toolbox-shell__identity {
flex: 1;
}
.toolbox-shell__actions {
order: 3;
width: 100%;
margin-left: 0;
}
.toolbox-shell__meta {
display: none;
}
}

View File

@@ -0,0 +1,202 @@
import { render, screen, waitFor } from "@testing-library/react";
import type { ToolboxAppManifest } from "@add-ideas/toolbox-contract";
import { describe, expect, it, vi } from "vitest";
import { AppShell } from "../src/index.js";
const currentApp: ToolboxAppManifest = {
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"],
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",
},
};
const otherApp: ToolboxAppManifest = {
...currentApp,
id: "de.add-ideas.xslt-tools",
name: "XSLT Workbench",
entry: "./index.html",
source: {
repository: "https://git.example.test/xslt-tools",
license: "AGPL-3.0-only",
},
};
const catalog = {
schemaVersion: 1,
id: "de.add-ideas.toolbox",
name: "Toolbox",
home: "./",
theme: { mode: "system", brand: "add·ideas" },
apps: [
{ manifest: "./pdf/toolbox-app.json", enabled: true },
{ manifest: "./xslt/toolbox-app.json", enabled: true },
],
};
const json = (value: unknown, status = 200): Response =>
new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json" },
});
function catalogFetch() {
return vi.fn(async (input: string | URL | Request) => {
const url = new URL(input instanceof Request ? input.url : input);
if (url.pathname === "/toolbox.catalog.json") return json(catalog);
if (url.pathname === "/pdf/toolbox-app.json") return json(currentApp);
if (url.pathname === "/xslt/toolbox-app.json") return json(otherApp);
return json({}, 404);
});
}
describe("AppShell", () => {
it("renders identity, privacy, version, actions, and content standalone", async () => {
const fetch = vi.fn();
render(
<AppShell
app={currentApp}
appActions={<a href="/help">Help</a>}
contextOptions={{
location: "https://tools.example.test/pdf/",
fetch,
}}
>
<p>Application content</p>
</AppShell>,
);
expect(
screen.getByRole("heading", { level: 1, name: "PDF Workbench" }),
).toBeInTheDocument();
expect(screen.getByLabelText("Version 1.2.3")).toBeInTheDocument();
expect(screen.getByText(/Local processing/u)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Help" })).toHaveAttribute(
"href",
"/help",
);
expect(screen.getByText("Application content")).toBeInTheDocument();
await waitFor(() => expect(fetch).not.toHaveBeenCalled());
expect(
document.querySelector("[data-toolbox-context='standalone']"),
).toBeInTheDocument();
expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }),
).not.toBeInTheDocument();
});
it("loads same-origin context and creates full-page contextual switch links", async () => {
const fetch = catalogFetch();
render(
<AppShell
app={currentApp}
manifestUrl="https://tools.example.test/pdf/toolbox-app.json"
contextOptions={{
location:
"https://tools.example.test/pdf/?toolbox=/toolbox.catalog.json",
fetch,
}}
>
PDF
</AppShell>,
);
const navigation = await screen.findByRole("navigation", {
name: "Toolbox applications",
});
expect(navigation).toBeInTheDocument();
const current = screen.getByRole("link", { name: "PDF Workbench" });
expect(current).toHaveAttribute("aria-current", "page");
const other = screen.getByRole("link", { name: "XSLT Workbench" });
const destination = new URL(other.getAttribute("href") as string);
expect(destination.pathname).toBe("/xslt/index.html");
expect(destination.searchParams.get("toolbox")).toBe(
"https://tools.example.test/toolbox.catalog.json",
);
expect(screen.getByRole("link", { name: "add·ideas" })).toHaveAttribute(
"href",
"https://tools.example.test/",
);
expect(
document.querySelector("[data-toolbox-context='connected']"),
).toBeInTheDocument();
});
it("rejects cross-origin context and remains standalone", async () => {
const fetch = vi.fn();
const onContextError = vi.fn();
render(
<AppShell
app={currentApp}
contextOptions={{
location:
"https://tools.example.test/pdf/?toolbox=https://evil.example/catalog.json",
fetch,
}}
onContextError={onContextError}
>
PDF
</AppShell>,
);
await waitFor(() => expect(onContextError).toHaveBeenCalledOnce());
expect(onContextError.mock.calls[0]?.[0]).toMatchObject({
code: "cross-origin",
});
expect(fetch).not.toHaveBeenCalled();
expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }),
).not.toBeInTheDocument();
});
it.each([
["invalid catalog", async () => json({ schemaVersion: 2 })],
["fetch failure", async () => json({}, 503)],
])("survives %s in standalone mode", async (_label, fetchImplementation) => {
const onContextError = vi.fn();
render(
<AppShell
app={currentApp}
contextOptions={{
location:
"https://tools.example.test/pdf/?toolbox=/toolbox.catalog.json",
fetch: fetchImplementation,
}}
onContextError={onContextError}
>
Still usable
</AppShell>,
);
await waitFor(() => expect(onContextError).toHaveBeenCalledOnce());
expect(screen.getByText("Still usable")).toBeInTheDocument();
expect(
document.querySelector("[data-toolbox-context='standalone']"),
).toBeInTheDocument();
});
});

View File

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

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"noEmit": true,
"paths": {
"@add-ideas/toolbox-contract": ["../contract/src/index.ts"]
}
},
"include": ["src"]
}

192
packages/testkit/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,17 @@
# @add-ideas/toolbox-testkit
Validate and smoke-check a built toolbox application:
```sh
toolbox-check dist
```
The checker validates `toolbox-app.json`, its id and SemVer version, verifies
the entry, icon, and declared assets, rejects unsafe local paths, serves the
build under a deep nested prefix, and fetches it both standalone and with
`?toolbox=/toolbox.catalog.json`. It also fetches local scripts, stylesheets,
images, icons, linked web manifests, and their icons from the entry HTML, so
root-absolute references fail the nested-deployment check. It does not launch a
browser.
The same behavior is available programmatically as `checkToolboxDist()`.

View File

@@ -0,0 +1,52 @@
{
"name": "@add-ideas/toolbox-testkit",
"version": "0.1.0",
"description": "Manifest, asset, and nested-deployment smoke checks for built toolbox applications.",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
"directory": "packages/testkit"
},
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
"keywords": [
"toolbox",
"manifest",
"smoke-test",
"cli"
],
"type": "module",
"sideEffects": false,
"engines": {
"node": ">=20"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"bin": {
"toolbox-check": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./package.json": "./package.json"
},
"types": "./dist/index.d.ts",
"dependencies": {
"@add-ideas/toolbox-contract": "0.1.0"
},
"scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"",
"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,482 @@
import {
loadToolboxContext,
parseToolboxApp,
type ToolboxAppManifest,
} from "@add-ideas/toolbox-contract";
import { createReadStream } from "node:fs";
import { lstat, readFile, realpath, stat } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { extname, relative, resolve, sep } from "node:path";
const DEEP_PREFIX = "/__toolbox-check__/deep/nested/app/";
const CATALOG_PATH = "/toolbox.catalog.json";
const SEMVER_PATTERN =
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
export class ToolboxCheckError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "ToolboxCheckError";
}
}
export interface ToolboxCheckOptions {
manifestName?: string;
}
export interface ToolboxCheckReport {
root: string;
manifestPath: string;
app: ToolboxAppManifest;
checkedFiles: readonly string[];
smoke: {
standaloneUrl: string;
contextualUrl: string;
fetchedUrls: readonly string[];
};
}
interface CheckedReference {
reference: string;
path: string;
}
function decodedReference(reference: string, label: string): string {
let decoded = reference;
try {
for (let count = 0; count < 3; count += 1) {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
}
} catch (cause) {
throw new ToolboxCheckError(`${label} contains invalid URL encoding`, {
cause,
});
}
return decoded;
}
function localReference(
root: string,
reference: string,
label: string,
): CheckedReference {
const decoded = decodedReference(reference, label);
const pathPart = decoded.split(/[?#]/u, 1)[0] ?? "";
if (
pathPart.startsWith("/") ||
pathPart.includes("\\") ||
/^[a-z][a-z\d+.-]*:/iu.test(pathPart) ||
pathPart.split("/").includes("..") ||
pathPart.includes("\0")
) {
throw new ToolboxCheckError(`${label} uses an unsafe path: ${reference}`);
}
let filePath = resolve(root, pathPart || ".");
if (pathPart.endsWith("/") || pathPart === "." || pathPart === "./") {
filePath = resolve(filePath, "index.html");
}
const fromRoot = relative(root, filePath);
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`)) {
throw new ToolboxCheckError(`${label} escapes the distribution directory`);
}
return { reference, path: filePath };
}
async function assertRegularContainedFile(
root: string,
item: CheckedReference,
label: string,
): Promise<string> {
try {
await lstat(item.path);
} catch (cause) {
throw new ToolboxCheckError(`${label} does not exist: ${item.reference}`, {
cause,
});
}
const [realRoot, realFile] = await Promise.all([
realpath(root),
realpath(item.path),
]);
const fromRealRoot = relative(realRoot, realFile);
if (fromRealRoot === ".." || fromRealRoot.startsWith(`..${sep}`)) {
throw new ToolboxCheckError(`${label} resolves outside the distribution`);
}
const info = await stat(realFile);
if (!info.isFile()) {
throw new ToolboxCheckError(`${label} is not a file: ${item.reference}`);
}
return item.path;
}
function contentType(path: string): string {
switch (extname(path).toLowerCase()) {
case ".html":
return "text/html; charset=utf-8";
case ".json":
return "application/json; charset=utf-8";
case ".css":
return "text/css; charset=utf-8";
case ".js":
case ".mjs":
return "text/javascript; charset=utf-8";
case ".svg":
return "image/svg+xml";
case ".png":
return "image/png";
default:
return "application/octet-stream";
}
}
function encodedPath(path: string): string {
return path.split("/").map(encodeURIComponent).join("/");
}
function catalogDocument(manifestName: string): object {
return {
schemaVersion: 1,
id: "de.add-ideas.toolbox-check",
name: "Toolbox check",
home: "/",
theme: { mode: "system", brand: "Toolbox check" },
apps: [
{
manifest: `${DEEP_PREFIX}${encodedPath(manifestName)}`,
enabled: true,
},
],
};
}
function staticServer(root: string, manifestName: string): Server {
return createServer((request, response) => {
void (async () => {
const requestUrl = new URL(request.url ?? "/", "http://localhost");
if (requestUrl.pathname === CATALOG_PATH) {
response.writeHead(200, {
"content-type": "application/json; charset=utf-8",
});
response.end(JSON.stringify(catalogDocument(manifestName)));
return;
}
if (!requestUrl.pathname.startsWith(DEEP_PREFIX)) {
response.writeHead(404).end("Not found");
return;
}
let relativePath: string;
try {
relativePath = decodeURIComponent(
requestUrl.pathname.slice(DEEP_PREFIX.length),
);
} catch {
response.writeHead(400).end("Bad path");
return;
}
const checked = localReference(
root,
relativePath || "./",
"Request path",
);
try {
await assertRegularContainedFile(root, checked, "Requested file");
} catch {
response.writeHead(404).end("Not found");
return;
}
response.writeHead(200, { "content-type": contentType(checked.path) });
createReadStream(checked.path).pipe(response);
})().catch(() => {
if (!response.headersSent) response.writeHead(500);
response.end("Server error");
});
});
}
async function listen(server: Server): Promise<number> {
await new Promise<void>((resolvePromise, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolvePromise());
});
const address = server.address();
if (address === null || typeof address === "string") {
throw new ToolboxCheckError("Could not determine smoke-test server port");
}
return address.port;
}
async function close(server: Server): Promise<void> {
await new Promise<void>((resolvePromise, reject) => {
server.close((error) => (error ? reject(error) : resolvePromise()));
});
}
async function requireOk(url: URL, label: string): Promise<Response> {
const response = await fetch(url);
if (!response.ok) {
throw new ToolboxCheckError(`${label} returned HTTP ${response.status}`);
}
return response;
}
interface HtmlResource {
kind: "icon" | "image" | "manifest" | "script" | "stylesheet";
reference: string;
}
function attributes(source: string): Map<string, string> {
const result = new Map<string, string>();
const pattern = /([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gu;
for (const match of source.matchAll(pattern)) {
const name = match[1]?.toLowerCase();
const value = match[2] ?? match[3] ?? match[4];
if (name !== undefined && value !== undefined) result.set(name, value);
}
return result;
}
function htmlResources(html: string): readonly HtmlResource[] {
const resources: HtmlResource[] = [];
const tagPattern = /<(script|link|img)\b([^>]*)>/giu;
for (const match of html.matchAll(tagPattern)) {
const tag = match[1]?.toLowerCase();
const values = attributes(match[2] ?? "");
if (tag === "script") {
const reference = values.get("src");
if (reference) resources.push({ kind: "script", reference });
} else if (tag === "img") {
const reference = values.get("src");
if (reference) resources.push({ kind: "image", reference });
} else if (tag === "link") {
const reference = values.get("href");
const relationships = new Set(
(values.get("rel") ?? "").toLowerCase().split(/\s+/u).filter(Boolean),
);
if (!reference) continue;
if (relationships.has("manifest")) {
resources.push({ kind: "manifest", reference });
} else if (relationships.has("stylesheet")) {
resources.push({ kind: "stylesheet", reference });
} else if (
relationships.has("icon") ||
relationships.has("apple-touch-icon")
) {
resources.push({ kind: "icon", reference });
} else if (
relationships.has("modulepreload") ||
relationships.has("preload")
) {
resources.push({ kind: "script", reference });
}
}
}
return resources;
}
function nestedLocalUrl(
reference: string,
base: URL,
label: string,
): URL | undefined {
let url: URL;
try {
url = new URL(reference, base);
} catch (cause) {
throw new ToolboxCheckError(`${label} has an invalid URL: ${reference}`, {
cause,
});
}
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
if (url.origin !== base.origin) return undefined;
if (!url.pathname.startsWith(DEEP_PREFIX)) {
throw new ToolboxCheckError(
`${label} is not relocatable under the nested prefix: ${reference}`,
);
}
return url;
}
async function inspectWebManifest(
response: Response,
manifestUrl: URL,
): Promise<readonly string[]> {
let document: unknown;
try {
document = await response.json();
} catch (cause) {
throw new ToolboxCheckError("Linked web manifest is not valid JSON", {
cause,
});
}
if (typeof document !== "object" || document === null) return [];
const icons = (document as { icons?: unknown }).icons;
if (icons === undefined) return [];
if (!Array.isArray(icons)) {
throw new ToolboxCheckError("Linked web manifest icons must be an array");
}
const fetched: string[] = [];
for (const [index, icon] of icons.entries()) {
if (typeof icon !== "object" || icon === null) {
throw new ToolboxCheckError(
`Web manifest icon ${index} must be an object`,
);
}
const source = (icon as { src?: unknown }).src;
if (typeof source !== "string" || source.length === 0) {
throw new ToolboxCheckError(`Web manifest icon ${index} needs a src`);
}
const iconUrl = nestedLocalUrl(source, manifestUrl, "Web manifest icon");
if (iconUrl !== undefined) {
await requireOk(iconUrl, "Web manifest icon");
fetched.push(iconUrl.href);
}
}
return fetched;
}
async function inspectEntryHtml(
html: string,
entryUrl: URL,
): Promise<readonly string[]> {
const fetched: string[] = [];
for (const resource of htmlResources(html)) {
const label = `HTML ${resource.kind}`;
const url = nestedLocalUrl(resource.reference, entryUrl, label);
if (url === undefined) continue;
const response = await requireOk(url, label);
fetched.push(url.href);
if (resource.kind === "manifest") {
fetched.push(...(await inspectWebManifest(response, url)));
}
}
return fetched;
}
async function smokeCheck(
root: string,
manifest: ToolboxAppManifest,
manifestName: string,
): Promise<ToolboxCheckReport["smoke"]> {
const server = staticServer(root, manifestName);
const port = await listen(server);
try {
const appBase = new URL(DEEP_PREFIX, `http://127.0.0.1:${port}/`);
const standaloneUrl = new URL(manifest.entry, appBase);
const standaloneResponse = await requireOk(
standaloneUrl,
"Standalone entry",
);
const fetchedUrls = await inspectEntryHtml(
await standaloneResponse.text(),
standaloneUrl,
);
const standalone = await loadToolboxContext({ location: standaloneUrl });
if (standalone.status !== "standalone") {
throw new ToolboxCheckError(
"The standalone smoke URL discovered a toolbox",
);
}
const contextualUrl = new URL(standaloneUrl);
contextualUrl.searchParams.set("toolbox", CATALOG_PATH);
await requireOk(contextualUrl, "Contextual entry");
const contextual = await loadToolboxContext({ location: contextualUrl });
if (contextual.status !== "ready") {
const reason =
contextual.status === "error" ? `: ${contextual.error.message}` : "";
throw new ToolboxCheckError(
`Toolbox context smoke check failed${reason}`,
);
}
const loaded = contextual.context.catalog.apps.find(
(entry) => entry.kind === "manifest",
);
if (loaded?.kind !== "manifest" || loaded.app.manifest.id !== manifest.id) {
throw new ToolboxCheckError(
"Smoke catalog did not resolve the built app",
);
}
return {
standaloneUrl: standaloneUrl.href,
contextualUrl: contextualUrl.href,
fetchedUrls,
};
} finally {
await close(server);
}
}
export async function checkToolboxDist(
dist: string,
options: ToolboxCheckOptions = {},
): Promise<ToolboxCheckReport> {
const root = resolve(dist);
let rootInfo;
try {
rootInfo = await stat(root);
} catch (cause) {
throw new ToolboxCheckError(
`Distribution directory does not exist: ${root}`,
{
cause,
},
);
}
if (!rootInfo.isDirectory()) {
throw new ToolboxCheckError(
`Distribution path is not a directory: ${root}`,
);
}
const manifestName = options.manifestName ?? "toolbox-app.json";
const manifestReference = localReference(root, manifestName, "Manifest");
const manifestPath = await assertRegularContainedFile(
root,
manifestReference,
"Manifest",
);
let document: unknown;
try {
document = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (cause) {
throw new ToolboxCheckError(`Manifest is not valid JSON: ${manifestPath}`, {
cause,
});
}
let app: ToolboxAppManifest;
try {
app = parseToolboxApp(document);
} catch (cause) {
throw new ToolboxCheckError("Manifest does not satisfy toolbox app v1", {
cause,
});
}
if (!SEMVER_PATTERN.test(app.version)) {
throw new ToolboxCheckError(
`Manifest version is not SemVer: ${app.version}`,
);
}
const references: Array<[string, string]> = [
[app.entry, "Application entry"],
[app.icon, "Application icon"],
...(app.assets ?? []).map((asset): [string, string] => [
asset,
"Application asset",
]),
];
const checkedFiles = await Promise.all(
references.map(([reference, label]) =>
assertRegularContainedFile(
root,
localReference(root, reference, label),
label,
),
),
);
const smoke = await smokeCheck(root, app, manifestName);
return { root, manifestPath, app, checkedFiles, smoke };
}

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env node
import { checkToolboxDist } from "./check.js";
function usage(): void {
console.log("Usage: toolbox-check <dist>");
}
const arguments_ = process.argv.slice(2);
if (arguments_.includes("--help") || arguments_.includes("-h")) {
usage();
} else if (arguments_.length !== 1) {
usage();
process.exitCode = 2;
} else {
checkToolboxDist(arguments_[0] as string)
.then((report) => {
console.log(
`toolbox-check: ${report.app.id}@${report.app.version} passed (${report.checkedFiles.length} files, standalone + contextual smoke checks)`,
);
})
.catch((error: unknown) => {
console.error(
`toolbox-check: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,2 @@
export { checkToolboxDist, ToolboxCheckError } from "./check.js";
export type { ToolboxCheckOptions, ToolboxCheckReport } from "./check.js";

View File

@@ -0,0 +1,118 @@
// @vitest-environment node
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { checkToolboxDist } from "../src/index.js";
const temporaryDirectories: string[] = [];
const manifest = (overrides: Record<string, unknown> = {}) => ({
schemaVersion: 1,
id: "de.add-ideas.example",
name: "Example",
version: "1.2.3",
description: "Example app",
entry: "./",
icon: "./icon.svg",
categories: ["example"],
tags: ["test"],
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://git.example.test/example",
license: "MIT",
},
assets: ["./app.js"],
...overrides,
});
async function fixture(document = manifest()): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "toolbox-check-"));
temporaryDirectories.push(root);
await Promise.all([
writeFile(join(root, "toolbox-app.json"), JSON.stringify(document)),
writeFile(
join(root, "index.html"),
'<!doctype html><link rel="manifest" href="./site.webmanifest"><link rel="icon" href="./icon.svg"><link rel="stylesheet" href="./style.css"><img src="./image.png" alt=""><script type="module" src="./app.js"></script><title>Example</title>',
),
writeFile(
join(root, "icon.svg"),
'<svg xmlns="http://www.w3.org/2000/svg"/>',
),
writeFile(join(root, "app.js"), "console.log('ok')"),
writeFile(join(root, "style.css"), "body { color: black; }"),
writeFile(join(root, "image.png"), "not-a-real-png"),
writeFile(
join(root, "site.webmanifest"),
JSON.stringify({ icons: [{ src: "./icon.svg" }] }),
),
]);
return root;
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
describe("toolbox-check", () => {
it("validates files and smoke-fetches nested standalone and context URLs", async () => {
const report = await checkToolboxDist(await fixture());
expect(report.app.id).toBe("de.add-ideas.example");
expect(report.checkedFiles).toHaveLength(3);
expect(new URL(report.smoke.standaloneUrl).pathname).toBe(
"/__toolbox-check__/deep/nested/app/",
);
expect(
new URL(report.smoke.contextualUrl).searchParams.get("toolbox"),
).toBe("/toolbox.catalog.json");
expect(report.smoke.fetchedUrls).toEqual(
expect.arrayContaining([
expect.stringContaining("/site.webmanifest"),
expect.stringContaining("/style.css"),
expect.stringContaining("/app.js"),
]),
);
});
it("rejects traversal in declared assets", async () => {
const root = await fixture(manifest({ assets: ["../secret.txt"] }));
await expect(checkToolboxDist(root)).rejects.toThrow(/unsafe path/u);
});
it("rejects a non-SemVer manifest version", async () => {
const root = await fixture(manifest({ version: "next" }));
await expect(checkToolboxDist(root)).rejects.toThrow(/not SemVer/u);
});
it("rejects root-absolute HTML assets that break nested deployment", async () => {
const root = await fixture();
await writeFile(
join(root, "index.html"),
'<!doctype html><script src="/app.js"></script>',
);
await expect(checkToolboxDist(root)).rejects.toThrow(/not relocatable/u);
});
});

View File

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

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"],
"paths": {
"@add-ideas/toolbox-contract": ["../contract/src/index.ts"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,219 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"title": "Toolbox application manifest v1",
"type": "object",
"required": [
"schemaVersion",
"id",
"name",
"version",
"description",
"entry",
"icon",
"categories",
"tags",
"integration",
"requirements",
"privacy"
],
"properties": {
"schemaVersion": {
"const": 1
},
"id": {
"type": "string",
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$"
},
"name": {
"$ref": "#/$defs/nonEmptyString"
},
"version": {
"$ref": "#/$defs/nonEmptyString"
},
"entry": {
"$ref": "#/$defs/urlReference"
},
"description": {
"type": "string"
},
"icon": {
"$ref": "#/$defs/urlReference"
},
"privacy": {
"type": "object",
"required": ["processing", "fileUploads", "telemetry"],
"properties": {
"processing": {
"enum": ["local", "remote", "mixed"]
},
"fileUploads": {
"type": "boolean"
},
"telemetry": {
"type": "boolean"
},
"label": {
"$ref": "#/$defs/nonEmptyString"
},
"url": {
"$ref": "#/$defs/urlReference"
}
},
"additionalProperties": true
},
"categories": {
"$ref": "#/$defs/stringList"
},
"tags": {
"$ref": "#/$defs/stringList"
},
"integration": {
"type": "object",
"required": ["contextVersion", "launchModes", "embedding"],
"properties": {
"contextVersion": {
"const": 1
},
"launchModes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"enum": ["navigate", "new-tab"]
}
},
"embedding": {
"enum": ["unsupported", "supported"]
}
},
"additionalProperties": true
},
"requirements": {
"type": "object",
"required": [
"secureContext",
"workers",
"indexedDb",
"crossOriginIsolated"
],
"properties": {
"secureContext": {
"type": "boolean"
},
"workers": {
"type": "boolean"
},
"indexedDb": {
"type": "boolean"
},
"crossOriginIsolated": {
"type": "boolean"
},
"topLevelContext": {
"type": "boolean"
}
},
"additionalProperties": true
},
"source": {
"type": "object",
"required": ["repository", "license"],
"properties": {
"repository": {
"$ref": "#/$defs/absoluteWebUrl"
},
"license": {
"$ref": "#/$defs/nonEmptyString"
}
},
"additionalProperties": true
},
"actions": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "label", "url"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$"
},
"label": {
"$ref": "#/$defs/nonEmptyString"
},
"url": {
"$ref": "#/$defs/urlReference"
}
},
"additionalProperties": true
}
},
"assets": {
"type": "array",
"items": {
"$ref": "#/$defs/urlReference"
},
"uniqueItems": true
}
},
"$defs": {
"urlReference": {
"type": "string",
"allOf": [
{
"$ref": "#/$defs/nonEmptyString"
},
{
"type": "string",
"description": "Backslashes are not URL path separators in toolbox documents.",
"pattern": "^[^\\\\]*$"
},
{
"type": "string",
"description": "Explicit schemes are limited to HTTP and HTTPS; relative references remain valid.",
"pattern": "^[\\t\\n\\r ]*(?:[Hh][Tt][Tt][Pp][Ss]?:|(?!(?:[A-Za-z][A-Za-z0-9+.-]*):))"
},
{
"type": "string",
"description": "An explicit HTTP(S) authority must not be empty.",
"pattern": "^(?![\\t\\n\\r ]*[Hh][Tt][Tt][Pp][Ss]?:/+(?:[?#]|$))"
},
{
"type": "string",
"description": "HTTP(S) authority references must not contain credentials.",
"pattern": "^[\\t\\n\\r ]*(?!(?:[Hh][Tt][Tt][Pp]:/*|[Hh][Tt][Tt][Pp][Ss]:/+|/+)[^/?#]*@)"
}
]
},
"absoluteWebUrl": {
"allOf": [
{
"$ref": "#/$defs/urlReference"
},
{
"type": "string",
"description": "Repository URLs must be absolute HTTP(S) URLs.",
"pattern": "^[\\t\\n\\r ]*[Hh][Tt][Tt][Pp][Ss]?:/*[^\\s/?#]"
},
{
"type": "string",
"description": "Absolute repository URLs must not contain credentials.",
"pattern": "^[\\t\\n\\r ]*(?![Hh][Tt][Tt][Pp][Ss]?:/*[^/?#]*@)"
}
]
},
"nonEmptyString": {
"type": "string",
"pattern": "\\S"
},
"stringList": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "#/$defs/nonEmptyString"
}
}
},
"additionalProperties": true
}

View File

@@ -0,0 +1,115 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
"title": "Toolbox catalog v1",
"type": "object",
"required": ["schemaVersion", "id", "name", "home", "theme", "apps"],
"properties": {
"schemaVersion": {
"const": 1
},
"id": {
"type": "string",
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$"
},
"name": {
"$ref": "#/$defs/nonEmptyString"
},
"home": {
"$ref": "#/$defs/urlReference"
},
"theme": {
"type": "object",
"required": ["mode", "brand"],
"properties": {
"mode": {
"enum": ["light", "dark", "system"]
},
"brand": {
"$ref": "#/$defs/nonEmptyString"
}
},
"additionalProperties": true
},
"apps": {
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"required": ["manifest", "enabled"],
"not": {
"required": ["entry"]
},
"properties": {
"manifest": {
"$ref": "#/$defs/urlReference"
},
"enabled": {
"type": "boolean"
}
},
"additionalProperties": true
},
{
"type": "object",
"required": ["name", "entry", "launch", "enabled"],
"not": {
"required": ["manifest"]
},
"properties": {
"name": {
"$ref": "#/$defs/nonEmptyString"
},
"entry": {
"$ref": "#/$defs/urlReference"
},
"launch": {
"enum": ["navigate", "new-tab"]
},
"enabled": {
"type": "boolean"
}
},
"additionalProperties": true
}
]
}
}
},
"$defs": {
"urlReference": {
"type": "string",
"allOf": [
{
"$ref": "#/$defs/nonEmptyString"
},
{
"type": "string",
"description": "Backslashes are not URL path separators in toolbox documents.",
"pattern": "^[^\\\\]*$"
},
{
"type": "string",
"description": "Explicit schemes are limited to HTTP and HTTPS; relative references remain valid.",
"pattern": "^[\\t\\n\\r ]*(?:[Hh][Tt][Tt][Pp][Ss]?:|(?!(?:[A-Za-z][A-Za-z0-9+.-]*):))"
},
{
"type": "string",
"description": "An explicit HTTP(S) authority must not be empty.",
"pattern": "^(?![\\t\\n\\r ]*[Hh][Tt][Tt][Pp][Ss]?:/+(?:[?#]|$))"
},
{
"type": "string",
"description": "HTTP(S) authority references must not contain credentials.",
"pattern": "^[\\t\\n\\r ]*(?!(?:[Hh][Tt][Tt][Pp]:/*|[Hh][Tt][Tt][Pp][Ss]:/+|/+)[^/?#]*@)"
}
]
},
"nonEmptyString": {
"type": "string",
"pattern": "\\S"
}
},
"additionalProperties": true
}

18
tsconfig.base.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}

26
vitest.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const fromRoot = (path: string): string =>
fileURLToPath(new URL(path, import.meta.url));
export default defineConfig({
resolve: {
alias: {
"@add-ideas/toolbox-contract": fromRoot(
"./packages/contract/src/index.ts",
),
"@add-ideas/toolbox-shell-react": fromRoot(
"./packages/shell-react/src/index.ts",
),
},
},
test: {
environment: "jsdom",
setupFiles: [fromRoot("./vitest.setup.ts")],
coverage: {
reporter: ["text", "html"],
},
},
});

8
vitest.setup.ts Normal file
View File

@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});