cli: waybetter store build|sign|verify, the store catalog JSON Schema

Moved from wayBetter for X (scripts/build-store.mjs, scripts/sign-store.mjs) into the `waybetter`
npm package, generic over the app:

- bin/waybetter.mjs + cli/store.mjs. `build --config <store.config.mjs>`: the config names its app
  and returns its items (metadata, file path + bytes, optional preview); build replaces only that
  app's items in the shared catalog (other apps' items and files stay), removes files only its old
  items used, fills url/sha256/size/apps, and validates the whole catalog with the rules apps apply
  natively before anything is written. `sign` (and `sign --init`: create the key once, 0600, never
  overwritten) signs the exact catalog bytes; `verify` checks the signature with any listed public
  key (rotation) and the catalog rules. The private key is never printed, copied or moved; --key
  defaults to $WAYBETTER_STORE_KEY. pluginHeader() for configs.
- store-catalog.schema.json (format 2, `apps` per item), exported with the CLI module.
- Tests: multi-app builds in one folder, refusals (bad items, duplicates, paths outside the folder,
  hosts), schema vs CLI rules, sign/verify round trip incl. rotation and tampering, no key
  material printed. jsonSchema.mjs learns minItems, uniqueItems and allOf.
This commit is contained in:
CRTkafa 2026-09-29 11:12:38 +07:00
parent dc449de9c2
commit 877dd8fa58
7 changed files with 518 additions and 4 deletions

View file

@ -0,0 +1,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://unpkg.com/waybetter/store-catalog.schema.json",
"title": "wayBetter Store catalog",
"description": "The shared, signed catalog of the wayBetter Store (format 2). Every wayBetter app reads the same file (catalog.json, signed by catalog.json.sig) and keeps only the items whose `apps` list its id. Apps verify the Ed25519 signature over the exact bytes first, then these rules (waybetter-host store::catalog), then each download's sha256 and size. Build it with `waybetter store build`, sign it with `waybetter store sign`.",
"type": "object",
"required": ["format", "name", "generatedAt", "items"],
"additionalProperties": false,
"properties": {
"format": { "const": 2 },
"name": { "type": "string" },
"generatedAt": { "type": "string", "description": "When the catalog was built (RFC 3339, UTC)." },
"items": { "type": "array", "maxItems": 500, "items": { "$ref": "#/$defs/item" } }
},
"$defs": {
"text": { "type": "string", "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f]*$" },
"https": { "type": "string", "pattern": "^https://", "maxLength": 2048 },
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
"item": {
"type": "object",
"required": ["apps", "kind", "id", "name", "version", "url", "sha256", "size"],
"additionalProperties": false,
"properties": {
"apps": {
"type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true,
"description": "The wayBetter apps this item is for (app ids such as \"x\"). An app never shows, stages or installs another app's items.",
"items": { "type": "string", "pattern": "^[a-z0-9-]{1,32}$" }
},
"kind": { "enum": ["theme", "plugin"] },
"id": { "type": "string", "pattern": "^[a-z0-9-]{1,40}$", "description": "Unique per app and kind." },
"name": { "allOf": [{ "$ref": "#/$defs/text" }], "type": "string", "minLength": 1, "maxLength": 60 },
"author": { "allOf": [{ "$ref": "#/$defs/text" }], "type": "string", "maxLength": 60 },
"version": { "allOf": [{ "$ref": "#/$defs/text" }], "type": "string", "minLength": 1, "maxLength": 32 },
"description": { "allOf": [{ "$ref": "#/$defs/text" }], "type": "string", "maxLength": 600 },
"category": { "allOf": [{ "$ref": "#/$defs/text" }], "type": "string", "maxLength": 32, "description": "Shown as `wb.store.category.<id>` when the framework catalogs name it (reading, writing, filters, focus, tools, dark, light), else as is." },
"tags": { "type": "array", "maxItems": 12, "items": { "allOf": [{ "$ref": "#/$defs/text" }], "type": "string", "maxLength": 32 } },
"url": { "$ref": "#/$defs/https", "description": "The file. It must be on the reading app's download hosts (waybetter.fyi, cdn1.crt.fyi, git.crt.fyi raw files by default): https, default port, no credentials, no fragment." },
"file": { "type": "string", "pattern": "^[A-Za-z0-9_\\-. ]+\\.plugin\\.js$", "maxLength": 128, "description": "Plugins only: the file name it is installed under (unique per app, ignoring case)." },
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"size": { "type": "integer", "minimum": 1, "maximum": 2097152 },
"preview": { "$ref": "#/$defs/https" },
"permissions": {
"description": "Plugins only: the declared @permissions; null = no @permissions line (legacy, full API).",
"anyOf": [
{ "type": "null" },
{ "type": "array", "uniqueItems": true, "items": { "enum": ["storage", "ui", "clipboard", "account", "users", "commands", "network", "network:none"] } }
]
},
"colors": {
"description": "Themes only: swatches for the card.",
"anyOf": [
{ "type": "null" },
{
"type": "object", "required": ["bg", "text", "accent"], "additionalProperties": false,
"properties": { "bg": { "$ref": "#/$defs/color" }, "surface": { "$ref": "#/$defs/color" }, "text": { "$ref": "#/$defs/color" }, "accent": { "$ref": "#/$defs/color" } }
}
]
}
}
}
}
}