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