Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc91659a42 | ||
|
|
ef2dab4b46 | ||
|
|
53c40a61ba |
@@ -0,0 +1,35 @@
|
|||||||
|
name: Verify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
- name: Select declared npm version
|
||||||
|
run: npm install --global npm@11.17.0
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Audit runtime dependencies
|
||||||
|
run: npm audit --omit=dev --audit-level=moderate
|
||||||
|
- name: Check, test, and build
|
||||||
|
run: npm run check
|
||||||
@@ -1 +1 @@
|
|||||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
|
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
|
||||||
|
|||||||
@@ -3,6 +3,46 @@
|
|||||||
All notable changes to the independently versioned Toolbox SDK packages are
|
All notable changes to the independently versioned Toolbox SDK packages are
|
||||||
recorded here. The packages currently share one release version.
|
recorded here. The packages currently share one release version.
|
||||||
|
|
||||||
|
## 0.3.0 — 2026-09-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Runtime-validated manifest I/O and progressive browser-capability profiles.
|
||||||
|
- Bounded, same-origin, target-routed, expiring, one-time artifact handoffs
|
||||||
|
through IndexedDB, with versioned operation evidence and opaque tokens.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Reject unsafe artifact names, malformed descriptors, oversized evidence,
|
||||||
|
excessive file counts and cross-origin handoff destinations before storage or
|
||||||
|
navigation.
|
||||||
|
- Document the origin-wide IndexedDB trust boundary explicitly: target app ids
|
||||||
|
route transfers but cannot authorize mutually untrusted same-origin code.
|
||||||
|
|
||||||
|
## 0.2.3 — 2026-07-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the canonical repository, schema identifiers, examples, and source
|
||||||
|
metadata to the `lotobo` (LocalToolBox) Gitea organization.
|
||||||
|
- Moved publication of all three packages to the `lotobo` Gitea npm registry.
|
||||||
|
|
||||||
|
## 0.2.2 — 2026-07-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `ToolboxPersonalizePanel` for the portal-style personalization heading,
|
||||||
|
storage warning, appearance picker, and portal extension content.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The invariant top row now fixes the Toolbox brand/home at the far left and
|
||||||
|
Help, source, Apps, and Personalize at the far right in that order while the
|
||||||
|
text identity remains geometrically centered.
|
||||||
|
- Default app personalization now matches the central portal's panel styling;
|
||||||
|
portal-only catalogue counts and import/export actions remain extensible
|
||||||
|
content.
|
||||||
|
|
||||||
## 0.2.1 — 2026-07-23
|
## 0.2.1 — 2026-07-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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
|
catalog URL, the same app gains a toolbox home link and an app switcher without
|
||||||
becoming coupled to a portal or client-side router.
|
becoming coupled to a portal or client-side router.
|
||||||
|
|
||||||
Version `0.2.1` contains three publish-ready packages:
|
Version `0.3.0` contains three publish-ready packages:
|
||||||
|
|
||||||
- `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context
|
- `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context
|
||||||
discovery/loading, resolved URLs, and contextual link helpers.
|
discovery/loading, resolved URLs, and contextual link helpers.
|
||||||
@@ -36,7 +36,7 @@ Deploy `toolbox-app.json` beside the application entry. The canonical schema is
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.pdf-tools",
|
"id": "de.add-ideas.pdf-tools",
|
||||||
"name": "PDF Workbench",
|
"name": "PDF Workbench",
|
||||||
@@ -57,24 +57,41 @@ Deploy `toolbox-app.json` beside the application entry. The canonical schema is
|
|||||||
"indexedDb": true,
|
"indexedDb": true,
|
||||||
"crossOriginIsolated": false
|
"crossOriginIsolated": false
|
||||||
},
|
},
|
||||||
|
"io": {
|
||||||
|
"accepts": [{ "mediaType": "application/pdf", "extensions": [".pdf"] }],
|
||||||
|
"produces": [{ "mediaType": "application/pdf", "extensions": [".pdf"] }]
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"required": ["workers"],
|
||||||
|
"optional": ["file-system-access"]
|
||||||
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": false,
|
"fileUploads": false,
|
||||||
"telemetry": false
|
"telemetry": false
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"repository": "https://git.add-ideas.de/zemion/pdf-tools",
|
"repository": "https://git.add-ideas.de/lotobo/pdf-tools",
|
||||||
"license": "AGPL-3.0-only"
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`source`, `privacy.label`, `privacy.url`, `requirements.topLevelContext`,
|
`source`, `privacy.label`, `privacy.url`, `requirements.topLevelContext`, `io`,
|
||||||
`actions`, and `assets` are optional v1 additions. `toolbox-check` verifies the
|
`capabilities`, `actions`, and `assets` are optional v1 additions. `io`
|
||||||
entry, icon, and every declared asset. Runtime parsers validate every known
|
advertises accepted and produced media types/extensions; `capabilities`
|
||||||
|
describes required and progressive browser features. `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
|
field, require `schemaVersion: 1`, and deliberately discard unknown fields so
|
||||||
future optional additions do not break v1 consumers.
|
future optional additions do not break v1 consumers.
|
||||||
|
|
||||||
|
When a manifest includes `capabilities`, worker declarations are cross-checked:
|
||||||
|
`requirements.workers: true` means the app cannot run without workers and
|
||||||
|
therefore requires `"workers"` in `capabilities.required`. Apps with a
|
||||||
|
main-thread fallback set the requirement to `false` and may list `"workers"` in
|
||||||
|
`capabilities.optional` instead. Legacy v1 manifests without a capability
|
||||||
|
profile remain valid.
|
||||||
|
|
||||||
For typed source definitions, use the literal-preserving identity helper:
|
For typed source definitions, use the literal-preserving identity helper:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -88,6 +105,24 @@ export const manifest = defineToolboxApp({
|
|||||||
Use `parseToolboxApp(unknownValue)` at trust boundaries; `defineToolboxApp()` is
|
Use `parseToolboxApp(unknownValue)` at trust boundaries; `defineToolboxApp()` is
|
||||||
compile-time only and does not replace runtime parsing.
|
compile-time only and does not replace runtime parsing.
|
||||||
|
|
||||||
|
## Explicit local artifact handoff
|
||||||
|
|
||||||
|
`createToolboxTransfer()` stores bounded `Blob` objects in same-origin IndexedDB
|
||||||
|
using a cryptographic, short-lived token routed to one target app. Only that
|
||||||
|
opaque token is added to the target URL by `createToolboxTransferUrl()`. The
|
||||||
|
target calls `consumeToolboxTransfer()` and the record is atomically deleted.
|
||||||
|
File bytes are neither uploaded nor placed in URLs, cross-origin destinations
|
||||||
|
are rejected, and transfers expire after fifteen minutes by default. File
|
||||||
|
descriptors, evidence, counts, names, sizes, and lifetimes are bounded before
|
||||||
|
IndexedDB receives them.
|
||||||
|
|
||||||
|
The target app id is a routing/integrity check in this API, not a browser access
|
||||||
|
control boundary. IndexedDB is shared by the entire origin, so any script
|
||||||
|
running on that origin can open the transfer database directly and read or
|
||||||
|
delete its records. Deploy only mutually trusted, reviewed Toolbox apps on one
|
||||||
|
origin; an untrusted app must use a separate origin and cannot participate in
|
||||||
|
this same-origin handoff.
|
||||||
|
|
||||||
## Catalog v1
|
## Catalog v1
|
||||||
|
|
||||||
The canonical schema is
|
The canonical schema is
|
||||||
@@ -167,11 +202,11 @@ import "@add-ideas/toolbox-shell-react/styles.css";
|
|||||||
</AppShell>;
|
</AppShell>;
|
||||||
```
|
```
|
||||||
|
|
||||||
The shell's fixed top row renders Personalize, Apps, source, and Help controls
|
The shell's fixed top row renders the Toolbox brand/home at the far left, a
|
||||||
in that order, a geometrically centered text identity, and the Toolbox
|
geometrically centered text identity, and Help, source, Apps, and Personalize
|
||||||
brand/home at the far right. Personalize and Apps have labeled desktop controls
|
controls at the far right in that order. Personalize and Apps have labeled
|
||||||
and retain their accessible names when compact. The mutually exclusive popovers
|
desktop controls and retain their accessible names when compact. The mutually
|
||||||
close on Escape, outside interaction, and link selection.
|
exclusive popovers close on Escape, outside interaction, and link selection.
|
||||||
|
|
||||||
`AppShell` moves version, derived privacy facts, manifest actions, and legacy
|
`AppShell` moves version, derived privacy facts, manifest actions, and legacy
|
||||||
`appActions` into its standard footer, keeping the top row stable in connected
|
`appActions` into its standard footer, keeping the top row stable in connected
|
||||||
@@ -183,7 +218,9 @@ optional observability.
|
|||||||
|
|
||||||
For a custom portal-style header, `ToolboxHeader` accepts `personalizeContent`
|
For a custom portal-style header, `ToolboxHeader` accepts `personalizeContent`
|
||||||
for content hosted inside the shared controlled popover and `brandIconUrl` for
|
for content hosted inside the shared controlled popover and `brandIconUrl` for
|
||||||
the Toolbox mark. `onPersonalize` remains available for callback-only
|
the Toolbox mark. `ToolboxPersonalizePanel` provides the shared portal-style
|
||||||
|
heading, storage warning, and appearance picker, with a child slot for
|
||||||
|
portal-specific controls. `onPersonalize` remains available for callback-only
|
||||||
integrations. The deprecated `iconUrl`, `leadingActions`, and `metadata` props
|
integrations. The deprecated `iconUrl`, `leadingActions`, and `metadata` props
|
||||||
are retained for source compatibility; the centered app icon is intentionally no
|
are retained for source compatibility; the centered app icon is intentionally no
|
||||||
longer rendered.
|
longer rendered.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.example-tool",
|
"id": "de.add-ideas.example-tool",
|
||||||
"name": "Example Tool",
|
"name": "Example Tool",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"telemetry": false
|
"telemetry": false
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"repository": "https://git.add-ideas.de/zemion/toolbox-sdk",
|
"repository": "https://git.add-ideas.de/lotobo/toolbox-sdk",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
|
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.example-toolbox",
|
"id": "de.add-ideas.example-toolbox",
|
||||||
"name": "Example Toolbox",
|
"name": "Example Toolbox",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const toolboxApp = defineToolboxApp({
|
|||||||
telemetry: false,
|
telemetry: false,
|
||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
repository: "https://git.add-ideas.de/zemion/toolbox-sdk",
|
repository: "https://git.add-ideas.de/lotobo/toolbox-sdk",
|
||||||
license: "Apache-2.0",
|
license: "Apache-2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -3946,7 +3946,7 @@
|
|||||||
},
|
},
|
||||||
"packages/contract": {
|
"packages/contract": {
|
||||||
"name": "@add-ideas/toolbox-contract",
|
"name": "@add-ideas/toolbox-contract",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -3954,10 +3954,10 @@
|
|||||||
},
|
},
|
||||||
"packages/shell-react": {
|
"packages/shell-react": {
|
||||||
"name": "@add-ideas/toolbox-shell-react",
|
"name": "@add-ideas/toolbox-shell-react",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.1"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -3966,10 +3966,10 @@
|
|||||||
},
|
},
|
||||||
"packages/testkit": {
|
"packages/testkit": {
|
||||||
"name": "@add-ideas/toolbox-testkit",
|
"name": "@add-ideas/toolbox-testkit",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.1"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
|
|||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A small, framework-neutral toolbox contract with a React application shell.",
|
"description": "A small, framework-neutral toolbox contract with a React application shell.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git"
|
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
@@ -17,7 +17,30 @@ The package also owns the versioned same-origin browser preference contract. Use
|
|||||||
`readToolboxPreferences()` and `writeToolboxPreferences()` to share pinned apps,
|
`readToolboxPreferences()` and `writeToolboxPreferences()` to share pinned apps,
|
||||||
ordering, visibility, and light/dark/system mode with the Toolbox portal.
|
ordering, visibility, and light/dark/system mode with the Toolbox portal.
|
||||||
|
|
||||||
See the workspace [README](https://git.add-ideas.de/zemion/toolbox-sdk#readme)
|
Applications can advertise accepted and produced formats through the optional
|
||||||
|
`io` manifest profile, and required or optional browser features through
|
||||||
|
`capabilities`. These fields are runtime validated while remaining compatible
|
||||||
|
with existing v1 manifests. When the capability profile is present, a manifest
|
||||||
|
that sets `requirements.workers` to `true` must also list `"workers"` in
|
||||||
|
`capabilities.required`; progressive worker enhancements belong in
|
||||||
|
`capabilities.optional` with the requirement set to `false`. Legacy v1 manifests
|
||||||
|
without `capabilities` remain valid.
|
||||||
|
|
||||||
|
The package also provides an explicit local artifact handoff. A sender stores
|
||||||
|
one or more bounded `Blob` objects in same-origin IndexedDB with a
|
||||||
|
cryptographic, short-lived token and a target routing label.
|
||||||
|
`createToolboxTransferUrl()` places only the opaque token in the target URL;
|
||||||
|
`consumeToolboxTransfer()` atomically consumes it once. No file bytes are put in
|
||||||
|
a URL, uploaded, or persisted after consumption. Destination URLs must remain
|
||||||
|
same-origin, and descriptors, evidence, file counts, total bytes, and lifetimes
|
||||||
|
are validated and bounded before storage.
|
||||||
|
|
||||||
|
All code on one origin is inside the trust boundary: same-origin scripts can
|
||||||
|
open IndexedDB without using this API, so `targetAppId` is not authorization.
|
||||||
|
Host mutually untrusted apps on distinct origins; they deliberately cannot use
|
||||||
|
this transfer mechanism.
|
||||||
|
|
||||||
|
See the workspace [README](https://git.add-ideas.de/lotobo/toolbox-sdk#readme)
|
||||||
for the v1 document formats and full API.
|
for the v1 document formats and full API.
|
||||||
|
|
||||||
The canonical schemas are also published at
|
The canonical schemas are also published at
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-contract",
|
"name": "@add-ideas/toolbox-contract",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.",
|
"description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
|
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git",
|
||||||
"directory": "packages/contract"
|
"directory": "packages/contract"
|
||||||
},
|
},
|
||||||
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
|
"homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"toolbox",
|
"toolbox",
|
||||||
"manifest",
|
"manifest",
|
||||||
@@ -43,6 +43,6 @@
|
|||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/"
|
"registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export {
|
export {
|
||||||
TOOLBOX_META_NAME,
|
TOOLBOX_META_NAME,
|
||||||
TOOLBOX_QUERY_PARAMETER,
|
TOOLBOX_QUERY_PARAMETER,
|
||||||
|
TOOLBOX_TRANSFER_QUERY_PARAMETER,
|
||||||
|
TOOLBOX_ARTIFACT_VERSION,
|
||||||
TOOLBOX_SCHEMA_VERSION,
|
TOOLBOX_SCHEMA_VERSION,
|
||||||
ToolboxError,
|
ToolboxError,
|
||||||
ToolboxValidationError,
|
ToolboxValidationError,
|
||||||
@@ -26,6 +28,9 @@ export type {
|
|||||||
ToolboxDiscoverySource,
|
ToolboxDiscoverySource,
|
||||||
ToolboxErrorCode,
|
ToolboxErrorCode,
|
||||||
ToolboxIntegration,
|
ToolboxIntegration,
|
||||||
|
ToolboxFormat,
|
||||||
|
ToolboxIoProfile,
|
||||||
|
ToolboxCapabilityProfile,
|
||||||
ToolboxLaunchMode,
|
ToolboxLaunchMode,
|
||||||
ToolboxPrivacy,
|
ToolboxPrivacy,
|
||||||
ToolboxRequirements,
|
ToolboxRequirements,
|
||||||
@@ -53,6 +58,24 @@ export {
|
|||||||
loadToolboxCatalog,
|
loadToolboxCatalog,
|
||||||
loadToolboxContext,
|
loadToolboxContext,
|
||||||
} from "./load.js";
|
} from "./load.js";
|
||||||
|
export {
|
||||||
|
consumeToolboxTransfer,
|
||||||
|
createToolboxTransfer,
|
||||||
|
createToolboxTransferUrl,
|
||||||
|
deleteExpiredToolboxTransfers,
|
||||||
|
readToolboxTransferToken,
|
||||||
|
} from "./transfer.js";
|
||||||
|
export type {
|
||||||
|
ToolboxArtifactDescriptor,
|
||||||
|
ToolboxArtifactEvidence,
|
||||||
|
ToolboxArtifactFile,
|
||||||
|
ToolboxArtifactSource,
|
||||||
|
ToolboxTransfer,
|
||||||
|
ToolboxTransferCreateInput,
|
||||||
|
ToolboxTransferOptions,
|
||||||
|
ToolboxTransferStore,
|
||||||
|
ToolboxTransferUrlOptions,
|
||||||
|
} from "./transfer.js";
|
||||||
export {
|
export {
|
||||||
TOOLBOX_PREFERENCES_KEY,
|
TOOLBOX_PREFERENCES_KEY,
|
||||||
defaultToolboxPreferences,
|
defaultToolboxPreferences,
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
type ToolboxCatalogManifestEntry,
|
type ToolboxCatalogManifestEntry,
|
||||||
type ToolboxCatalogTheme,
|
type ToolboxCatalogTheme,
|
||||||
type ToolboxIntegration,
|
type ToolboxIntegration,
|
||||||
|
type ToolboxIoProfile,
|
||||||
|
type ToolboxFormat,
|
||||||
|
type ToolboxCapabilityProfile,
|
||||||
type ToolboxLaunchMode,
|
type ToolboxLaunchMode,
|
||||||
type ToolboxPrivacy,
|
type ToolboxPrivacy,
|
||||||
type ToolboxRequirements,
|
type ToolboxRequirements,
|
||||||
@@ -21,6 +24,9 @@ type UnknownRecord = Record<string, unknown>;
|
|||||||
const ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
const ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
||||||
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
|
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
|
||||||
const REFERENCE_BASE = "https://toolbox.invalid/";
|
const REFERENCE_BASE = "https://toolbox.invalid/";
|
||||||
|
const MEDIA_TYPE_PATTERN =
|
||||||
|
/^(?:\*|[a-z0-9!#$&^_.+-]+)\/(?:\*|[a-z0-9!#$&^_.+-]+)$/iu;
|
||||||
|
const EXTENSION_PATTERN = /^\.[a-z0-9][a-z0-9._+-]*$/iu;
|
||||||
|
|
||||||
function recordAt(
|
function recordAt(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
@@ -186,6 +192,107 @@ function stringListAt(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function identifierListAt(
|
||||||
|
object: UnknownRecord,
|
||||||
|
key: string,
|
||||||
|
path: string,
|
||||||
|
issues: string[],
|
||||||
|
): readonly string[] {
|
||||||
|
const values = stringListAt(object, key, path, issues);
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
if (!ID_PATTERN.test(value)) {
|
||||||
|
issues.push(`${path}.${key}[${index}] must be a lowercase toolbox id`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return values.filter((value) => ID_PATTERN.test(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFormats(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
issues: string[],
|
||||||
|
): readonly ToolboxFormat[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
issues.push(`${path} must be an array`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const identities = new Set<string>();
|
||||||
|
return value.flatMap((item, index) => {
|
||||||
|
const itemPath = `${path}[${index}]`;
|
||||||
|
const object = recordAt(item, itemPath, issues);
|
||||||
|
const mediaType = stringAt(object, "mediaType", itemPath, issues);
|
||||||
|
const extensions = stringListAt(object, "extensions", itemPath, issues);
|
||||||
|
const label = stringAt(object, "label", itemPath, issues, {
|
||||||
|
optional: true,
|
||||||
|
});
|
||||||
|
if (mediaType !== undefined && !MEDIA_TYPE_PATTERN.test(mediaType)) {
|
||||||
|
issues.push(`${itemPath}.mediaType must be an Internet media type`);
|
||||||
|
}
|
||||||
|
extensions.forEach((extension, extensionIndex) => {
|
||||||
|
if (!EXTENSION_PATTERN.test(extension)) {
|
||||||
|
issues.push(
|
||||||
|
`${itemPath}.extensions[${extensionIndex}] must start with a dot`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
mediaType === undefined ||
|
||||||
|
!MEDIA_TYPE_PATTERN.test(mediaType) ||
|
||||||
|
extensions.some((extension) => !EXTENSION_PATTERN.test(extension))
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const normalizedMediaType = mediaType.toLowerCase();
|
||||||
|
const normalizedExtensions = extensions.map((extension) =>
|
||||||
|
extension.toLowerCase(),
|
||||||
|
);
|
||||||
|
const identity = `${normalizedMediaType}\u0000${normalizedExtensions.join(",")}`;
|
||||||
|
if (identities.has(identity)) {
|
||||||
|
issues.push(`${itemPath} duplicates an earlier format`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
identities.add(identity);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
mediaType: normalizedMediaType,
|
||||||
|
extensions: normalizedExtensions,
|
||||||
|
...(label === undefined ? {} : { label }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIoProfile(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
issues: string[],
|
||||||
|
): ToolboxIoProfile | undefined {
|
||||||
|
const object = recordAt(value, path, issues);
|
||||||
|
const accepts = parseFormats(object.accepts, `${path}.accepts`, issues);
|
||||||
|
const produces = parseFormats(object.produces, `${path}.produces`, issues);
|
||||||
|
return { accepts, produces };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCapabilities(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
issues: string[],
|
||||||
|
): ToolboxCapabilityProfile | undefined {
|
||||||
|
const object = recordAt(value, path, issues);
|
||||||
|
const required = identifierListAt(object, "required", path, issues);
|
||||||
|
const optional = identifierListAt(object, "optional", path, issues);
|
||||||
|
const requiredSet = new Set(required);
|
||||||
|
optional.forEach((capability, index) => {
|
||||||
|
if (requiredSet.has(capability)) {
|
||||||
|
issues.push(`${path}.optional[${index}] is already required`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
required,
|
||||||
|
optional: optional.filter((item) => !requiredSet.has(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parsePrivacy(
|
function parsePrivacy(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -384,6 +491,25 @@ export function parseToolboxApp(value: unknown): ToolboxAppManifest {
|
|||||||
"$.requirements",
|
"$.requirements",
|
||||||
issues,
|
issues,
|
||||||
);
|
);
|
||||||
|
const io =
|
||||||
|
"io" in object ? parseIoProfile(object.io, "$.io", issues) : undefined;
|
||||||
|
const capabilities =
|
||||||
|
"capabilities" in object
|
||||||
|
? parseCapabilities(object.capabilities, "$.capabilities", issues)
|
||||||
|
: undefined;
|
||||||
|
if (requirements !== undefined && capabilities !== undefined) {
|
||||||
|
const workersRequired = capabilities.required.includes("workers");
|
||||||
|
if (requirements.workers && !workersRequired) {
|
||||||
|
issues.push(
|
||||||
|
"$.capabilities.required must include workers when $.requirements.workers is true",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!requirements.workers && workersRequired) {
|
||||||
|
issues.push(
|
||||||
|
"$.capabilities.required must not include workers when $.requirements.workers is false",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const privacy = parsePrivacy(object.privacy, "$.privacy", issues);
|
const privacy = parsePrivacy(object.privacy, "$.privacy", issues);
|
||||||
const source =
|
const source =
|
||||||
"source" in object
|
"source" in object
|
||||||
@@ -424,6 +550,8 @@ export function parseToolboxApp(value: unknown): ToolboxAppManifest {
|
|||||||
tags,
|
tags,
|
||||||
integration,
|
integration,
|
||||||
requirements,
|
requirements,
|
||||||
|
...(io === undefined ? {} : { io }),
|
||||||
|
...(capabilities === undefined ? {} : { capabilities }),
|
||||||
privacy,
|
privacy,
|
||||||
...(source === undefined ? {} : { source }),
|
...(source === undefined ? {} : { source }),
|
||||||
...(actions === undefined ? {} : { actions }),
|
...(actions === undefined ? {} : { actions }),
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import {
|
||||||
|
TOOLBOX_ARTIFACT_VERSION,
|
||||||
|
TOOLBOX_TRANSFER_QUERY_PARAMETER,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const DATABASE_NAME = "add-ideas-toolbox-transfers-v1";
|
||||||
|
const STORE_NAME = "transfers";
|
||||||
|
const DEFAULT_TTL_MS = 15 * 60 * 1000;
|
||||||
|
const MAX_TTL_MS = 60 * 60 * 1000;
|
||||||
|
const DEFAULT_MAX_BYTES = 512 * 1024 * 1024;
|
||||||
|
const MAX_FILES = 128;
|
||||||
|
const MAX_NAME_CHARACTERS = 255;
|
||||||
|
const MAX_EVIDENCE_BYTES = 1024 * 1024;
|
||||||
|
const APP_ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
||||||
|
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{22}$/;
|
||||||
|
const MEDIA_TYPE_PATTERN =
|
||||||
|
/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(?:\s*;\s*[a-z0-9!#$&^_.+-]+=(?:[a-z0-9!#$&^_.+-]+|"[^"]*"))*$/iu;
|
||||||
|
|
||||||
|
export interface ToolboxArtifactSource {
|
||||||
|
appId: string;
|
||||||
|
appVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxArtifactDescriptor {
|
||||||
|
name: string;
|
||||||
|
mediaType: string;
|
||||||
|
size: number;
|
||||||
|
lastModified?: number;
|
||||||
|
sha256?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxArtifactFile extends ToolboxArtifactDescriptor {
|
||||||
|
blob: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxArtifactEvidence {
|
||||||
|
formatVersion: string;
|
||||||
|
operation?: string;
|
||||||
|
engine?: string;
|
||||||
|
settings?: Readonly<Record<string, unknown>>;
|
||||||
|
warnings?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxTransfer {
|
||||||
|
artifactVersion: typeof TOOLBOX_ARTIFACT_VERSION;
|
||||||
|
token: string;
|
||||||
|
source: ToolboxArtifactSource;
|
||||||
|
targetAppId: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
files: readonly ToolboxArtifactFile[];
|
||||||
|
evidence?: ToolboxArtifactEvidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxTransferCreateInput {
|
||||||
|
source: ToolboxArtifactSource;
|
||||||
|
targetAppId: string;
|
||||||
|
files: readonly ToolboxArtifactFile[];
|
||||||
|
evidence?: ToolboxArtifactEvidence;
|
||||||
|
ttlMs?: number;
|
||||||
|
maxBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxTransferStore {
|
||||||
|
put(transfer: ToolboxTransfer): Promise<void>;
|
||||||
|
take(
|
||||||
|
token: string,
|
||||||
|
expectedTargetAppId: string,
|
||||||
|
now: number,
|
||||||
|
): Promise<ToolboxTransfer | undefined>;
|
||||||
|
deleteExpired(now: number): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxTransferOptions {
|
||||||
|
store?: ToolboxTransferStore;
|
||||||
|
indexedDB?: IDBFactory;
|
||||||
|
crypto?: Pick<Crypto, "getRandomValues">;
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxTransferUrlOptions {
|
||||||
|
location?: string | URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDatabase(factory: IDBFactory): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = factory.open(DATABASE_NAME, 1);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const database = request.result;
|
||||||
|
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
const store = database.createObjectStore(STORE_NAME, {
|
||||||
|
keyPath: "token",
|
||||||
|
});
|
||||||
|
store.createIndex("expiresAt", "expiresAt");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () =>
|
||||||
|
reject(request.error ?? new Error("IndexedDB open failed"));
|
||||||
|
request.onblocked = () => reject(new Error("IndexedDB upgrade is blocked"));
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request.onerror = () =>
|
||||||
|
reject(request.error ?? new Error("IndexedDB request failed"));
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function transactionComplete(transaction: IDBTransaction): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onabort = () =>
|
||||||
|
reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
|
||||||
|
transaction.onerror = () =>
|
||||||
|
reject(transaction.error ?? new Error("IndexedDB transaction failed"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class IndexedDbTransferStore implements ToolboxTransferStore {
|
||||||
|
constructor(private readonly factory: IDBFactory) {}
|
||||||
|
|
||||||
|
async put(transfer: ToolboxTransfer): Promise<void> {
|
||||||
|
const database = await openDatabase(this.factory);
|
||||||
|
try {
|
||||||
|
const transaction = database.transaction(STORE_NAME, "readwrite");
|
||||||
|
transaction.objectStore(STORE_NAME).put(transfer);
|
||||||
|
await transactionComplete(transaction);
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async take(
|
||||||
|
token: string,
|
||||||
|
expectedTargetAppId: string,
|
||||||
|
now: number,
|
||||||
|
): Promise<ToolboxTransfer | undefined> {
|
||||||
|
const database = await openDatabase(this.factory);
|
||||||
|
try {
|
||||||
|
const transaction = database.transaction(STORE_NAME, "readwrite");
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
const value = await requestResult(store.get(token));
|
||||||
|
const transfer = value as ToolboxTransfer | undefined;
|
||||||
|
if (
|
||||||
|
transfer !== undefined &&
|
||||||
|
transfer.targetAppId !== expectedTargetAppId
|
||||||
|
) {
|
||||||
|
transaction.abort();
|
||||||
|
throw new Error(
|
||||||
|
"Artifact transfer was addressed to another application",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (transfer !== undefined) store.delete(token);
|
||||||
|
await transactionComplete(transaction);
|
||||||
|
return transfer === undefined || transfer.expiresAt <= now
|
||||||
|
? undefined
|
||||||
|
: transfer;
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteExpired(now: number): Promise<number> {
|
||||||
|
const database = await openDatabase(this.factory);
|
||||||
|
let count = 0;
|
||||||
|
try {
|
||||||
|
const transaction = database.transaction(STORE_NAME, "readwrite");
|
||||||
|
const index = transaction.objectStore(STORE_NAME).index("expiresAt");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = index.openCursor(IDBKeyRange.upperBound(now));
|
||||||
|
request.onerror = () =>
|
||||||
|
reject(request.error ?? new Error("IndexedDB cursor failed"));
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const cursor = request.result;
|
||||||
|
if (cursor === null) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cursor.delete();
|
||||||
|
count += 1;
|
||||||
|
cursor.continue();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await transactionComplete(transaction);
|
||||||
|
return count;
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStore(options: ToolboxTransferOptions): ToolboxTransferStore {
|
||||||
|
const factory = options.indexedDB ?? globalThis.indexedDB;
|
||||||
|
if (factory === undefined) throw new Error("IndexedDB is not available");
|
||||||
|
return new IndexedDbTransferStore(factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAppId(value: string, label: string): void {
|
||||||
|
if (!APP_ID_PATTERN.test(value))
|
||||||
|
throw new TypeError(`${label} is not a toolbox app id`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedFile(file: ToolboxArtifactFile): ToolboxArtifactFile {
|
||||||
|
if (!(file.blob instanceof Blob))
|
||||||
|
throw new TypeError("Artifact file blob is invalid");
|
||||||
|
const hasControlCharacter = Array.from(file.name).some((character) => {
|
||||||
|
const point = character.codePointAt(0)!;
|
||||||
|
return point <= 0x1f || point === 0x7f;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
file.name.trim() === "" ||
|
||||||
|
Array.from(file.name).length > MAX_NAME_CHARACTERS ||
|
||||||
|
hasControlCharacter ||
|
||||||
|
file.name.includes("/") ||
|
||||||
|
file.name.includes("\\")
|
||||||
|
) {
|
||||||
|
throw new TypeError("Artifact file name is invalid");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(file.size) ||
|
||||||
|
file.size < 0 ||
|
||||||
|
file.size !== file.blob.size
|
||||||
|
) {
|
||||||
|
throw new TypeError(`Artifact size does not match ${file.name}`);
|
||||||
|
}
|
||||||
|
if (file.sha256 !== undefined && !/^[a-f0-9]{64}$/u.test(file.sha256)) {
|
||||||
|
throw new TypeError(`Artifact SHA-256 is invalid for ${file.name}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
file.lastModified !== undefined &&
|
||||||
|
(!Number.isSafeInteger(file.lastModified) || file.lastModified < 0)
|
||||||
|
) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Artifact modification time is invalid for ${file.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mediaType = file.mediaType || "application/octet-stream";
|
||||||
|
if (mediaType.length > 255 || !MEDIA_TYPE_PATTERN.test(mediaType)) {
|
||||||
|
throw new TypeError(`Artifact media type is invalid for ${file.name}`);
|
||||||
|
}
|
||||||
|
return { ...file, mediaType };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedEvidence(
|
||||||
|
evidence: ToolboxArtifactEvidence | undefined,
|
||||||
|
): ToolboxArtifactEvidence | undefined {
|
||||||
|
if (evidence === undefined) return undefined;
|
||||||
|
if (
|
||||||
|
typeof evidence.formatVersion !== "string" ||
|
||||||
|
evidence.formatVersion.trim() === "" ||
|
||||||
|
evidence.formatVersion.length > 64
|
||||||
|
) {
|
||||||
|
throw new TypeError("Artifact evidence formatVersion is invalid");
|
||||||
|
}
|
||||||
|
for (const [label, value] of [
|
||||||
|
["operation", evidence.operation],
|
||||||
|
["engine", evidence.engine],
|
||||||
|
] as const) {
|
||||||
|
if (
|
||||||
|
value !== undefined &&
|
||||||
|
(typeof value !== "string" || value.length > 256)
|
||||||
|
) {
|
||||||
|
throw new TypeError(`Artifact evidence ${label} is invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
evidence.warnings !== undefined &&
|
||||||
|
(!Array.isArray(evidence.warnings) ||
|
||||||
|
evidence.warnings.length > 100 ||
|
||||||
|
evidence.warnings.some(
|
||||||
|
(warning) => typeof warning !== "string" || warning.length > 1_024,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw new TypeError("Artifact evidence warnings are invalid");
|
||||||
|
}
|
||||||
|
let serialized: string;
|
||||||
|
try {
|
||||||
|
serialized = JSON.stringify(evidence);
|
||||||
|
} catch {
|
||||||
|
throw new TypeError("Artifact evidence must be JSON-serializable");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
serialized === undefined ||
|
||||||
|
new TextEncoder().encode(serialized).byteLength > MAX_EVIDENCE_BYTES
|
||||||
|
) {
|
||||||
|
throw new RangeError("Artifact evidence exceeds the 1 MiB limit");
|
||||||
|
}
|
||||||
|
return evidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenFrom(random: Pick<Crypto, "getRandomValues">): string {
|
||||||
|
const bytes = random.getRandomValues(new Uint8Array(16));
|
||||||
|
let binary = "";
|
||||||
|
bytes.forEach((value) => {
|
||||||
|
binary += String.fromCharCode(value);
|
||||||
|
});
|
||||||
|
return btoa(binary)
|
||||||
|
.replaceAll("+", "-")
|
||||||
|
.replaceAll("/", "_")
|
||||||
|
.replace(/=+$/u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatedStoredTransfer(
|
||||||
|
value: ToolboxTransfer,
|
||||||
|
token: string,
|
||||||
|
expectedTargetAppId: string,
|
||||||
|
now: number,
|
||||||
|
): ToolboxTransfer {
|
||||||
|
if (
|
||||||
|
value.artifactVersion !== TOOLBOX_ARTIFACT_VERSION ||
|
||||||
|
value.token !== token ||
|
||||||
|
value.targetAppId !== expectedTargetAppId
|
||||||
|
) {
|
||||||
|
throw new TypeError("Stored artifact transfer identity is invalid");
|
||||||
|
}
|
||||||
|
if (typeof value.source?.appId !== "string") {
|
||||||
|
throw new TypeError("Stored source app id is invalid");
|
||||||
|
}
|
||||||
|
assertAppId(value.source.appId, "Stored source app id");
|
||||||
|
if (
|
||||||
|
value.source.appVersion !== undefined &&
|
||||||
|
(typeof value.source.appVersion !== "string" ||
|
||||||
|
value.source.appVersion.trim() === "" ||
|
||||||
|
value.source.appVersion.length > 64)
|
||||||
|
) {
|
||||||
|
throw new TypeError("Stored source app version is invalid");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value.createdAt) ||
|
||||||
|
!Number.isSafeInteger(value.expiresAt) ||
|
||||||
|
value.createdAt < 0 ||
|
||||||
|
value.expiresAt <= value.createdAt ||
|
||||||
|
value.expiresAt - value.createdAt > MAX_TTL_MS ||
|
||||||
|
value.expiresAt <= now
|
||||||
|
) {
|
||||||
|
throw new TypeError("Stored artifact transfer lifetime is invalid");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(value.files) ||
|
||||||
|
value.files.length === 0 ||
|
||||||
|
value.files.length > MAX_FILES
|
||||||
|
) {
|
||||||
|
throw new RangeError("Stored artifact transfer file count is invalid");
|
||||||
|
}
|
||||||
|
const files = value.files.map(normalizedFile);
|
||||||
|
const totalBytes = files.reduce((total, file) => total + file.size, 0);
|
||||||
|
if (!Number.isSafeInteger(totalBytes) || totalBytes > DEFAULT_MAX_BYTES) {
|
||||||
|
throw new RangeError("Stored artifact transfer exceeds the byte limit");
|
||||||
|
}
|
||||||
|
const evidence = normalizedEvidence(value.evidence);
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
source: { ...value.source },
|
||||||
|
files,
|
||||||
|
...(evidence === undefined ? {} : { evidence }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createToolboxTransfer(
|
||||||
|
input: ToolboxTransferCreateInput,
|
||||||
|
options: ToolboxTransferOptions = {},
|
||||||
|
): Promise<ToolboxTransfer> {
|
||||||
|
assertAppId(input.source.appId, "Source app id");
|
||||||
|
assertAppId(input.targetAppId, "Target app id");
|
||||||
|
if (
|
||||||
|
input.source.appVersion !== undefined &&
|
||||||
|
(input.source.appVersion.trim() === "" ||
|
||||||
|
input.source.appVersion.length > 64)
|
||||||
|
) {
|
||||||
|
throw new TypeError("Source app version is invalid");
|
||||||
|
}
|
||||||
|
if (input.files.length === 0 || input.files.length > MAX_FILES) {
|
||||||
|
throw new RangeError(`Artifact transfers require 1–${MAX_FILES} files`);
|
||||||
|
}
|
||||||
|
const files = input.files.map(normalizedFile);
|
||||||
|
const evidence = normalizedEvidence(input.evidence);
|
||||||
|
const totalBytes = files.reduce((total, file) => total + file.size, 0);
|
||||||
|
const maxBytes = input.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(maxBytes) ||
|
||||||
|
maxBytes <= 0 ||
|
||||||
|
maxBytes > DEFAULT_MAX_BYTES
|
||||||
|
) {
|
||||||
|
throw new RangeError("Artifact transfer byte limit is invalid");
|
||||||
|
}
|
||||||
|
if (totalBytes > maxBytes)
|
||||||
|
throw new RangeError(
|
||||||
|
`Artifact transfer exceeds the ${maxBytes}-byte limit`,
|
||||||
|
);
|
||||||
|
const ttlMs = input.ttlMs ?? DEFAULT_TTL_MS;
|
||||||
|
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > MAX_TTL_MS) {
|
||||||
|
throw new RangeError(`Artifact TTL must be between 1 and ${MAX_TTL_MS} ms`);
|
||||||
|
}
|
||||||
|
const now = (options.now ?? Date.now)();
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(now) ||
|
||||||
|
now < 0 ||
|
||||||
|
now + ttlMs > Number.MAX_SAFE_INTEGER
|
||||||
|
)
|
||||||
|
throw new RangeError("Artifact transfer creation time is invalid");
|
||||||
|
const random = options.crypto ?? globalThis.crypto;
|
||||||
|
if (random === undefined)
|
||||||
|
throw new Error("Cryptographic randomness is not available");
|
||||||
|
const transfer: ToolboxTransfer = {
|
||||||
|
artifactVersion: TOOLBOX_ARTIFACT_VERSION,
|
||||||
|
token: tokenFrom(random),
|
||||||
|
source: input.source,
|
||||||
|
targetAppId: input.targetAppId,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + ttlMs,
|
||||||
|
files,
|
||||||
|
...(evidence === undefined ? {} : { evidence }),
|
||||||
|
};
|
||||||
|
await (options.store ?? defaultStore(options)).put(transfer);
|
||||||
|
return transfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeToolboxTransfer(
|
||||||
|
token: string,
|
||||||
|
expectedTargetAppId: string,
|
||||||
|
options: ToolboxTransferOptions = {},
|
||||||
|
): Promise<ToolboxTransfer | undefined> {
|
||||||
|
if (!TOKEN_PATTERN.test(token))
|
||||||
|
throw new TypeError("Artifact token is invalid");
|
||||||
|
assertAppId(expectedTargetAppId, "Target app id");
|
||||||
|
const now = (options.now ?? Date.now)();
|
||||||
|
const transfer = await (options.store ?? defaultStore(options)).take(
|
||||||
|
token,
|
||||||
|
expectedTargetAppId,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
if (transfer === undefined) return undefined;
|
||||||
|
return validatedStoredTransfer(transfer, token, expectedTargetAppId, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteExpiredToolboxTransfers(
|
||||||
|
options: ToolboxTransferOptions = {},
|
||||||
|
): Promise<number> {
|
||||||
|
return (options.store ?? defaultStore(options)).deleteExpired(
|
||||||
|
(options.now ?? Date.now)(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createToolboxTransferUrl(
|
||||||
|
target: string | URL,
|
||||||
|
token: string,
|
||||||
|
options: ToolboxTransferUrlOptions = {},
|
||||||
|
): URL {
|
||||||
|
if (!TOKEN_PATTERN.test(token))
|
||||||
|
throw new TypeError("Artifact token is invalid");
|
||||||
|
const location = options.location ?? globalThis.location?.href;
|
||||||
|
if (location === undefined) {
|
||||||
|
throw new Error("Current location is required for a safe artifact handoff");
|
||||||
|
}
|
||||||
|
const base = new URL(location);
|
||||||
|
const url = new URL(target, base);
|
||||||
|
if (url.origin !== base.origin) {
|
||||||
|
throw new TypeError("Artifact transfers must remain on the current origin");
|
||||||
|
}
|
||||||
|
url.searchParams.set(TOOLBOX_TRANSFER_QUERY_PARAMETER, token);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readToolboxTransferToken(
|
||||||
|
location: string | URL = globalThis.location.href,
|
||||||
|
): string | undefined {
|
||||||
|
const token = new URL(location).searchParams.get(
|
||||||
|
TOOLBOX_TRANSFER_QUERY_PARAMETER,
|
||||||
|
);
|
||||||
|
if (token === null) return undefined;
|
||||||
|
if (!TOKEN_PATTERN.test(token))
|
||||||
|
throw new TypeError("Artifact token is invalid");
|
||||||
|
return token;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export const TOOLBOX_SCHEMA_VERSION = 1 as const;
|
export const TOOLBOX_SCHEMA_VERSION = 1 as const;
|
||||||
export const TOOLBOX_QUERY_PARAMETER = "toolbox" as const;
|
export const TOOLBOX_QUERY_PARAMETER = "toolbox" as const;
|
||||||
export const TOOLBOX_META_NAME = "toolbox" as const;
|
export const TOOLBOX_META_NAME = "toolbox" as const;
|
||||||
|
export const TOOLBOX_TRANSFER_QUERY_PARAMETER = "toolbox-transfer" as const;
|
||||||
|
export const TOOLBOX_ARTIFACT_VERSION = 1 as const;
|
||||||
|
|
||||||
export interface ToolboxAction {
|
export interface ToolboxAction {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,6 +34,22 @@ export interface ToolboxRequirements {
|
|||||||
topLevelContext?: boolean;
|
topLevelContext?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolboxFormat {
|
||||||
|
mediaType: string;
|
||||||
|
extensions: readonly string[];
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxIoProfile {
|
||||||
|
accepts: readonly ToolboxFormat[];
|
||||||
|
produces: readonly ToolboxFormat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolboxCapabilityProfile {
|
||||||
|
required: readonly string[];
|
||||||
|
optional: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolboxSource {
|
export interface ToolboxSource {
|
||||||
repository: string;
|
repository: string;
|
||||||
license: string;
|
license: string;
|
||||||
@@ -49,6 +67,8 @@ export interface ToolboxAppManifest {
|
|||||||
tags: readonly string[];
|
tags: readonly string[];
|
||||||
integration: ToolboxIntegration;
|
integration: ToolboxIntegration;
|
||||||
requirements: ToolboxRequirements;
|
requirements: ToolboxRequirements;
|
||||||
|
io?: ToolboxIoProfile;
|
||||||
|
capabilities?: ToolboxCapabilityProfile;
|
||||||
privacy: ToolboxPrivacy;
|
privacy: ToolboxPrivacy;
|
||||||
source?: ToolboxSource;
|
source?: ToolboxSource;
|
||||||
actions?: readonly ToolboxAction[];
|
actions?: readonly ToolboxAction[];
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const app = (
|
|||||||
indexedDb: true,
|
indexedDb: true,
|
||||||
crossOriginIsolated: false,
|
crossOriginIsolated: false,
|
||||||
},
|
},
|
||||||
|
capabilities: {
|
||||||
|
required: ["workers"],
|
||||||
|
optional: [],
|
||||||
|
},
|
||||||
privacy: {
|
privacy: {
|
||||||
processing: "local",
|
processing: "local",
|
||||||
fileUploads: false,
|
fileUploads: false,
|
||||||
@@ -117,6 +121,86 @@ describe("v1 runtime parsing", () => {
|
|||||||
).toThrow(/absolute HTTP\(S\) URL/u);
|
).toThrow(/absolute HTTP\(S\) URL/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes declared formats and capability profiles", () => {
|
||||||
|
const parsed = parseToolboxApp(
|
||||||
|
app({
|
||||||
|
io: {
|
||||||
|
accepts: [
|
||||||
|
{
|
||||||
|
mediaType: "Application/PDF",
|
||||||
|
extensions: [".PDF"],
|
||||||
|
label: "PDF",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
produces: [{ mediaType: "image/*", extensions: [".png"] }],
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
required: ["workers"],
|
||||||
|
optional: ["file-system-access"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed.io?.accepts[0]).toEqual({
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
extensions: [".pdf"],
|
||||||
|
label: "PDF",
|
||||||
|
});
|
||||||
|
expect(parsed.capabilities).toEqual({
|
||||||
|
required: ["workers"],
|
||||||
|
optional: ["file-system-access"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed formats and duplicate capabilities", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseToolboxApp(
|
||||||
|
app({
|
||||||
|
io: {
|
||||||
|
accepts: [{ mediaType: "pdf", extensions: ["pdf"] }],
|
||||||
|
produces: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow(/mediaType|start with a dot/u);
|
||||||
|
expect(() =>
|
||||||
|
parseToolboxApp(
|
||||||
|
app({
|
||||||
|
capabilities: { required: ["workers"], optional: ["workers"] },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow(/already required/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps worker requirements and required capabilities consistent", () => {
|
||||||
|
const legacyManifest = app();
|
||||||
|
delete legacyManifest.capabilities;
|
||||||
|
expect(parseToolboxApp(legacyManifest).requirements.workers).toBe(true);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
parseToolboxApp(
|
||||||
|
app({
|
||||||
|
capabilities: { required: [], optional: ["workers"] },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow(/required must include workers.*requirements\.workers is true/u);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
parseToolboxApp(
|
||||||
|
app({
|
||||||
|
requirements: {
|
||||||
|
secureContext: true,
|
||||||
|
workers: false,
|
||||||
|
indexedDb: true,
|
||||||
|
crossOriginIsolated: false,
|
||||||
|
},
|
||||||
|
capabilities: { required: ["workers"], optional: [] },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow(
|
||||||
|
/required must not include workers.*requirements\.workers is false/u,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("parses manifest references and external inline catalog entries", () => {
|
it("parses manifest references and external inline catalog entries", () => {
|
||||||
const parsed = parseToolboxCatalog(
|
const parsed = parseToolboxCatalog(
|
||||||
catalog({
|
catalog({
|
||||||
|
|||||||
@@ -124,6 +124,38 @@ describe("canonical schema and runtime parser parity", () => {
|
|||||||
source: { repository: "HTTPS:example.test", license: "MIT" },
|
source: { repository: "HTTPS:example.test", license: "MIT" },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"a required worker capability paired with a worker requirement",
|
||||||
|
() => ({
|
||||||
|
...validApp(),
|
||||||
|
requirements: {
|
||||||
|
secureContext: false,
|
||||||
|
workers: true,
|
||||||
|
indexedDb: false,
|
||||||
|
crossOriginIsolated: false,
|
||||||
|
},
|
||||||
|
capabilities: { required: ["workers"], optional: [] },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"an optional worker capability without a worker requirement",
|
||||||
|
() => ({
|
||||||
|
...validApp(),
|
||||||
|
capabilities: { required: [], optional: ["workers"] },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a legacy worker requirement without a capability profile",
|
||||||
|
() => ({
|
||||||
|
...validApp(),
|
||||||
|
requirements: {
|
||||||
|
secureContext: false,
|
||||||
|
workers: true,
|
||||||
|
indexedDb: false,
|
||||||
|
crossOriginIsolated: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
it.each(acceptedApps)("accepts %s", (_label, fixture) => {
|
it.each(acceptedApps)("accepts %s", (_label, fixture) => {
|
||||||
@@ -175,6 +207,26 @@ describe("canonical schema and runtime parser parity", () => {
|
|||||||
actions: [{ id: "docs", label: " ", url: "./docs" }],
|
actions: [{ id: "docs", label: " ", url: "./docs" }],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"a worker requirement without a required worker capability",
|
||||||
|
() => ({
|
||||||
|
...validApp(),
|
||||||
|
requirements: {
|
||||||
|
secureContext: false,
|
||||||
|
workers: true,
|
||||||
|
indexedDb: false,
|
||||||
|
crossOriginIsolated: false,
|
||||||
|
},
|
||||||
|
capabilities: { required: [], optional: ["workers"] },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a required worker capability with workers disabled",
|
||||||
|
() => ({
|
||||||
|
...validApp(),
|
||||||
|
capabilities: { required: ["workers"], optional: [] },
|
||||||
|
}),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
it.each(rejectedApps)("rejects %s", (_label, fixture) => {
|
it.each(rejectedApps)("rejects %s", (_label, fixture) => {
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import {
|
||||||
|
consumeToolboxTransfer,
|
||||||
|
createToolboxTransfer,
|
||||||
|
createToolboxTransferUrl,
|
||||||
|
readToolboxTransferToken,
|
||||||
|
type ToolboxTransfer,
|
||||||
|
type ToolboxTransferStore,
|
||||||
|
} from "../src/index.js";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
class MemoryStore implements ToolboxTransferStore {
|
||||||
|
readonly values = new Map<string, ToolboxTransfer>();
|
||||||
|
|
||||||
|
async put(transfer: ToolboxTransfer): Promise<void> {
|
||||||
|
this.values.set(transfer.token, transfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async take(
|
||||||
|
token: string,
|
||||||
|
expectedTargetAppId: string,
|
||||||
|
now: number,
|
||||||
|
): Promise<ToolboxTransfer | undefined> {
|
||||||
|
const value = this.values.get(token);
|
||||||
|
if (value !== undefined && value.targetAppId !== expectedTargetAppId) {
|
||||||
|
throw new Error("Artifact transfer was addressed to another application");
|
||||||
|
}
|
||||||
|
this.values.delete(token);
|
||||||
|
return value === undefined || value.expiresAt <= now ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteExpired(now: number): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
for (const [token, value] of this.values) {
|
||||||
|
if (value.expiresAt <= now) {
|
||||||
|
this.values.delete(token);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deterministicCrypto = {
|
||||||
|
getRandomValues<T extends ArrayBufferView | null>(array: T): T {
|
||||||
|
if (array instanceof Uint8Array)
|
||||||
|
array.forEach((_value, index) => (array[index] = index));
|
||||||
|
return array;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("one-time artifact transfers", () => {
|
||||||
|
it("stores, addresses and consumes an artifact exactly once", async () => {
|
||||||
|
const store = new MemoryStore();
|
||||||
|
const blob = new Blob(["hello"], { type: "text/plain" });
|
||||||
|
const transfer = await createToolboxTransfer(
|
||||||
|
{
|
||||||
|
source: { appId: "de.add-ideas.file-tools", appVersion: "1.0.0" },
|
||||||
|
targetAppId: "de.add-ideas.text-tools",
|
||||||
|
files: [
|
||||||
|
{ blob, name: "hello.txt", mediaType: blob.type, size: blob.size },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ store, crypto: deterministicCrypto, now: () => 1_000 },
|
||||||
|
);
|
||||||
|
expect(transfer.token).toHaveLength(22);
|
||||||
|
const url = createToolboxTransferUrl(
|
||||||
|
"https://tools.test/apps/text/",
|
||||||
|
transfer.token,
|
||||||
|
{ location: "https://tools.test/apps/file/" },
|
||||||
|
);
|
||||||
|
expect(readToolboxTransferToken(url)).toBe(transfer.token);
|
||||||
|
const consumed = await consumeToolboxTransfer(
|
||||||
|
transfer.token,
|
||||||
|
"de.add-ideas.text-tools",
|
||||||
|
{ store, now: () => 2_000 },
|
||||||
|
);
|
||||||
|
expect(await consumed?.files[0]?.blob.text()).toBe("hello");
|
||||||
|
expect(
|
||||||
|
await consumeToolboxTransfer(transfer.token, "de.add-ideas.text-tools", {
|
||||||
|
store,
|
||||||
|
now: () => 2_000,
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects expired, oversized and wrongly addressed transfers", async () => {
|
||||||
|
const store = new MemoryStore();
|
||||||
|
const blob = new Blob(["hello"]);
|
||||||
|
const transfer = await createToolboxTransfer(
|
||||||
|
{
|
||||||
|
source: { appId: "source" },
|
||||||
|
targetAppId: "target",
|
||||||
|
ttlMs: 10,
|
||||||
|
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||||
|
},
|
||||||
|
{ store, crypto: deterministicCrypto, now: () => 10 },
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
consumeToolboxTransfer(transfer.token, "target", {
|
||||||
|
store,
|
||||||
|
now: () => 20,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
createToolboxTransfer(
|
||||||
|
{
|
||||||
|
source: { appId: "source" },
|
||||||
|
targetAppId: "target",
|
||||||
|
maxBytes: 4,
|
||||||
|
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||||
|
},
|
||||||
|
{ store, crypto: deterministicCrypto },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/exceeds/u);
|
||||||
|
await expect(
|
||||||
|
createToolboxTransfer(
|
||||||
|
{
|
||||||
|
source: { appId: "source" },
|
||||||
|
targetAppId: "target",
|
||||||
|
maxBytes: 512 * 1024 * 1024 + 1,
|
||||||
|
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||||
|
},
|
||||||
|
{ store, crypto: deterministicCrypto },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/invalid/iu);
|
||||||
|
await expect(
|
||||||
|
createToolboxTransfer(
|
||||||
|
{
|
||||||
|
source: { appId: "source" },
|
||||||
|
targetAppId: "target",
|
||||||
|
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||||
|
},
|
||||||
|
{ store, crypto: deterministicCrypto, now: () => Number.NaN },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/creation time/iu);
|
||||||
|
|
||||||
|
const addressed = await createToolboxTransfer(
|
||||||
|
{
|
||||||
|
source: { appId: "source" },
|
||||||
|
targetAppId: "target",
|
||||||
|
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||||
|
},
|
||||||
|
{ store, crypto: deterministicCrypto },
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
consumeToolboxTransfer(addressed.token, "another-target", { store }),
|
||||||
|
).rejects.toThrow(/another application/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps opaque handoff tokens on-origin and rejects unsafe descriptors", async () => {
|
||||||
|
const store = new MemoryStore();
|
||||||
|
const blob = new Blob(["hello"]);
|
||||||
|
await expect(
|
||||||
|
createToolboxTransfer(
|
||||||
|
{
|
||||||
|
source: { appId: "source" },
|
||||||
|
targetAppId: "target",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
blob,
|
||||||
|
name: "../hello.txt",
|
||||||
|
mediaType: "text/plain",
|
||||||
|
size: blob.size,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ store, crypto: deterministicCrypto },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/name is invalid/u);
|
||||||
|
expect(() =>
|
||||||
|
createToolboxTransferUrl(
|
||||||
|
"https://other.test/apps/text/",
|
||||||
|
"AAECAwQFBgcICQoLDA0ODw",
|
||||||
|
{ location: "https://tools.test/apps/file/" },
|
||||||
|
),
|
||||||
|
).toThrow(/current origin/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revalidates same-origin storage records at the consuming trust boundary", async () => {
|
||||||
|
const store = new MemoryStore();
|
||||||
|
const blob = new Blob(["hello"]);
|
||||||
|
const transfer = await createToolboxTransfer(
|
||||||
|
{
|
||||||
|
source: { appId: "source" },
|
||||||
|
targetAppId: "target",
|
||||||
|
files: [{ blob, name: "hello.txt", mediaType: "text/plain", size: 5 }],
|
||||||
|
},
|
||||||
|
{ store, crypto: deterministicCrypto, now: () => 100 },
|
||||||
|
);
|
||||||
|
store.values.set(transfer.token, {
|
||||||
|
...transfer,
|
||||||
|
artifactVersion: 99 as 1,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
consumeToolboxTransfer(transfer.token, "target", {
|
||||||
|
store,
|
||||||
|
now: () => 200,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/identity is invalid/u);
|
||||||
|
expect(store.values.has(transfer.token)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,14 +13,17 @@ import "@add-ideas/toolbox-shell-react/styles.css";
|
|||||||
standalone mode if context is absent, unavailable, cross-origin, or invalid. Its
|
standalone mode if context is absent, unavailable, cross-origin, or invalid. Its
|
||||||
app switcher is exposed as the accessible navigation landmark
|
app switcher is exposed as the accessible navigation landmark
|
||||||
`Toolbox applications` and all destinations use ordinary document navigation.
|
`Toolbox applications` and all destinations use ordinary document navigation.
|
||||||
The fixed top row exposes Personalize, Apps, Gitea source, and Help in that
|
The fixed top row exposes a Toolbox home/brand block at the far left, a centered
|
||||||
order, a centered title/subtitle, and a Toolbox home/brand block. Personalize
|
title/subtitle, and Help, Gitea source, Apps, and Personalize at the far right
|
||||||
offers light/dark/system mode by default. Version, privacy, and application
|
in that order. Personalize uses the portal-style panel and offers
|
||||||
actions render in the standard footer so connected and standalone headers keep
|
light/dark/system mode by default. Version, privacy, and application actions
|
||||||
the same geometry.
|
render in the standard footer so connected and standalone headers keep the same
|
||||||
|
geometry.
|
||||||
|
|
||||||
`ToolboxHeader` is exported separately for custom shells. Its
|
`ToolboxHeader` is exported separately for custom shells. Its
|
||||||
`personalizeContent` prop puts custom preferences inside the shared controlled
|
`personalizeContent` prop puts custom preferences inside the shared controlled
|
||||||
popover, while `brandIconUrl` supplies a portal favicon; an inline Toolbox cube
|
popover, while `brandIconUrl` supplies a portal favicon; an inline Toolbox cube
|
||||||
is the fallback. Personalize and Apps popovers are mutually exclusive and close
|
is the fallback. `ToolboxPersonalizePanel` shares the portal heading, optional
|
||||||
on Escape, outside interaction, or link selection.
|
storage warning, and appearance picker with custom preference content.
|
||||||
|
Personalize and Apps popovers are mutually exclusive and close on Escape,
|
||||||
|
outside interaction, or link selection.
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-shell-react",
|
"name": "@add-ideas/toolbox-shell-react",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"description": "A lightweight React application shell for toolbox-compatible browser applications.",
|
"description": "A lightweight React application shell for toolbox-compatible browser applications.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
|
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git",
|
||||||
"directory": "packages/shell-react"
|
"directory": "packages/shell-react"
|
||||||
},
|
},
|
||||||
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
|
"homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"toolbox",
|
"toolbox",
|
||||||
"react",
|
"react",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"react-dom": ">=18 <20"
|
"react-dom": ">=18 <20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.1"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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')\"",
|
"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')\"",
|
||||||
@@ -48,6 +48,6 @@
|
|||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/"
|
"registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export interface ToolboxHeaderProps {
|
|||||||
className?: string | undefined;
|
className?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolboxPersonalizePanelProps {
|
||||||
|
theme: ToolboxThemeMode;
|
||||||
|
onThemeChange: (theme: ToolboxThemeMode) => void;
|
||||||
|
storageAvailable?: boolean | undefined;
|
||||||
|
children?: ReactNode | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
type OpenPopover = "personalize" | "apps" | null;
|
type OpenPopover = "personalize" | "apps" | null;
|
||||||
|
|
||||||
function HelpIcon() {
|
function HelpIcon() {
|
||||||
@@ -117,6 +124,57 @@ function ToolboxBrandMark({ iconUrl }: { iconUrl?: string | undefined }) {
|
|||||||
|
|
||||||
const THEMES: readonly ToolboxThemeMode[] = ["system", "light", "dark"];
|
const THEMES: readonly ToolboxThemeMode[] = ["system", "light", "dark"];
|
||||||
|
|
||||||
|
export function ToolboxPersonalizePanel({
|
||||||
|
theme,
|
||||||
|
onThemeChange,
|
||||||
|
storageAvailable,
|
||||||
|
children,
|
||||||
|
}: ToolboxPersonalizePanelProps) {
|
||||||
|
const headingId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="toolbox-shell__preferences-content"
|
||||||
|
aria-labelledby={headingId}
|
||||||
|
>
|
||||||
|
<div className="toolbox-shell__preferences-heading">
|
||||||
|
<p className="toolbox-shell__preferences-kicker">On this device</p>
|
||||||
|
<h2 id={headingId}>Personalize your toolbox</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{storageAvailable === false ? (
|
||||||
|
<p className="toolbox-shell__preferences-warning" role="status">
|
||||||
|
Browser storage is unavailable. Changes will last only until this page
|
||||||
|
closes.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<fieldset className="toolbox-shell__theme-choice">
|
||||||
|
<legend>Appearance</legend>
|
||||||
|
<div className="toolbox-shell__theme-options">
|
||||||
|
{THEMES.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={theme === option}
|
||||||
|
onClick={() => onThemeChange(option)}
|
||||||
|
>
|
||||||
|
{option.slice(0, 1).toUpperCase() + option.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{children ?? (
|
||||||
|
<p className="toolbox-shell__preferences-copy">
|
||||||
|
The Toolbox stores your appearance choice in this browser and does not
|
||||||
|
transmit it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ToolboxHeader({
|
export function ToolboxHeader({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
@@ -219,11 +277,89 @@ export function ToolboxHeader({
|
|||||||
data-toolbox-theme={theme}
|
data-toolbox-theme={theme}
|
||||||
>
|
>
|
||||||
<div className="toolbox-shell__bar">
|
<div className="toolbox-shell__bar">
|
||||||
|
{homeHref ? (
|
||||||
|
<a
|
||||||
|
className="toolbox-shell__brand"
|
||||||
|
href={homeHref}
|
||||||
|
aria-label={homeLabel}
|
||||||
|
onClick={() => setOpenPopover(null)}
|
||||||
|
>
|
||||||
|
{brandContent}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="toolbox-shell__brand">{brandContent}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="toolbox-shell__identity">
|
||||||
|
<h1 className="toolbox-shell__name">{title}</h1>
|
||||||
|
{subtitle ? (
|
||||||
|
<span className="toolbox-shell__description">{subtitle}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="toolbox-shell__controls"
|
className="toolbox-shell__controls"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Toolbox controls"
|
aria-label="Toolbox controls"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
className="toolbox-shell__icon-button"
|
||||||
|
type="button"
|
||||||
|
aria-label={helpAction?.label ?? "Help"}
|
||||||
|
title={
|
||||||
|
helpAction?.title ??
|
||||||
|
helpAction?.label ??
|
||||||
|
(helpAction ? "Help" : "Help unavailable")
|
||||||
|
}
|
||||||
|
disabled={!helpAction}
|
||||||
|
onClick={() => {
|
||||||
|
setOpenPopover(null);
|
||||||
|
helpAction?.onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HelpIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{sourceHref ? (
|
||||||
|
<a
|
||||||
|
className="toolbox-shell__icon-button toolbox-shell__source"
|
||||||
|
href={sourceHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label={sourceLabel}
|
||||||
|
title={sourceLabel}
|
||||||
|
onClick={() => setOpenPopover(null)}
|
||||||
|
>
|
||||||
|
<GiteaIcon />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="toolbox-shell__icon-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="Source unavailable"
|
||||||
|
title="Source unavailable"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<GiteaIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref={appsButtonRef}
|
||||||
|
className="toolbox-shell__menu-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="Apps"
|
||||||
|
title="Apps"
|
||||||
|
aria-expanded={visiblePopover === "apps"}
|
||||||
|
aria-controls={visiblePopover === "apps" ? appsId : undefined}
|
||||||
|
onClick={() =>
|
||||||
|
setOpenPopover((current) => (current === "apps" ? null : "apps"))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AppsIcon />
|
||||||
|
<span>Apps</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
ref={personalizeButtonRef}
|
ref={personalizeButtonRef}
|
||||||
className="toolbox-shell__menu-button"
|
className="toolbox-shell__menu-button"
|
||||||
@@ -260,64 +396,6 @@ export function ToolboxHeader({
|
|||||||
<span>Personalize</span>
|
<span>Personalize</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
ref={appsButtonRef}
|
|
||||||
className="toolbox-shell__menu-button"
|
|
||||||
type="button"
|
|
||||||
aria-label="Apps"
|
|
||||||
title="Apps"
|
|
||||||
aria-expanded={visiblePopover === "apps"}
|
|
||||||
aria-controls={visiblePopover === "apps" ? appsId : undefined}
|
|
||||||
onClick={() =>
|
|
||||||
setOpenPopover((current) => (current === "apps" ? null : "apps"))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AppsIcon />
|
|
||||||
<span>Apps</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{sourceHref ? (
|
|
||||||
<a
|
|
||||||
className="toolbox-shell__icon-button toolbox-shell__source"
|
|
||||||
href={sourceHref}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
aria-label={sourceLabel}
|
|
||||||
title={sourceLabel}
|
|
||||||
onClick={() => setOpenPopover(null)}
|
|
||||||
>
|
|
||||||
<GiteaIcon />
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="toolbox-shell__icon-button"
|
|
||||||
type="button"
|
|
||||||
aria-label="Source unavailable"
|
|
||||||
title="Source unavailable"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<GiteaIcon />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="toolbox-shell__icon-button"
|
|
||||||
type="button"
|
|
||||||
aria-label={helpAction?.label ?? "Help"}
|
|
||||||
title={
|
|
||||||
helpAction?.title ??
|
|
||||||
helpAction?.label ??
|
|
||||||
(helpAction ? "Help" : "Help unavailable")
|
|
||||||
}
|
|
||||||
disabled={!helpAction}
|
|
||||||
onClick={() => {
|
|
||||||
setOpenPopover(null);
|
|
||||||
helpAction?.onClick();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HelpIcon />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{visiblePopover === "personalize" ? (
|
{visiblePopover === "personalize" ? (
|
||||||
<section
|
<section
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
@@ -331,22 +409,10 @@ export function ToolboxHeader({
|
|||||||
{customPersonalizeContent ? (
|
{customPersonalizeContent ? (
|
||||||
personalizeContent
|
personalizeContent
|
||||||
) : (
|
) : (
|
||||||
<>
|
<ToolboxPersonalizePanel
|
||||||
<strong>Appearance</strong>
|
theme={theme}
|
||||||
<div className="toolbox-shell__theme-options">
|
onThemeChange={(option) => onThemeChange?.(option)}
|
||||||
{THEMES.map((option) => (
|
/>
|
||||||
<button
|
|
||||||
key={option}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={theme === option}
|
|
||||||
onClick={() => onThemeChange?.(option)}
|
|
||||||
>
|
|
||||||
{option.slice(0, 1).toUpperCase() + option.slice(1)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<small>Saved only in this browser.</small>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -387,26 +453,6 @@ export function ToolboxHeader({
|
|||||||
</nav>
|
</nav>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbox-shell__identity">
|
|
||||||
<h1 className="toolbox-shell__name">{title}</h1>
|
|
||||||
{subtitle ? (
|
|
||||||
<span className="toolbox-shell__description">{subtitle}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{homeHref ? (
|
|
||||||
<a
|
|
||||||
className="toolbox-shell__brand"
|
|
||||||
href={homeHref}
|
|
||||||
aria-label={homeLabel}
|
|
||||||
onClick={() => setOpenPopover(null)}
|
|
||||||
>
|
|
||||||
{brandContent}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div className="toolbox-shell__brand">{brandContent}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{leadingActions || metadata ? (
|
{leadingActions || metadata ? (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
export { AppShell } from "./AppShell.js";
|
export { AppShell } from "./AppShell.js";
|
||||||
export type { AppShellProps } from "./AppShell.js";
|
export type { AppShellProps } from "./AppShell.js";
|
||||||
export { ToolboxHeader } from "./ToolboxHeader.js";
|
export { ToolboxHeader, ToolboxPersonalizePanel } from "./ToolboxHeader.js";
|
||||||
export type {
|
export type {
|
||||||
ToolboxHeaderAction,
|
ToolboxHeaderAction,
|
||||||
ToolboxHeaderApp,
|
ToolboxHeaderApp,
|
||||||
ToolboxHeaderProps,
|
ToolboxHeaderProps,
|
||||||
|
ToolboxPersonalizePanelProps,
|
||||||
} from "./ToolboxHeader.js";
|
} from "./ToolboxHeader.js";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
--toolbox-accent-soft: #e9edff;
|
--toolbox-accent-soft: #e9edff;
|
||||||
--toolbox-accent-contrast: #ffffff;
|
--toolbox-accent-contrast: #ffffff;
|
||||||
--toolbox-focus: #63cdbc;
|
--toolbox-focus: #63cdbc;
|
||||||
|
--toolbox-danger: #b4233a;
|
||||||
--toolbox-radius: 0.7rem;
|
--toolbox-radius: 0.7rem;
|
||||||
--toolbox-shadow: 0 10px 34px rgb(24 34 68 / 9%);
|
--toolbox-shadow: 0 10px 34px rgb(24 34 68 / 9%);
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
--toolbox-accent-soft: #252f55;
|
--toolbox-accent-soft: #252f55;
|
||||||
--toolbox-accent-contrast: #11182a;
|
--toolbox-accent-contrast: #11182a;
|
||||||
--toolbox-focus: #66cdbd;
|
--toolbox-focus: #66cdbd;
|
||||||
|
--toolbox-danger: #ff8fa3;
|
||||||
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
|
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@
|
|||||||
--toolbox-accent-soft: #252f55;
|
--toolbox-accent-soft: #252f55;
|
||||||
--toolbox-accent-contrast: #11182a;
|
--toolbox-accent-contrast: #11182a;
|
||||||
--toolbox-focus: #66cdbd;
|
--toolbox-focus: #66cdbd;
|
||||||
|
--toolbox-danger: #ff8fa3;
|
||||||
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
|
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
justify-self: start;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__header a {
|
.toolbox-shell__header a {
|
||||||
@@ -233,7 +236,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-self: end;
|
justify-self: start;
|
||||||
gap: 0.58rem;
|
gap: 0.58rem;
|
||||||
padding: 0.28rem 0.35rem;
|
padding: 0.28rem 0.35rem;
|
||||||
border-radius: var(--toolbox-radius);
|
border-radius: var(--toolbox-radius);
|
||||||
@@ -278,7 +281,8 @@ a.toolbox-shell__brand:hover {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
top: calc(100% + 0.7rem);
|
top: calc(100% + 0.7rem);
|
||||||
left: 0;
|
right: 0;
|
||||||
|
left: auto;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 13rem;
|
min-width: 13rem;
|
||||||
max-width: min(22rem, calc(100vw - 1.5rem));
|
max-width: min(22rem, calc(100vw - 1.5rem));
|
||||||
@@ -292,7 +296,12 @@ a.toolbox-shell__brand:hover {
|
|||||||
color: var(--toolbox-text);
|
color: var(--toolbox-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__popover strong {
|
.toolbox-shell__personalize-popover {
|
||||||
|
width: min(26rem, calc(100vw - 1.5rem));
|
||||||
|
max-width: min(26rem, calc(100vw - 1.5rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__popover > strong {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.35rem 0.4rem 0.5rem;
|
padding: 0.35rem 0.4rem 0.5rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -300,8 +309,8 @@ a.toolbox-shell__brand:hover {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__popover small,
|
.toolbox-shell__popover > small,
|
||||||
.toolbox-shell__popover p {
|
.toolbox-shell__switcher > p {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.55rem 0.4rem 0.25rem;
|
padding: 0.55rem 0.4rem 0.25rem;
|
||||||
@@ -310,17 +319,59 @@ a.toolbox-shell__brand:hover {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__preferences-content {
|
||||||
|
padding: 0.35rem;
|
||||||
|
color: var(--toolbox-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__preferences-heading {
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__preferences-content h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--toolbox-text);
|
||||||
|
font-family: var(--toolbox-font);
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__preferences-content .toolbox-shell__preferences-kicker {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--toolbox-accent);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__theme-choice {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__theme-choice legend {
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbox-shell__theme-options {
|
.toolbox-shell__theme-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 0.25rem;
|
gap: 0.45rem;
|
||||||
padding: 0.25rem;
|
padding: 0.35rem;
|
||||||
border-radius: 0.65rem;
|
border-radius: 0.75rem;
|
||||||
background: var(--toolbox-surface-soft);
|
background: var(--toolbox-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__theme-options button {
|
.toolbox-shell__theme-options button {
|
||||||
padding: 0.5rem 0.45rem;
|
padding: 0.62rem;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -333,8 +384,30 @@ a.toolbox-shell__brand:hover {
|
|||||||
.toolbox-shell__theme-options button[aria-pressed="true"] {
|
.toolbox-shell__theme-options button[aria-pressed="true"] {
|
||||||
background: var(--toolbox-surface);
|
background: var(--toolbox-surface);
|
||||||
color: var(--toolbox-accent);
|
color: var(--toolbox-accent);
|
||||||
box-shadow: 0 1px 5px rgb(24 34 68 / 12%);
|
box-shadow: 0 2px 8px rgb(20 30 60 / 9%);
|
||||||
font-weight: 750;
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__preferences-content .toolbox-shell__preferences-copy {
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell__preferences-content .toolbox-shell__preferences-warning {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--toolbox-danger) 8%,
|
||||||
|
var(--toolbox-surface)
|
||||||
|
);
|
||||||
|
color: var(--toolbox-danger);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-shell__switcher ul {
|
.toolbox-shell__switcher ul {
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
} from "@add-ideas/toolbox-contract";
|
} from "@add-ideas/toolbox-contract";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { AppShell, ToolboxHeader } from "../src/index.js";
|
import {
|
||||||
|
AppShell,
|
||||||
|
ToolboxHeader,
|
||||||
|
ToolboxPersonalizePanel,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
const currentApp: ToolboxAppManifest = {
|
const currentApp: ToolboxAppManifest = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -34,6 +38,10 @@ const currentApp: ToolboxAppManifest = {
|
|||||||
indexedDb: true,
|
indexedDb: true,
|
||||||
crossOriginIsolated: false,
|
crossOriginIsolated: false,
|
||||||
},
|
},
|
||||||
|
capabilities: {
|
||||||
|
required: ["workers"],
|
||||||
|
optional: [],
|
||||||
|
},
|
||||||
privacy: {
|
privacy: {
|
||||||
processing: "local",
|
processing: "local",
|
||||||
fileUploads: false,
|
fileUploads: false,
|
||||||
@@ -124,10 +132,20 @@ describe("AppShell", () => {
|
|||||||
element.getAttribute("aria-label"),
|
element.getAttribute("aria-label"),
|
||||||
),
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
"Personalize",
|
|
||||||
"Apps",
|
|
||||||
"Source for PDF Workbench on Gitea",
|
|
||||||
"Help",
|
"Help",
|
||||||
|
"Source for PDF Workbench on Gitea",
|
||||||
|
"Apps",
|
||||||
|
"Personalize",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
Array.from(
|
||||||
|
document.querySelector(".toolbox-shell__bar")?.children ?? [],
|
||||||
|
(element) => element.className,
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"toolbox-shell__brand",
|
||||||
|
"toolbox-shell__identity",
|
||||||
|
"toolbox-shell__controls",
|
||||||
]);
|
]);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
|
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
|
||||||
expect(
|
expect(
|
||||||
@@ -170,10 +188,12 @@ describe("AppShell", () => {
|
|||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(
|
||||||
|
() =>
|
||||||
expect(
|
expect(
|
||||||
document.querySelector("[data-toolbox-context='connected']"),
|
document.querySelector("[data-toolbox-context='connected']"),
|
||||||
).toBeInTheDocument(),
|
).toBeInTheDocument(),
|
||||||
|
{ timeout: 4_000 },
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
|
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
|
||||||
const navigation = screen.getByRole("navigation", {
|
const navigation = screen.getByRole("navigation", {
|
||||||
@@ -278,6 +298,21 @@ describe("AppShell", () => {
|
|||||||
const shell = document.querySelector(".toolbox-shell");
|
const shell = document.querySelector(".toolbox-shell");
|
||||||
expect(shell).toHaveAttribute("data-toolbox-theme", "dark");
|
expect(shell).toHaveAttribute("data-toolbox-theme", "dark");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
|
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Personalize your toolbox" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("On this device")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("group", { name: "Appearance" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Pinned")).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Export JSON" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Light" }));
|
fireEvent.click(screen.getByRole("button", { name: "Light" }));
|
||||||
expect(shell).toHaveAttribute("data-toolbox-theme", "light");
|
expect(shell).toHaveAttribute("data-toolbox-theme", "light");
|
||||||
expect(
|
expect(
|
||||||
@@ -285,6 +320,29 @@ describe("AppShell", () => {
|
|||||||
).toMatchObject({ theme: "light" });
|
).toMatchObject({ theme: "light" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shares the portal-style personalization frame and extension slot", () => {
|
||||||
|
const onThemeChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ToolboxPersonalizePanel
|
||||||
|
theme="system"
|
||||||
|
onThemeChange={onThemeChange}
|
||||||
|
storageAvailable={false}
|
||||||
|
>
|
||||||
|
<p>Portal preference actions</p>
|
||||||
|
</ToolboxPersonalizePanel>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Personalize your toolbox" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent(
|
||||||
|
"Browser storage is unavailable",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Portal preference actions")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Dark" }));
|
||||||
|
expect(onThemeChange).toHaveBeenCalledWith("dark");
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves manifest-relative header links without rendering an app icon", async () => {
|
it("resolves manifest-relative header links without rendering an app icon", async () => {
|
||||||
render(
|
render(
|
||||||
<AppShell
|
<AppShell
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-testkit",
|
"name": "@add-ideas/toolbox-testkit",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"description": "Manifest, asset, and nested-deployment smoke checks for built toolbox applications.",
|
"description": "Manifest, asset, and nested-deployment smoke checks for built toolbox applications.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
|
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git",
|
||||||
"directory": "packages/testkit"
|
"directory": "packages/testkit"
|
||||||
},
|
},
|
||||||
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
|
"homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"toolbox",
|
"toolbox",
|
||||||
"manifest",
|
"manifest",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.1"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"",
|
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"",
|
||||||
@@ -47,6 +47,6 @@
|
|||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/"
|
"registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$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",
|
"$id": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||||
"title": "Toolbox application manifest v1",
|
"title": "Toolbox application manifest v1",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -116,6 +116,24 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
},
|
},
|
||||||
|
"io": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["accepts", "produces"],
|
||||||
|
"properties": {
|
||||||
|
"accepts": { "$ref": "#/$defs/formats" },
|
||||||
|
"produces": { "$ref": "#/$defs/formats" }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["required", "optional"],
|
||||||
|
"properties": {
|
||||||
|
"required": { "$ref": "#/$defs/identifierList" },
|
||||||
|
"optional": { "$ref": "#/$defs/identifierList" }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["repository", "license"],
|
"required": ["repository", "license"],
|
||||||
@@ -157,6 +175,60 @@
|
|||||||
"uniqueItems": true
|
"uniqueItems": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"required": ["requirements", "capabilities"],
|
||||||
|
"properties": {
|
||||||
|
"requirements": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["workers"],
|
||||||
|
"properties": { "workers": { "const": true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"capabilities": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["required"],
|
||||||
|
"properties": {
|
||||||
|
"required": {
|
||||||
|
"type": "array",
|
||||||
|
"contains": { "const": "workers" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"required": ["capabilities"],
|
||||||
|
"properties": {
|
||||||
|
"capabilities": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["required"],
|
||||||
|
"properties": {
|
||||||
|
"required": {
|
||||||
|
"type": "array",
|
||||||
|
"contains": { "const": "workers" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"requirements": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["workers"],
|
||||||
|
"properties": { "workers": { "const": true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"$defs": {
|
"$defs": {
|
||||||
"urlReference": {
|
"urlReference": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -218,6 +290,37 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/$defs/nonEmptyString"
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"identifierList": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formats": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["mediaType", "extensions"],
|
||||||
|
"properties": {
|
||||||
|
"mediaType": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(?:\\*|[A-Za-z0-9!#$&^_.+-]+)/(?:\\*|[A-Za-z0-9!#$&^_.+-]+)$"
|
||||||
|
},
|
||||||
|
"extensions": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^\\.[A-Za-z0-9][A-Za-z0-9._+-]*$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"label": { "$ref": "#/$defs/nonEmptyString" }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$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",
|
"$id": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
|
||||||
"title": "Toolbox catalog v1",
|
"title": "Toolbox catalog v1",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["schemaVersion", "id", "name", "home", "theme", "apps"],
|
"required": ["schemaVersion", "id", "name", "home", "theme", "apps"],
|
||||||
|
|||||||
Reference in New Issue
Block a user