commit b9d86fa3035546b88ce59cac40991cb42922ab2c Author: local Date: Mon May 11 20:03:27 2026 -0500 initial relay scaffold diff --git a/.deploy.env.example b/.deploy.env.example new file mode 100644 index 0000000..ecf2097 --- /dev/null +++ b/.deploy.env.example @@ -0,0 +1,25 @@ +# Copy this file to `.deploy.env` and fill in your values. +# `.deploy.env` is gitignored — it holds secrets. +# +# Usage: make deploy (builds + uploads + registers + re-indexes) +# or bin/deploy.sh (just the deploy step; assumes .s9pk already built) + +# --- FileBrowser upload target --- +# Local URL to your FileBrowser (from the FileBrowser Dashboard → URL column). +FILEBROWSER_URL=https://immense-voyage.local:51165 +FILEBROWSER_USER=your-filebrowser-user +FILEBROWSER_PASS=your-filebrowser-pass + +# Path on FileBrowser to overwrite (leading slash, relative to FB root). +FILEBROWSER_PATH=/websites/packages/youtube-summarizer_x86_64.s9pk + +# --- Start9 server that hosts the registry --- +START9_SERVER=https://immense-voyage.local:62185 + +# --- Public registry --- +# REGISTRY_URL is the JSON-RPC endpoint of your public registry. +REGISTRY_URL=https://registry.satsflows.com +# REGISTRY_PUBLIC_URL is the publicly-reachable URL of the .s9pk file — this +# is the URL clients download from when they install the package. Your +# "Websites" service mirrors /websites/packages/ → files.satsflows.com/. +REGISTRY_PUBLIC_URL=https://files.satsflows.com/youtube-summarizer_x86_64.s9pk diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..487a5b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Deploy secrets — copy .deploy.env.example to .deploy.env and fill in values +.deploy.env + +# Scratch file used by bin/bump-version.sh to pre-fill the release-notes +# prompt. Written by Claude after code changes, consumed + deleted on the +# next `make deploy` / `make bump`. +.release-notes-pending.txt + +# OS +.DS_Store + +# Node +node_modules/ + +# Build artifacts (regenerated by `make` / `npm run build`) +javascript/ +*.s9pk +image.tar + +# Runtime / user data — must never be committed +history/ +cookies.txt +*.txt.bak +youtube-summarizer-library-export*.json +ytdlp-cache/ + +# Local dev secrets +.env + +# Claude Code state (worktrees, plans, etc.) +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..623c4a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +# ───────────────────────────────────────────────────────── +# Recap Relay — StartOS 0.4 Docker image +# +# Includes: Node.js 20 only. No yt-dlp / ffmpeg / Python — the relay +# receives audio buffers from Recap clients and forwards to Gemini's +# File API; no local audio processing. +# +# Uses Debian slim for the same reason Recap does — pip-free, but +# pulled-in C deps from npm packages prefer glibc over musl. +# ───────────────────────────────────────────────────────── + +# ── Stage 1: install dependencies ────────────────────────── +FROM node:20-slim AS builder + +# @keysat/licensing-client is vendored from a private git repo into +# vendor/keysat-licensing-client. Install its deps in place first so +# the file: dep on it works downstream. +WORKDIR /app +COPY vendor/keysat-licensing-client /app/vendor/keysat-licensing-client +WORKDIR /app/vendor/keysat-licensing-client +RUN npm install --omit=dev --ignore-scripts + +WORKDIR /app/server +COPY server/package.json server/package-lock.json* ./ +RUN npm ci --omit=dev --ignore-scripts 2>/dev/null || npm install --omit=dev --ignore-scripts + +# ── Stage 2: final runtime image ─────────────────────────── +FROM node:20-slim AS runner + +WORKDIR /app + +# Runtime deps: +# - dumb-init: PID 1 signal handling +# - ca-certificates: HTTPS for Gemini + Keysat +RUN apt-get update && apt-get install -y --no-install-recommends \ + dumb-init \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Copy installed deps + app code from builder +COPY --from=builder /app/vendor ./vendor/ +COPY --from=builder /app/server/node_modules ./server/node_modules/ +COPY server/package.json ./server/ +# Top-level *.js files (index.js, config.js, credits.js, admin-auth.js, +# job-credits.js, keysat-client.js) plus the backends/ + routes/ +# subdirectories. Anything added under server// needs its own +# COPY line — the glob doesn't recurse. +COPY server/*.js ./server/ +COPY server/backends/ ./server/backends/ +COPY server/routes/ ./server/routes/ +COPY public/ ./public/ + +COPY docker_entrypoint.sh /usr/local/bin/docker_entrypoint.sh +RUN chmod +x /usr/local/bin/docker_entrypoint.sh + +RUN mkdir -p /data + +ENV NODE_ENV=production \ + PORT=3002 \ + DATA_DIR=/data + +EXPOSE 3002 + +ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker_entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9d2709f --- /dev/null +++ b/LICENSE @@ -0,0 +1,10 @@ +Proprietary License + +Copyright (c) 2026. All rights reserved. + +This software and associated documentation files (the "Software") are +proprietary. Unauthorized copying, modification, distribution, or use +of this Software, via any medium, is strictly prohibited without prior +written permission from the copyright holder. + +The Software is provided "as is", without warranty of any kind. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..01e2861 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +# overrides to s9pk.mk must precede the include statement +include s9pk.mk + +# ── Deploy ───────────────────────────────────────────────────────────────── +# `make deploy` — bump the version (only if .release-notes-pending.txt +# exists, signalling unshipped changes), build the x86 .s9pk, upload to +# FileBrowser, register with the Start9 registry, and trigger a re-index. +# Reads credentials from .deploy.env — copy .deploy.env.example to +# .deploy.env and fill in your values. +# +# The bump step is skipped automatically if you've already run `make bump` +# (which consumes the pending-notes file). No more double-prompt. +.PHONY: deploy redeploy bump +deploy: + @bash bin/bump-version.sh --from-deploy + @$(MAKE) --no-print-directory x86 + @bash bin/deploy.sh + +# `make redeploy` — push the existing .s9pk as-is. No version bump, no build. +redeploy: + @bash bin/deploy.sh + +# `make bump` — interactively bump the version (create a new version file and +# wire it into the version graph). No build, no deploy. +bump: + @bash bin/bump-version.sh diff --git a/bin/bump-version.sh b/bin/bump-version.sh new file mode 100755 index 0000000..6c4c590 --- /dev/null +++ b/bin/bump-version.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Interactive version bump. +# +# Shows the current version, asks for the new version (with a sensible default: +# patch-level bump), and asks for release notes. Then: +# 1. Creates startos/versions/v.ts +# 2. Updates startos/versions/index.ts (adds import, updates `current:`, +# pushes old current into `other:`) +# +# If the file .release-notes-pending.txt exists in the project root, its +# contents are shown as the default release notes (just press Enter to accept). +# Drop a note in that file before running this script to pre-fill the +# prompt. The file is deleted on a successful bump. +# +# Flags: +# --from-deploy Treat the absence of .release-notes-pending.txt as the +# signal that no new work needs to ship — exit 0 without +# bumping. Lets `make deploy` always include this step +# without forcing a redundant bump when the user (or a +# prior run) already bumped. +# +# Run standalone with `make bump`, or as the first step of `make deploy`. + +set -euo pipefail + +FROM_DEPLOY=0 +for arg in "$@"; do + case "$arg" in + --from-deploy) FROM_DEPLOY=1 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +VERSIONS_DIR="$PROJECT_ROOT/startos/versions" +INDEX_FILE="$VERSIONS_DIR/index.ts" +PENDING_NOTES_FILE="$PROJECT_ROOT/.release-notes-pending.txt" + +# When invoked from `make deploy`, treat a missing pending-notes file as +# "nothing new to ship — current version is already fresh, skip the bump." +# Standalone `make bump` always prompts. +if [ "$FROM_DEPLOY" = "1" ] && [ ! -f "$PENDING_NOTES_FILE" ]; then + echo "" + echo " (No .release-notes-pending.txt — current version is already bumped. Skipping.)" + echo "" + exit 0 +fi + +# --- Discover current version --- +CURRENT_VAR="$(sed -nE "s/.*current:[[:space:]]*(v_[0-9_]+).*/\1/p" "$INDEX_FILE" | head -1)" +if [ -z "$CURRENT_VAR" ]; then + echo "X Could not find current version in $INDEX_FILE" >&2 + exit 1 +fi + +CURRENT_DOT="$(echo "$CURRENT_VAR" | sed 's/^v_//; s/_/./g')" +IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_DOT" +SUGGESTED="$MAJOR.$MINOR.$((PATCH + 1))" + +echo "" +echo "═══════════════════════════════════════════" +echo " Bumping version" +echo "═══════════════════════════════════════════" +echo "" +echo " Current: $CURRENT_DOT" +echo "" + +# --- Prompt for new version --- +read -r -p " New version [$SUGGESTED]: " NEW_VERSION +NEW_VERSION="${NEW_VERSION:-$SUGGESTED}" + +if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "X Invalid version: '$NEW_VERSION' (expected format: x.y.z, e.g. 0.1.10)" >&2 + exit 1 +fi + +NEW_FILE="$VERSIONS_DIR/v${NEW_VERSION}.ts" +if [ -f "$NEW_FILE" ]; then + echo "X Version file already exists: $NEW_FILE" >&2 + echo " (If you want to re-run, delete that file first.)" >&2 + exit 1 +fi + +NEW_VAR="v_$(echo "$NEW_VERSION" | tr '.' '_')" + +# --- Prompt for release notes --- +# If suggested notes are sitting in .release-notes-pending.txt, show them +# as the default. Press Enter to accept, or type something different. +SUGGESTED_NOTES="" +if [ -f "$PENDING_NOTES_FILE" ]; then + # Read the file, trim leading/trailing whitespace, collapse interior newlines + # into spaces so it fits the one-line release-notes format. + SUGGESTED_NOTES="$(tr '\n' ' ' < "$PENDING_NOTES_FILE" | sed -e 's/[[:space:]]\{1,\}/ /g' -e 's/^ //' -e 's/ $//')" +fi + +echo "" +if [ -n "$SUGGESTED_NOTES" ]; then + echo " Suggested release notes (from .release-notes-pending.txt):" + echo " $SUGGESTED_NOTES" + echo "" + read -r -p " Release notes (Enter to accept, or type new): " RELEASE_NOTES + RELEASE_NOTES="${RELEASE_NOTES:-$SUGGESTED_NOTES}" +else + read -r -p " Release notes (one-liner, what changed in $NEW_VERSION?): " RELEASE_NOTES +fi + +if [ -z "$RELEASE_NOTES" ]; then + echo "X Release notes are required" >&2 + exit 1 +fi + +# Escape for TypeScript single-quoted string: backslash first, then single quote +ESCAPED_NOTES="$(printf '%s' "$RELEASE_NOTES" | sed -e 's/\\/\\\\/g' -e "s/'/\\\\'/g")" + +# Clean up partial state if anything below fails +cleanup_on_error() { + [ -f "$NEW_FILE" ] && rm -f "$NEW_FILE" + [ -f "$INDEX_FILE.tmp" ] && rm -f "$INDEX_FILE.tmp" + [ -f "$INDEX_FILE.bak" ] && rm -f "$INDEX_FILE.bak" +} +trap cleanup_on_error ERR + +# --- 1. Create new version file --- +cat > "$NEW_FILE" < {}, + down: async ({ effects }) => {}, + }, +}) +EOF + +# --- 2. Update index.ts --- +# Insert `import { v_NEW } from './vNEW'` right after the last existing version +# import, so the imports stay contiguous. +NEW_IMPORT="import { $NEW_VAR } from './v$NEW_VERSION'" + +awk -v new_import="$NEW_IMPORT" ' + /^import \{ v_[0-9_]+ \} from/ { last_imp = NR } + { lines[NR] = $0 } + END { + for (i = 1; i <= NR; i++) { + print lines[i] + if (i == last_imp) print new_import + } + } +' "$INDEX_FILE" > "$INDEX_FILE.tmp" +mv "$INDEX_FILE.tmp" "$INDEX_FILE" + +# Point `current:` at the new version, and prepend the old current into `other:`. +# Use -i.bak for macOS/BSD + Linux/GNU sed compatibility. +sed -i.bak \ + -e "s/current: $CURRENT_VAR/current: $NEW_VAR/" \ + -e "s/other: \[/other: [$CURRENT_VAR, /" \ + "$INDEX_FILE" +rm -f "$INDEX_FILE.bak" + +trap - ERR + +# --- Clean up the pending-notes scratch file now that we've consumed it --- +if [ -f "$PENDING_NOTES_FILE" ]; then + rm -f "$PENDING_NOTES_FILE" +fi + +echo "" +echo " ✓ Created startos/versions/v${NEW_VERSION}.ts" +echo " ✓ Wired up in startos/versions/index.ts" +echo " ✓ Version: $CURRENT_DOT → $NEW_VERSION" +echo "" diff --git a/bin/deploy.sh b/bin/deploy.sh new file mode 100755 index 0000000..b11673e --- /dev/null +++ b/bin/deploy.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# One-shot deploy: upload the built .s9pk to FileBrowser, register it with the +# Start9 registry, and trigger a re-index so clients see the new version. +# +# Config is loaded from, in order: +# 1. Environment variables +# 2. $PROJECT_ROOT/.deploy.env (gitignored; see .deploy.env.example) +# +# Required config: +# FILEBROWSER_URL — e.g. https://immense-voyage.local:51165 +# FILEBROWSER_USER — your FileBrowser login +# FILEBROWSER_PASS — your FileBrowser password +# START9_SERVER — your Start9 server, e.g. https://immense-voyage.local:62185 +# +# Optional config (sensible defaults): +# FILEBROWSER_PATH — path on FileBrowser to overwrite. Default: /websites/keysat-registry/recap-relay_x86_64.s9pk +# REGISTRY_URL — registry JSON-RPC URL. Default: https://registry.keysat.xyz +# REGISTRY_PUBLIC_URL — public .s9pk URL registered with start-cli. +# Default: https://files.keysat.xyz/recap-relay_x86_64.s9pk + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="$PROJECT_ROOT/.deploy.env" + +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + set -a; source "$ENV_FILE"; set +a +fi + +# --- Validate config --- +: "${FILEBROWSER_URL:?FILEBROWSER_URL is required (see bin/deploy.sh header)}" +: "${FILEBROWSER_USER:?FILEBROWSER_USER is required}" +: "${FILEBROWSER_PASS:?FILEBROWSER_PASS is required}" +: "${START9_SERVER:?START9_SERVER is required (e.g. https://immense-voyage.local:62185)}" + +FILEBROWSER_PATH="${FILEBROWSER_PATH:-/websites/keysat-registry/recap-relay_x86_64.s9pk}" +REGISTRY_URL="${REGISTRY_URL:-https://registry.keysat.xyz}" +REGISTRY_PUBLIC_URL="${REGISTRY_PUBLIC_URL:-https://files.keysat.xyz/recap-relay_x86_64.s9pk}" + +S9PK_FILE="$PROJECT_ROOT/recap-relay_x86_64.s9pk" + +if [ ! -f "$S9PK_FILE" ]; then + echo "X $S9PK_FILE not found. Run 'make x86' first." >&2 + exit 1 +fi + +# --- Discover current version from startos/versions --- +CURRENT_VAR="$(sed -nE "s/.*current:[[:space:]]*(v_[0-9_]+).*/\1/p" "$PROJECT_ROOT/startos/versions/index.ts" | head -1)" +if [ -n "$CURRENT_VAR" ]; then + VERSION_DOT="$(echo "$CURRENT_VAR" | sed 's/^v_//; s/_/./g')" + VERSION_FILE="$PROJECT_ROOT/startos/versions/v${VERSION_DOT}.ts" + CURRENT_VERSION="$(sed -nE "s/.*version:[[:space:]]*'([^']+)'.*/\1/p" "$VERSION_FILE" | head -1)" +else + CURRENT_VERSION="unknown" +fi + +echo "==> Deploying recap-relay $CURRENT_VERSION" +echo " source : $S9PK_FILE" +echo " upload : $FILEBROWSER_URL$FILEBROWSER_PATH" +echo " public : $REGISTRY_PUBLIC_URL" +echo " start9 : $START9_SERVER" +echo "" + +# FileBrowser is typically on a *.local address with a self-signed cert, so +# curl needs -k (--insecure) to connect. Override with FILEBROWSER_CURL_OPTS if +# you've set up trust for the Start9 root CA. +FB_CURL_OPTS="${FILEBROWSER_CURL_OPTS:--k}" + +# --- 1. Authenticate with FileBrowser --- +echo "[1/4] Authenticating with FileBrowser..." +LOGIN_BODY="$(printf '{"username":"%s","password":"%s"}' \ + "$(printf '%s' "$FILEBROWSER_USER" | sed 's/[\\"]/\\&/g')" \ + "$(printf '%s' "$FILEBROWSER_PASS" | sed 's/[\\"]/\\&/g')")" + +TOKEN="$(curl $FB_CURL_OPTS -fsS -X POST "$FILEBROWSER_URL/api/login" \ + -H "Content-Type: application/json" \ + --data-raw "$LOGIN_BODY")" || { + echo "X FileBrowser login failed (check URL, username, password)" >&2 + exit 1 + } + +if [ -z "$TOKEN" ]; then + echo "X FileBrowser login returned an empty token" >&2 + exit 1 +fi + +# --- 2. Upload .s9pk (override existing) --- +echo "[2/4] Uploading $(basename "$S9PK_FILE") ($(du -h "$S9PK_FILE" | cut -f1))..." +curl $FB_CURL_OPTS -fsS -X POST "$FILEBROWSER_URL/api/resources${FILEBROWSER_PATH}?override=true" \ + -H "X-Auth: $TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$S9PK_FILE" \ + -o /dev/null + +# --- 3. Register with Start9 registry (adds manifest + pointer to public URL) --- +echo "[3/4] Registering package with registry..." +start-cli -r "$START9_SERVER" registry package add "$S9PK_FILE" --url "$REGISTRY_PUBLIC_URL" + +# --- 4. Trigger registry re-index so clients see the new version --- +echo "[4/4] Re-indexing registry..." +curl -fsS -X POST "$REGISTRY_URL/rpc/v0" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"index","id":1}' \ + -o /dev/null + +echo "" +echo "==> Done. recap-relay $CURRENT_VERSION is live." diff --git a/docker_entrypoint.sh b/docker_entrypoint.sh new file mode 100755 index 0000000..9c18e96 --- /dev/null +++ b/docker_entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +DATA_DIR="/data" +CONFIG_DIR="$DATA_DIR/config" + +mkdir -p "$CONFIG_DIR" + +# Ensure the volume is writable by the non-root node user +chown -R 1001:1001 "$DATA_DIR" 2>/dev/null || true + +export DATA_DIR="$DATA_DIR" +export PORT="${PORT:-3002}" +export HOSTNAME="0.0.0.0" + +echo "Starting Recap Relay..." +echo " Node: $(node --version)" +echo " Data: $DATA_DIR" +echo " Port: $PORT" + +cd /app/server +exec node index.js diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..d326778 Binary files /dev/null and b/icon.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..8cc67c6 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "recap-relay-startos", + "private": true, + "scripts": { + "build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", + "prettier": "prettier --write startos", + "check": "tsc --noEmit" + }, + "dependencies": { + "@start9labs/start-sdk": "1.0.0" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true + } +} diff --git a/s9pk.mk b/s9pk.mk new file mode 100644 index 0000000..ee8a215 --- /dev/null +++ b/s9pk.mk @@ -0,0 +1,104 @@ +# ** Plumbing. DO NOT EDIT **. +# This file is imported by ./Makefile. Make edits there + +PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2}' startos/manifest/index.ts) +INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null) +ARCHES ?= x86 arm +TARGETS ?= arches + +ifdef VARIANT +BASE_NAME := $(PACKAGE_ID)_$(VARIANT) +else +BASE_NAME := $(PACKAGE_ID) +endif + +.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients +.DELETE_ON_ERROR: +.SECONDARY: + +define SUMMARY + @manifest=$$(start-cli s9pk inspect $(1) manifest); \ + size=$$(du -h $(1) | awk '{print $$1}'); \ + title=$$(printf '%s' "$$manifest" | jq -r .title); \ + version=$$(printf '%s' "$$manifest" | jq -r .version); \ + arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \ + sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \ + gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \ + printf "\n"; \ + printf "\033[1;32m✅ Build Complete!\033[0m\n"; \ + printf "\n"; \ + printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \ + printf "───────────────────────────────\n"; \ + printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \ + printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \ + printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \ + printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \ + printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \ + echo "" +endef + +all: $(TARGETS) + +arches: $(ARCHES) + +universal: $(BASE_NAME).s9pk + $(call SUMMARY,$<) + +arch/%: $(BASE_NAME)_%.s9pk + $(call SUMMARY,$<) + +x86 x86_64: arch/x86_64 +arm arm64 aarch64: arch/aarch64 +riscv riscv64: arch/riscv64 + +$(BASE_NAME).s9pk: $(INGREDIENTS) .git/HEAD .git/index + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack -o $@ + +$(BASE_NAME)_%.s9pk: $(INGREDIENTS) .git/HEAD .git/index + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack --arch=$* -o $@ + +ingredients: $(INGREDIENTS) + @echo " Re-evaluating ingredients..." + +install: | check-deps check-init + @HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$HOST" ]; then \ + echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S9PK=$$(ls -t *.s9pk 2>/dev/null | head -1); \ + if [ -z "$$S9PK" ]; then \ + echo "Error: No .s9pk file found. Run 'make' first."; \ + exit 1; \ + fi; \ + printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \ + start-cli package install -s "$$S9PK" + +check-deps: + @command -v start-cli >/dev/null || \ + (echo "Error: start-cli not found. Please see https://docs.start9.com/packaging/0.4.0.x/environment-setup.html" && exit 1) + @command -v npm >/dev/null || \ + (echo "Error: npm not found. Please install Node.js and npm." && exit 1) + +check-init: + @if [ ! -f ~/.startos/developer.key.pem ]; then \ + echo "Initializing StartOS developer environment..."; \ + start-cli init-key; \ + fi + +javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules + npm run build + +node_modules: package-lock.json + npm ci + +package-lock.json: package.json + npm i + +clean: + @echo "Cleaning up build artifacts..." + @rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules diff --git a/server/admin-auth.js b/server/admin-auth.js new file mode 100644 index 0000000..2238e43 --- /dev/null +++ b/server/admin-auth.js @@ -0,0 +1,127 @@ +// Admin dashboard auth. Mirrors Recap's server/admin-auth.js shape so +// the patterns are familiar. Single-user auth (operator only) — verify +// scrypt(password, salt) against the stored hash, mint a signed +// session cookie on success. Cookie is HMAC-signed with a per-install +// session secret so attackers can't forge sessions even with read +// access to the relay's /admin endpoints. + +import { scryptSync, timingSafeEqual, createHmac } from "crypto"; +import { getConfigSnapshot } from "./config.js"; + +const SCRYPT_KEYLEN = 64; +const SESSION_COOKIE = "recap-relay-admin"; +const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24h + +// Path prefix that requires admin auth. Public /relay/* paths are +// authenticated per-call by the route handlers, not the cookie. +const ADMIN_PREFIX = "/admin"; + +function constantTimeEqual(a, b) { + if (typeof a !== "string" || typeof b !== "string") return false; + if (a.length !== b.length) return false; + try { + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); + } catch { + return false; + } +} + +function signSessionToken(payload, secret) { + const body = JSON.stringify(payload); + const sig = createHmac("sha256", secret).update(body).digest("hex"); + return `${Buffer.from(body).toString("base64url")}.${sig}`; +} + +function verifySessionToken(token, secret) { + if (!token) return null; + const parts = token.split("."); + if (parts.length !== 2) return null; + const [b64, sig] = parts; + let body; + try { + body = Buffer.from(b64, "base64url").toString("utf8"); + } catch { + return null; + } + const expectedSig = createHmac("sha256", secret).update(body).digest("hex"); + if (!constantTimeEqual(sig, expectedSig)) return null; + let payload; + try { + payload = JSON.parse(body); + } catch { + return null; + } + if (typeof payload?.exp !== "number" || Date.now() > payload.exp) return null; + return payload; +} + +export function setupAdminAuthMiddleware(app) { + app.use(async (req, res, next) => { + if (!req.path.startsWith(ADMIN_PREFIX)) return next(); + // /admin/login is reachable without auth. + if (req.path === "/admin/login" || req.path === "/admin/status") return next(); + const cfg = await getConfigSnapshot(); + if (!cfg.relay_admin_password_hash) { + // No password set — admin endpoints are disabled entirely. + return res.status(401).json({ error: "admin_disabled" }); + } + const token = req.cookies?.[SESSION_COOKIE]; + const payload = verifySessionToken(token, cfg.relay_admin_session_secret); + if (!payload) return res.status(401).json({ error: "unauthorized" }); + req.adminUser = payload.user; + next(); + }); +} + +export function setupAdminAuthRoutes(app) { + app.get("/admin/status", async (_req, res) => { + const cfg = await getConfigSnapshot(); + res.json({ + enabled: !!cfg.relay_admin_password_hash, + username: cfg.relay_admin_username || "admin", + }); + }); + + app.post("/admin/login", async (req, res) => { + const cfg = await getConfigSnapshot(); + if (!cfg.relay_admin_password_hash) { + return res.status(400).json({ error: "admin_disabled" }); + } + const { username, password } = req.body || {}; + if ( + !username || + !password || + typeof username !== "string" || + typeof password !== "string" + ) { + return res.status(400).json({ error: "missing_credentials" }); + } + if (username.trim() !== (cfg.relay_admin_username || "admin")) { + return res.status(401).json({ error: "invalid_credentials" }); + } + const hash = scryptSync(password, cfg.relay_admin_password_salt, SCRYPT_KEYLEN).toString("hex"); + if (!constantTimeEqual(hash, cfg.relay_admin_password_hash)) { + return res.status(401).json({ error: "invalid_credentials" }); + } + const token = signSessionToken( + { user: username, exp: Date.now() + SESSION_TTL_MS }, + cfg.relay_admin_session_secret + ); + res.cookie(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: "lax", + // secure: true would be ideal but the relay runs behind + // StartTunnel which terminates TLS — the cookie travels over + // plain HTTP inside the tunnel. Leave secure false so the + // cookie sticks; the tunnel itself provides the encryption. + secure: false, + maxAge: SESSION_TTL_MS, + }); + res.json({ ok: true, username }); + }); + + app.post("/admin/logout", (_req, res) => { + res.clearCookie(SESSION_COOKIE); + res.json({ ok: true }); + }); +} diff --git a/server/backends/gemini.js b/server/backends/gemini.js new file mode 100644 index 0000000..311ab4f --- /dev/null +++ b/server/backends/gemini.js @@ -0,0 +1,176 @@ +// Gemini backend forwarder. Receives a transcribe or analyze request +// from a route handler, calls the corresponding Gemini API, and +// returns a normalized result the route can wrap in the standard +// envelope. +// +// v0.1 implements: +// - transcribeAudio({ audio: Buffer, mimeType, title?, channel?, +// description?, chapters?, offsetSeconds? }) → { text, segments, +// duration_seconds } +// - analyzeText({ prompt }) → { text } +// +// Both go through @google/genai with similar prompts to Recap's +// gemini.js provider, so output shapes line up with what Recap's +// orchestration layer expects. + +import { GoogleGenAI } from "@google/genai"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +const TRANSCRIPTION_MODEL = "gemini-3-flash-preview"; +const ANALYSIS_MODEL = "gemini-3.1-pro-preview"; +const EMPTY_RETRIES = 3; + +const TRANSCRIPTION_SAFETY = [ + { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }, + { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" }, + { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE" }, + { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE" }, +]; + +export function createGeminiBackend({ apiKey, timeoutMs = 900_000 } = {}) { + if (!apiKey) { + throw new Error("createGeminiBackend: apiKey is required"); + } + const ai = new GoogleGenAI({ + apiKey, + httpOptions: { timeout: timeoutMs, headersTimeout: timeoutMs }, + }); + + async function transcribeAudio({ + audio, + mimeType, + title = "", + channel = "", + description = "", + chapters = [], + offsetSeconds = 0, + }) { + // The Files API requires a path on disk; write to a temp file. + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "relay-tx-")); + const tmpPath = path.join(tmpDir, "audio.bin"); + await fs.writeFile(tmpPath, audio); + try { + const uploaded = await ai.files.upload({ + file: tmpPath, + config: { mimeType }, + }); + let f = uploaded; + const pStart = Date.now(); + while (f.state === "PROCESSING") { + if (Date.now() - pStart > 5 * 60 * 1000) { + throw new Error("Gemini file processing exceeded 5 min"); + } + await new Promise((r) => setTimeout(r, 3000)); + f = await ai.files.get({ name: f.name }); + } + if (f.state === "FAILED") { + throw new Error("Gemini failed to process audio file"); + } + + const prompt = buildTranscriptionPrompt({ title, channel, description, chapters }); + let result; + for (let attempt = 0; attempt < EMPTY_RETRIES; attempt++) { + result = await ai.models.generateContent({ + model: TRANSCRIPTION_MODEL, + config: { + thinkingConfig: { thinkingLevel: "minimal" }, + safetySettings: TRANSCRIPTION_SAFETY, + }, + contents: [ + { + role: "user", + parts: [ + { fileData: { fileUri: f.uri, mimeType } }, + { text: prompt }, + ], + }, + ], + }); + if (safeText(result)) break; + } + + // Best-effort cleanup of the uploaded File API artifact. + try { await ai.files.delete({ name: f.name }); } catch {} + + const text = safeText(result) || ""; + return { + text, + // Gemini returns a single timestamped blob — segments are + // parsed client-side by the orchestration layer. We could + // pre-parse here but Recap already has parseTimestampedTranscript + // that handles this exact shape. + segments: [], + duration_seconds: 0, + }; + } finally { + try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch {} + } + } + + async function analyzeText({ prompt }) { + const result = await ai.models.generateContent({ + model: ANALYSIS_MODEL, + contents: [ + { + role: "user", + parts: [{ text: prompt }], + }, + ], + }); + return { + text: safeText(result) || "", + }; + } + + return { transcribeAudio, analyzeText }; +} + +function safeText(r) { + try { + if (r?.text) return r.text; + } catch {} + try { + const parts = r?.candidates?.[0]?.content?.parts; + if (parts) return parts.map((p) => p.text || "").join(""); + } catch {} + return ""; +} + +function buildTranscriptionPrompt({ title, channel, description, chapters } = {}) { + let ctx = ""; + if (title) ctx += `Video title: "${title}"\n`; + if (channel) ctx += `Channel: ${channel}\n`; + if (description) { + const d = description.length > 1500 ? description.slice(0, 1500) + "…" : description; + ctx += `Video description (use to identify speakers by name):\n${d}\n`; + } + if (Array.isArray(chapters) && chapters.length > 0) { + const lines = chapters + .slice(0, 30) + .map((c) => { + const start = typeof c.start_time === "number" ? c.start_time : 0; + const mm = Math.floor(start / 60); + const ss = Math.floor(start % 60).toString().padStart(2, "0"); + return ` [${mm}:${ss}] ${c.title || ""}`; + }) + .join("\n"); + ctx += `Chapter markers:\n${lines}\n`; + } + if (ctx) ctx += "\n"; + + return `${ctx}Transcribe this audio completely and verbatim. Include timestamps at regular intervals (every 15-30 seconds or at natural pauses). + +Format each line as: +[MM:SS] The spoken text here... + +Rules: +- Transcribe EVERY word spoken, do not skip or summarize anything. +- Use [MM:SS] or [H:MM:SS] timestamp format at the start of each line. +- Start a new timestamped line every 15-30 seconds or at natural speech pauses. +- Include filler words (um, uh, you know) for accuracy. +- Speaker identification: FIRST consult the metadata above — descriptions and chapter titles usually name the host(s) and guest(s) explicitly. Format as: [MM:SS] Name: text. Only fall back to "Host"/"Guest" if no names appear. + +Return ONLY the timestamped transcript, nothing else.`; +} diff --git a/server/backends/hardware.js b/server/backends/hardware.js new file mode 100644 index 0000000..b9278f6 --- /dev/null +++ b/server/backends/hardware.js @@ -0,0 +1,55 @@ +// Operator-hardware fallback backend. Forwards transcribe requests to +// the operator's Parakeet (or any Whisper-API-compatible) endpoint and +// analyze requests to their Gemma (or any OpenAI-API-compatible) endpoint. +// +// v0.1 is a stub — the endpoints are wired up, but no operator has +// pointed a real Parakeet/Gemma at the relay yet. Returns a 503 +// "hardware fallback not yet wired" so the credits.js routing logic +// still applies but users get a clear message instead of a silent +// failure. + +export function createHardwareBackend({ + parakeetBaseURL = "", + gemmaBaseURL = "", +} = {}) { + const hasParakeet = !!parakeetBaseURL; + const hasGemma = !!gemmaBaseURL; + + return { + hasTranscribe: hasParakeet, + hasAnalyze: hasGemma, + + async transcribeAudio() { + if (!hasParakeet) { + const err = new Error( + "operator-hardware transcribe path is not configured (relay_parakeet_base_url is empty)" + ); + err.status = 503; + throw err; + } + // TODO v0.2: POST audio to parakeetBaseURL using the OpenAI + // audio-transcriptions wire format Recap already speaks. Return + // { text, segments, duration_seconds } in the same shape as + // gemini.js's transcribeAudio. + const err = new Error("operator-hardware transcribe path not yet implemented in relay v0.1"); + err.status = 503; + throw err; + }, + + async analyzeText() { + if (!hasGemma) { + const err = new Error( + "operator-hardware analyze path is not configured (relay_gemma_base_url is empty)" + ); + err.status = 503; + throw err; + } + // TODO v0.2: POST prompt to gemmaBaseURL using either /api/generate + // (Ollama native) or /v1/chat/completions (OpenAI-compatible). + // Return { text } matching gemini.js's analyzeText. + const err = new Error("operator-hardware analyze path not yet implemented in relay v0.1"); + err.status = 503; + throw err; + }, + }; +} diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..fc09080 --- /dev/null +++ b/server/config.js @@ -0,0 +1,98 @@ +// Live-reloading config layer. Mirrors Recap's config.js pattern: read +// /data/config/relay-config.json on every access (filesystem watcher +// pulls in StartOS-action changes without a daemon restart), parse, +// and expose typed accessors. +// +// All defaults match the schema in startos/file-models/config.json.ts. + +import fs from "fs/promises"; +import path from "path"; + +let dataDir = "/data"; +let cached = { mtimeMs: 0, snapshot: defaultConfig() }; + +function defaultConfig() { + return { + relay_gemini_api_key: "", + relay_parakeet_base_url: "", + relay_gemma_base_url: "", + relay_keysat_base_url: "https://keysat.xyz", + relay_admin_username: "", + relay_admin_password_hash: "", + relay_admin_password_salt: "", + relay_admin_session_secret: "", + relay_tier_quotas_json: JSON.stringify({ + core: { lifetime: 5, monthly: null, geminiCapMonthly: null }, + pro: { lifetime: null, monthly: 50, geminiCapMonthly: 25 }, + max: { lifetime: null, monthly: null, geminiCapMonthly: 50 }, + }), + }; +} + +function configPath() { + return path.join(dataDir, "config", "relay-config.json"); +} + +export async function initConfig({ dataDir: dd }) { + if (dd) dataDir = dd; + await fs.mkdir(path.dirname(configPath()), { recursive: true }).catch(() => {}); + // Prime the cache so the first request doesn't pay for a file-read. + await getConfigSnapshot(); +} + +// Reads the on-disk config and merges with defaults. Cheap — single +// stat + read per call, but the result is cached until the file mtime +// changes so repeat callers within one request don't re-read. +export async function getConfigSnapshot() { + const p = configPath(); + let stat; + try { + stat = await fs.stat(p); + } catch { + return cached.snapshot; + } + if (stat.mtimeMs === cached.mtimeMs) return cached.snapshot; + try { + const raw = await fs.readFile(p, "utf8"); + const parsed = JSON.parse(raw); + cached = { + mtimeMs: stat.mtimeMs, + snapshot: { ...defaultConfig(), ...parsed }, + }; + } catch (err) { + console.warn(`[config] failed to parse ${p}: ${err?.message}`); + } + return cached.snapshot; +} + +// Parsed view of relay_tier_quotas_json, with safe fallbacks if the +// blob is missing or malformed. +export async function getTierQuotas() { + const cfg = await getConfigSnapshot(); + try { + const parsed = JSON.parse(cfg.relay_tier_quotas_json); + return { + core: { + lifetime: parsed?.core?.lifetime ?? 5, + monthly: parsed?.core?.monthly ?? null, + geminiCapMonthly: parsed?.core?.geminiCapMonthly ?? null, + }, + pro: { + lifetime: parsed?.pro?.lifetime ?? null, + monthly: parsed?.pro?.monthly ?? 50, + geminiCapMonthly: parsed?.pro?.geminiCapMonthly ?? 25, + }, + max: { + lifetime: parsed?.max?.lifetime ?? null, + monthly: parsed?.max?.monthly ?? null, + geminiCapMonthly: parsed?.max?.geminiCapMonthly ?? 50, + }, + }; + } catch { + return { + core: { lifetime: 5, monthly: null, geminiCapMonthly: null }, + pro: { lifetime: null, monthly: 50, geminiCapMonthly: 25 }, + max: { lifetime: null, monthly: null, geminiCapMonthly: 50 }, + }; + } +} diff --git a/server/credits.js b/server/credits.js new file mode 100644 index 0000000..a547885 --- /dev/null +++ b/server/credits.js @@ -0,0 +1,179 @@ +// Credit ledger keyed by install-id. JSON-file backed (single file at +// /data/credits.json). Write throughput is low — at most one mutation +// per relay request — so a plain JSON file with mutex-style serial +// writes is plenty. Swap to SQLite if a single relay starts seeing +// dozens of req/sec sustained. +// +// Per-install row shape: +// { +// install_id: "uuid", +// tier_snapshot: "core" | "pro" | "max", // last-seen tier +// lifetime_consumed: number, // for Core lifetime cap +// month: "YYYY-MM", // calendar-month key +// monthly_consumed: number, // total this month +// monthly_gemini_consumed: number, // Gemini-only this month +// last_active_at: ISO-8601 string, +// } + +import fs from "fs/promises"; +import path from "path"; + +let dataDir = "/data"; +let ledgerPath = "/data/credits.json"; +let ledger = { rows: {} }; +let writing = null; // serializes concurrent writes + +export async function initCredits({ dataDir: dd }) { + if (dd) dataDir = dd; + ledgerPath = path.join(dataDir, "credits.json"); + await fs.mkdir(dataDir, { recursive: true }).catch(() => {}); + try { + const raw = await fs.readFile(ledgerPath, "utf8"); + ledger = JSON.parse(raw) || { rows: {} }; + if (!ledger.rows) ledger.rows = {}; + } catch (err) { + if (err.code !== "ENOENT") { + console.warn(`[credits] failed to read ledger: ${err.message} — starting empty`); + } + ledger = { rows: {} }; + } + console.log(`[credits] loaded ${Object.keys(ledger.rows).length} install rows from ${ledgerPath}`); +} + +function currentMonthKey() { + const d = new Date(); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; +} + +// Lazily rolls over the per-install monthly counters when the calendar +// month changes. Lifetime counter is left untouched (Core lifetime +// credits never reset). +function ensureCurrentMonth(row) { + const m = currentMonthKey(); + if (row.month !== m) { + row.month = m; + row.monthly_consumed = 0; + row.monthly_gemini_consumed = 0; + } + return row; +} + +function blankRow(installId) { + return { + install_id: installId, + tier_snapshot: "core", + lifetime_consumed: 0, + month: currentMonthKey(), + monthly_consumed: 0, + monthly_gemini_consumed: 0, + last_active_at: new Date().toISOString(), + }; +} + +async function persist() { + // Coalesce concurrent writes — multiple in-flight mutations resolve + // against the same persisted snapshot in fifo order. + if (writing) await writing; + writing = (async () => { + const tmp = ledgerPath + ".tmp"; + await fs.writeFile(tmp, JSON.stringify(ledger), { mode: 0o600 }); + await fs.rename(tmp, ledgerPath); + })(); + try { + await writing; + } finally { + writing = null; + } +} + +// Returns the row for an install, creating + persisting a blank one +// if this is the first time we've seen it. +export async function getOrCreateRow(installId) { + if (!installId) throw new Error("getOrCreateRow: installId required"); + let row = ledger.rows[installId]; + if (!row) { + row = blankRow(installId); + ledger.rows[installId] = row; + await persist(); + } + return ensureCurrentMonth(row); +} + +// Compute the remaining balance for a row against its tier's quota. +// Returns: +// { remaining: number | null, capped: "lifetime" | "monthly" | "none", gemini_remaining: number | null } +// `null` for remaining means "unlimited" (Max tier total). +export function computeRemaining(row, quota) { + const tier = row.tier_snapshot; + const tierQuota = quota[tier] || quota.core; + + if (tierQuota.lifetime != null) { + const remaining = Math.max(0, tierQuota.lifetime - (row.lifetime_consumed || 0)); + return { + remaining, + capped: "lifetime", + gemini_remaining: null, // lifetime tier doesn't split Gemini/hardware + }; + } + + let remaining; + if (tierQuota.monthly == null) { + remaining = null; // unlimited + } else { + remaining = Math.max(0, tierQuota.monthly - (row.monthly_consumed || 0)); + } + const geminiRemaining = + tierQuota.geminiCapMonthly == null + ? null + : Math.max(0, tierQuota.geminiCapMonthly - (row.monthly_gemini_consumed || 0)); + + return { + remaining, + capped: "monthly", + gemini_remaining: geminiRemaining, + }; +} + +// Decide what backend a request should go to and whether it can be +// served at all. Returns { allowed, backend: "gemini"|"hardware", +// reason }. Does NOT debit — that's a separate commit step after the +// backend call succeeds. +export function planBackend(row, quota, { hasHardware }) { + const balance = computeRemaining(row, quota); + + // Out of credits entirely? + if (balance.remaining === 0) { + return { allowed: false, backend: null, reason: "out_of_credits" }; + } + + // Pick backend: Gemini if there's room under the Gemini cap; else + // fall back to hardware if configured; else 402. + if (balance.gemini_remaining === null || balance.gemini_remaining > 0) { + return { allowed: true, backend: "gemini", reason: null }; + } + if (hasHardware) { + return { allowed: true, backend: "hardware", reason: null }; + } + return { allowed: false, backend: null, reason: "gemini_cap_exceeded_no_hardware" }; +} + +// Debit one credit on a successful call. Persists immediately. +export async function commitCredit(installId, { backend, tier }) { + const row = await getOrCreateRow(installId); + row.tier_snapshot = tier; + if (tier === "core") { + row.lifetime_consumed = (row.lifetime_consumed || 0) + 1; + } else { + row.monthly_consumed = (row.monthly_consumed || 0) + 1; + if (backend === "gemini") { + row.monthly_gemini_consumed = (row.monthly_gemini_consumed || 0) + 1; + } + } + row.last_active_at = new Date().toISOString(); + await persist(); +} + +// For the admin dashboard. +export function snapshotAll() { + return Object.values(ledger.rows).map((r) => ({ ...r })); +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..31ed85d --- /dev/null +++ b/server/index.js @@ -0,0 +1,75 @@ +// Recap Relay — operator-side credit-metered proxy in front of Gemini +// (and optionally a co-located Parakeet+Gemma setup). +// +// Two public endpoints: +// POST /relay/transcribe — audio → text (Gemini File API) +// POST /relay/analyze — text → topic sections JSON (Gemini Pro) +// Plus admin endpoints under /admin/* gated by an HTTP session cookie. + +import express from "express"; +import cors from "cors"; +import cookieParser from "cookie-parser"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { initConfig } from "./config.js"; +import { initCredits } from "./credits.js"; +import { + setupAdminAuthMiddleware, + setupAdminAuthRoutes, +} from "./admin-auth.js"; +import { transcribeRouter } from "./routes/transcribe.js"; +import { analyzeRouter } from "./routes/analyze.js"; +import { healthRouter } from "./routes/health.js"; +import { adminRouter } from "./routes/admin.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// DATA_DIR is /data on StartOS, project root in dev. +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, ".."); +const PORT = parseInt(process.env.PORT || "3002", 10); + +await initConfig({ dataDir: DATA_DIR }); +await initCredits({ dataDir: DATA_DIR }); + +const app = express(); +app.use(cors()); +app.use(cookieParser()); + +// Admin auth must run BEFORE the admin routes register so the cookie +// check applies to /admin/usage, /admin/config, etc. /admin/login and +// /admin/status are explicitly exempted inside the middleware. +setupAdminAuthMiddleware(app); +setupAdminAuthRoutes(app); + +// Public relay endpoints. No app-level auth — each route handler +// authenticates per-call via headers (X-Recap-Install-Id required, +// Authorization optional). +app.use("/relay", healthRouter()); +app.use("/relay", transcribeRouter()); +app.use("/relay", analyzeRouter()); + +// Admin dashboard endpoints (cookie-gated). +app.use("/admin", adminRouter({ dataDir: DATA_DIR })); + +// Static admin UI (v0.2 will flesh out public/admin.html). For v0.1 +// the dashboard is JSON-only; serve any static assets dropped into +// public/ but don't error if the directory is empty. +app.use(express.static(path.join(__dirname, "..", "public"))); + +// Root: redirect to /admin/ for operator convenience, or show a tiny +// placeholder for Recap clients that hit the root by mistake. +app.get("/", (_req, res) => { + res.type("text/plain").send( + "Recap Relay\n" + + "===========\n" + + "Public endpoints: POST /relay/transcribe, POST /relay/analyze, GET /relay/health\n" + + "Operator dashboard: /admin/\n" + ); +}); + +const HOSTNAME = process.env.HOSTNAME || "0.0.0.0"; +app.listen(PORT, HOSTNAME, () => { + console.log(`[relay] listening on http://${HOSTNAME}:${PORT}`); + console.log(`[relay] data directory: ${DATA_DIR}`); +}); diff --git a/server/job-credits.js b/server/job-credits.js new file mode 100644 index 0000000..669b28a --- /dev/null +++ b/server/job-credits.js @@ -0,0 +1,68 @@ +// Job-id deduplication. Recap mints a UUID per summarize job (the +// transcribe + analyze pair) and sends it in X-Recap-Job-Id on every +// relay call. The first call with a given (install_id, job_id) tuple +// reserves a credit; subsequent calls with the same tuple are free +// until the job_id expires (1 hour). +// +// Stored in-memory only — not persisted across restarts because (a) +// a restart breaks all in-flight Recap streams anyway and (b) the +// worst-case outcome of a "lost reservation" is the user being +// charged for a single retry, which is acceptable. + +const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour + +// Map +const jobs = new Map(); + +function key(installId, jobId) { + return `${installId}|${jobId}`; +} + +// On a new request: returns { charged: true } if this is the first call +// for the job (caller must commit a credit), or { charged: false, +// backend, tier } if it's a retry/follow-up. +export function lookupJob(installId, jobId) { + if (!installId || !jobId) return null; + pruneExpired(); + const k = key(installId, jobId); + const existing = jobs.get(k); + if (existing && !existing.refunded) return existing; + return null; +} + +// Mark a job as having been charged. Idempotent — second call for the +// same (install_id, job_id) is a no-op. +export function markJobCharged(installId, jobId, { backend, tier }) { + if (!installId || !jobId) return; + pruneExpired(); + const k = key(installId, jobId); + if (jobs.has(k) && !jobs.get(k).refunded) return; + jobs.set(k, { + backend, + tier, + charged_at: Date.now(), + refunded: false, + }); +} + +// Refund a previously charged credit for a failed job. Future calls +// with the same job_id will be treated as new (since the reservation +// is no longer valid). +export function refundJob(installId, jobId) { + if (!installId || !jobId) return; + const k = key(installId, jobId); + const existing = jobs.get(k); + if (existing) existing.refunded = true; +} + +function pruneExpired() { + const cutoff = Date.now() - JOB_TTL_MS; + for (const [k, v] of jobs) { + if (v.charged_at < cutoff) jobs.delete(k); + } +} + +export function snapshotJobs() { + pruneExpired(); + return Array.from(jobs.entries()).map(([k, v]) => ({ key: k, ...v })); +} diff --git a/server/keysat-client.js b/server/keysat-client.js new file mode 100644 index 0000000..02a35fa --- /dev/null +++ b/server/keysat-client.js @@ -0,0 +1,183 @@ +// Cached license validation against the Keysat license server. Two +// layers: +// 1. Offline signature verification using the vendored +// @keysat/licensing-client. Fast and works without network. +// 2. Cached online check against keysat.xyz (or wherever +// relay_keysat_base_url points) — confirms the key hasn't been +// revoked since the last sync. Cached per license-key for +// KEYSAT_CACHE_TTL_MS to avoid hammering keysat on hot paths. +// +// Returns a normalized object with tier resolved from entitlements: +// { state: "licensed" | "invalid" | "anonymous", +// tier: "core" | "pro" | "max", +// licenseUuid: string | null, +// entitlements: string[], +// reason: string | null } + +import { getConfigSnapshot } from "./config.js"; + +const KEYSAT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +// Map +const cache = new Map(); + +// Dynamically import the licensing client so it doesn't block boot +// if vendor/keysat-licensing-client is missing in dev environments. +let verifierLoaded = false; +let verifier = null; +async function loadVerifier() { + if (verifierLoaded) return verifier; + verifierLoaded = true; + try { + const mod = await import("@keysat/licensing-client"); + // Same pattern Recap uses: build a verifier with the embedded + // public key. Recap's license.js shows the exact call; copy it + // here. For v0.1 we only need offline verification — if the + // vendor module signature differs across Recap versions we can + // tweak this. + if (mod?.createVerifier) { + verifier = mod.createVerifier(); + } + } catch (err) { + console.warn( + `[keysat] failed to load @keysat/licensing-client (${err.message}) — will treat all licenses as anonymous` + ); + } + return verifier; +} + +// Resolve a tier from an entitlements set. Bundles aren't supported +// yet — explicit entitlement names only. relay_max wins over relay_pro +// if somehow both are present. +function tierFromEntitlements(entitlements) { + if (entitlements.has("relay_max")) return "max"; + if (entitlements.has("relay_pro")) return "pro"; + return "core"; +} + +// Public entry: takes the raw `Authorization: Bearer ` value (or +// null) and returns a resolved license. Anonymous = no header = Core +// tier. +export async function resolveLicense(rawAuth) { + if (!rawAuth) { + return { + state: "anonymous", + tier: "core", + licenseUuid: null, + entitlements: [], + reason: null, + }; + } + const key = stripBearer(rawAuth); + if (!key) { + return { + state: "invalid", + tier: "core", + licenseUuid: null, + entitlements: [], + reason: "malformed_auth_header", + }; + } + + // Cache hit (still fresh)? + const cached = cache.get(key); + if (cached && Date.now() - cached.validated_at < KEYSAT_CACHE_TTL_MS) { + return cached.result; + } + + // Offline verify first — establishes the entitlements + license id. + const v = await loadVerifier(); + let offline = null; + if (v) { + try { + offline = v.verify(key); + } catch (err) { + const result = { + state: "invalid", + tier: "core", + licenseUuid: null, + entitlements: [], + reason: `verify_failed: ${err.message}`, + }; + cache.set(key, { result, validated_at: Date.now() }); + return result; + } + } + + // If offline verify worked, use its payload as the source of truth + // for entitlements + license id. Then hit keysat for revocation + // status. + const entitlements = new Set(offline?.payload?.entitlements || []); + const licenseUuid = offline?.payload?.licenseUuid || null; + let online = await onlineCheck(key); + if (online && online.revoked) { + const result = { + state: "invalid", + tier: "core", + licenseUuid, + entitlements: [], + reason: online.reason || "revoked", + }; + cache.set(key, { result, validated_at: Date.now() }); + return result; + } + + const tier = tierFromEntitlements(entitlements); + const result = { + state: "licensed", + tier, + licenseUuid, + entitlements: [...entitlements], + reason: null, + }; + cache.set(key, { result, validated_at: Date.now() }); + return result; +} + +function stripBearer(raw) { + const m = (raw || "").trim().match(/^Bearer\s+(.+)$/i); + if (m) return m[1].trim(); + // Accept a bare key without the Bearer prefix for tolerance. + return raw.trim(); +} + +// Best-effort online check. Returns null on network error (cache the +// offline-verified result with a short TTL so we don't pound keysat +// while it's down) or { revoked: boolean, reason?: string }. +async function onlineCheck(licenseKey) { + try { + const cfg = await getConfigSnapshot(); + const base = (cfg.relay_keysat_base_url || "").replace(/\/$/, ""); + if (!base) return null; + // POST /validate is the standard Keysat shape (per the licensing + // client docs). If the actual endpoint differs we'll wire it up + // once we point the relay at a real Keysat server. + const res = await fetch(`${base}/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ license_key: licenseKey }), + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + console.warn(`[keysat] online check ${base}/validate returned ${res.status}`); + return null; + } + const data = await res.json(); + return { + revoked: !!data?.revoked || data?.status === "revoked", + reason: data?.reason || null, + }; + } catch (err) { + console.warn(`[keysat] online check failed: ${err?.message}`); + return null; + } +} + +export function snapshotCache() { + return Array.from(cache.entries()).map(([k, v]) => ({ + license_prefix: k.slice(0, 12) + "…", + tier: v.result?.tier, + state: v.result?.state, + validated_at: new Date(v.validated_at).toISOString(), + })); +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..64a1f87 --- /dev/null +++ b/server/package.json @@ -0,0 +1,14 @@ +{ + "name": "recap-relay-server", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "@google/genai": "^1.0.0", + "@keysat/licensing-client": "file:../vendor/keysat-licensing-client", + "cors": "^2.8.5", + "cookie-parser": "^1.4.6", + "express": "^4.21.0", + "multer": "^1.4.5-lts.1" + } +} diff --git a/server/routes/admin.js b/server/routes/admin.js new file mode 100644 index 0000000..8aba063 --- /dev/null +++ b/server/routes/admin.js @@ -0,0 +1,102 @@ +// /admin/* — operator dashboard endpoints. All require the admin +// session cookie (enforced by admin-auth middleware). +// +// v0.1 endpoints (JSON only; v0.2 will add an HTML dashboard): +// GET /admin/usage — all install rows + last-month aggregates +// GET /admin/config — current operator config (sans password hash) +// POST /admin/quotas — adjust tier quotas live (mirror of StartOS +// action but reachable from the dashboard) + +import express from "express"; +import { getConfigSnapshot } from "../config.js"; +import { snapshotAll } from "../credits.js"; +import { snapshotCache } from "../keysat-client.js"; +import { snapshotJobs } from "../job-credits.js"; +import fs from "fs/promises"; +import path from "path"; + +export function adminRouter({ dataDir }) { + const router = express.Router(); + + router.get("/usage", async (_req, res) => { + const rows = snapshotAll(); + res.json({ + installs: rows.length, + rows, + }); + }); + + router.get("/config", async (_req, res) => { + const cfg = await getConfigSnapshot(); + // Strip secrets before exposing to the dashboard. + const safe = { + keysat_base_url: cfg.relay_keysat_base_url, + parakeet_base_url: cfg.relay_parakeet_base_url, + gemma_base_url: cfg.relay_gemma_base_url, + gemini_configured: !!cfg.relay_gemini_api_key, + admin_username: cfg.relay_admin_username, + tier_quotas: tryParse(cfg.relay_tier_quotas_json), + }; + res.json(safe); + }); + + router.get("/license-cache", async (_req, res) => { + res.json({ entries: snapshotCache() }); + }); + + router.get("/jobs", async (_req, res) => { + res.json({ entries: snapshotJobs() }); + }); + + // Adjust the live quotas blob. Same shape the StartOS action writes + // to relay_tier_quotas_json — kept here so the dashboard can tune + // quotas without round-tripping the StartOS UI. + router.post("/quotas", express.json(), async (req, res) => { + const incoming = req.body || {}; + const normalized = { + core: { + lifetime: numOrNull(incoming?.core?.lifetime, 5), + monthly: numOrNull(incoming?.core?.monthly, null), + geminiCapMonthly: numOrNull(incoming?.core?.geminiCapMonthly, null), + }, + pro: { + lifetime: numOrNull(incoming?.pro?.lifetime, null), + monthly: numOrNull(incoming?.pro?.monthly, 50), + geminiCapMonthly: numOrNull(incoming?.pro?.geminiCapMonthly, 25), + }, + max: { + lifetime: numOrNull(incoming?.max?.lifetime, null), + monthly: numOrNull(incoming?.max?.monthly, null), + geminiCapMonthly: numOrNull(incoming?.max?.geminiCapMonthly, 50), + }, + }; + // Write directly into relay-config.json — the live-reloader picks + // it up on the next read. + const configPath = path.join(dataDir, "config", "relay-config.json"); + let existing = {}; + try { + existing = JSON.parse(await fs.readFile(configPath, "utf8")); + } catch {} + existing.relay_tier_quotas_json = JSON.stringify(normalized); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(existing), { mode: 0o600 }); + res.json({ ok: true, quotas: normalized }); + }); + + return router; +} + +function numOrNull(v, fallback) { + if (v === null) return null; + const n = Number(v); + if (Number.isFinite(n)) return n; + return fallback; +} + +function tryParse(s) { + try { + return JSON.parse(s); + } catch { + return null; + } +} diff --git a/server/routes/analyze.js b/server/routes/analyze.js new file mode 100644 index 0000000..31eb9e0 --- /dev/null +++ b/server/routes/analyze.js @@ -0,0 +1,114 @@ +// POST /relay/analyze — forwards an analysis prompt to the chosen +// backend and returns the standard envelope. +// +// Request body (application/json): +// { prompt: string } +// +// Headers: same as /relay/transcribe (X-Recap-Install-Id required, +// X-Recap-Job-Id optional, Authorization optional Bearer license). +// +// Same charge-once-per-job semantics: a Recap summarize job pairs +// transcribe + analyze with the same X-Recap-Job-Id. The first call +// (whichever endpoint) charges 1 credit; the second is free. + +import express from "express"; +import { resolveLicense } from "../keysat-client.js"; +import { getOrCreateRow, planBackend, commitCredit } from "../credits.js"; +import { lookupJob, markJobCharged, refundJob } from "../job-credits.js"; +import { getConfigSnapshot, getTierQuotas } from "../config.js"; +import { createGeminiBackend } from "../backends/gemini.js"; +import { createHardwareBackend } from "../backends/hardware.js"; +import { envelope, errorEnvelope } from "./envelope.js"; + +export function analyzeRouter() { + const router = express.Router(); + + router.post("/analyze", express.json({ limit: "10mb" }), async (req, res) => { + const installId = req.header("X-Recap-Install-Id"); + const jobId = req.header("X-Recap-Job-Id") || null; + const auth = req.header("Authorization"); + + if (!installId) { + const e = await errorEnvelope({ + error: "missing X-Recap-Install-Id header", + statusHint: 400, + }); + return res.status(400).json(e.body); + } + const prompt = req.body?.prompt; + if (!prompt || typeof prompt !== "string") { + const e = await errorEnvelope({ + error: "missing or non-string body.prompt", + installId, + statusHint: 400, + }); + return res.status(400).json(e.body); + } + + const license = await resolveLicense(auth); + const tier = license.tier; + + const row = await getOrCreateRow(installId); + row.tier_snapshot = tier; + + let reusedJob = false; + let chosenBackend = null; + const existingJob = lookupJob(installId, jobId); + if (existingJob) { + reusedJob = true; + chosenBackend = existingJob.backend; + } else { + const cfg = await getConfigSnapshot(); + const hasHardware = !!cfg.relay_gemma_base_url; + const quota = await getTierQuotas(); + const plan = planBackend(row, quota, { hasHardware }); + if (!plan.allowed) { + const e = await errorEnvelope({ + error: plan.reason, + installId, + tier, + statusHint: 402, + }); + return res.status(402).json(e.body); + } + chosenBackend = plan.backend; + } + + const cfg = await getConfigSnapshot(); + let result; + try { + if (chosenBackend === "gemini") { + const backend = createGeminiBackend({ apiKey: cfg.relay_gemini_api_key }); + result = await backend.analyzeText({ prompt }); + } else { + const backend = createHardwareBackend({ + parakeetBaseURL: cfg.relay_parakeet_base_url, + gemmaBaseURL: cfg.relay_gemma_base_url, + }); + result = await backend.analyzeText({ prompt }); + } + } catch (err) { + if (reusedJob) refundJob(installId, jobId); + console.error(`[relay/analyze] backend error: ${err?.message}`); + const e = await errorEnvelope({ + error: err?.message || "backend_error", + installId, + tier, + statusHint: err?.status || 502, + }); + return res.status(e.statusHint).json(e.body); + } + + let creditCharged = 0; + if (!reusedJob) { + await commitCredit(installId, { backend: chosenBackend, tier }); + markJobCharged(installId, jobId, { backend: chosenBackend, tier }); + creditCharged = 1; + } + + const body = await envelope({ result, installId, tier, creditCharged }); + res.json(body); + }); + + return router; +} diff --git a/server/routes/envelope.js b/server/routes/envelope.js new file mode 100644 index 0000000..1963912 --- /dev/null +++ b/server/routes/envelope.js @@ -0,0 +1,58 @@ +// Standard response envelope. Every /relay/* response (success and +// error both) goes through this so Recap clients can keep their +// credit-balance display accurate regardless of what happened. +// +// Shape: { result, credits_remaining, tier, credit_charged } + +import { getOrCreateRow, computeRemaining } from "../credits.js"; +import { getTierQuotas } from "../config.js"; + +// Build the envelope around a result object. +export async function envelope({ + result = null, + installId, + tier, + creditCharged = 0, +}) { + const quota = await getTierQuotas(); + const row = await getOrCreateRow(installId); + // tier_snapshot on the row was just updated by commitCredit; if no + // credit was committed (free reuse via job_id) it still reflects + // the last-known tier for this install, which is fine. + const balance = computeRemaining(row, quota); + return { + result, + credits_remaining: balance.remaining, // null = unlimited (Max) + tier, + credit_charged: creditCharged, + }; +} + +// Same shape but for error responses. The error reason goes in `error` +// alongside `result: null`. Clients should still update their balance +// display from `credits_remaining` so failed calls (which were +// refunded) reflect the unchanged balance. +export async function errorEnvelope({ + error, + installId, + tier = "core", + statusHint = 500, +}) { + let creditsRemaining = null; + try { + const quota = await getTierQuotas(); + const row = await getOrCreateRow(installId || "unknown"); + const balance = computeRemaining(row, quota); + creditsRemaining = balance.remaining; + } catch {} + return { + statusHint, + body: { + result: null, + error: typeof error === "string" ? error : error?.message || "unknown_error", + credits_remaining: creditsRemaining, + tier, + credit_charged: 0, + }, + }; +} diff --git a/server/routes/health.js b/server/routes/health.js new file mode 100644 index 0000000..3e1ef21 --- /dev/null +++ b/server/routes/health.js @@ -0,0 +1,27 @@ +// GET /relay/health — public liveness check. No auth, no credit +// accounting. Returns a minimal status object so monitoring + Recap's +// /api/relay/status can verify the relay is reachable. + +import express from "express"; +import { getConfigSnapshot } from "../config.js"; + +export function healthRouter() { + const router = express.Router(); + + router.get("/health", async (_req, res) => { + const cfg = await getConfigSnapshot(); + res.json({ + ok: true, + service: "recap-relay", + version: "0.1.0", + backends: { + gemini: !!cfg.relay_gemini_api_key, + parakeet: !!cfg.relay_parakeet_base_url, + gemma: !!cfg.relay_gemma_base_url, + }, + admin_enabled: !!cfg.relay_admin_password_hash, + }); + }); + + return router; +} diff --git a/server/routes/transcribe.js b/server/routes/transcribe.js new file mode 100644 index 0000000..8dea556 --- /dev/null +++ b/server/routes/transcribe.js @@ -0,0 +1,157 @@ +// POST /relay/transcribe — forwards an audio payload to the chosen +// backend (Gemini first, operator hardware as overflow) and returns +// the standard envelope. +// +// Request shape: multipart/form-data +// audio: binary audio file (required) +// mime_type: string (default application/octet-stream) +// title: string (optional, used by Gemini prompt) +// channel: string (optional) +// description: string (optional) +// chapters: JSON-stringified array (optional) +// offset_seconds: number string (optional, for chunked audio) +// +// Headers: +// X-Recap-Install-Id (required) +// X-Recap-Job-Id (optional but expected — pairs with /analyze) +// Authorization (optional Bearer LIC1-... for licensed tiers) +// +// Response (standard envelope): +// { +// result: { text: "[MM:SS] ...", segments: [], duration_seconds: 0 }, +// credits_remaining, tier, credit_charged +// } + +import express from "express"; +import multer from "multer"; +import { resolveLicense } from "../keysat-client.js"; +import { getOrCreateRow, planBackend, commitCredit } from "../credits.js"; +import { lookupJob, markJobCharged, refundJob } from "../job-credits.js"; +import { getConfigSnapshot, getTierQuotas } from "../config.js"; +import { createGeminiBackend } from "../backends/gemini.js"; +import { createHardwareBackend } from "../backends/hardware.js"; +import { envelope, errorEnvelope } from "./envelope.js"; + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 200 * 1024 * 1024 }, // 200 MB per request +}); + +export function transcribeRouter() { + const router = express.Router(); + + router.post("/transcribe", upload.single("audio"), async (req, res) => { + const installId = req.header("X-Recap-Install-Id"); + const jobId = req.header("X-Recap-Job-Id") || null; + const auth = req.header("Authorization"); + + if (!installId) { + const e = await errorEnvelope({ + error: "missing X-Recap-Install-Id header", + statusHint: 400, + }); + return res.status(400).json(e.body); + } + if (!req.file) { + const e = await errorEnvelope({ error: "missing audio file", installId, statusHint: 400 }); + return res.status(400).json(e.body); + } + + const license = await resolveLicense(auth); + const tier = license.tier; + + // Persist tier on the row so the admin dashboard reflects the + // most recently seen tier for this install. + const row = await getOrCreateRow(installId); + row.tier_snapshot = tier; + + // Job-id dedup. If we've already charged this job, skip the + // credit check entirely — the user is paying once for the whole + // summarize job. + let reusedJob = false; + let chosenBackend = null; + const existingJob = lookupJob(installId, jobId); + if (existingJob) { + reusedJob = true; + chosenBackend = existingJob.backend; + } else { + const cfg = await getConfigSnapshot(); + const hasHardware = !!cfg.relay_parakeet_base_url; + const quota = await getTierQuotas(); + const plan = planBackend(row, quota, { hasHardware }); + if (!plan.allowed) { + const e = await errorEnvelope({ + error: plan.reason, + installId, + tier, + statusHint: 402, + }); + return res.status(402).json(e.body); + } + chosenBackend = plan.backend; + } + + // Build the backend client based on chosenBackend. + const cfg = await getConfigSnapshot(); + let result; + try { + if (chosenBackend === "gemini") { + const backend = createGeminiBackend({ apiKey: cfg.relay_gemini_api_key }); + result = await backend.transcribeAudio({ + audio: req.file.buffer, + mimeType: req.body?.mime_type || req.file.mimetype || "application/octet-stream", + title: req.body?.title || "", + channel: req.body?.channel || "", + description: req.body?.description || "", + chapters: parseChaptersField(req.body?.chapters), + offsetSeconds: Number(req.body?.offset_seconds) || 0, + }); + } else { + const backend = createHardwareBackend({ + parakeetBaseURL: cfg.relay_parakeet_base_url, + gemmaBaseURL: cfg.relay_gemma_base_url, + }); + result = await backend.transcribeAudio({ + audio: req.file.buffer, + mimeType: req.body?.mime_type || req.file.mimetype || "application/octet-stream", + offsetSeconds: Number(req.body?.offset_seconds) || 0, + }); + } + } catch (err) { + // If we'd charged this job already (rare — most refundable + // failures happen on the FIRST call), refund. + if (reusedJob) refundJob(installId, jobId); + console.error(`[relay/transcribe] backend error: ${err?.message}`); + const e = await errorEnvelope({ + error: err?.message || "backend_error", + installId, + tier, + statusHint: err?.status || 502, + }); + return res.status(e.statusHint).json(e.body); + } + + // Commit the credit on success (unless this was a job-id reuse). + let creditCharged = 0; + if (!reusedJob) { + await commitCredit(installId, { backend: chosenBackend, tier }); + markJobCharged(installId, jobId, { backend: chosenBackend, tier }); + creditCharged = 1; + } + + const body = await envelope({ result, installId, tier, creditCharged }); + res.json(body); + }); + + return router; +} + +function parseChaptersField(raw) { + if (!raw) return []; + try { + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } +} diff --git a/startos/actions/adjustTierQuotas.ts b/startos/actions/adjustTierQuotas.ts new file mode 100644 index 0000000..e56b48a --- /dev/null +++ b/startos/actions/adjustTierQuotas.ts @@ -0,0 +1,124 @@ +import { sdk } from '../sdk' +import { configFile } from '../file-models/config.json' + +const { InputSpec, Value } = sdk + +// Operator-facing knob for tier-quota tuning without a code change or +// redeploy. The schema is { core: TierConfig, pro: TierConfig, +// max: TierConfig } where TierConfig is +// { lifetime: number|null, monthly: number|null, geminiCapMonthly: number|null } +// null means "no cap on this dimension." The relay reads this on every +// request via configFile's live-reload. +const inputSpec = InputSpec.of({ + // Core tier knobs. + core_lifetime: Value.number({ + name: 'Core — Lifetime Credits', + description: + 'Total credits a Core (unlicensed) install can ever spend. Default 5.', + required: true, + default: 5, + min: 0, + max: 1_000_000, + integer: true, + step: 1, + units: 'credits', + placeholder: null, + }), + // Pro tier knobs. + pro_monthly: Value.number({ + name: 'Pro — Monthly Credits', + description: + 'Total credits a Pro user gets each calendar month. Resets on the 1st. Default 50.', + required: true, + default: 50, + min: 0, + max: 1_000_000, + integer: true, + step: 1, + units: 'credits', + placeholder: null, + }), + pro_gemini_cap: Value.number({ + name: 'Pro — Gemini Cap (monthly)', + description: + 'Within the Pro monthly allowance, how many credits may be served via Gemini (the rest spill to the operator-hardware fallback). Default 25.', + required: true, + default: 25, + min: 0, + max: 1_000_000, + integer: true, + step: 1, + units: 'credits', + placeholder: null, + }), + // Max tier knobs. + max_gemini_cap: Value.number({ + name: 'Max — Gemini Cap (monthly)', + description: + 'Max-tier users get unlimited total credits but a capped slice goes via Gemini. Default 50.', + required: true, + default: 50, + min: 0, + max: 1_000_000, + integer: true, + step: 1, + units: 'credits', + placeholder: null, + }), +}) + +export const adjustTierQuotas = sdk.Action.withInput( + 'adjust-tier-quotas', + + async ({ effects }) => ({ + name: 'Adjust Tier Quotas', + description: + 'Tune the per-tier monthly credit caps and Gemini exposure without redeploying. Changes apply to the next request — no restart needed.', + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const config = await configFile.read().once() + let parsed: any = {} + try { + parsed = JSON.parse(config?.relay_tier_quotas_json || '{}') + } catch { + parsed = {} + } + return { + core_lifetime: parsed?.core?.lifetime ?? 5, + pro_monthly: parsed?.pro?.monthly ?? 50, + pro_gemini_cap: parsed?.pro?.geminiCapMonthly ?? 25, + max_gemini_cap: parsed?.max?.geminiCapMonthly ?? 50, + } + }, + + async ({ effects, input }) => { + const quotas = { + core: { + lifetime: input.core_lifetime ?? 5, + monthly: null, + geminiCapMonthly: null, + }, + pro: { + lifetime: null, + monthly: input.pro_monthly ?? 50, + geminiCapMonthly: input.pro_gemini_cap ?? 25, + }, + max: { + lifetime: null, + monthly: null, + geminiCapMonthly: input.max_gemini_cap ?? 50, + }, + } + await configFile.merge(effects, { + relay_tier_quotas_json: JSON.stringify(quotas), + }) + return null + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 0000000..ed9e1e7 --- /dev/null +++ b/startos/actions/index.ts @@ -0,0 +1,15 @@ +import { sdk } from '../sdk' +import { setGeminiKey } from './setGeminiKey' +import { setKeysatBaseUrl } from './setKeysatBaseUrl' +import { setParakeetUrl } from './setParakeetUrl' +import { setGemmaUrl } from './setGemmaUrl' +import { setAdminPassword } from './setAdminPassword' +import { adjustTierQuotas } from './adjustTierQuotas' + +export const actions = sdk.Actions.of() + .addAction(setGeminiKey) + .addAction(setKeysatBaseUrl) + .addAction(setParakeetUrl) + .addAction(setGemmaUrl) + .addAction(setAdminPassword) + .addAction(adjustTierQuotas) diff --git a/startos/actions/setAdminPassword.ts b/startos/actions/setAdminPassword.ts new file mode 100644 index 0000000..eaa15e3 --- /dev/null +++ b/startos/actions/setAdminPassword.ts @@ -0,0 +1,107 @@ +import { sdk } from '../sdk' +import { configFile } from '../file-models/config.json' +import { randomBytes, scryptSync } from 'crypto' + +const { InputSpec, Value } = sdk + +const SCRYPT_KEYLEN = 64 + +// Mirror of Recap's setAdminPassword — same shape so server-side +// admin-auth code can be lifted with minimal change. +const inputSpec = InputSpec.of({ + relay_admin_username: Value.text({ + name: 'Admin Username', + description: 'Username for the relay admin dashboard. Defaults to "admin".', + required: true, + default: 'admin', + minLength: 1, + maxLength: 64, + }), + relay_admin_password: Value.text({ + name: 'Admin Password', + description: + 'Password for the relay admin dashboard. Must be at least 8 characters. Leave blank to disable /admin entirely (useful while testing /relay/* endpoints).', + required: false, + default: null, + masked: true, + minLength: 0, + maxLength: 256, + }), + relay_admin_password_confirm: Value.text({ + name: 'Confirm Password', + description: 'Re-enter the password to confirm.', + required: false, + default: null, + masked: true, + minLength: 0, + maxLength: 256, + }), +}) + +export const setAdminPassword = sdk.Action.withInput( + 'set-admin-password', + + async ({ effects }) => ({ + name: 'Set Admin Password', + description: + "Gate the relay's /admin dashboard. The public /relay/* endpoints are unaffected — they're per-call authenticated via X-Recap-Install-Id + Authorization headers.", + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const config = await configFile.read().once() + return { + relay_admin_username: config?.relay_admin_username || 'admin', + relay_admin_password: undefined, + relay_admin_password_confirm: undefined, + } + }, + + async ({ effects, input }) => { + const username = (input.relay_admin_username || '').trim() + const password = input.relay_admin_password || '' + const confirm = input.relay_admin_password_confirm || '' + + if (!username) throw new Error('Username is required.') + + if (password === '' && confirm === '') { + await configFile.merge(effects, { + relay_admin_username: username, + relay_admin_password_hash: '', + relay_admin_password_salt: '', + }) + return null + } + + if (password !== confirm) { + throw new Error('Password and confirmation do not match.') + } + if (password.length < 8) { + throw new Error('Password must be at least 8 characters.') + } + + const salt = randomBytes(16).toString('hex') + const hash = scryptSync(password, salt, SCRYPT_KEYLEN).toString('hex') + + const existing = await configFile.read().once() + const sessionSecret = + existing?.relay_admin_session_secret && + existing.relay_admin_session_secret.length > 0 + ? existing.relay_admin_session_secret + : randomBytes(32).toString('hex') + + await configFile.merge(effects, { + relay_admin_username: username, + relay_admin_password_hash: hash, + relay_admin_password_salt: salt, + relay_admin_session_secret: sessionSecret, + }) + + return null + }, +) diff --git a/startos/actions/setGeminiKey.ts b/startos/actions/setGeminiKey.ts new file mode 100644 index 0000000..03896dc --- /dev/null +++ b/startos/actions/setGeminiKey.ts @@ -0,0 +1,54 @@ +import { sdk } from '../sdk' +import { configFile } from '../file-models/config.json' + +const { InputSpec, Value } = sdk + +// The operator's Gemini API key. This is the relay's primary backend +// — Recap requests for both transcribe and analyze go to Gemini first, +// and only spill to the optional Parakeet/Gemma backends once a user +// exceeds their tier's monthly Gemini cap. +// +// Free key from https://aistudio.google.com/apikey. Track usage in +// the Google AI Studio dashboard to know what tier pricing should be. +const inputSpec = InputSpec.of({ + relay_gemini_api_key: Value.text({ + name: 'Gemini API Key', + description: + 'The relay\'s Google Gemini API key. Used for transcribe + analyze forwarding. Get one at https://aistudio.google.com/apikey', + required: true, + default: null, + masked: true, + minLength: 1, + maxLength: 256, + }), +}) + +export const setGeminiKey = sdk.Action.withInput( + 'set-gemini-key', + + async ({ effects }) => ({ + name: 'Set Gemini API Key', + description: + "The operator's Gemini key. Required — the relay will refuse to serve traffic until this is set.", + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const config = await configFile.read().once() + return { + relay_gemini_api_key: config?.relay_gemini_api_key || undefined, + } + }, + + async ({ effects, input }) => { + await configFile.merge(effects, { + relay_gemini_api_key: input.relay_gemini_api_key, + }) + return null + }, +) diff --git a/startos/actions/setGemmaUrl.ts b/startos/actions/setGemmaUrl.ts new file mode 100644 index 0000000..4706f09 --- /dev/null +++ b/startos/actions/setGemmaUrl.ts @@ -0,0 +1,55 @@ +import { sdk } from '../sdk' +import { configFile } from '../file-models/config.json' + +const { InputSpec, Value } = sdk + +// Optional Gemma/Ollama endpoint for the operator-hardware analysis +// fallback. Counterpart to setParakeetUrl — Parakeet handles transcribe +// overflow, this handles analyze overflow. +const inputSpec = InputSpec.of({ + relay_gemma_base_url: Value.text({ + name: 'Gemma Base URL', + description: + "URL of the operator's Gemma / Ollama / OpenAI-compatible analysis endpoint. Used as the overflow path once a user exceeds their monthly Gemini cap. Leave empty to hard-cap at the Gemini limit. Example: http://192.168.1.87:11434", + required: false, + default: '', + minLength: 0, + maxLength: 256, + patterns: [ + { + regex: '^(https?://.+)?$', + description: 'Must be empty or start with http:// or https://', + }, + ], + }), +}) + +export const setGemmaUrl = sdk.Action.withInput( + 'set-gemma-url', + + async ({ effects }) => ({ + name: 'Set Gemma URL', + description: + 'Optional. Where the relay forwards analysis requests once a user exceeds their monthly Gemini cap. Leave empty to disable the fallback.', + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const config = await configFile.read().once() + return { + relay_gemma_base_url: config?.relay_gemma_base_url || '', + } + }, + + async ({ effects, input }) => { + await configFile.merge(effects, { + relay_gemma_base_url: (input.relay_gemma_base_url || '').trim(), + }) + return null + }, +) diff --git a/startos/actions/setKeysatBaseUrl.ts b/startos/actions/setKeysatBaseUrl.ts new file mode 100644 index 0000000..6d16d4f --- /dev/null +++ b/startos/actions/setKeysatBaseUrl.ts @@ -0,0 +1,57 @@ +import { sdk } from '../sdk' +import { configFile } from '../file-models/config.json' + +const { InputSpec, Value } = sdk + +// Where the relay calls to validate licenses. Defaults to the public +// Keysat endpoint. Operators running Keysat on the same Start9 server +// can override to the internal hostname (e.g. http://keysat.startos:3000) +// for a lower-latency hot path — every relay request hits this for the +// cached online check. +const inputSpec = InputSpec.of({ + relay_keysat_base_url: Value.text({ + name: 'Keysat Base URL', + description: + "URL of the Keysat license server. Defaults to https://keysat.xyz. If you're running Keysat as a co-located StartOS package, override to the internal hostname (http://keysat.startos:) to skip the public-internet roundtrip.", + required: true, + default: 'https://keysat.xyz', + minLength: 8, + maxLength: 256, + patterns: [ + { + regex: '^https?://.+$', + description: 'Must start with http:// or https://', + }, + ], + }), +}) + +export const setKeysatBaseUrl = sdk.Action.withInput( + 'set-keysat-base-url', + + async ({ effects }) => ({ + name: 'Set Keysat URL', + description: + "Where the relay validates Recap user licenses. Defaults to https://keysat.xyz — override to a co-located internal hostname if Keysat is on the same Start9 server.", + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const config = await configFile.read().once() + return { + relay_keysat_base_url: config?.relay_keysat_base_url || 'https://keysat.xyz', + } + }, + + async ({ effects, input }) => { + await configFile.merge(effects, { + relay_keysat_base_url: (input.relay_keysat_base_url || '').trim(), + }) + return null + }, +) diff --git a/startos/actions/setParakeetUrl.ts b/startos/actions/setParakeetUrl.ts new file mode 100644 index 0000000..ee3e182 --- /dev/null +++ b/startos/actions/setParakeetUrl.ts @@ -0,0 +1,59 @@ +import { sdk } from '../sdk' +import { configFile } from '../file-models/config.json' + +const { InputSpec, Value } = sdk + +// Optional Parakeet endpoint for the operator-hardware fallback path. +// When a Pro/Max user exceeds their Gemini monthly cap, the relay +// routes transcribe requests here instead. Empty disables the fallback +// — over-cap users get 402. +// +// In a typical setup this points at the operator's NVIDIA Spark or +// similar local GPU box running the NeMo / Parakeet HTTP wrapper. +const inputSpec = InputSpec.of({ + relay_parakeet_base_url: Value.text({ + name: 'Parakeet Base URL', + description: + 'URL of the operator\'s Parakeet (or any Whisper-API-compatible) transcription endpoint. Used as the overflow path once a user exceeds their monthly Gemini cap. Leave empty to hard-cap at the Gemini limit. Example: http://192.168.1.87:8000', + required: false, + default: '', + minLength: 0, + maxLength: 256, + patterns: [ + { + regex: '^(https?://.+)?$', + description: 'Must be empty or start with http:// or https://', + }, + ], + }), +}) + +export const setParakeetUrl = sdk.Action.withInput( + 'set-parakeet-url', + + async ({ effects }) => ({ + name: 'Set Parakeet URL', + description: + "Optional. Where the relay forwards transcription requests once a user exceeds their monthly Gemini cap. Leave empty to disable the operator-hardware fallback.", + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const config = await configFile.read().once() + return { + relay_parakeet_base_url: config?.relay_parakeet_base_url || '', + } + }, + + async ({ effects, input }) => { + await configFile.merge(effects, { + relay_parakeet_base_url: (input.relay_parakeet_base_url || '').trim(), + }) + return null + }, +) diff --git a/startos/backups.ts b/startos/backups.ts new file mode 100644 index 0000000..0a90b1e --- /dev/null +++ b/startos/backups.ts @@ -0,0 +1,5 @@ +import { sdk } from './sdk' + +export const { createBackup, restoreInit } = sdk.setupBackups( + async ({ effects }) => sdk.Backups.ofVolumes('main'), +) diff --git a/startos/dependencies.ts b/startos/dependencies.ts new file mode 100644 index 0000000..54a7877 --- /dev/null +++ b/startos/dependencies.ts @@ -0,0 +1,12 @@ +import { sdk } from './sdk' + +// Recap declares Ollama as an OPTIONAL dependency in the manifest. +// We do not return it here because we don't want to enforce a runtime +// requirement on it — Recap runs fine using cloud providers +// (Gemini/Anthropic/OpenAI) when Ollama is not installed. The optional +// declaration in the manifest is what surfaces it as a suggested +// install on the Marketplace; this empty result keeps it from blocking +// startup. +export const setDependencies = sdk.setupDependencies(async ({ effects }) => { + return {} +}) diff --git a/startos/file-models/config.json.ts b/startos/file-models/config.json.ts new file mode 100644 index 0000000..94e2997 --- /dev/null +++ b/startos/file-models/config.json.ts @@ -0,0 +1,64 @@ +import { FileHelper } from '@start9labs/start-sdk' +import { Volume } from '@start9labs/start-sdk/package/lib/util/Volume' +import { z } from 'zod' + +const mainVolume = new Volume('main') + +// Operator-side configuration for the Recap Relay package. All fields +// are optional and ship with sensible defaults — the relay will boot +// even with an empty config, but will refuse to serve traffic until at +// least relay_gemini_api_key and relay_admin_password_hash are set. +export const configFile = FileHelper.json( + { + base: mainVolume, + subpath: 'config/relay-config.json', + }, + z.object({ + // ── Backend credentials ── + // The relay's Gemini API key. Used for all transcribe + analyze + // forwarding until a user exceeds their tier's Gemini cap (then + // overflows to operator hardware below). Empty disables the + // Gemini backend entirely — relay will then either route to + // hardware (if configured) or 503 every request. + relay_gemini_api_key: z.string().default(''), + + // ── Operator hardware (optional fallback) ── + // When a Pro/Max user exceeds their monthly Gemini cap, the relay + // routes overflow here. Leave empty to hard-cap at the Gemini limit + // and return 402 once exceeded (no fallback). + relay_parakeet_base_url: z.string().default(''), + relay_gemma_base_url: z.string().default(''), + + // ── License server ── + // URL of the Keysat license server used for the cached online + // license-validation check. Defaults to the public endpoint; + // operators co-located with Keysat on the same Start9 server can + // override to the internal `http://keysat.startos:` hostname + // for a lower-latency hot path. + relay_keysat_base_url: z.string().default('https://keysat.xyz'), + + // ── Admin dashboard auth ── + // Username + scrypt-hashed password + session secret for the + // /admin/* dashboard. Same shape Recap uses (see Recap's + // server/admin-auth.js for the hash + verify code). Empty hash + // disables /admin entirely — useful while testing the public + // /relay/* endpoints. + relay_admin_username: z.string().default(''), + relay_admin_password_hash: z.string().default(''), + relay_admin_password_salt: z.string().default(''), + relay_admin_session_secret: z.string().default(''), + + // ── Tier quotas (operator-adjustable without redeploy) ── + // JSON blob driving credits.js. Defaults match the v1 product + // spec: Core lifetime-5, Pro 50/mo with 25 Gemini cap, Max + // unlimited with 50 Gemini cap. Operators can tweak via the + // "Adjust Tier Quotas" action without a code change or restart. + relay_tier_quotas_json: z.string().default( + JSON.stringify({ + core: { lifetime: 5, monthly: null, geminiCapMonthly: null }, + pro: { lifetime: null, monthly: 50, geminiCapMonthly: 25 }, + max: { lifetime: null, monthly: null, geminiCapMonthly: 50 }, + }), + ), + }), +) diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts new file mode 100644 index 0000000..4cf71d8 --- /dev/null +++ b/startos/i18n/dictionaries/default.ts @@ -0,0 +1,20 @@ +export const DEFAULT_LANG = 'en_US' + +const dict = { + // main.ts + 'Starting Recap...': 0, + 'Web Interface': 1, + 'Recap is ready': 2, + 'Recap is not responding': 3, + + // interfaces.ts + 'Web UI': 4, + 'The web interface for Recap — browse, search, and manage your transcript library': 5, +} as const + +/** + * Plumbing. DO NOT EDIT. + */ +export type I18nKey = keyof typeof dict +export type LangDict = Record<(typeof dict)[I18nKey], string> +export default dict diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts new file mode 100644 index 0000000..c9a8dc0 --- /dev/null +++ b/startos/i18n/dictionaries/translations.ts @@ -0,0 +1,4 @@ +import { LangDict } from './default' + +// English-only for now. Add translations here as needed. +export default {} satisfies Record diff --git a/startos/i18n/index.ts b/startos/i18n/index.ts new file mode 100644 index 0000000..04cea20 --- /dev/null +++ b/startos/i18n/index.ts @@ -0,0 +1,8 @@ +/** + * Plumbing. DO NOT EDIT this file. + */ +import { setupI18n } from '@start9labs/start-sdk' +import defaultDict, { DEFAULT_LANG } from './dictionaries/default' +import translations from './dictionaries/translations' + +export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG) diff --git a/startos/index.ts b/startos/index.ts new file mode 100644 index 0000000..7af589b --- /dev/null +++ b/startos/index.ts @@ -0,0 +1,11 @@ +/** + * Plumbing. DO NOT EDIT. + */ +export { createBackup } from './backups' +export { main } from './main' +export { init, uninit } from './init' +export { actions } from './actions' +import { buildManifest } from '@start9labs/start-sdk' +import { manifest as sdkManifest } from './manifest' +import { versionGraph } from './versions' +export const manifest = buildManifest(versionGraph, sdkManifest) diff --git a/startos/init/index.ts b/startos/init/index.ts new file mode 100644 index 0000000..92d5040 --- /dev/null +++ b/startos/init/index.ts @@ -0,0 +1,18 @@ +import { sdk } from '../sdk' +import { setDependencies } from '../dependencies' +import { setInterfaces } from '../interfaces' +import { versionGraph } from '../versions' +import { actions } from '../actions' +import { restoreInit } from '../backups' +import { setup } from './setup' + +export const init = sdk.setupInit( + restoreInit, + versionGraph, + setup, + setInterfaces, + setDependencies, + actions, +) + +export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/init/setup.ts b/startos/init/setup.ts new file mode 100644 index 0000000..e8ca449 --- /dev/null +++ b/startos/init/setup.ts @@ -0,0 +1,8 @@ +import { sdk } from '../sdk' + +// Recap needs no special initialization. +// Directories are created by docker_entrypoint.sh and +// config is loaded from the persistent volume at runtime. +export const setup = sdk.setupOnInit(async (effects, kind) => { + // Nothing to do on install, update, restore, or rebuild. +}) diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 0000000..f591cca --- /dev/null +++ b/startos/interfaces.ts @@ -0,0 +1,34 @@ +import { i18n } from './i18n' +import { sdk } from './sdk' +import { uiPort } from './utils' + +// Single HTTP interface on port 3002. Operators wire a public hostname +// (e.g. relay.yourdomain.com) to this interface via StartTunnel; Recap +// installs point their "Set Relay URL" action at that hostname. The +// /admin/* paths require admin auth (set via "Set Admin Password" +// action); the /relay/* paths are authenticated per-call via +// X-Recap-Install-Id + optional Authorization: Bearer LIC1-... headers. +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + const apiMulti = sdk.MultiHost.of(effects, 'api-multi') + const apiOrigin = await apiMulti.bindPort(uiPort, { + protocol: 'http', + }) + const api = sdk.createInterface(effects, { + name: i18n('Relay Endpoint'), + id: 'api', + description: i18n( + 'HTTP endpoint for Recap clients to relay transcribe + analyze ' + + 'calls. Also serves the operator admin dashboard at /admin/.', + ), + type: 'ui', + masked: false, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + + const apiReceipt = await apiOrigin.export([api]) + + return [apiReceipt] +}) diff --git a/startos/main.ts b/startos/main.ts new file mode 100644 index 0000000..fc2ad43 --- /dev/null +++ b/startos/main.ts @@ -0,0 +1,37 @@ +import { i18n } from './i18n' +import { sdk } from './sdk' +import { uiPort } from './utils' + +export const main = sdk.setupMain(async ({ effects }) => { + console.info(i18n('Starting Recap Relay...')) + + return sdk.Daemons.of(effects).addDaemon('primary', { + subcontainer: await sdk.SubContainer.of( + effects, + { imageId: 'main' }, + sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/data', + readonly: false, + }), + 'recap-relay-sub', + ), + exec: { + command: [ + 'dumb-init', + '--', + '/usr/local/bin/docker_entrypoint.sh', + ], + }, + ready: { + display: i18n('Relay Endpoint'), + fn: () => + sdk.healthCheck.checkPortListening(effects, uiPort, { + successMessage: i18n('Relay is accepting connections'), + errorMessage: i18n('Relay is not responding'), + }), + }, + requires: [], + }) +}) diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts new file mode 100644 index 0000000..8bf2691 --- /dev/null +++ b/startos/manifest/i18n.ts @@ -0,0 +1,18 @@ +export const short = + 'Credit-metered relay backend for Recap clients.' + +export const long = + 'Recap Relay is the operator-side service that fronts Gemini (and ' + + 'optionally a local Parakeet+Gemma setup) for Recap installs. It ' + + "tracks per-install credit balances, enforces tier-based monthly " + + 'quotas, and proxies transcribe/analyze calls so Core users can ' + + 'summarize a handful of videos without paying and paid tiers get ' + + 'metered access on the operator dime. Designed to be paired with ' + + 'a Keysat license server.' + +export const alertInstall = + 'Recap Relay needs at least a Gemini API key + an admin password ' + + 'set via the StartOS actions before it can serve traffic. Forward ' + + "a public hostname (e.g. relay.yourdomain.com) to this service's " + + "Web Interface via StartTunnel, then point your Recap install's " + + '"Set Relay URL" action at that hostname.' diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts new file mode 100644 index 0000000..9b7da8a --- /dev/null +++ b/startos/manifest/index.ts @@ -0,0 +1,38 @@ +import { setupManifest } from '@start9labs/start-sdk' +import { alertInstall, long, short } from './i18n' + +export const manifest = setupManifest({ + id: 'recap-relay', + title: 'Recap Relay', + license: 'Proprietary', + packageRepo: 'https://ten31.xyz', + upstreamRepo: 'https://ten31.xyz', + marketingUrl: 'https://ten31.xyz', + donationUrl: null, + docsUrls: [], + description: { short, long }, + volumes: ['main'], + images: { + main: { + source: { + dockerBuild: { + workdir: '.', + dockerfile: './Dockerfile', + }, + }, + arch: ['x86_64', 'aarch64'], + }, + }, + alerts: { + install: alertInstall, + update: null, + uninstall: null, + restore: null, + start: null, + stop: null, + }, + // Relay has no required dependencies — Gemini is internet-fronted + // and the optional Parakeet/Gemma backends are at user-configured + // URLs (typically a separate machine on the operator's LAN). + dependencies: {}, +}) diff --git a/startos/sdk.ts b/startos/sdk.ts new file mode 100644 index 0000000..04ae4b1 --- /dev/null +++ b/startos/sdk.ts @@ -0,0 +1,9 @@ +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' + +/** + * Plumbing. DO NOT EDIT. + * + * The exported "sdk" const is used throughout this package codebase. + */ +export const sdk = StartSdk.of().withManifest(manifest).build(true) diff --git a/startos/utils.ts b/startos/utils.ts new file mode 100644 index 0000000..f0af034 --- /dev/null +++ b/startos/utils.ts @@ -0,0 +1,4 @@ +// Shared constants used across the package codebase. +// Port 3002 to keep it distinct from Recap's 3001 if anyone ever +// co-installs both packages on the same host for testing. +export const uiPort = 3002 diff --git a/startos/versions/index.ts b/startos/versions/index.ts new file mode 100644 index 0000000..ca23373 --- /dev/null +++ b/startos/versions/index.ts @@ -0,0 +1,7 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { v_0_1_0 } from './v0.1.0' + +export const versionGraph = VersionGraph.of({ + current: v_0_1_0, + other: [], +}) diff --git a/startos/versions/v0.1.0.ts b/startos/versions/v0.1.0.ts new file mode 100644 index 0000000..3e4a903 --- /dev/null +++ b/startos/versions/v0.1.0.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_1_0 = VersionInfo.of({ + version: '0.1.0:0', + releaseNotes: { + en_US: + 'Initial release. Two-endpoint relay (transcribe + analyze) backed by Gemini, with per-install-id credit ledger, job-id deduplication, cached license validation against Keysat, and StartOS actions for operator configuration. Parakeet+Gemma fallback is wired but inert in v0.1.', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a2945a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["startos/**/*.ts", "node_modules/**/startos"], + "compilerOptions": { + "target": "ES2018", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/vendor/keysat-licensing-client/LICENSE b/vendor/keysat-licensing-client/LICENSE new file mode 100644 index 0000000..1b63053 --- /dev/null +++ b/vendor/keysat-licensing-client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Keysat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/keysat-licensing-client/README.md b/vendor/keysat-licensing-client/README.md new file mode 100644 index 0000000..11220be --- /dev/null +++ b/vendor/keysat-licensing-client/README.md @@ -0,0 +1,71 @@ +# @keysat/licensing-client + +TypeScript / JavaScript client for [`Keysat`](https://github.com/keysat-xyz/keysat) — a self-hosted Bitcoin-paid software licensing server that runs on Start9. + +Works in modern browsers and Node 18+. No native dependencies; signature verification is done in pure JS via [`@noble/ed25519`](https://github.com/paulmillr/noble-ed25519). + +## What you get + +- **Offline verification**: check a license key with just the issuing server's public key. No network. +- **Online validation**: live revocation check and fingerprint binding via the service's `/v1/validate` endpoint. +- **Purchase flow**: kick off a BTCPay checkout and poll for the issued key. + +## Install + +```bash +npm install @keysat/licensing-client +``` + +## 5-line offline check + +```ts +import { Verifier, PublicKey } from '@keysat/licensing-client' + +const verifier = new Verifier(PublicKey.fromPem(ISSUER_PUBKEY_PEM)) +const ok = verifier.verify(keyFromUser) +console.log('licensed for product', ok.productId) +``` + +That's the whole integration. Embed your public key as a string at build time (e.g. Vite's `?raw` import, webpack raw-loader, or just a `const`). If the verifier returns without throwing, the key is real and was issued by you. + +## 10-line online check (with revocation + fingerprint) + +```ts +import { Client } from '@keysat/licensing-client' + +const client = new Client('https://license.example.com') +const result = await client.validate(keyFromUser, 'my-product', machineFingerprint) +if (!result.ok) { + console.error('rejected:', result.reason) + process.exit(1) +} +``` + +The server enforces revocation live and does trust-on-first-use fingerprint binding, so the same key used from a second machine gets rejected. + +## Purchase flow + +```ts +const session = await client.startPurchase('my-product') +console.log('pay at:', session.checkoutUrl) +const key = await client.waitForLicense(session.invoiceId) +console.log('got license:', key) +``` + +`waitForLicense` polls until the BTCPay invoice settles and the service issues a key. It throws if the invoice expires or becomes invalid. + +## Browser usage + +Everything here works in the browser too. Drop the library into your React/Svelte/Vue app and run offline verification client-side — no server call needed for the common case. + +```ts +// Vite: import the PEM as a raw string at build time +import issuerPem from './issuer.pub?raw' +import { Verifier, PublicKey } from '@keysat/licensing-client' + +const verifier = new Verifier(PublicKey.fromPem(issuerPem)) +``` + +## License + +MIT. diff --git a/vendor/keysat-licensing-client/dist/index.cjs b/vendor/keysat-licensing-client/dist/index.cjs new file mode 100644 index 0000000..8536fe3 --- /dev/null +++ b/vendor/keysat-licensing-client/dist/index.cjs @@ -0,0 +1,594 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/index.ts +var index_exports = {}; +__export(index_exports, { + Client: () => Client, + FLAG_FINGERPRINT_BOUND: () => FLAG_FINGERPRINT_BOUND, + FLAG_TRIAL: () => FLAG_TRIAL, + KEY_PREFIX: () => KEY_PREFIX, + KEY_VERSION: () => KEY_VERSION, + KEY_VERSION_V1: () => KEY_VERSION_V1, + KEY_VERSION_V2: () => KEY_VERSION_V2, + LicensingError: () => LicensingError, + PublicKey: () => PublicKey, + Verifier: () => Verifier, + hasEntitlement: () => hasEntitlement, + hashFingerprint: () => hashFingerprint, + isExpiredAt: () => isExpiredAt, + parseLicenseKey: () => parseLicenseKey +}); +module.exports = __toCommonJS(index_exports); + +// src/verify.ts +var ed = __toESM(require("@noble/ed25519"), 1); +var import_sha512 = require("@noble/hashes/sha512"); + +// src/errors.ts +var LicensingError = class extends Error { + /** + * Machine-readable reason code. Common values: + * `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`, + * `"expired"`, `"server_error"`, `"http_error"`, `"other"`. + */ + code; + constructor(code, message) { + super(message); + this.name = "LicensingError"; + this.code = code; + } +}; + +// src/base32.ts +var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; +var DECODE_TABLE = (() => { + const t = {}; + for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET[i]] = i; + return t; +})(); +function decodeBase32NoPad(input) { + const up = input.toUpperCase(); + const out = new Uint8Array(Math.floor(up.length * 5 / 8)); + let bits = 0; + let value = 0; + let outPos = 0; + for (let i = 0; i < up.length; i++) { + const ch = up[i]; + const v = DECODE_TABLE[ch]; + if (v === void 0) { + throw new LicensingError("bad_encoding", `invalid base32 character '${ch}'`); + } + value = value << 5 | v; + bits += 5; + if (bits >= 8) { + bits -= 8; + out[outPos++] = value >> bits & 255; + } + } + return out.subarray(0, outPos); +} + +// src/key.ts +var KEY_PREFIX = "LIC1"; +var KEY_VERSION_V1 = 1; +var KEY_VERSION_V2 = 2; +var KEY_VERSION = KEY_VERSION_V2; +var FLAG_FINGERPRINT_BOUND = 1; +var FLAG_TRIAL = 2; +var PAYLOAD_V1_LEN = 74; +var PAYLOAD_V2_HEAD_LEN = 83; +var SIGNATURE_LEN = 64; +function isExpiredAt(payload, nowUnixSeconds) { + return payload.expiresAt !== 0 && nowUnixSeconds >= payload.expiresAt; +} +function hasEntitlement(payload, slug) { + return payload.entitlements.includes(slug); +} +function parseLicenseKey(raw) { + const trimmed = raw.trim(); + const firstDash = trimmed.indexOf("-"); + if (firstDash < 0) throw new LicensingError("bad_format", "key is missing prefix delimiter"); + const prefix = trimmed.slice(0, firstDash); + if (prefix !== KEY_PREFIX) throw new LicensingError("bad_format", `unknown key prefix '${prefix}'`); + const body = trimmed.slice(firstDash + 1); + const lastDash = body.lastIndexOf("-"); + if (lastDash < 0) throw new LicensingError("bad_format", "key is missing signature delimiter"); + const payloadB32 = body.slice(0, lastDash); + const signatureB32 = body.slice(lastDash + 1); + const payloadBytes = decodeBase32NoPad(payloadB32); + const signature = decodeBase32NoPad(signatureB32); + if (signature.length !== SIGNATURE_LEN) { + throw new LicensingError( + "bad_format", + `signature is ${signature.length} bytes; expected ${SIGNATURE_LEN}` + ); + } + if (payloadBytes.length < 1) { + throw new LicensingError("bad_format", "empty payload"); + } + const version = payloadBytes[0]; + let payload; + switch (version) { + case KEY_VERSION_V1: + payload = parseV1(payloadBytes); + break; + case KEY_VERSION_V2: + payload = parseV2(payloadBytes); + break; + default: + throw new LicensingError("bad_version", `unsupported key version ${version}`); + } + return { + payload, + signedBytes: payloadBytes, + signature + }; +} +function parseV1(payloadBytes) { + if (payloadBytes.length !== PAYLOAD_V1_LEN) { + throw new LicensingError( + "bad_format", + `v1 payload is ${payloadBytes.length} bytes; expected ${PAYLOAD_V1_LEN}` + ); + } + const flags = payloadBytes[1]; + const productId = payloadBytes.slice(2, 18); + const licenseId = payloadBytes.slice(18, 34); + const issuedAt = readBigEndianI64(payloadBytes, 34); + const fingerprintHash = payloadBytes.slice(42, 74); + return { + version: KEY_VERSION_V1, + flags, + productId, + licenseId, + issuedAt, + expiresAt: 0, + fingerprintHash, + entitlements: [], + productUuid: uuidString(productId), + licenseUuid: uuidString(licenseId), + isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0, + isTrial: (flags & FLAG_TRIAL) !== 0 + }; +} +function parseV2(payloadBytes) { + if (payloadBytes.length < PAYLOAD_V2_HEAD_LEN) { + throw new LicensingError( + "bad_format", + `v2 payload is ${payloadBytes.length} bytes; expected >= ${PAYLOAD_V2_HEAD_LEN}` + ); + } + const flags = payloadBytes[1]; + const productId = payloadBytes.slice(2, 18); + const licenseId = payloadBytes.slice(18, 34); + const issuedAt = readBigEndianI64(payloadBytes, 34); + const expiresAt = readBigEndianI64(payloadBytes, 42); + const fingerprintHash = payloadBytes.slice(50, 82); + const numEntitlements = payloadBytes[82]; + const entitlements = []; + let cursor = PAYLOAD_V2_HEAD_LEN; + const decoder = new TextDecoder("utf-8", { fatal: true }); + for (let i = 0; i < numEntitlements; i++) { + if (cursor >= payloadBytes.length) { + throw new LicensingError("bad_format", "truncated entitlement list"); + } + const len = payloadBytes[cursor]; + cursor += 1; + if (cursor + len > payloadBytes.length) { + throw new LicensingError("bad_format", "truncated entitlement"); + } + try { + entitlements.push(decoder.decode(payloadBytes.slice(cursor, cursor + len))); + } catch { + throw new LicensingError("bad_format", "entitlement not utf-8"); + } + cursor += len; + } + if (cursor !== payloadBytes.length) { + throw new LicensingError("bad_format", "trailing bytes in payload"); + } + return { + version: KEY_VERSION_V2, + flags, + productId, + licenseId, + issuedAt, + expiresAt, + fingerprintHash, + entitlements, + productUuid: uuidString(productId), + licenseUuid: uuidString(licenseId), + isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0, + isTrial: (flags & FLAG_TRIAL) !== 0 + }; +} +function readBigEndianI64(buf, offset) { + const view = new DataView(buf.buffer, buf.byteOffset + offset, 8); + const hi = view.getInt32(0, false); + const lo = view.getUint32(4, false); + return hi * 2 ** 32 + lo; +} +function uuidString(b) { + const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; +} + +// src/fingerprint.ts +var import_sha256 = require("@noble/hashes/sha256"); +function hashFingerprint(raw) { + return (0, import_sha256.sha256)(new TextEncoder().encode(raw)); +} + +// src/verify.ts +ed.etc.sha512Sync = (...m) => (0, import_sha512.sha512)(ed.etc.concatBytes(...m)); +var Verifier = class { + pubkey; + constructor(pubkey) { + this.pubkey = pubkey; + } + /** Verify a license key string. Throws on any failure. */ + verify(keyStr) { + const key = parseLicenseKey(keyStr); + const ok = ed.verify(key.signature, key.signedBytes, this.pubkey.raw); + if (!ok) throw new LicensingError("bad_signature", "signature did not verify"); + return { + payload: key.payload, + licenseId: key.payload.licenseUuid, + productId: key.payload.productUuid + }; + } + /** + * Verify AND enforce that, if the key is fingerprint-bound, the given + * fingerprint matches. If the key is not bound, the fingerprint is + * ignored. Throws on any failure. + */ + verifyWithFingerprint(keyStr, fingerprint) { + const result = this.verify(keyStr); + if (result.payload.isFingerprintBound) { + const expected = hashFingerprint(fingerprint); + const stored = result.payload.fingerprintHash; + if (!equalBytes(expected, stored)) { + throw new LicensingError("bad_signature", "fingerprint does not match bound key"); + } + } + return result; + } + /** + * Verify a key and additionally reject it with an `expired` error if + * `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys + * (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is + * offline-only — no grace window logic; use `Client.validate` for that. + */ + verifyWithTime(keyStr, nowUnixSeconds) { + const result = this.verify(keyStr); + if (isExpiredAt(result.payload, nowUnixSeconds)) { + throw new LicensingError("expired", "license has expired"); + } + return result; + } +}; +function equalBytes(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +// src/online.ts +var Client = class { + base; + constructor(baseUrl) { + this.base = baseUrl.replace(/\/+$/, ""); + } + /** The normalized base URL this client is pinned to. */ + baseUrl() { + return this.base; + } + /** Fetch the server's PEM-encoded public key. */ + async fetchPubkeyPem() { + const data = await this.get("/v1/pubkey"); + return data.public_key_pem; + } + /** + * Server-authoritative validation. Returns the full response including + * expiry / entitlements / seat fields introduced in v2. + * + * Two-argument form kept for call-site compatibility with earlier SDK + * versions; pass an options object for the full set of fields. + */ + async validate(key, productSlugOrOptions, fingerprint) { + const opts = typeof productSlugOrOptions === "string" ? { productSlug: productSlugOrOptions, fingerprint } : productSlugOrOptions ?? {}; + const raw = await this.post("/v1/validate", { + key, + product_slug: opts.productSlug, + fingerprint: opts.fingerprint, + hostname: opts.hostname, + platform: opts.platform + }); + return this.toValidateResponse(raw); + } + /** Lightweight heartbeat. Server updates `last_heartbeat_at`. */ + async heartbeat(key, fingerprint) { + const raw = await this.post("/v1/machines/heartbeat", { + key, + fingerprint + }); + return this.toMachineResponse(raw); + } + /** Explicitly activate a seat for the given fingerprint. */ + async activate(key, fingerprint, opts = {}) { + const raw = await this.post("/v1/machines/activate", { + key, + fingerprint, + hostname: opts.hostname, + platform: opts.platform + }); + return this.toMachineResponse(raw); + } + /** Free a seat held by the given fingerprint. */ + async deactivate(key, fingerprint, reason) { + const raw = await this.post("/v1/machines/deactivate", { + key, + fingerprint, + reason + }); + return this.toMachineResponse(raw); + } + /** Start a purchase. Returns the checkout URL and invoice id. */ + async startPurchase(productSlug, opts = {}) { + const raw = await this.post("/v1/purchase", { + product: productSlug, + buyer_email: opts.buyerEmail, + buyer_note: opts.buyerNote, + redirect_url: opts.redirectUrl, + code: opts.code, + policy_slug: opts.policySlug + }); + return { + invoiceId: raw.invoice_id, + btcpayInvoiceId: raw.btcpay_invoice_id, + checkoutUrl: raw.checkout_url, + amountSats: raw.amount_sats, + pollUrl: raw.poll_url + }; + } + /** + * List public, buyer-visible policies (tiers) for a product. No + * auth — same data the licensing service's `/buy/` page + * uses server-side. Use this to render an in-app tier picker + * that stays in sync with the operator's admin-side tier setup. + * + * Returns each policy's slug, display name, price (in the + * product's listed currency's smallest unit — sats or cents), + * entitlements, recurring/trial flags. Internal fields (id, + * tip recipients, raw metadata) are deliberately omitted. + */ + async listPublicPolicies(productSlug) { + const raw = await this.get( + `/v1/products/${encodeURIComponent(productSlug)}/policies` + ); + const product = raw.product; + const policies = raw.policies ?? []; + return { + product: { + slug: product.slug, + name: product.name, + description: product.description ?? "", + basePriceSats: product.base_price_sats + }, + policies: policies.map((p) => ({ + slug: p.slug, + name: p.name, + description: p.description ?? "", + priceSats: p.price_sats, + durationSeconds: p.duration_seconds ?? 0, + maxMachines: p.max_machines ?? 1, + isTrial: !!p.is_trial, + entitlements: p.entitlements ?? [], + highlighted: !!p.highlighted, + isRecurring: !!p.is_recurring, + renewalPeriodDays: p.renewal_period_days ?? 0, + trialDays: p.trial_days ?? 0 + })) + }; + } + /** + * Redeem a `free_license` code: bypass BTCPay entirely and receive the + * signed license key directly. Throws if the code is unknown / disabled + * / expired / wrong product / not a free_license code, or if the cap + * has been reached. + */ + async redeemFreeLicense(productSlug, code, opts = {}) { + const raw = await this.post("/v1/redeem", { + product: productSlug, + code, + buyer_email: opts.buyerEmail, + buyer_note: opts.buyerNote + }); + return { + licenseId: raw.license_id, + licenseKey: raw.license_key, + invoiceId: raw.invoice_id, + redemptionId: raw.redemption_id + }; + } + /** Poll a purchase by its invoice id. */ + async pollPurchase(invoiceId) { + const raw = await this.get( + `/v1/purchase/${encodeURIComponent(invoiceId)}` + ); + return { + invoiceId: raw.invoice_id, + status: raw.status, + productId: raw.product_id, + amountSats: raw.amount_sats, + licenseKey: raw.license_key ?? void 0, + licenseId: raw.license_id ?? void 0 + }; + } + /** + * Convenience: open the checkout, poll until a license key is issued, + * then return it. Suitable for CLI usage or for an app UI that shows a + * spinner while the buyer pays. + */ + async waitForLicense(invoiceId, options = {}) { + const interval = options.intervalMs ?? 5e3; + const deadline = options.timeoutMs ? Date.now() + options.timeoutMs : Infinity; + while (true) { + const poll = await this.pollPurchase(invoiceId); + if (poll.licenseKey) return poll.licenseKey; + if (poll.status === "expired" || poll.status === "invalid") { + throw new LicensingError("server_error", `invoice ended in status ${poll.status}`); + } + if (Date.now() > deadline) { + throw new LicensingError("server_error", "timed out waiting for license issuance"); + } + await sleep(interval); + } + } + // --- internals --- + toValidateResponse(raw) { + const entitlements = Array.isArray(raw.entitlements) ? raw.entitlements.filter((x) => typeof x === "string") : void 0; + return { + ok: !!raw.ok, + reason: raw.reason, + licenseId: raw.license_id, + productId: raw.product_id, + productSlug: raw.product_slug, + issuedAt: raw.issued_at, + expiresAt: raw.expires_at, + graceUntil: raw.grace_until, + inGracePeriod: raw.in_grace_period, + isTrial: raw.is_trial, + entitlements, + status: raw.status, + machineId: raw.machine_id, + maxMachines: raw.max_machines + }; + } + toMachineResponse(raw) { + return { + ok: !!raw.ok, + reason: raw.reason, + machineId: raw.machine_id, + activeCount: raw.active_count, + maxMachines: raw.max_machines + }; + } + async get(path) { + return this.request(path, { method: "GET" }); + } + async post(path, body) { + return this.request(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body) + }); + } + async request(path, init) { + let resp; + try { + resp = await fetch(`${this.base}${path}`, init); + } catch (e) { + throw new LicensingError("http_error", e instanceof Error ? e.message : String(e)); + } + const text = await resp.text(); + if (!resp.ok) { + throw new LicensingError("server_error", `HTTP ${resp.status}: ${text}`); + } + try { + return JSON.parse(text); + } catch { + throw new LicensingError("server_error", `non-JSON response: ${text}`); + } + } +}; +function sleep(ms) { + return new Promise((res) => setTimeout(res, ms)); +} + +// src/pubkey.ts +var PublicKey = class _PublicKey { + /** Raw 32-byte Ed25519 public key material. */ + raw; + constructor(raw) { + if (raw.length !== 32) { + throw new LicensingError( + "bad_format", + `public key must be 32 bytes; got ${raw.length}` + ); + } + this.raw = raw; + } + /** Parse a PEM blob as emitted by the service. */ + static fromPem(pem) { + const stripped = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, ""); + if (!stripped) { + throw new LicensingError("bad_format", "empty PEM input"); + } + const der = base64Decode(stripped); + if (der.length < 32) { + throw new LicensingError("bad_format", "PEM body too short to contain a public key"); + } + const raw = der.slice(der.length - 32); + return new _PublicKey(raw); + } + /** Construct from raw bytes (no PEM envelope). */ + static fromBytes(bytes) { + return new _PublicKey(bytes); + } +}; +function base64Decode(b64) { + const nodeBuffer = globalThis.Buffer; + if (nodeBuffer) { + return new Uint8Array(nodeBuffer.from(b64, "base64")); + } + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + Client, + FLAG_FINGERPRINT_BOUND, + FLAG_TRIAL, + KEY_PREFIX, + KEY_VERSION, + KEY_VERSION_V1, + KEY_VERSION_V2, + LicensingError, + PublicKey, + Verifier, + hasEntitlement, + hashFingerprint, + isExpiredAt, + parseLicenseKey +}); diff --git a/vendor/keysat-licensing-client/dist/index.d.cts b/vendor/keysat-licensing-client/dist/index.d.cts new file mode 100644 index 0000000..9be9478 --- /dev/null +++ b/vendor/keysat-licensing-client/dist/index.d.cts @@ -0,0 +1,377 @@ +/** + * License-key parsing. Matches the service's wire format exactly. + * + * ## Wire format + * + * A key string looks like `LIC1--`. Both halves + * are Crockford base32 (no padding) of the raw bytes. + * + * ### v1 payload (74 bytes, fixed) + * + * ```text + * offset size field + * 0 1 version = 1 + * 1 1 flags + * 2 16 product_id (UUID bytes) + * 18 16 license_id (UUID bytes) + * 34 8 issued_at (i64 unix seconds, big-endian) + * 42 32 fingerprint_hash (SHA-256, or all-zero) + * ``` + * + * ### v2 payload (83 bytes + variable entitlements) + * + * ```text + * offset size field + * 0 1 version = 2 + * 1 1 flags + * 2 16 product_id + * 18 16 license_id + * 34 8 issued_at + * 42 8 expires_at (i64, 0 = perpetual) + * 50 32 fingerprint_hash + * 82 1 num_entitlements (u8) + * 83 * entitlements — each: [u8 len][len utf-8 bytes] + * ``` + * + * Clients verifying a v1 key treat `expiresAt` as 0 and `entitlements` as + * empty, so application code can branch on flags / fields uniformly. + */ +declare const KEY_PREFIX = "LIC1"; +/** v1 format identifier. */ +declare const KEY_VERSION_V1 = 1; +/** v2 format identifier. */ +declare const KEY_VERSION_V2 = 2; +/** Highest format version this client understands. */ +declare const KEY_VERSION = 2; +/** Set when the key is bound to a specific machine fingerprint hash. */ +declare const FLAG_FINGERPRINT_BOUND = 1; +/** Set on trial keys. */ +declare const FLAG_TRIAL = 2; +/** Decoded fields of the signed payload. */ +interface LicensePayload { + /** Format version (1 or 2). */ + version: number; + /** Feature flags. */ + flags: number; + /** Raw 16-byte product id (UUID). */ + productId: Uint8Array; + /** Raw 16-byte license id (UUID). */ + licenseId: Uint8Array; + /** Unix seconds issued. */ + issuedAt: number; + /** Unix seconds expiry; `0` for perpetual. Always `0` on v1 keys. */ + expiresAt: number; + /** SHA-256 hash of the bound machine fingerprint, or all-zero. */ + fingerprintHash: Uint8Array; + /** Entitlement slugs granted by this license. Empty on v1 keys. */ + entitlements: string[]; + /** Product UUID in canonical string form. */ + productUuid: string; + /** License UUID in canonical string form. */ + licenseUuid: string; + /** True if the key is fingerprint-bound. */ + isFingerprintBound: boolean; + /** True if the key is flagged as a trial. */ + isTrial: boolean; +} +/** A parsed (not yet verified) license key. */ +interface LicenseKey { + payload: LicensePayload; + /** + * Raw payload bytes (what the server signed over). Length is 74 on v1, + * `>= 83` on v2. + */ + signedBytes: Uint8Array; + /** Raw 64-byte signature. */ + signature: Uint8Array; +} +/** True if `nowUnixSeconds` is at or after the key's `expiresAt`. */ +declare function isExpiredAt(payload: LicensePayload, nowUnixSeconds: number): boolean; +/** True if the license grants the given entitlement slug. */ +declare function hasEntitlement(payload: LicensePayload, slug: string): boolean; +/** Parse a `LIC1-...-...` string. Does NOT verify. */ +declare function parseLicenseKey(raw: string): LicenseKey; + +/** + * Issuer public key. Accepts either raw 32-byte Ed25519 key material or a + * PEM-encoded SubjectPublicKeyInfo blob (which is what the service returns + * from `/v1/pubkey`). + */ +/** Parsed Ed25519 public key, ready for signature verification. */ +declare class PublicKey { + /** Raw 32-byte Ed25519 public key material. */ + readonly raw: Uint8Array; + constructor(raw: Uint8Array); + /** Parse a PEM blob as emitted by the service. */ + static fromPem(pem: string): PublicKey; + /** Construct from raw bytes (no PEM envelope). */ + static fromBytes(bytes: Uint8Array): PublicKey; +} + +/** Offline Ed25519 signature verification. */ + +interface VerifyOk { + /** Parsed payload fields. */ + payload: LicensePayload; + /** License UUID as a canonical string. */ + licenseId: string; + /** Product UUID as a canonical string. */ + productId: string; +} +/** Verifies license keys against a single issuing server's public key. */ +declare class Verifier { + private pubkey; + constructor(pubkey: PublicKey); + /** Verify a license key string. Throws on any failure. */ + verify(keyStr: string): VerifyOk; + /** + * Verify AND enforce that, if the key is fingerprint-bound, the given + * fingerprint matches. If the key is not bound, the fingerprint is + * ignored. Throws on any failure. + */ + verifyWithFingerprint(keyStr: string, fingerprint: string): VerifyOk; + /** + * Verify a key and additionally reject it with an `expired` error if + * `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys + * (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is + * offline-only — no grace window logic; use `Client.validate` for that. + */ + verifyWithTime(keyStr: string, nowUnixSeconds: number): VerifyOk; +} + +/** + * Online operations against a running `licensing-service` instance. + * + * All methods use the global `fetch` available in Node 18+ and every modern + * browser. No additional runtime required. + */ +interface ValidateResponse { + ok: boolean; + /** + * Machine-readable reason on failure. One of: + * `bad_format`, `bad_signature`, `not_found`, `revoked`, `suspended`, + * `expired`, `product_mismatch`, `fingerprint_mismatch`, + * `too_many_machines`, `rate_limited`, `invalid_state`. + */ + reason?: string; + licenseId?: string; + productId?: string; + productSlug?: string; + issuedAt?: string; + /** Expiry timestamp (RFC 3339) if the license has one. */ + expiresAt?: string; + /** End of the grace window (RFC 3339) when in a grace period. */ + graceUntil?: string; + /** True when the key is past `expiresAt` but still inside the grace window. */ + inGracePeriod?: boolean; + /** True if this license is flagged as a trial. */ + isTrial?: boolean; + /** Entitlement slugs granted by the license. */ + entitlements?: string[]; + /** License status string: `active`, `suspended`, `revoked`. */ + status?: string; + /** Machine id created or matched by this call (when fingerprint was sent). */ + machineId?: string; + /** Seat cap: `0` unlimited, `1` single-seat, `n` n-seat. */ + maxMachines?: number; +} +interface ValidateOptions { + /** Product slug the caller expects the key to cover. */ + productSlug?: string; + /** Raw machine fingerprint; enables seat binding / cap enforcement. */ + fingerprint?: string; + /** Client-supplied hostname, stored against the machine row on activation. */ + hostname?: string; + /** Client-supplied platform descriptor, e.g. `'linux-x86_64'`. */ + platform?: string; +} +interface MachineResponse { + ok: boolean; + reason?: string; + machineId?: string; + activeCount?: number; + maxMachines?: number; +} +interface PurchaseSession { + /** Our internal invoice id — use with `pollPurchase`. */ + invoiceId: string; + /** BTCPay's invoice id (opaque). */ + btcpayInvoiceId: string; + /** URL to open in the buyer's browser to pay. */ + checkoutUrl: string; + /** Price in satoshis. */ + amountSats: number; + /** Where the service recommends polling. */ + pollUrl: string; +} +interface PollResponse { + invoiceId: string; + /** `pending | settled | expired | invalid`. */ + status: string; + productId: string; + amountSats: number; + /** Populated once the license has been issued. */ + licenseKey?: string; + licenseId?: string; +} +interface StartPurchaseOptions { + /** Optional email for the receipt. */ + buyerEmail?: string; + /** Optional URL the buyer should be returned to after payment. */ + redirectUrl?: string; + /** Optional discount / referral code. */ + code?: string; + /** Optional buyer note recorded on the invoice (admin-visible). */ + buyerNote?: string; + /** + * Optional tier slug (the policy the buyer chose). When set, the + * licensing service prices the invoice at the policy's + * `price_sats_override` and remembers the chosen policy on the + * invoice so the issued license carries that policy's + * entitlements / duration / max_machines / trial flag. + * + * When omitted, the service falls back to the product's default + * policy (the policy slugged "default", or the first active one). + * + * To list available tiers for a product without auth, see + * {@link Client.listPublicPolicies}. + */ + policySlug?: string; +} +/** + * One tier on the buyer-facing tier picker. Returned by + * {@link Client.listPublicPolicies}. The shape mirrors what the + * licensing service's `/buy/` page reads server-side, so an + * in-app tier picker can render identical text and pricing without + * the buyer ever leaving the app. + */ +interface PublicPolicy { + slug: string; + name: string; + /** Free-form per-tier blurb (operator-set in admin UI). May be empty. */ + description: string; + /** + * Effective price in the smallest unit of the product's listed + * currency: sats for SAT-priced products, cents for USD/EUR-priced + * products. The product-level currency is on the parent + * {@link PublicPoliciesResponse.product.basePriceSats} (sats only) and + * via the daemon's `/v1/products/` endpoint for the full + * currency-typed view. + */ + priceSats: number; + /** 0 = perpetual; otherwise license lifetime in seconds. */ + durationSeconds: number; + /** Seat cap. 0 = unlimited, 1 = single-seat, n = n-seat. */ + maxMachines: number; + isTrial: boolean; + entitlements: string[]; + /** True if the operator marked this tier as "Most popular". */ + highlighted: boolean; + /** True if the policy is a recurring subscription. */ + isRecurring: boolean; + /** Renewal cadence in days (0 for non-recurring). */ + renewalPeriodDays: number; + /** First-cycle free-trial length (0 for none). */ + trialDays: number; +} +interface PublicPoliciesResponse { + product: { + slug: string; + name: string; + description: string; + basePriceSats: number; + }; + policies: PublicPolicy[]; +} +interface RedeemFreeOptions { + /** Optional email recorded on the synthetic invoice + license. */ + buyerEmail?: string; + /** Optional buyer note. */ + buyerNote?: string; +} +interface RedeemFreeResponse { + licenseId: string; + /** The fully-signed license key, ready for offline verification. */ + licenseKey: string; + invoiceId: string; + redemptionId: string; +} +/** An HTTP client pinned to one licensing-service base URL. */ +declare class Client { + private base; + constructor(baseUrl: string); + /** The normalized base URL this client is pinned to. */ + baseUrl(): string; + /** Fetch the server's PEM-encoded public key. */ + fetchPubkeyPem(): Promise; + /** + * Server-authoritative validation. Returns the full response including + * expiry / entitlements / seat fields introduced in v2. + * + * Two-argument form kept for call-site compatibility with earlier SDK + * versions; pass an options object for the full set of fields. + */ + validate(key: string, productSlugOrOptions?: string | ValidateOptions, fingerprint?: string): Promise; + /** Lightweight heartbeat. Server updates `last_heartbeat_at`. */ + heartbeat(key: string, fingerprint: string): Promise; + /** Explicitly activate a seat for the given fingerprint. */ + activate(key: string, fingerprint: string, opts?: { + hostname?: string; + platform?: string; + }): Promise; + /** Free a seat held by the given fingerprint. */ + deactivate(key: string, fingerprint: string, reason?: string): Promise; + /** Start a purchase. Returns the checkout URL and invoice id. */ + startPurchase(productSlug: string, opts?: StartPurchaseOptions): Promise; + /** + * List public, buyer-visible policies (tiers) for a product. No + * auth — same data the licensing service's `/buy/` page + * uses server-side. Use this to render an in-app tier picker + * that stays in sync with the operator's admin-side tier setup. + * + * Returns each policy's slug, display name, price (in the + * product's listed currency's smallest unit — sats or cents), + * entitlements, recurring/trial flags. Internal fields (id, + * tip recipients, raw metadata) are deliberately omitted. + */ + listPublicPolicies(productSlug: string): Promise; + /** + * Redeem a `free_license` code: bypass BTCPay entirely and receive the + * signed license key directly. Throws if the code is unknown / disabled + * / expired / wrong product / not a free_license code, or if the cap + * has been reached. + */ + redeemFreeLicense(productSlug: string, code: string, opts?: RedeemFreeOptions): Promise; + /** Poll a purchase by its invoice id. */ + pollPurchase(invoiceId: string): Promise; + /** + * Convenience: open the checkout, poll until a license key is issued, + * then return it. Suitable for CLI usage or for an app UI that shows a + * spinner while the buyer pays. + */ + waitForLicense(invoiceId: string, options?: { + intervalMs?: number; + timeoutMs?: number; + }): Promise; + private toValidateResponse; + private toMachineResponse; + private get; + private post; + private request; +} + +/** Hash a raw fingerprint string to the 32-byte form embedded in keys. */ +declare function hashFingerprint(raw: string): Uint8Array; + +/** All errors thrown by this library inherit from `LicensingError`. */ +declare class LicensingError extends Error { + /** + * Machine-readable reason code. Common values: + * `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`, + * `"expired"`, `"server_error"`, `"http_error"`, `"other"`. + */ + readonly code: string; + constructor(code: string, message: string); +} + +export { Client, FLAG_FINGERPRINT_BOUND, FLAG_TRIAL, KEY_PREFIX, KEY_VERSION, KEY_VERSION_V1, KEY_VERSION_V2, type LicenseKey, type LicensePayload, LicensingError, type MachineResponse, type PollResponse, PublicKey, type PublicPoliciesResponse, type PublicPolicy, type PurchaseSession, type RedeemFreeOptions, type RedeemFreeResponse, type StartPurchaseOptions, type ValidateOptions, type ValidateResponse, Verifier, type VerifyOk, hasEntitlement, hashFingerprint, isExpiredAt, parseLicenseKey }; diff --git a/vendor/keysat-licensing-client/dist/index.d.ts b/vendor/keysat-licensing-client/dist/index.d.ts new file mode 100644 index 0000000..9be9478 --- /dev/null +++ b/vendor/keysat-licensing-client/dist/index.d.ts @@ -0,0 +1,377 @@ +/** + * License-key parsing. Matches the service's wire format exactly. + * + * ## Wire format + * + * A key string looks like `LIC1--`. Both halves + * are Crockford base32 (no padding) of the raw bytes. + * + * ### v1 payload (74 bytes, fixed) + * + * ```text + * offset size field + * 0 1 version = 1 + * 1 1 flags + * 2 16 product_id (UUID bytes) + * 18 16 license_id (UUID bytes) + * 34 8 issued_at (i64 unix seconds, big-endian) + * 42 32 fingerprint_hash (SHA-256, or all-zero) + * ``` + * + * ### v2 payload (83 bytes + variable entitlements) + * + * ```text + * offset size field + * 0 1 version = 2 + * 1 1 flags + * 2 16 product_id + * 18 16 license_id + * 34 8 issued_at + * 42 8 expires_at (i64, 0 = perpetual) + * 50 32 fingerprint_hash + * 82 1 num_entitlements (u8) + * 83 * entitlements — each: [u8 len][len utf-8 bytes] + * ``` + * + * Clients verifying a v1 key treat `expiresAt` as 0 and `entitlements` as + * empty, so application code can branch on flags / fields uniformly. + */ +declare const KEY_PREFIX = "LIC1"; +/** v1 format identifier. */ +declare const KEY_VERSION_V1 = 1; +/** v2 format identifier. */ +declare const KEY_VERSION_V2 = 2; +/** Highest format version this client understands. */ +declare const KEY_VERSION = 2; +/** Set when the key is bound to a specific machine fingerprint hash. */ +declare const FLAG_FINGERPRINT_BOUND = 1; +/** Set on trial keys. */ +declare const FLAG_TRIAL = 2; +/** Decoded fields of the signed payload. */ +interface LicensePayload { + /** Format version (1 or 2). */ + version: number; + /** Feature flags. */ + flags: number; + /** Raw 16-byte product id (UUID). */ + productId: Uint8Array; + /** Raw 16-byte license id (UUID). */ + licenseId: Uint8Array; + /** Unix seconds issued. */ + issuedAt: number; + /** Unix seconds expiry; `0` for perpetual. Always `0` on v1 keys. */ + expiresAt: number; + /** SHA-256 hash of the bound machine fingerprint, or all-zero. */ + fingerprintHash: Uint8Array; + /** Entitlement slugs granted by this license. Empty on v1 keys. */ + entitlements: string[]; + /** Product UUID in canonical string form. */ + productUuid: string; + /** License UUID in canonical string form. */ + licenseUuid: string; + /** True if the key is fingerprint-bound. */ + isFingerprintBound: boolean; + /** True if the key is flagged as a trial. */ + isTrial: boolean; +} +/** A parsed (not yet verified) license key. */ +interface LicenseKey { + payload: LicensePayload; + /** + * Raw payload bytes (what the server signed over). Length is 74 on v1, + * `>= 83` on v2. + */ + signedBytes: Uint8Array; + /** Raw 64-byte signature. */ + signature: Uint8Array; +} +/** True if `nowUnixSeconds` is at or after the key's `expiresAt`. */ +declare function isExpiredAt(payload: LicensePayload, nowUnixSeconds: number): boolean; +/** True if the license grants the given entitlement slug. */ +declare function hasEntitlement(payload: LicensePayload, slug: string): boolean; +/** Parse a `LIC1-...-...` string. Does NOT verify. */ +declare function parseLicenseKey(raw: string): LicenseKey; + +/** + * Issuer public key. Accepts either raw 32-byte Ed25519 key material or a + * PEM-encoded SubjectPublicKeyInfo blob (which is what the service returns + * from `/v1/pubkey`). + */ +/** Parsed Ed25519 public key, ready for signature verification. */ +declare class PublicKey { + /** Raw 32-byte Ed25519 public key material. */ + readonly raw: Uint8Array; + constructor(raw: Uint8Array); + /** Parse a PEM blob as emitted by the service. */ + static fromPem(pem: string): PublicKey; + /** Construct from raw bytes (no PEM envelope). */ + static fromBytes(bytes: Uint8Array): PublicKey; +} + +/** Offline Ed25519 signature verification. */ + +interface VerifyOk { + /** Parsed payload fields. */ + payload: LicensePayload; + /** License UUID as a canonical string. */ + licenseId: string; + /** Product UUID as a canonical string. */ + productId: string; +} +/** Verifies license keys against a single issuing server's public key. */ +declare class Verifier { + private pubkey; + constructor(pubkey: PublicKey); + /** Verify a license key string. Throws on any failure. */ + verify(keyStr: string): VerifyOk; + /** + * Verify AND enforce that, if the key is fingerprint-bound, the given + * fingerprint matches. If the key is not bound, the fingerprint is + * ignored. Throws on any failure. + */ + verifyWithFingerprint(keyStr: string, fingerprint: string): VerifyOk; + /** + * Verify a key and additionally reject it with an `expired` error if + * `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys + * (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is + * offline-only — no grace window logic; use `Client.validate` for that. + */ + verifyWithTime(keyStr: string, nowUnixSeconds: number): VerifyOk; +} + +/** + * Online operations against a running `licensing-service` instance. + * + * All methods use the global `fetch` available in Node 18+ and every modern + * browser. No additional runtime required. + */ +interface ValidateResponse { + ok: boolean; + /** + * Machine-readable reason on failure. One of: + * `bad_format`, `bad_signature`, `not_found`, `revoked`, `suspended`, + * `expired`, `product_mismatch`, `fingerprint_mismatch`, + * `too_many_machines`, `rate_limited`, `invalid_state`. + */ + reason?: string; + licenseId?: string; + productId?: string; + productSlug?: string; + issuedAt?: string; + /** Expiry timestamp (RFC 3339) if the license has one. */ + expiresAt?: string; + /** End of the grace window (RFC 3339) when in a grace period. */ + graceUntil?: string; + /** True when the key is past `expiresAt` but still inside the grace window. */ + inGracePeriod?: boolean; + /** True if this license is flagged as a trial. */ + isTrial?: boolean; + /** Entitlement slugs granted by the license. */ + entitlements?: string[]; + /** License status string: `active`, `suspended`, `revoked`. */ + status?: string; + /** Machine id created or matched by this call (when fingerprint was sent). */ + machineId?: string; + /** Seat cap: `0` unlimited, `1` single-seat, `n` n-seat. */ + maxMachines?: number; +} +interface ValidateOptions { + /** Product slug the caller expects the key to cover. */ + productSlug?: string; + /** Raw machine fingerprint; enables seat binding / cap enforcement. */ + fingerprint?: string; + /** Client-supplied hostname, stored against the machine row on activation. */ + hostname?: string; + /** Client-supplied platform descriptor, e.g. `'linux-x86_64'`. */ + platform?: string; +} +interface MachineResponse { + ok: boolean; + reason?: string; + machineId?: string; + activeCount?: number; + maxMachines?: number; +} +interface PurchaseSession { + /** Our internal invoice id — use with `pollPurchase`. */ + invoiceId: string; + /** BTCPay's invoice id (opaque). */ + btcpayInvoiceId: string; + /** URL to open in the buyer's browser to pay. */ + checkoutUrl: string; + /** Price in satoshis. */ + amountSats: number; + /** Where the service recommends polling. */ + pollUrl: string; +} +interface PollResponse { + invoiceId: string; + /** `pending | settled | expired | invalid`. */ + status: string; + productId: string; + amountSats: number; + /** Populated once the license has been issued. */ + licenseKey?: string; + licenseId?: string; +} +interface StartPurchaseOptions { + /** Optional email for the receipt. */ + buyerEmail?: string; + /** Optional URL the buyer should be returned to after payment. */ + redirectUrl?: string; + /** Optional discount / referral code. */ + code?: string; + /** Optional buyer note recorded on the invoice (admin-visible). */ + buyerNote?: string; + /** + * Optional tier slug (the policy the buyer chose). When set, the + * licensing service prices the invoice at the policy's + * `price_sats_override` and remembers the chosen policy on the + * invoice so the issued license carries that policy's + * entitlements / duration / max_machines / trial flag. + * + * When omitted, the service falls back to the product's default + * policy (the policy slugged "default", or the first active one). + * + * To list available tiers for a product without auth, see + * {@link Client.listPublicPolicies}. + */ + policySlug?: string; +} +/** + * One tier on the buyer-facing tier picker. Returned by + * {@link Client.listPublicPolicies}. The shape mirrors what the + * licensing service's `/buy/` page reads server-side, so an + * in-app tier picker can render identical text and pricing without + * the buyer ever leaving the app. + */ +interface PublicPolicy { + slug: string; + name: string; + /** Free-form per-tier blurb (operator-set in admin UI). May be empty. */ + description: string; + /** + * Effective price in the smallest unit of the product's listed + * currency: sats for SAT-priced products, cents for USD/EUR-priced + * products. The product-level currency is on the parent + * {@link PublicPoliciesResponse.product.basePriceSats} (sats only) and + * via the daemon's `/v1/products/` endpoint for the full + * currency-typed view. + */ + priceSats: number; + /** 0 = perpetual; otherwise license lifetime in seconds. */ + durationSeconds: number; + /** Seat cap. 0 = unlimited, 1 = single-seat, n = n-seat. */ + maxMachines: number; + isTrial: boolean; + entitlements: string[]; + /** True if the operator marked this tier as "Most popular". */ + highlighted: boolean; + /** True if the policy is a recurring subscription. */ + isRecurring: boolean; + /** Renewal cadence in days (0 for non-recurring). */ + renewalPeriodDays: number; + /** First-cycle free-trial length (0 for none). */ + trialDays: number; +} +interface PublicPoliciesResponse { + product: { + slug: string; + name: string; + description: string; + basePriceSats: number; + }; + policies: PublicPolicy[]; +} +interface RedeemFreeOptions { + /** Optional email recorded on the synthetic invoice + license. */ + buyerEmail?: string; + /** Optional buyer note. */ + buyerNote?: string; +} +interface RedeemFreeResponse { + licenseId: string; + /** The fully-signed license key, ready for offline verification. */ + licenseKey: string; + invoiceId: string; + redemptionId: string; +} +/** An HTTP client pinned to one licensing-service base URL. */ +declare class Client { + private base; + constructor(baseUrl: string); + /** The normalized base URL this client is pinned to. */ + baseUrl(): string; + /** Fetch the server's PEM-encoded public key. */ + fetchPubkeyPem(): Promise; + /** + * Server-authoritative validation. Returns the full response including + * expiry / entitlements / seat fields introduced in v2. + * + * Two-argument form kept for call-site compatibility with earlier SDK + * versions; pass an options object for the full set of fields. + */ + validate(key: string, productSlugOrOptions?: string | ValidateOptions, fingerprint?: string): Promise; + /** Lightweight heartbeat. Server updates `last_heartbeat_at`. */ + heartbeat(key: string, fingerprint: string): Promise; + /** Explicitly activate a seat for the given fingerprint. */ + activate(key: string, fingerprint: string, opts?: { + hostname?: string; + platform?: string; + }): Promise; + /** Free a seat held by the given fingerprint. */ + deactivate(key: string, fingerprint: string, reason?: string): Promise; + /** Start a purchase. Returns the checkout URL and invoice id. */ + startPurchase(productSlug: string, opts?: StartPurchaseOptions): Promise; + /** + * List public, buyer-visible policies (tiers) for a product. No + * auth — same data the licensing service's `/buy/` page + * uses server-side. Use this to render an in-app tier picker + * that stays in sync with the operator's admin-side tier setup. + * + * Returns each policy's slug, display name, price (in the + * product's listed currency's smallest unit — sats or cents), + * entitlements, recurring/trial flags. Internal fields (id, + * tip recipients, raw metadata) are deliberately omitted. + */ + listPublicPolicies(productSlug: string): Promise; + /** + * Redeem a `free_license` code: bypass BTCPay entirely and receive the + * signed license key directly. Throws if the code is unknown / disabled + * / expired / wrong product / not a free_license code, or if the cap + * has been reached. + */ + redeemFreeLicense(productSlug: string, code: string, opts?: RedeemFreeOptions): Promise; + /** Poll a purchase by its invoice id. */ + pollPurchase(invoiceId: string): Promise; + /** + * Convenience: open the checkout, poll until a license key is issued, + * then return it. Suitable for CLI usage or for an app UI that shows a + * spinner while the buyer pays. + */ + waitForLicense(invoiceId: string, options?: { + intervalMs?: number; + timeoutMs?: number; + }): Promise; + private toValidateResponse; + private toMachineResponse; + private get; + private post; + private request; +} + +/** Hash a raw fingerprint string to the 32-byte form embedded in keys. */ +declare function hashFingerprint(raw: string): Uint8Array; + +/** All errors thrown by this library inherit from `LicensingError`. */ +declare class LicensingError extends Error { + /** + * Machine-readable reason code. Common values: + * `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`, + * `"expired"`, `"server_error"`, `"http_error"`, `"other"`. + */ + readonly code: string; + constructor(code: string, message: string); +} + +export { Client, FLAG_FINGERPRINT_BOUND, FLAG_TRIAL, KEY_PREFIX, KEY_VERSION, KEY_VERSION_V1, KEY_VERSION_V2, type LicenseKey, type LicensePayload, LicensingError, type MachineResponse, type PollResponse, PublicKey, type PublicPoliciesResponse, type PublicPolicy, type PurchaseSession, type RedeemFreeOptions, type RedeemFreeResponse, type StartPurchaseOptions, type ValidateOptions, type ValidateResponse, Verifier, type VerifyOk, hasEntitlement, hashFingerprint, isExpiredAt, parseLicenseKey }; diff --git a/vendor/keysat-licensing-client/dist/index.js b/vendor/keysat-licensing-client/dist/index.js new file mode 100644 index 0000000..c93c0a4 --- /dev/null +++ b/vendor/keysat-licensing-client/dist/index.js @@ -0,0 +1,544 @@ +// src/verify.ts +import * as ed from "@noble/ed25519"; +import { sha512 } from "@noble/hashes/sha512"; + +// src/errors.ts +var LicensingError = class extends Error { + /** + * Machine-readable reason code. Common values: + * `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`, + * `"expired"`, `"server_error"`, `"http_error"`, `"other"`. + */ + code; + constructor(code, message) { + super(message); + this.name = "LicensingError"; + this.code = code; + } +}; + +// src/base32.ts +var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; +var DECODE_TABLE = (() => { + const t = {}; + for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET[i]] = i; + return t; +})(); +function decodeBase32NoPad(input) { + const up = input.toUpperCase(); + const out = new Uint8Array(Math.floor(up.length * 5 / 8)); + let bits = 0; + let value = 0; + let outPos = 0; + for (let i = 0; i < up.length; i++) { + const ch = up[i]; + const v = DECODE_TABLE[ch]; + if (v === void 0) { + throw new LicensingError("bad_encoding", `invalid base32 character '${ch}'`); + } + value = value << 5 | v; + bits += 5; + if (bits >= 8) { + bits -= 8; + out[outPos++] = value >> bits & 255; + } + } + return out.subarray(0, outPos); +} + +// src/key.ts +var KEY_PREFIX = "LIC1"; +var KEY_VERSION_V1 = 1; +var KEY_VERSION_V2 = 2; +var KEY_VERSION = KEY_VERSION_V2; +var FLAG_FINGERPRINT_BOUND = 1; +var FLAG_TRIAL = 2; +var PAYLOAD_V1_LEN = 74; +var PAYLOAD_V2_HEAD_LEN = 83; +var SIGNATURE_LEN = 64; +function isExpiredAt(payload, nowUnixSeconds) { + return payload.expiresAt !== 0 && nowUnixSeconds >= payload.expiresAt; +} +function hasEntitlement(payload, slug) { + return payload.entitlements.includes(slug); +} +function parseLicenseKey(raw) { + const trimmed = raw.trim(); + const firstDash = trimmed.indexOf("-"); + if (firstDash < 0) throw new LicensingError("bad_format", "key is missing prefix delimiter"); + const prefix = trimmed.slice(0, firstDash); + if (prefix !== KEY_PREFIX) throw new LicensingError("bad_format", `unknown key prefix '${prefix}'`); + const body = trimmed.slice(firstDash + 1); + const lastDash = body.lastIndexOf("-"); + if (lastDash < 0) throw new LicensingError("bad_format", "key is missing signature delimiter"); + const payloadB32 = body.slice(0, lastDash); + const signatureB32 = body.slice(lastDash + 1); + const payloadBytes = decodeBase32NoPad(payloadB32); + const signature = decodeBase32NoPad(signatureB32); + if (signature.length !== SIGNATURE_LEN) { + throw new LicensingError( + "bad_format", + `signature is ${signature.length} bytes; expected ${SIGNATURE_LEN}` + ); + } + if (payloadBytes.length < 1) { + throw new LicensingError("bad_format", "empty payload"); + } + const version = payloadBytes[0]; + let payload; + switch (version) { + case KEY_VERSION_V1: + payload = parseV1(payloadBytes); + break; + case KEY_VERSION_V2: + payload = parseV2(payloadBytes); + break; + default: + throw new LicensingError("bad_version", `unsupported key version ${version}`); + } + return { + payload, + signedBytes: payloadBytes, + signature + }; +} +function parseV1(payloadBytes) { + if (payloadBytes.length !== PAYLOAD_V1_LEN) { + throw new LicensingError( + "bad_format", + `v1 payload is ${payloadBytes.length} bytes; expected ${PAYLOAD_V1_LEN}` + ); + } + const flags = payloadBytes[1]; + const productId = payloadBytes.slice(2, 18); + const licenseId = payloadBytes.slice(18, 34); + const issuedAt = readBigEndianI64(payloadBytes, 34); + const fingerprintHash = payloadBytes.slice(42, 74); + return { + version: KEY_VERSION_V1, + flags, + productId, + licenseId, + issuedAt, + expiresAt: 0, + fingerprintHash, + entitlements: [], + productUuid: uuidString(productId), + licenseUuid: uuidString(licenseId), + isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0, + isTrial: (flags & FLAG_TRIAL) !== 0 + }; +} +function parseV2(payloadBytes) { + if (payloadBytes.length < PAYLOAD_V2_HEAD_LEN) { + throw new LicensingError( + "bad_format", + `v2 payload is ${payloadBytes.length} bytes; expected >= ${PAYLOAD_V2_HEAD_LEN}` + ); + } + const flags = payloadBytes[1]; + const productId = payloadBytes.slice(2, 18); + const licenseId = payloadBytes.slice(18, 34); + const issuedAt = readBigEndianI64(payloadBytes, 34); + const expiresAt = readBigEndianI64(payloadBytes, 42); + const fingerprintHash = payloadBytes.slice(50, 82); + const numEntitlements = payloadBytes[82]; + const entitlements = []; + let cursor = PAYLOAD_V2_HEAD_LEN; + const decoder = new TextDecoder("utf-8", { fatal: true }); + for (let i = 0; i < numEntitlements; i++) { + if (cursor >= payloadBytes.length) { + throw new LicensingError("bad_format", "truncated entitlement list"); + } + const len = payloadBytes[cursor]; + cursor += 1; + if (cursor + len > payloadBytes.length) { + throw new LicensingError("bad_format", "truncated entitlement"); + } + try { + entitlements.push(decoder.decode(payloadBytes.slice(cursor, cursor + len))); + } catch { + throw new LicensingError("bad_format", "entitlement not utf-8"); + } + cursor += len; + } + if (cursor !== payloadBytes.length) { + throw new LicensingError("bad_format", "trailing bytes in payload"); + } + return { + version: KEY_VERSION_V2, + flags, + productId, + licenseId, + issuedAt, + expiresAt, + fingerprintHash, + entitlements, + productUuid: uuidString(productId), + licenseUuid: uuidString(licenseId), + isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0, + isTrial: (flags & FLAG_TRIAL) !== 0 + }; +} +function readBigEndianI64(buf, offset) { + const view = new DataView(buf.buffer, buf.byteOffset + offset, 8); + const hi = view.getInt32(0, false); + const lo = view.getUint32(4, false); + return hi * 2 ** 32 + lo; +} +function uuidString(b) { + const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; +} + +// src/fingerprint.ts +import { sha256 } from "@noble/hashes/sha256"; +function hashFingerprint(raw) { + return sha256(new TextEncoder().encode(raw)); +} + +// src/verify.ts +ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m)); +var Verifier = class { + pubkey; + constructor(pubkey) { + this.pubkey = pubkey; + } + /** Verify a license key string. Throws on any failure. */ + verify(keyStr) { + const key = parseLicenseKey(keyStr); + const ok = ed.verify(key.signature, key.signedBytes, this.pubkey.raw); + if (!ok) throw new LicensingError("bad_signature", "signature did not verify"); + return { + payload: key.payload, + licenseId: key.payload.licenseUuid, + productId: key.payload.productUuid + }; + } + /** + * Verify AND enforce that, if the key is fingerprint-bound, the given + * fingerprint matches. If the key is not bound, the fingerprint is + * ignored. Throws on any failure. + */ + verifyWithFingerprint(keyStr, fingerprint) { + const result = this.verify(keyStr); + if (result.payload.isFingerprintBound) { + const expected = hashFingerprint(fingerprint); + const stored = result.payload.fingerprintHash; + if (!equalBytes(expected, stored)) { + throw new LicensingError("bad_signature", "fingerprint does not match bound key"); + } + } + return result; + } + /** + * Verify a key and additionally reject it with an `expired` error if + * `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys + * (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is + * offline-only — no grace window logic; use `Client.validate` for that. + */ + verifyWithTime(keyStr, nowUnixSeconds) { + const result = this.verify(keyStr); + if (isExpiredAt(result.payload, nowUnixSeconds)) { + throw new LicensingError("expired", "license has expired"); + } + return result; + } +}; +function equalBytes(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +// src/online.ts +var Client = class { + base; + constructor(baseUrl) { + this.base = baseUrl.replace(/\/+$/, ""); + } + /** The normalized base URL this client is pinned to. */ + baseUrl() { + return this.base; + } + /** Fetch the server's PEM-encoded public key. */ + async fetchPubkeyPem() { + const data = await this.get("/v1/pubkey"); + return data.public_key_pem; + } + /** + * Server-authoritative validation. Returns the full response including + * expiry / entitlements / seat fields introduced in v2. + * + * Two-argument form kept for call-site compatibility with earlier SDK + * versions; pass an options object for the full set of fields. + */ + async validate(key, productSlugOrOptions, fingerprint) { + const opts = typeof productSlugOrOptions === "string" ? { productSlug: productSlugOrOptions, fingerprint } : productSlugOrOptions ?? {}; + const raw = await this.post("/v1/validate", { + key, + product_slug: opts.productSlug, + fingerprint: opts.fingerprint, + hostname: opts.hostname, + platform: opts.platform + }); + return this.toValidateResponse(raw); + } + /** Lightweight heartbeat. Server updates `last_heartbeat_at`. */ + async heartbeat(key, fingerprint) { + const raw = await this.post("/v1/machines/heartbeat", { + key, + fingerprint + }); + return this.toMachineResponse(raw); + } + /** Explicitly activate a seat for the given fingerprint. */ + async activate(key, fingerprint, opts = {}) { + const raw = await this.post("/v1/machines/activate", { + key, + fingerprint, + hostname: opts.hostname, + platform: opts.platform + }); + return this.toMachineResponse(raw); + } + /** Free a seat held by the given fingerprint. */ + async deactivate(key, fingerprint, reason) { + const raw = await this.post("/v1/machines/deactivate", { + key, + fingerprint, + reason + }); + return this.toMachineResponse(raw); + } + /** Start a purchase. Returns the checkout URL and invoice id. */ + async startPurchase(productSlug, opts = {}) { + const raw = await this.post("/v1/purchase", { + product: productSlug, + buyer_email: opts.buyerEmail, + buyer_note: opts.buyerNote, + redirect_url: opts.redirectUrl, + code: opts.code, + policy_slug: opts.policySlug + }); + return { + invoiceId: raw.invoice_id, + btcpayInvoiceId: raw.btcpay_invoice_id, + checkoutUrl: raw.checkout_url, + amountSats: raw.amount_sats, + pollUrl: raw.poll_url + }; + } + /** + * List public, buyer-visible policies (tiers) for a product. No + * auth — same data the licensing service's `/buy/` page + * uses server-side. Use this to render an in-app tier picker + * that stays in sync with the operator's admin-side tier setup. + * + * Returns each policy's slug, display name, price (in the + * product's listed currency's smallest unit — sats or cents), + * entitlements, recurring/trial flags. Internal fields (id, + * tip recipients, raw metadata) are deliberately omitted. + */ + async listPublicPolicies(productSlug) { + const raw = await this.get( + `/v1/products/${encodeURIComponent(productSlug)}/policies` + ); + const product = raw.product; + const policies = raw.policies ?? []; + return { + product: { + slug: product.slug, + name: product.name, + description: product.description ?? "", + basePriceSats: product.base_price_sats + }, + policies: policies.map((p) => ({ + slug: p.slug, + name: p.name, + description: p.description ?? "", + priceSats: p.price_sats, + durationSeconds: p.duration_seconds ?? 0, + maxMachines: p.max_machines ?? 1, + isTrial: !!p.is_trial, + entitlements: p.entitlements ?? [], + highlighted: !!p.highlighted, + isRecurring: !!p.is_recurring, + renewalPeriodDays: p.renewal_period_days ?? 0, + trialDays: p.trial_days ?? 0 + })) + }; + } + /** + * Redeem a `free_license` code: bypass BTCPay entirely and receive the + * signed license key directly. Throws if the code is unknown / disabled + * / expired / wrong product / not a free_license code, or if the cap + * has been reached. + */ + async redeemFreeLicense(productSlug, code, opts = {}) { + const raw = await this.post("/v1/redeem", { + product: productSlug, + code, + buyer_email: opts.buyerEmail, + buyer_note: opts.buyerNote + }); + return { + licenseId: raw.license_id, + licenseKey: raw.license_key, + invoiceId: raw.invoice_id, + redemptionId: raw.redemption_id + }; + } + /** Poll a purchase by its invoice id. */ + async pollPurchase(invoiceId) { + const raw = await this.get( + `/v1/purchase/${encodeURIComponent(invoiceId)}` + ); + return { + invoiceId: raw.invoice_id, + status: raw.status, + productId: raw.product_id, + amountSats: raw.amount_sats, + licenseKey: raw.license_key ?? void 0, + licenseId: raw.license_id ?? void 0 + }; + } + /** + * Convenience: open the checkout, poll until a license key is issued, + * then return it. Suitable for CLI usage or for an app UI that shows a + * spinner while the buyer pays. + */ + async waitForLicense(invoiceId, options = {}) { + const interval = options.intervalMs ?? 5e3; + const deadline = options.timeoutMs ? Date.now() + options.timeoutMs : Infinity; + while (true) { + const poll = await this.pollPurchase(invoiceId); + if (poll.licenseKey) return poll.licenseKey; + if (poll.status === "expired" || poll.status === "invalid") { + throw new LicensingError("server_error", `invoice ended in status ${poll.status}`); + } + if (Date.now() > deadline) { + throw new LicensingError("server_error", "timed out waiting for license issuance"); + } + await sleep(interval); + } + } + // --- internals --- + toValidateResponse(raw) { + const entitlements = Array.isArray(raw.entitlements) ? raw.entitlements.filter((x) => typeof x === "string") : void 0; + return { + ok: !!raw.ok, + reason: raw.reason, + licenseId: raw.license_id, + productId: raw.product_id, + productSlug: raw.product_slug, + issuedAt: raw.issued_at, + expiresAt: raw.expires_at, + graceUntil: raw.grace_until, + inGracePeriod: raw.in_grace_period, + isTrial: raw.is_trial, + entitlements, + status: raw.status, + machineId: raw.machine_id, + maxMachines: raw.max_machines + }; + } + toMachineResponse(raw) { + return { + ok: !!raw.ok, + reason: raw.reason, + machineId: raw.machine_id, + activeCount: raw.active_count, + maxMachines: raw.max_machines + }; + } + async get(path) { + return this.request(path, { method: "GET" }); + } + async post(path, body) { + return this.request(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body) + }); + } + async request(path, init) { + let resp; + try { + resp = await fetch(`${this.base}${path}`, init); + } catch (e) { + throw new LicensingError("http_error", e instanceof Error ? e.message : String(e)); + } + const text = await resp.text(); + if (!resp.ok) { + throw new LicensingError("server_error", `HTTP ${resp.status}: ${text}`); + } + try { + return JSON.parse(text); + } catch { + throw new LicensingError("server_error", `non-JSON response: ${text}`); + } + } +}; +function sleep(ms) { + return new Promise((res) => setTimeout(res, ms)); +} + +// src/pubkey.ts +var PublicKey = class _PublicKey { + /** Raw 32-byte Ed25519 public key material. */ + raw; + constructor(raw) { + if (raw.length !== 32) { + throw new LicensingError( + "bad_format", + `public key must be 32 bytes; got ${raw.length}` + ); + } + this.raw = raw; + } + /** Parse a PEM blob as emitted by the service. */ + static fromPem(pem) { + const stripped = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, ""); + if (!stripped) { + throw new LicensingError("bad_format", "empty PEM input"); + } + const der = base64Decode(stripped); + if (der.length < 32) { + throw new LicensingError("bad_format", "PEM body too short to contain a public key"); + } + const raw = der.slice(der.length - 32); + return new _PublicKey(raw); + } + /** Construct from raw bytes (no PEM envelope). */ + static fromBytes(bytes) { + return new _PublicKey(bytes); + } +}; +function base64Decode(b64) { + const nodeBuffer = globalThis.Buffer; + if (nodeBuffer) { + return new Uint8Array(nodeBuffer.from(b64, "base64")); + } + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} +export { + Client, + FLAG_FINGERPRINT_BOUND, + FLAG_TRIAL, + KEY_PREFIX, + KEY_VERSION, + KEY_VERSION_V1, + KEY_VERSION_V2, + LicensingError, + PublicKey, + Verifier, + hasEntitlement, + hashFingerprint, + isExpiredAt, + parseLicenseKey +}; diff --git a/vendor/keysat-licensing-client/package-lock.json b/vendor/keysat-licensing-client/package-lock.json new file mode 100644 index 0000000..96d6738 --- /dev/null +++ b/vendor/keysat-licensing-client/package-lock.json @@ -0,0 +1,2777 @@ +{ + "name": "@keysat/licensing-client", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@keysat/licensing-client", + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "@noble/ed25519": "^2.0.0", + "@noble/hashes": "^1.3.3" + }, + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@noble/ed25519": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz", + "integrity": "sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/vendor/keysat-licensing-client/package.json b/vendor/keysat-licensing-client/package.json new file mode 100644 index 0000000..f5ff606 --- /dev/null +++ b/vendor/keysat-licensing-client/package.json @@ -0,0 +1,33 @@ +{ + "name": "@keysat/licensing-client", + "version": "0.2.0", + "description": "Client library for Keysat. Verifies signed license keys offline and wraps the HTTP API for purchase and revocation checks.", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": ["dist", "README.md", "LICENSE"], + "scripts": {}, + "keywords": [ + "bitcoin", + "licensing", + "btcpay", + "start9", + "ed25519" + ], + "repository": "https://github.com/keysat-xyz/keysat-client-ts", + "license": "MIT", + "engines": { "node": ">=18" }, + "dependencies": { + "@noble/ed25519": "^2.0.0", + "@noble/hashes": "^1.3.3" + }, + "devDependencies": {} +}