feat: add toolbox contract shell and testkit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
|
||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
coverage
|
||||
node_modules
|
||||
package-lock.json
|
||||
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"proseWrap": "always",
|
||||
"trailingComma": "all"
|
||||
}
|
||||
21
CHANGELOG.md
Normal file
21
CHANGELOG.md
Normal 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
192
LICENSE
Normal file
@@ -0,0 +1,192 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
Copyright 2026 ADD Ideas
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
231
README.md
Normal file
231
README.md
Normal 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
31
eslint.config.js
Normal 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,
|
||||
},
|
||||
);
|
||||
15
examples/react-app/README.md
Normal file
15
examples/react-app/README.md
Normal 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.
|
||||
4
examples/react-app/public/icon.svg
Normal file
4
examples/react-app/public/icon.svg
Normal 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 |
32
examples/react-app/public/toolbox-app.json
Normal file
32
examples/react-app/public/toolbox-app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
examples/react-app/public/toolbox.catalog.json
Normal file
23
examples/react-app/public/toolbox.catalog.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
19
examples/react-app/src/App.tsx
Normal file
19
examples/react-app/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
examples/react-app/src/main.tsx
Normal file
13
examples/react-app/src/main.tsx
Normal 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>,
|
||||
);
|
||||
33
examples/react-app/src/toolbox-app.ts
Normal file
33
examples/react-app/src/toolbox-app.ts
Normal 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
3982
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
package.json
Normal file
49
package.json
Normal 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
192
packages/contract/LICENSE
Normal file
@@ -0,0 +1,192 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
Copyright 2026 ADD Ideas
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
21
packages/contract/README.md
Normal file
21
packages/contract/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @add-ideas/toolbox-contract
|
||||
|
||||
Typed, runtime-validated toolbox application manifests and catalogs, plus safe
|
||||
same-origin context discovery and URL resolution.
|
||||
|
||||
```ts
|
||||
import {
|
||||
loadToolboxContext,
|
||||
parseToolboxApp,
|
||||
} from "@add-ideas/toolbox-contract";
|
||||
|
||||
const app = parseToolboxApp(await response.json());
|
||||
const result = await loadToolboxContext();
|
||||
```
|
||||
|
||||
See the workspace [README](https://git.add-ideas.de/zemion/toolbox-sdk#readme)
|
||||
for the v1 document formats and full API.
|
||||
|
||||
The canonical schemas are also published at
|
||||
`@add-ideas/toolbox-contract/schemas/toolbox-app.v1.schema.json` and
|
||||
`@add-ideas/toolbox-contract/schemas/toolbox-catalog.v1.schema.json`.
|
||||
48
packages/contract/package.json
Normal file
48
packages/contract/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@add-ideas/toolbox-contract",
|
||||
"version": "0.1.0",
|
||||
"description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
|
||||
"directory": "packages/contract"
|
||||
},
|
||||
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
|
||||
"keywords": [
|
||||
"toolbox",
|
||||
"manifest",
|
||||
"catalog",
|
||||
"typescript"
|
||||
],
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./schemas/toolbox-app.v1.schema.json": "./dist/schemas/toolbox-app.v1.schema.json",
|
||||
"./schemas/toolbox-catalog.v1.schema.json": "./dist/schemas/toolbox-catalog.v1.schema.json",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.mkdirSync('dist/schemas',{recursive:true});for(const name of ['toolbox-app.v1.schema.json','toolbox-catalog.v1.schema.json'])fs.copyFileSync('../../schemas/'+name,'dist/schemas/'+name);fs.copyFileSync('../../LICENSE','LICENSE')\"",
|
||||
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"prepack": "npm run build",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/"
|
||||
}
|
||||
}
|
||||
110
packages/contract/src/discovery.ts
Normal file
110
packages/contract/src/discovery.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
TOOLBOX_META_NAME,
|
||||
TOOLBOX_QUERY_PARAMETER,
|
||||
ToolboxError,
|
||||
type ToolboxDiscovery,
|
||||
} from "./types.js";
|
||||
import { requireSameOrigin, resolveWebUrl } from "./urls.js";
|
||||
|
||||
interface MetaElementLike {
|
||||
getAttribute(name: string): string | null;
|
||||
}
|
||||
|
||||
interface DocumentLike {
|
||||
querySelector(selector: string): MetaElementLike | null;
|
||||
}
|
||||
|
||||
export interface ToolboxDiscoveryOptions {
|
||||
location?: string | URL;
|
||||
document?: DocumentLike;
|
||||
queryParameter?: string;
|
||||
metaName?: string;
|
||||
}
|
||||
|
||||
function browserLocation(): string | undefined {
|
||||
return typeof globalThis.location === "undefined"
|
||||
? undefined
|
||||
: globalThis.location.href;
|
||||
}
|
||||
|
||||
function browserDocument(): DocumentLike | undefined {
|
||||
return typeof globalThis.document === "undefined"
|
||||
? undefined
|
||||
: globalThis.document;
|
||||
}
|
||||
|
||||
export function discoverToolboxCatalog(
|
||||
options: ToolboxDiscoveryOptions = {},
|
||||
): ToolboxDiscovery {
|
||||
const locationReference = options.location ?? browserLocation();
|
||||
if (locationReference === undefined) return { status: "none" };
|
||||
|
||||
let location: URL;
|
||||
try {
|
||||
location = resolveWebUrl(locationReference);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
error:
|
||||
error instanceof ToolboxError
|
||||
? error
|
||||
: new ToolboxError("invalid-url", "The page URL is invalid", {
|
||||
cause: error,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const parameter = options.queryParameter ?? TOOLBOX_QUERY_PARAMETER;
|
||||
const queryValue = location.searchParams.get(parameter);
|
||||
const document = options.document ?? browserDocument();
|
||||
let metaValue: string | null | undefined;
|
||||
try {
|
||||
const metaName = (options.metaName ?? TOOLBOX_META_NAME)
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll('"', '\\"');
|
||||
metaValue = document
|
||||
?.querySelector(`meta[name="${metaName}"]`)
|
||||
?.getAttribute("content");
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
error: new ToolboxError("invalid-url", "Toolbox meta discovery failed", {
|
||||
cause: error,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const candidate = queryValue ?? metaValue;
|
||||
if (candidate === null || candidate === undefined) return { status: "none" };
|
||||
if (candidate.trim().length === 0) {
|
||||
return {
|
||||
status: "error",
|
||||
error: new ToolboxError(
|
||||
"invalid-url",
|
||||
`The ${queryValue === null ? "meta" : "query"} toolbox URL is empty`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const catalogUrl = requireSameOrigin(
|
||||
resolveWebUrl(candidate, location),
|
||||
location,
|
||||
"Discovered toolbox catalog",
|
||||
);
|
||||
return {
|
||||
status: "found",
|
||||
source: queryValue === null ? "meta" : "query",
|
||||
catalogUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
error:
|
||||
error instanceof ToolboxError
|
||||
? error
|
||||
: new ToolboxError("invalid-url", "The toolbox URL is invalid", {
|
||||
cause: error,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
60
packages/contract/src/index.ts
Normal file
60
packages/contract/src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export {
|
||||
TOOLBOX_META_NAME,
|
||||
TOOLBOX_QUERY_PARAMETER,
|
||||
TOOLBOX_SCHEMA_VERSION,
|
||||
ToolboxError,
|
||||
ToolboxValidationError,
|
||||
} from "./types.js";
|
||||
export type {
|
||||
ResolvedToolboxAction,
|
||||
ResolvedToolboxApp,
|
||||
ResolvedToolboxCatalog,
|
||||
ResolvedToolboxCatalogEntry,
|
||||
ResolvedToolboxCatalogExternalEntry,
|
||||
ResolvedToolboxCatalogManifestEntry,
|
||||
ResolvedToolboxPrivacy,
|
||||
ToolboxAction,
|
||||
ToolboxAppManifest,
|
||||
ToolboxCatalog,
|
||||
ToolboxCatalogEntry,
|
||||
ToolboxCatalogExternalEntry,
|
||||
ToolboxCatalogManifestEntry,
|
||||
ToolboxCatalogTheme,
|
||||
ToolboxContext,
|
||||
ToolboxContextResult,
|
||||
ToolboxDiscovery,
|
||||
ToolboxDiscoverySource,
|
||||
ToolboxErrorCode,
|
||||
ToolboxIntegration,
|
||||
ToolboxLaunchMode,
|
||||
ToolboxPrivacy,
|
||||
ToolboxRequirements,
|
||||
ToolboxSource,
|
||||
} from "./types.js";
|
||||
export {
|
||||
createContextualLink,
|
||||
contextualizeToolboxLink,
|
||||
requireSameOrigin,
|
||||
resolveToolboxApp,
|
||||
resolveWebUrl,
|
||||
} from "./urls.js";
|
||||
export type { ContextualLinkOptions } from "./urls.js";
|
||||
export {
|
||||
defineToolboxApp,
|
||||
defineToolboxCatalog,
|
||||
parseToolboxApp,
|
||||
parseToolboxCatalog,
|
||||
} from "./parse.js";
|
||||
export { discoverToolboxCatalog } from "./discovery.js";
|
||||
export type { ToolboxDiscoveryOptions } from "./discovery.js";
|
||||
export {
|
||||
fetchToolboxAppManifest,
|
||||
fetchToolboxCatalog,
|
||||
loadToolboxCatalog,
|
||||
loadToolboxContext,
|
||||
} from "./load.js";
|
||||
export type {
|
||||
ToolboxContextLoadOptions,
|
||||
ToolboxFetch,
|
||||
ToolboxLoadOptions,
|
||||
} from "./load.js";
|
||||
245
packages/contract/src/load.ts
Normal file
245
packages/contract/src/load.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
discoverToolboxCatalog,
|
||||
type ToolboxDiscoveryOptions,
|
||||
} from "./discovery.js";
|
||||
import { parseToolboxApp, parseToolboxCatalog } from "./parse.js";
|
||||
import {
|
||||
ToolboxError,
|
||||
type ResolvedToolboxCatalog,
|
||||
type ToolboxAppManifest,
|
||||
type ToolboxCatalog,
|
||||
type ToolboxContextResult,
|
||||
type ToolboxDiscoverySource,
|
||||
} from "./types.js";
|
||||
import { requireSameOrigin, resolveToolboxApp, resolveWebUrl } from "./urls.js";
|
||||
|
||||
export type ToolboxFetch = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export interface ToolboxLoadOptions {
|
||||
fetch?: ToolboxFetch;
|
||||
signal?: AbortSignal;
|
||||
baseUrl?: string | URL;
|
||||
expectedOrigin?: string | URL;
|
||||
}
|
||||
|
||||
function defaultFetch(): ToolboxFetch {
|
||||
if (typeof globalThis.fetch !== "function") {
|
||||
throw new ToolboxError(
|
||||
"fetch-failed",
|
||||
"No fetch implementation is available",
|
||||
);
|
||||
}
|
||||
return globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
function absoluteUrl(reference: string | URL, base?: string | URL): URL {
|
||||
if (base !== undefined) return resolveWebUrl(reference, base);
|
||||
if (typeof reference !== "string" || /^[a-z][a-z\d+.-]*:/i.test(reference)) {
|
||||
return resolveWebUrl(reference);
|
||||
}
|
||||
if (typeof globalThis.location !== "undefined") {
|
||||
return resolveWebUrl(reference, globalThis.location.href);
|
||||
}
|
||||
throw new ToolboxError(
|
||||
"invalid-url",
|
||||
`A base URL is required for relative URL: ${reference}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchJson(
|
||||
url: URL,
|
||||
options: ToolboxLoadOptions,
|
||||
): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (options.fetch ?? defaultFetch())(url, {
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "error",
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new ToolboxError("fetch-failed", `Could not fetch ${url.href}`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
if (typeof response.url === "string" && response.url !== "") {
|
||||
requireSameOrigin(
|
||||
resolveWebUrl(response.url),
|
||||
url,
|
||||
`Final response for ${url.href}`,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ToolboxError(
|
||||
"fetch-failed",
|
||||
`Could not fetch ${url.href}: HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (cause) {
|
||||
throw new ToolboxError("fetch-failed", `${url.href} is not valid JSON`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchToolboxAppManifest(
|
||||
manifestUrl: string | URL,
|
||||
options: ToolboxLoadOptions = {},
|
||||
): Promise<ToolboxAppManifest> {
|
||||
const url = absoluteUrl(manifestUrl, options.baseUrl);
|
||||
if (options.expectedOrigin !== undefined) {
|
||||
requireSameOrigin(
|
||||
url,
|
||||
absoluteUrl(options.expectedOrigin),
|
||||
"Application manifest",
|
||||
);
|
||||
}
|
||||
return parseToolboxApp(await fetchJson(url, options));
|
||||
}
|
||||
|
||||
export async function fetchToolboxCatalog(
|
||||
catalogUrl: string | URL,
|
||||
options: ToolboxLoadOptions = {},
|
||||
): Promise<ToolboxCatalog> {
|
||||
const url = absoluteUrl(catalogUrl, options.baseUrl);
|
||||
if (options.expectedOrigin !== undefined) {
|
||||
requireSameOrigin(
|
||||
url,
|
||||
absoluteUrl(options.expectedOrigin),
|
||||
"Toolbox catalog",
|
||||
);
|
||||
}
|
||||
return parseToolboxCatalog(await fetchJson(url, options));
|
||||
}
|
||||
|
||||
export async function loadToolboxCatalog(
|
||||
catalogReference: string | URL,
|
||||
options: ToolboxLoadOptions = {},
|
||||
): Promise<ResolvedToolboxCatalog> {
|
||||
const catalogUrl = absoluteUrl(catalogReference, options.baseUrl);
|
||||
const expectedOrigin =
|
||||
options.expectedOrigin === undefined
|
||||
? catalogUrl
|
||||
: absoluteUrl(options.expectedOrigin);
|
||||
requireSameOrigin(catalogUrl, expectedOrigin, "Toolbox catalog");
|
||||
|
||||
const catalog = await fetchToolboxCatalog(catalogUrl, {
|
||||
...options,
|
||||
expectedOrigin,
|
||||
});
|
||||
const apps = await Promise.all(
|
||||
catalog.apps
|
||||
.filter((entry) => entry.enabled)
|
||||
.map(async (entry) => {
|
||||
if ("entry" in entry) {
|
||||
return {
|
||||
kind: "external" as const,
|
||||
enabled: entry.enabled,
|
||||
name: entry.name,
|
||||
entryUrl: resolveWebUrl(entry.entry, catalogUrl),
|
||||
launch: entry.launch,
|
||||
};
|
||||
}
|
||||
const manifestUrl = requireSameOrigin(
|
||||
resolveWebUrl(entry.manifest, catalogUrl),
|
||||
expectedOrigin,
|
||||
"Catalog application manifest",
|
||||
);
|
||||
const manifest = await fetchToolboxAppManifest(manifestUrl, {
|
||||
...options,
|
||||
expectedOrigin,
|
||||
});
|
||||
return {
|
||||
kind: "manifest" as const,
|
||||
enabled: entry.enabled,
|
||||
app: resolveToolboxApp(manifest, manifestUrl, expectedOrigin),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const homeUrl = requireSameOrigin(
|
||||
resolveWebUrl(catalog.home, catalogUrl),
|
||||
expectedOrigin,
|
||||
"Toolbox home",
|
||||
);
|
||||
|
||||
return {
|
||||
catalog,
|
||||
catalogUrl,
|
||||
apps,
|
||||
homeUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolboxContextLoadOptions
|
||||
extends
|
||||
ToolboxDiscoveryOptions,
|
||||
Omit<ToolboxLoadOptions, "baseUrl" | "expectedOrigin"> {
|
||||
catalogUrl?: string | URL;
|
||||
}
|
||||
|
||||
function asToolboxError(error: unknown): ToolboxError {
|
||||
return error instanceof ToolboxError
|
||||
? error
|
||||
: new ToolboxError(
|
||||
"fetch-failed",
|
||||
"The toolbox context could not be loaded",
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadToolboxContext(
|
||||
options: ToolboxContextLoadOptions = {},
|
||||
): Promise<ToolboxContextResult> {
|
||||
let source: ToolboxDiscoverySource;
|
||||
let catalogUrl: URL;
|
||||
let pageUrl: URL;
|
||||
|
||||
if (options.catalogUrl !== undefined) {
|
||||
source = "explicit";
|
||||
try {
|
||||
pageUrl = resolveWebUrl(
|
||||
options.location ??
|
||||
(typeof globalThis.location === "undefined"
|
||||
? options.catalogUrl
|
||||
: globalThis.location.href),
|
||||
);
|
||||
catalogUrl = requireSameOrigin(
|
||||
resolveWebUrl(options.catalogUrl, pageUrl),
|
||||
pageUrl,
|
||||
"Toolbox catalog",
|
||||
);
|
||||
} catch (error) {
|
||||
return { status: "error", error: asToolboxError(error) };
|
||||
}
|
||||
} else {
|
||||
const discovery = discoverToolboxCatalog(options);
|
||||
if (discovery.status === "none") return { status: "standalone" };
|
||||
if (discovery.status === "error") return discovery;
|
||||
source = discovery.source;
|
||||
catalogUrl = discovery.catalogUrl;
|
||||
pageUrl = resolveWebUrl(
|
||||
options.location ??
|
||||
(typeof globalThis.location === "undefined"
|
||||
? catalogUrl
|
||||
: globalThis.location.href),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const catalog = await loadToolboxCatalog(catalogUrl, {
|
||||
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
expectedOrigin: pageUrl,
|
||||
});
|
||||
return { status: "ready", context: { source, catalog } };
|
||||
} catch (error) {
|
||||
return { status: "error", error: asToolboxError(error) };
|
||||
}
|
||||
}
|
||||
528
packages/contract/src/parse.ts
Normal file
528
packages/contract/src/parse.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import {
|
||||
TOOLBOX_SCHEMA_VERSION,
|
||||
ToolboxValidationError,
|
||||
type ToolboxAction,
|
||||
type ToolboxAppManifest,
|
||||
type ToolboxCatalog,
|
||||
type ToolboxCatalogEntry,
|
||||
type ToolboxCatalogExternalEntry,
|
||||
type ToolboxCatalogManifestEntry,
|
||||
type ToolboxCatalogTheme,
|
||||
type ToolboxIntegration,
|
||||
type ToolboxLaunchMode,
|
||||
type ToolboxPrivacy,
|
||||
type ToolboxRequirements,
|
||||
type ToolboxSource,
|
||||
} from "./types.js";
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
||||
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
const REFERENCE_BASE = "https://toolbox.invalid/";
|
||||
|
||||
function recordAt(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): UnknownRecord {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
issues.push(`${path} must be an object`);
|
||||
return {};
|
||||
}
|
||||
return value as UnknownRecord;
|
||||
}
|
||||
|
||||
function stringAt(
|
||||
object: UnknownRecord,
|
||||
key: string,
|
||||
path: string,
|
||||
issues: string[],
|
||||
options: { optional?: boolean; allowEmpty?: boolean } = {},
|
||||
): string | undefined {
|
||||
if (!(key in object) && options.optional) return undefined;
|
||||
const value = object[key];
|
||||
if (typeof value !== "string") {
|
||||
issues.push(`${path}.${key} must be a string`);
|
||||
return undefined;
|
||||
}
|
||||
if (!options.allowEmpty && value.trim().length === 0) {
|
||||
issues.push(`${path}.${key} must not be empty`);
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanAt(
|
||||
object: UnknownRecord,
|
||||
key: string,
|
||||
path: string,
|
||||
issues: string[],
|
||||
optional = false,
|
||||
): boolean | undefined {
|
||||
if (!(key in object) && optional) return undefined;
|
||||
const value = object[key];
|
||||
if (typeof value !== "boolean") {
|
||||
issues.push(`${path}.${key} must be a boolean`);
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function enumAt<const T extends string>(
|
||||
object: UnknownRecord,
|
||||
key: string,
|
||||
allowed: readonly T[],
|
||||
path: string,
|
||||
issues: string[],
|
||||
): T | undefined {
|
||||
const value = stringAt(object, key, path, issues);
|
||||
if (value === undefined) return undefined;
|
||||
if (!allowed.includes(value as T)) {
|
||||
issues.push(`${path}.${key} must be one of ${allowed.join(", ")}`);
|
||||
return undefined;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function idAt(
|
||||
object: UnknownRecord,
|
||||
key: string,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): string | undefined {
|
||||
const value = stringAt(object, key, path, issues);
|
||||
if (value !== undefined && !ID_PATTERN.test(value)) {
|
||||
issues.push(`${path}.${key} must be a lowercase toolbox id`);
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function referenceAt(
|
||||
object: UnknownRecord,
|
||||
key: string,
|
||||
path: string,
|
||||
issues: string[],
|
||||
optional = false,
|
||||
): string | undefined {
|
||||
const value = stringAt(object, key, path, issues, { optional });
|
||||
if (value === undefined) return undefined;
|
||||
if (value.includes("\\")) {
|
||||
issues.push(`${path}.${key} must use URL path separators`);
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value, REFERENCE_BASE);
|
||||
if (!WEB_PROTOCOLS.has(url.protocol) || url.username || url.password) {
|
||||
issues.push(`${path}.${key} must be an HTTP(S) URL reference`);
|
||||
return undefined;
|
||||
}
|
||||
} catch {
|
||||
issues.push(`${path}.${key} must be a valid URL reference`);
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function absoluteReferenceAt(
|
||||
object: UnknownRecord,
|
||||
key: string,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): string | undefined {
|
||||
const value = referenceAt(object, key, path, issues);
|
||||
if (value === undefined) return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!WEB_PROTOCOLS.has(url.protocol) || url.username || url.password) {
|
||||
throw new Error("Unsupported URL");
|
||||
}
|
||||
} catch {
|
||||
issues.push(`${path}.${key} must be an absolute HTTP(S) URL`);
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function schemaVersionAt(
|
||||
object: UnknownRecord,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): void {
|
||||
if (object.schemaVersion !== TOOLBOX_SCHEMA_VERSION) {
|
||||
issues.push(`${path}.schemaVersion must be ${TOOLBOX_SCHEMA_VERSION}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringListAt(
|
||||
object: UnknownRecord,
|
||||
key: string,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): readonly string[] {
|
||||
const value = object[key];
|
||||
if (!Array.isArray(value)) {
|
||||
issues.push(`${path}.${key} must be an array`);
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return value.flatMap((item, index) => {
|
||||
if (typeof item !== "string" || item.trim().length === 0) {
|
||||
issues.push(`${path}.${key}[${index}] must be a non-empty string`);
|
||||
return [];
|
||||
}
|
||||
if (seen.has(item)) {
|
||||
issues.push(`${path}.${key}[${index}] must be unique`);
|
||||
return [];
|
||||
}
|
||||
seen.add(item);
|
||||
return [item];
|
||||
});
|
||||
}
|
||||
|
||||
function parsePrivacy(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): ToolboxPrivacy | undefined {
|
||||
const object = recordAt(value, path, issues);
|
||||
const processing = enumAt(
|
||||
object,
|
||||
"processing",
|
||||
["local", "remote", "mixed"] as const,
|
||||
path,
|
||||
issues,
|
||||
);
|
||||
const fileUploads = booleanAt(object, "fileUploads", path, issues);
|
||||
const telemetry = booleanAt(object, "telemetry", path, issues);
|
||||
const label = stringAt(object, "label", path, issues, { optional: true });
|
||||
const url = referenceAt(object, "url", path, issues, true);
|
||||
if (
|
||||
processing === undefined ||
|
||||
fileUploads === undefined ||
|
||||
telemetry === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
processing,
|
||||
fileUploads,
|
||||
telemetry,
|
||||
...(label === undefined ? {} : { label }),
|
||||
...(url === undefined ? {} : { url }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseIntegration(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): ToolboxIntegration | undefined {
|
||||
const object = recordAt(value, path, issues);
|
||||
if (object.contextVersion !== 1) {
|
||||
issues.push(`${path}.contextVersion must be 1`);
|
||||
}
|
||||
const launchModes = object.launchModes;
|
||||
const modes: ToolboxLaunchMode[] = [];
|
||||
if (!Array.isArray(launchModes) || launchModes.length === 0) {
|
||||
issues.push(`${path}.launchModes must be a non-empty array`);
|
||||
} else {
|
||||
const seen = new Set<string>();
|
||||
launchModes.forEach((mode, index) => {
|
||||
if (mode !== "navigate" && mode !== "new-tab") {
|
||||
issues.push(`${path}.launchModes[${index}] is invalid`);
|
||||
} else if (seen.has(mode)) {
|
||||
issues.push(`${path}.launchModes[${index}] must be unique`);
|
||||
} else {
|
||||
seen.add(mode);
|
||||
modes.push(mode);
|
||||
}
|
||||
});
|
||||
}
|
||||
const embedding = enumAt(
|
||||
object,
|
||||
"embedding",
|
||||
["unsupported", "supported"] as const,
|
||||
path,
|
||||
issues,
|
||||
);
|
||||
return object.contextVersion === 1 &&
|
||||
modes.length > 0 &&
|
||||
embedding !== undefined
|
||||
? { contextVersion: 1, launchModes: modes, embedding }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseRequirements(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): ToolboxRequirements | undefined {
|
||||
const object = recordAt(value, path, issues);
|
||||
const secureContext = booleanAt(object, "secureContext", path, issues);
|
||||
const workers = booleanAt(object, "workers", path, issues);
|
||||
const indexedDb = booleanAt(object, "indexedDb", path, issues);
|
||||
const crossOriginIsolated = booleanAt(
|
||||
object,
|
||||
"crossOriginIsolated",
|
||||
path,
|
||||
issues,
|
||||
);
|
||||
const topLevelContext = booleanAt(
|
||||
object,
|
||||
"topLevelContext",
|
||||
path,
|
||||
issues,
|
||||
true,
|
||||
);
|
||||
if (
|
||||
secureContext === undefined ||
|
||||
workers === undefined ||
|
||||
indexedDb === undefined ||
|
||||
crossOriginIsolated === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
secureContext,
|
||||
workers,
|
||||
indexedDb,
|
||||
crossOriginIsolated,
|
||||
...(topLevelContext === undefined ? {} : { topLevelContext }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSource(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): ToolboxSource | undefined {
|
||||
const object = recordAt(value, path, issues);
|
||||
const repository = absoluteReferenceAt(object, "repository", path, issues);
|
||||
const license = stringAt(object, "license", path, issues);
|
||||
return repository === undefined || license === undefined
|
||||
? undefined
|
||||
: { repository, license };
|
||||
}
|
||||
|
||||
function parseActions(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): readonly ToolboxAction[] {
|
||||
if (!Array.isArray(value)) {
|
||||
issues.push(`${path} must be an array`);
|
||||
return [];
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
return value.flatMap((item, index) => {
|
||||
const itemPath = `${path}[${index}]`;
|
||||
const object = recordAt(item, itemPath, issues);
|
||||
const id = idAt(object, "id", itemPath, issues);
|
||||
const label = stringAt(object, "label", itemPath, issues);
|
||||
const url = referenceAt(object, "url", itemPath, issues);
|
||||
if (id !== undefined) {
|
||||
if (ids.has(id)) issues.push(`${itemPath}.id must be unique`);
|
||||
ids.add(id);
|
||||
}
|
||||
return id === undefined || label === undefined || url === undefined
|
||||
? []
|
||||
: [{ id, label, url }];
|
||||
});
|
||||
}
|
||||
|
||||
function parseAssets(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
issues.push(`${path} must be an array`);
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return value.flatMap((item, index) => {
|
||||
const holder = { value: item };
|
||||
const reference = referenceAt(holder, "value", `${path}[${index}]`, issues);
|
||||
if (reference === undefined) return [];
|
||||
if (seen.has(reference)) {
|
||||
issues.push(`${path}[${index}] must be unique`);
|
||||
return [];
|
||||
}
|
||||
seen.add(reference);
|
||||
return [reference];
|
||||
});
|
||||
}
|
||||
|
||||
export function parseToolboxApp(value: unknown): ToolboxAppManifest {
|
||||
const issues: string[] = [];
|
||||
const object = recordAt(value, "$", issues);
|
||||
schemaVersionAt(object, "$", issues);
|
||||
const id = idAt(object, "id", "$", issues);
|
||||
const name = stringAt(object, "name", "$", issues);
|
||||
const version = stringAt(object, "version", "$", issues);
|
||||
const description = stringAt(object, "description", "$", issues, {
|
||||
allowEmpty: true,
|
||||
});
|
||||
const entry = referenceAt(object, "entry", "$", issues);
|
||||
const icon = referenceAt(object, "icon", "$", issues);
|
||||
const categories = stringListAt(object, "categories", "$", issues);
|
||||
const tags = stringListAt(object, "tags", "$", issues);
|
||||
const integration = parseIntegration(
|
||||
object.integration,
|
||||
"$.integration",
|
||||
issues,
|
||||
);
|
||||
const requirements = parseRequirements(
|
||||
object.requirements,
|
||||
"$.requirements",
|
||||
issues,
|
||||
);
|
||||
const privacy = parsePrivacy(object.privacy, "$.privacy", issues);
|
||||
const source =
|
||||
"source" in object
|
||||
? parseSource(object.source, "$.source", issues)
|
||||
: undefined;
|
||||
const actions =
|
||||
"actions" in object
|
||||
? parseActions(object.actions, "$.actions", issues)
|
||||
: undefined;
|
||||
const assets =
|
||||
"assets" in object
|
||||
? parseAssets(object.assets, "$.assets", issues)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
issues.length > 0 ||
|
||||
id === undefined ||
|
||||
name === undefined ||
|
||||
version === undefined ||
|
||||
description === undefined ||
|
||||
entry === undefined ||
|
||||
icon === undefined ||
|
||||
integration === undefined ||
|
||||
requirements === undefined ||
|
||||
privacy === undefined
|
||||
) {
|
||||
throw new ToolboxValidationError("invalid-app", issues);
|
||||
}
|
||||
return {
|
||||
schemaVersion: TOOLBOX_SCHEMA_VERSION,
|
||||
id,
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
entry,
|
||||
icon,
|
||||
categories,
|
||||
tags,
|
||||
integration,
|
||||
requirements,
|
||||
privacy,
|
||||
...(source === undefined ? {} : { source }),
|
||||
...(actions === undefined ? {} : { actions }),
|
||||
...(assets === undefined ? {} : { assets }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCatalogEntry(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): ToolboxCatalogEntry | undefined {
|
||||
const object = recordAt(value, path, issues);
|
||||
const enabled = booleanAt(object, "enabled", path, issues);
|
||||
if ("manifest" in object) {
|
||||
const manifest = referenceAt(object, "manifest", path, issues);
|
||||
if ("entry" in object) {
|
||||
issues.push(`${path} cannot contain both manifest and entry`);
|
||||
}
|
||||
return manifest === undefined || enabled === undefined
|
||||
? undefined
|
||||
: ({ manifest, enabled } satisfies ToolboxCatalogManifestEntry);
|
||||
}
|
||||
const name = stringAt(object, "name", path, issues);
|
||||
const entry = referenceAt(object, "entry", path, issues);
|
||||
const launch = enumAt(
|
||||
object,
|
||||
"launch",
|
||||
["navigate", "new-tab"] as const,
|
||||
path,
|
||||
issues,
|
||||
);
|
||||
return name === undefined ||
|
||||
entry === undefined ||
|
||||
launch === undefined ||
|
||||
enabled === undefined
|
||||
? undefined
|
||||
: ({ name, entry, launch, enabled } satisfies ToolboxCatalogExternalEntry);
|
||||
}
|
||||
|
||||
function parseTheme(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): ToolboxCatalogTheme | undefined {
|
||||
const object = recordAt(value, path, issues);
|
||||
const mode = enumAt(
|
||||
object,
|
||||
"mode",
|
||||
["light", "dark", "system"] as const,
|
||||
path,
|
||||
issues,
|
||||
);
|
||||
const brand = stringAt(object, "brand", path, issues);
|
||||
return mode === undefined || brand === undefined
|
||||
? undefined
|
||||
: { mode, brand };
|
||||
}
|
||||
|
||||
export function parseToolboxCatalog(value: unknown): ToolboxCatalog {
|
||||
const issues: string[] = [];
|
||||
const object = recordAt(value, "$", issues);
|
||||
schemaVersionAt(object, "$", issues);
|
||||
const id = idAt(object, "id", "$", issues);
|
||||
const name = stringAt(object, "name", "$", issues);
|
||||
const home = referenceAt(object, "home", "$", issues);
|
||||
const theme = parseTheme(object.theme, "$.theme", issues);
|
||||
const appsValue = object.apps;
|
||||
let apps: readonly ToolboxCatalogEntry[] = [];
|
||||
if (!Array.isArray(appsValue)) {
|
||||
issues.push("$.apps must be an array");
|
||||
} else {
|
||||
apps = appsValue.flatMap((entry, index) => {
|
||||
const parsed = parseCatalogEntry(entry, `$.apps[${index}]`, issues);
|
||||
return parsed === undefined ? [] : [parsed];
|
||||
});
|
||||
}
|
||||
if (
|
||||
issues.length > 0 ||
|
||||
id === undefined ||
|
||||
name === undefined ||
|
||||
home === undefined ||
|
||||
theme === undefined
|
||||
) {
|
||||
throw new ToolboxValidationError("invalid-catalog", issues);
|
||||
}
|
||||
return {
|
||||
schemaVersion: TOOLBOX_SCHEMA_VERSION,
|
||||
id,
|
||||
name,
|
||||
home,
|
||||
theme,
|
||||
apps,
|
||||
};
|
||||
}
|
||||
|
||||
export function defineToolboxApp<const T extends ToolboxAppManifest>(
|
||||
app: T,
|
||||
): T {
|
||||
return app;
|
||||
}
|
||||
|
||||
export function defineToolboxCatalog<const T extends ToolboxCatalog>(
|
||||
catalog: T,
|
||||
): T {
|
||||
return catalog;
|
||||
}
|
||||
178
packages/contract/src/types.ts
Normal file
178
packages/contract/src/types.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
export const TOOLBOX_SCHEMA_VERSION = 1 as const;
|
||||
export const TOOLBOX_QUERY_PARAMETER = "toolbox" as const;
|
||||
export const TOOLBOX_META_NAME = "toolbox" as const;
|
||||
|
||||
export interface ToolboxAction {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ToolboxPrivacy {
|
||||
processing: "local" | "remote" | "mixed";
|
||||
fileUploads: boolean;
|
||||
telemetry: boolean;
|
||||
label?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export type ToolboxLaunchMode = "navigate" | "new-tab";
|
||||
|
||||
export interface ToolboxIntegration {
|
||||
contextVersion: 1;
|
||||
launchModes: readonly ToolboxLaunchMode[];
|
||||
embedding: "unsupported" | "supported";
|
||||
}
|
||||
|
||||
export interface ToolboxRequirements {
|
||||
secureContext: boolean;
|
||||
workers: boolean;
|
||||
indexedDb: boolean;
|
||||
crossOriginIsolated: boolean;
|
||||
topLevelContext?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolboxSource {
|
||||
repository: string;
|
||||
license: string;
|
||||
}
|
||||
|
||||
export interface ToolboxAppManifest {
|
||||
schemaVersion: typeof TOOLBOX_SCHEMA_VERSION;
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
entry: string;
|
||||
icon: string;
|
||||
categories: readonly string[];
|
||||
tags: readonly string[];
|
||||
integration: ToolboxIntegration;
|
||||
requirements: ToolboxRequirements;
|
||||
privacy: ToolboxPrivacy;
|
||||
source?: ToolboxSource;
|
||||
actions?: readonly ToolboxAction[];
|
||||
assets?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ToolboxCatalogManifestEntry {
|
||||
manifest: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ToolboxCatalogExternalEntry {
|
||||
name: string;
|
||||
entry: string;
|
||||
launch: ToolboxLaunchMode;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type ToolboxCatalogEntry =
|
||||
ToolboxCatalogManifestEntry | ToolboxCatalogExternalEntry;
|
||||
|
||||
export interface ToolboxCatalogTheme {
|
||||
mode: "light" | "dark" | "system";
|
||||
brand: string;
|
||||
}
|
||||
|
||||
export interface ToolboxCatalog {
|
||||
schemaVersion: typeof TOOLBOX_SCHEMA_VERSION;
|
||||
id: string;
|
||||
name: string;
|
||||
home: string;
|
||||
theme: ToolboxCatalogTheme;
|
||||
apps: readonly ToolboxCatalogEntry[];
|
||||
}
|
||||
|
||||
export interface ResolvedToolboxAction extends Omit<ToolboxAction, "url"> {
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export interface ResolvedToolboxPrivacy extends Omit<ToolboxPrivacy, "url"> {
|
||||
url?: URL;
|
||||
}
|
||||
|
||||
export interface ResolvedToolboxApp {
|
||||
manifest: ToolboxAppManifest;
|
||||
manifestUrl: URL;
|
||||
entryUrl: URL;
|
||||
actions: readonly ResolvedToolboxAction[];
|
||||
assetUrls: readonly URL[];
|
||||
iconUrl: URL;
|
||||
privacy: ResolvedToolboxPrivacy;
|
||||
}
|
||||
|
||||
export interface ResolvedToolboxCatalogManifestEntry {
|
||||
kind: "manifest";
|
||||
enabled: boolean;
|
||||
app: ResolvedToolboxApp;
|
||||
}
|
||||
|
||||
export interface ResolvedToolboxCatalogExternalEntry {
|
||||
kind: "external";
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
entryUrl: URL;
|
||||
launch: ToolboxLaunchMode;
|
||||
}
|
||||
|
||||
export type ResolvedToolboxCatalogEntry =
|
||||
ResolvedToolboxCatalogManifestEntry | ResolvedToolboxCatalogExternalEntry;
|
||||
|
||||
export interface ResolvedToolboxCatalog {
|
||||
catalog: ToolboxCatalog;
|
||||
catalogUrl: URL;
|
||||
apps: readonly ResolvedToolboxCatalogEntry[];
|
||||
homeUrl: URL;
|
||||
}
|
||||
|
||||
export type ToolboxDiscoverySource = "query" | "meta" | "explicit";
|
||||
|
||||
export interface ToolboxContext {
|
||||
source: ToolboxDiscoverySource;
|
||||
catalog: ResolvedToolboxCatalog;
|
||||
}
|
||||
|
||||
export type ToolboxErrorCode =
|
||||
| "invalid-app"
|
||||
| "invalid-catalog"
|
||||
| "invalid-url"
|
||||
| "cross-origin"
|
||||
| "fetch-failed";
|
||||
|
||||
export class ToolboxError extends Error {
|
||||
readonly code: ToolboxErrorCode;
|
||||
|
||||
constructor(code: ToolboxErrorCode, message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "ToolboxError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolboxValidationError extends ToolboxError {
|
||||
readonly issues: readonly string[];
|
||||
|
||||
constructor(
|
||||
code: Extract<ToolboxErrorCode, "invalid-app" | "invalid-catalog">,
|
||||
issues: readonly string[],
|
||||
) {
|
||||
super(code, `Invalid toolbox document: ${issues.join("; ")}`);
|
||||
this.name = "ToolboxValidationError";
|
||||
this.issues = issues;
|
||||
}
|
||||
}
|
||||
|
||||
export type ToolboxDiscovery =
|
||||
| { status: "none" }
|
||||
| {
|
||||
status: "found";
|
||||
source: Exclude<ToolboxDiscoverySource, "explicit">;
|
||||
catalogUrl: URL;
|
||||
}
|
||||
| { status: "error"; error: ToolboxError };
|
||||
|
||||
export type ToolboxContextResult =
|
||||
| { status: "standalone" }
|
||||
| { status: "ready"; context: ToolboxContext }
|
||||
| { status: "error"; error: ToolboxError };
|
||||
131
packages/contract/src/urls.ts
Normal file
131
packages/contract/src/urls.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
TOOLBOX_QUERY_PARAMETER,
|
||||
ToolboxError,
|
||||
type ResolvedToolboxAction,
|
||||
type ResolvedToolboxApp,
|
||||
type ResolvedToolboxPrivacy,
|
||||
type ToolboxAppManifest,
|
||||
} from "./types.js";
|
||||
|
||||
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
|
||||
export function resolveWebUrl(
|
||||
reference: string | URL,
|
||||
base?: string | URL,
|
||||
): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = base === undefined ? new URL(reference) : new URL(reference, base);
|
||||
} catch (cause) {
|
||||
throw new ToolboxError("invalid-url", `Invalid URL: ${String(reference)}`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
if (!WEB_PROTOCOLS.has(url.protocol) || url.username || url.password) {
|
||||
throw new ToolboxError(
|
||||
"invalid-url",
|
||||
`Only credential-free HTTP(S) URLs are supported: ${url.href}`,
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function requireSameOrigin(
|
||||
url: URL,
|
||||
expected: URL,
|
||||
label = "Toolbox URL",
|
||||
): URL {
|
||||
if (url.origin !== expected.origin) {
|
||||
throw new ToolboxError(
|
||||
"cross-origin",
|
||||
`${label} must share the page origin (${expected.origin})`,
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function resolveToolboxApp(
|
||||
manifest: ToolboxAppManifest,
|
||||
manifestUrl: string | URL,
|
||||
expectedOrigin?: string | URL,
|
||||
): ResolvedToolboxApp {
|
||||
const resolvedManifestUrl = resolveWebUrl(manifestUrl);
|
||||
const origin =
|
||||
expectedOrigin === undefined
|
||||
? resolvedManifestUrl
|
||||
: resolveWebUrl(expectedOrigin);
|
||||
|
||||
requireSameOrigin(resolvedManifestUrl, origin, "Application manifest");
|
||||
|
||||
const localUrl = (reference: string, label: string): URL =>
|
||||
requireSameOrigin(
|
||||
resolveWebUrl(reference, resolvedManifestUrl),
|
||||
origin,
|
||||
label,
|
||||
);
|
||||
const externalUrl = (reference: string): URL =>
|
||||
resolveWebUrl(reference, resolvedManifestUrl);
|
||||
|
||||
const actions: readonly ResolvedToolboxAction[] = (
|
||||
manifest.actions ?? []
|
||||
).map((action) => ({
|
||||
...action,
|
||||
url: externalUrl(action.url),
|
||||
}));
|
||||
const privacy: ResolvedToolboxPrivacy = {
|
||||
processing: manifest.privacy.processing,
|
||||
fileUploads: manifest.privacy.fileUploads,
|
||||
telemetry: manifest.privacy.telemetry,
|
||||
...(manifest.privacy.label === undefined
|
||||
? {}
|
||||
: { label: manifest.privacy.label }),
|
||||
...(manifest.privacy.url === undefined
|
||||
? {}
|
||||
: { url: externalUrl(manifest.privacy.url) }),
|
||||
};
|
||||
|
||||
return {
|
||||
manifest,
|
||||
manifestUrl: resolvedManifestUrl,
|
||||
entryUrl: localUrl(manifest.entry, "Application entry"),
|
||||
actions,
|
||||
assetUrls: (manifest.assets ?? []).map((asset) =>
|
||||
localUrl(asset, "Application asset"),
|
||||
),
|
||||
iconUrl: localUrl(manifest.icon, "Application icon"),
|
||||
privacy,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContextualLinkOptions {
|
||||
location?: string | URL;
|
||||
parameter?: string;
|
||||
}
|
||||
|
||||
export function contextualizeToolboxLink(
|
||||
target: string | URL,
|
||||
catalog: string | URL,
|
||||
options: ContextualLinkOptions = {},
|
||||
): string {
|
||||
const location = resolveWebUrl(
|
||||
options.location ??
|
||||
(typeof globalThis.location === "undefined"
|
||||
? "http://localhost/"
|
||||
: globalThis.location.href),
|
||||
);
|
||||
const targetUrl = resolveWebUrl(target, location);
|
||||
if (targetUrl.origin !== location.origin) return targetUrl.href;
|
||||
|
||||
const catalogUrl = requireSameOrigin(
|
||||
resolveWebUrl(catalog, location),
|
||||
location,
|
||||
"Toolbox catalog",
|
||||
);
|
||||
targetUrl.searchParams.set(
|
||||
options.parameter ?? TOOLBOX_QUERY_PARAMETER,
|
||||
catalogUrl.href,
|
||||
);
|
||||
return targetUrl.href;
|
||||
}
|
||||
|
||||
export const createContextualLink = contextualizeToolboxLink;
|
||||
288
packages/contract/test/contract.test.ts
Normal file
288
packages/contract/test/contract.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
ToolboxValidationError,
|
||||
contextualizeToolboxLink,
|
||||
discoverToolboxCatalog,
|
||||
loadToolboxContext,
|
||||
parseToolboxApp,
|
||||
parseToolboxCatalog,
|
||||
type ToolboxAppManifest,
|
||||
} from "../src/index.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const app = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> => ({
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.pdf-tools",
|
||||
name: "PDF Workbench",
|
||||
version: "1.2.3",
|
||||
description: "Local PDF tools",
|
||||
entry: "./index.html",
|
||||
icon: "./icon.svg",
|
||||
categories: ["documents", "pdf"],
|
||||
tags: ["merge", "split"],
|
||||
integration: {
|
||||
contextVersion: 1,
|
||||
launchModes: ["navigate", "new-tab"],
|
||||
embedding: "unsupported",
|
||||
},
|
||||
requirements: {
|
||||
secureContext: true,
|
||||
workers: true,
|
||||
indexedDb: true,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
privacy: {
|
||||
processing: "local",
|
||||
fileUploads: false,
|
||||
telemetry: false,
|
||||
},
|
||||
source: {
|
||||
repository: "https://git.example.test/pdf-tools",
|
||||
license: "AGPL-3.0-only",
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const catalog = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> => ({
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.toolbox",
|
||||
name: "Toolbox",
|
||||
home: "./",
|
||||
theme: { mode: "system", brand: "add·ideas" },
|
||||
apps: [{ manifest: "./pdf/toolbox-app.json", enabled: true }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const json = (value: unknown, init: ResponseInit = {}): Response =>
|
||||
new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
|
||||
describe("v1 runtime parsing", () => {
|
||||
it("parses the full app contract and drops unknown optional fields", () => {
|
||||
const parsed = parseToolboxApp(
|
||||
app({
|
||||
futureField: "ignored",
|
||||
privacy: {
|
||||
processing: "local",
|
||||
fileUploads: false,
|
||||
telemetry: false,
|
||||
futurePrivacyField: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.pdf-tools",
|
||||
privacy: { processing: "local", fileUploads: false, telemetry: false },
|
||||
});
|
||||
expect(parsed).not.toHaveProperty("futureField");
|
||||
expect(parsed.privacy).not.toHaveProperty("futurePrivacyField");
|
||||
});
|
||||
|
||||
it("rejects unsupported versions and malformed known optional fields", () => {
|
||||
expect(() => parseToolboxApp(app({ schemaVersion: 2 }))).toThrow(
|
||||
ToolboxValidationError,
|
||||
);
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({
|
||||
requirements: {
|
||||
secureContext: true,
|
||||
workers: true,
|
||||
indexedDb: true,
|
||||
crossOriginIsolated: false,
|
||||
topLevelContext: "sometimes",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/topLevelContext must be a boolean/u);
|
||||
});
|
||||
|
||||
it("accepts omitted source metadata but validates it when supplied", () => {
|
||||
const withoutSource = app();
|
||||
delete withoutSource.source;
|
||||
expect(parseToolboxApp(withoutSource)).not.toHaveProperty("source");
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({ source: { repository: "../repository", license: "MIT" } }),
|
||||
),
|
||||
).toThrow(/absolute HTTP\(S\) URL/u);
|
||||
});
|
||||
|
||||
it("parses manifest references and external inline catalog entries", () => {
|
||||
const parsed = parseToolboxCatalog(
|
||||
catalog({
|
||||
futureField: 1,
|
||||
apps: [
|
||||
{ manifest: "./pdf/toolbox-app.json", enabled: true, future: true },
|
||||
{
|
||||
name: "External docs",
|
||||
entry: "https://docs.example.test/",
|
||||
launch: "new-tab",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed.apps).toHaveLength(2);
|
||||
expect(parsed.apps[1]).toEqual({
|
||||
name: "External docs",
|
||||
entry: "https://docs.example.test/",
|
||||
launch: "new-tab",
|
||||
enabled: true,
|
||||
});
|
||||
expect(parsed).not.toHaveProperty("futureField");
|
||||
});
|
||||
});
|
||||
|
||||
describe("same-origin discovery", () => {
|
||||
it("prefers a same-origin query context over meta context", () => {
|
||||
const result = discoverToolboxCatalog({
|
||||
location: "https://tools.example.test/pdf/?toolbox=%2Fcatalog.json",
|
||||
document: {
|
||||
querySelector: () => ({
|
||||
getAttribute: () => "/meta-catalog.json",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("found");
|
||||
if (result.status === "found") {
|
||||
expect(result.source).toBe("query");
|
||||
expect(result.catalogUrl.href).toBe(
|
||||
"https://tools.example.test/catalog.json",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a cross-origin query without falling through to meta", () => {
|
||||
const result = discoverToolboxCatalog({
|
||||
location:
|
||||
"https://tools.example.test/pdf/?toolbox=https%3A%2F%2Fevil.test%2Fcatalog.json",
|
||||
document: {
|
||||
querySelector: () => ({ getAttribute: () => "/safe.json" }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
if (result.status === "error")
|
||||
expect(result.error.code).toBe("cross-origin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loading and links", () => {
|
||||
it("loads a catalog, resolves manifests and URLs, and supports external apps", async () => {
|
||||
const fetch = vi.fn(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
void init;
|
||||
const url = new URL(input instanceof Request ? input.url : input);
|
||||
if (url.pathname === "/catalog.json") {
|
||||
return json(
|
||||
catalog({
|
||||
apps: [
|
||||
{ manifest: "./pdf/toolbox-app.json", enabled: true },
|
||||
{ manifest: "./disabled/toolbox-app.json", enabled: false },
|
||||
{
|
||||
name: "Documentation",
|
||||
entry: "https://docs.example.test/",
|
||||
launch: "new-tab",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/pdf/toolbox-app.json") return json(app());
|
||||
if (url.pathname === "/disabled/toolbox-app.json") {
|
||||
throw new Error("Disabled manifests must not be fetched");
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
);
|
||||
|
||||
const result = await loadToolboxContext({
|
||||
location: "https://tools.example.test/pdf/?toolbox=/catalog.json",
|
||||
fetch,
|
||||
});
|
||||
|
||||
for (const [, init] of fetch.mock.calls) {
|
||||
expect(init).toMatchObject({ redirect: "error" });
|
||||
}
|
||||
|
||||
expect(result.status).toBe("ready");
|
||||
if (result.status === "ready") {
|
||||
expect(result.context.catalog.homeUrl.href).toBe(
|
||||
"https://tools.example.test/",
|
||||
);
|
||||
expect(result.context.catalog.apps[0]?.kind).toBe("manifest");
|
||||
const first = result.context.catalog.apps[0];
|
||||
if (first?.kind === "manifest") {
|
||||
expect(first.app.entryUrl.href).toBe(
|
||||
"https://tools.example.test/pdf/index.html",
|
||||
);
|
||||
}
|
||||
expect(result.context.catalog.apps[1]).toMatchObject({
|
||||
kind: "external",
|
||||
launch: "new-tab",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a graceful error result for invalid JSON and fetch failure", async () => {
|
||||
const invalid = await loadToolboxContext({
|
||||
location: "https://tools.example.test/?toolbox=/catalog.json",
|
||||
fetch: async () => json(catalog({ schemaVersion: 2 })),
|
||||
});
|
||||
expect(invalid.status).toBe("error");
|
||||
|
||||
const unavailable = await loadToolboxContext({
|
||||
location: "https://tools.example.test/?toolbox=/catalog.json",
|
||||
fetch: async () => new Response("No", { status: 503 }),
|
||||
});
|
||||
expect(unavailable.status).toBe("error");
|
||||
if (unavailable.status === "error") {
|
||||
expect(unavailable.error.code).toBe("fetch-failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a fetch redirected to a cross-origin response URL", async () => {
|
||||
const response = json(catalog());
|
||||
Object.defineProperty(response, "url", {
|
||||
value: "https://evil.example/toolbox.catalog.json",
|
||||
});
|
||||
const result = await loadToolboxContext({
|
||||
location: "https://tools.example.test/?toolbox=/catalog.json",
|
||||
fetch: async () => response,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
if (result.status === "error")
|
||||
expect(result.error.code).toBe("cross-origin");
|
||||
});
|
||||
|
||||
it("adds context to same-origin links but leaves external links alone", () => {
|
||||
expect(
|
||||
new URL(
|
||||
contextualizeToolboxLink("./other/", "/catalog.json", {
|
||||
location: "https://tools.example.test/pdf/",
|
||||
}),
|
||||
).searchParams.get("toolbox"),
|
||||
).toBe("https://tools.example.test/catalog.json");
|
||||
|
||||
expect(
|
||||
contextualizeToolboxLink("https://docs.example.test/", "/catalog.json", {
|
||||
location: "https://tools.example.test/pdf/",
|
||||
}),
|
||||
).toBe("https://docs.example.test/");
|
||||
});
|
||||
});
|
||||
|
||||
export const validAppFixture = app() as unknown as ToolboxAppManifest;
|
||||
261
packages/contract/test/schema-parity.test.ts
Normal file
261
packages/contract/test/schema-parity.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseToolboxApp, parseToolboxCatalog } from "../src/index.js";
|
||||
|
||||
const appSchema = JSON.parse(
|
||||
readFileSync(
|
||||
new URL("../../../schemas/toolbox-app.v1.schema.json", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
) as object;
|
||||
const catalogSchema = JSON.parse(
|
||||
readFileSync(
|
||||
new URL("../../../schemas/toolbox-catalog.v1.schema.json", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
) as object;
|
||||
|
||||
const ajv = new Ajv2020({
|
||||
allErrors: true,
|
||||
strict: true,
|
||||
// `not: { required: [...] }` intentionally names the opposite union arm.
|
||||
strictRequired: false,
|
||||
});
|
||||
const validateApp = ajv.compile(appSchema);
|
||||
const validateCatalog = ajv.compile(catalogSchema);
|
||||
|
||||
type Parser = (value: unknown) => unknown;
|
||||
|
||||
function runtimeAccepts(parser: Parser, value: unknown): boolean {
|
||||
try {
|
||||
parser(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function expectParity(
|
||||
validate: ValidateFunction,
|
||||
parser: Parser,
|
||||
value: unknown,
|
||||
expected: boolean,
|
||||
): void {
|
||||
const schemaAccepted = validate(value);
|
||||
expect(
|
||||
schemaAccepted,
|
||||
ajv.errorsText(validate.errors, { separator: "\n" }),
|
||||
).toBe(expected);
|
||||
expect(runtimeAccepts(parser, value)).toBe(expected);
|
||||
}
|
||||
|
||||
const validApp = (): Record<string, unknown> => ({
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.example",
|
||||
name: "Example",
|
||||
version: "1.2.3",
|
||||
description: "Example application",
|
||||
entry: "./",
|
||||
icon: "./icon.svg",
|
||||
categories: ["documents"],
|
||||
tags: ["example"],
|
||||
integration: {
|
||||
contextVersion: 1,
|
||||
launchModes: ["navigate"],
|
||||
embedding: "unsupported",
|
||||
},
|
||||
requirements: {
|
||||
secureContext: false,
|
||||
workers: false,
|
||||
indexedDb: false,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
privacy: {
|
||||
processing: "local",
|
||||
fileUploads: false,
|
||||
telemetry: false,
|
||||
},
|
||||
source: {
|
||||
repository: "https://example.test/source",
|
||||
license: "MIT",
|
||||
},
|
||||
});
|
||||
|
||||
const validCatalog = (): Record<string, unknown> => ({
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.toolbox",
|
||||
name: "Example Toolbox",
|
||||
home: "./",
|
||||
theme: { mode: "system", brand: "Example" },
|
||||
apps: [{ manifest: "./apps/example/toolbox-app.json", enabled: true }],
|
||||
});
|
||||
|
||||
describe("canonical schema and runtime parser parity", () => {
|
||||
const acceptedApps: Array<[string, () => Record<string, unknown>]> = [
|
||||
["the baseline app", validApp],
|
||||
[
|
||||
"relative references containing spaces and path @ signs",
|
||||
() => ({
|
||||
...validApp(),
|
||||
entry: " ./nested app/?mode=1#result ",
|
||||
actions: [{ id: "docs", label: "Docs", url: "./people/@me" }],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"case-insensitive and compact absolute HTTP URLs",
|
||||
() => ({
|
||||
...validApp(),
|
||||
source: { repository: "HTTPS:example.test", license: "MIT" },
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(acceptedApps)("accepts %s", (_label, fixture) => {
|
||||
expectParity(validateApp, parseToolboxApp, fixture(), true);
|
||||
});
|
||||
|
||||
const rejectedApps: Array<[string, () => Record<string, unknown>]> = [
|
||||
["a whitespace-only name", () => ({ ...validApp(), name: " \t " })],
|
||||
[
|
||||
"a whitespace-only list item",
|
||||
() => ({ ...validApp(), categories: ["documents", " \n "] }),
|
||||
],
|
||||
[
|
||||
"a non-web entry scheme",
|
||||
() => ({ ...validApp(), entry: "javascript:alert(1)" }),
|
||||
],
|
||||
["a backslash path", () => ({ ...validApp(), icon: ".\\icon.svg" })],
|
||||
["an empty HTTP authority", () => ({ ...validApp(), entry: "https://" })],
|
||||
[
|
||||
"a credential-bearing privacy URL",
|
||||
() => ({
|
||||
...validApp(),
|
||||
privacy: {
|
||||
processing: "local",
|
||||
fileUploads: false,
|
||||
telemetry: false,
|
||||
url: "https://user:secret@example.test/privacy",
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a relative source repository",
|
||||
() => ({
|
||||
...validApp(),
|
||||
source: { repository: "../source", license: "MIT" },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a compact credential-bearing source repository",
|
||||
() => ({
|
||||
...validApp(),
|
||||
source: { repository: "https:user@example.test", license: "MIT" },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a whitespace-only action label",
|
||||
() => ({
|
||||
...validApp(),
|
||||
actions: [{ id: "docs", label: " ", url: "./docs" }],
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(rejectedApps)("rejects %s", (_label, fixture) => {
|
||||
expectParity(validateApp, parseToolboxApp, fixture(), false);
|
||||
});
|
||||
|
||||
const acceptedCatalogs: Array<[string, () => Record<string, unknown>]> = [
|
||||
["the baseline catalog", validCatalog],
|
||||
[
|
||||
"an uppercase HTTPS manifest URL",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
apps: [
|
||||
{
|
||||
manifest: "HTTPS://example.test/apps/example/toolbox-app.json",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(acceptedCatalogs)("accepts %s", (_label, fixture) => {
|
||||
expectParity(validateCatalog, parseToolboxCatalog, fixture(), true);
|
||||
});
|
||||
|
||||
const rejectedCatalogs: Array<[string, () => Record<string, unknown>]> = [
|
||||
[
|
||||
"a whitespace-only catalog name",
|
||||
() => ({ ...validCatalog(), name: " " }),
|
||||
],
|
||||
[
|
||||
"a whitespace-only brand",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
theme: { mode: "system", brand: "\t" },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a non-web home URL",
|
||||
() => ({ ...validCatalog(), home: "file:///tmp/toolbox" }),
|
||||
],
|
||||
[
|
||||
"a backslash manifest path",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
apps: [{ manifest: ".\\apps\\toolbox-app.json", enabled: true }],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a credential-bearing manifest URL",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
apps: [
|
||||
{
|
||||
manifest: "https://user:secret@example.test/toolbox-app.json",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a non-web external entry",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
apps: [
|
||||
{
|
||||
name: "External",
|
||||
entry: "data:text/html,hello",
|
||||
launch: "new-tab",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a whitespace-only external name",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
apps: [
|
||||
{
|
||||
name: " ",
|
||||
entry: "https://example.test/",
|
||||
launch: "new-tab",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(rejectedCatalogs)("rejects %s", (_label, fixture) => {
|
||||
expectParity(validateCatalog, parseToolboxCatalog, fixture(), false);
|
||||
});
|
||||
});
|
||||
7
packages/contract/tsconfig.build.json
Normal file
7
packages/contract/tsconfig.build.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["test"]
|
||||
}
|
||||
8
packages/contract/tsconfig.json
Normal file
8
packages/contract/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
192
packages/shell-react/LICENSE
Normal file
192
packages/shell-react/LICENSE
Normal file
@@ -0,0 +1,192 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
Copyright 2026 ADD Ideas
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
15
packages/shell-react/README.md
Normal file
15
packages/shell-react/README.md
Normal 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.
|
||||
53
packages/shell-react/package.json
Normal file
53
packages/shell-react/package.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
231
packages/shell-react/src/AppShell.tsx
Normal file
231
packages/shell-react/src/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
packages/shell-react/src/index.ts
Normal file
2
packages/shell-react/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AppShell } from "./AppShell.js";
|
||||
export type { AppShellProps } from "./AppShell.js";
|
||||
202
packages/shell-react/src/styles.css
Normal file
202
packages/shell-react/src/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
202
packages/shell-react/test/AppShell.test.tsx
Normal file
202
packages/shell-react/test/AppShell.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
9
packages/shell-react/tsconfig.build.json
Normal file
9
packages/shell-react/tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
packages/shell-react/tsconfig.json
Normal file
11
packages/shell-react/tsconfig.json
Normal 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
192
packages/testkit/LICENSE
Normal file
@@ -0,0 +1,192 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
Copyright 2026 ADD Ideas
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
17
packages/testkit/README.md
Normal file
17
packages/testkit/README.md
Normal 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()`.
|
||||
52
packages/testkit/package.json
Normal file
52
packages/testkit/package.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
482
packages/testkit/src/check.ts
Normal file
482
packages/testkit/src/check.ts
Normal 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 };
|
||||
}
|
||||
28
packages/testkit/src/cli.ts
Normal file
28
packages/testkit/src/cli.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
2
packages/testkit/src/index.ts
Normal file
2
packages/testkit/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { checkToolboxDist, ToolboxCheckError } from "./check.js";
|
||||
export type { ToolboxCheckOptions, ToolboxCheckReport } from "./check.js";
|
||||
118
packages/testkit/test/check.test.ts
Normal file
118
packages/testkit/test/check.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
9
packages/testkit/tsconfig.build.json
Normal file
9
packages/testkit/tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
packages/testkit/tsconfig.json
Normal file
11
packages/testkit/tsconfig.json
Normal 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"]
|
||||
}
|
||||
219
schemas/toolbox-app.v1.schema.json
Normal file
219
schemas/toolbox-app.v1.schema.json
Normal 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
|
||||
}
|
||||
115
schemas/toolbox-catalog.v1.schema.json
Normal file
115
schemas/toolbox-catalog.v1.schema.json
Normal 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
18
tsconfig.base.json
Normal 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
26
vitest.config.ts
Normal 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
8
vitest.setup.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Reference in New Issue
Block a user